Greasy Fork

Greasy Fork is available in English.

X Grok 翻译按钮外置

在 X 首页信息流和评论区外置官方 Grok 翻译按钮

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         X Grok 翻译按钮外置
// @namespace    https://x.com/
// @license      MIT
// @version      0.2.0
// @description  在 X 首页信息流和评论区外置官方 Grok 翻译按钮
// @author       XianYuDaXian
// @match        https://x.com/*
// @match        https://twitter.com/*
// @grant        GM_addStyle
// @run-at       document-idle
// ==/UserScript==


(function () {
  'use strict';

  const API_URL = 'https://api.x.com/2/grok/translation.json';
  const BUTTON_ATTR = 'data-xgrok-translate-button';
  const BATCH_BUTTON_ATTR = 'data-xgrok-translate-all-button';
  const BLOCK_ATTR = 'data-xgrok-translation-block';
  const HOST_ATTR = 'data-xgrok-translate-host';
  const BATCH_HOST_ATTR = 'data-xgrok-translate-all-host';

  const state = {
    cache: new Map(),
    pending: new Map(),
    observer: null,
    runtimeHeaders: {},
    lastTranslationTemplate: null,
    autoTranslateAll: false,
    autoTranslateScheduled: false,
    autoTranslateDetailTweetId: '',
    lastPathname: location.pathname,
  };

  const PUBLIC_BEARER =
    'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';

  GM_addStyle(`
    [${HOST_ATTR}="1"] {
      display: inline-flex;
      align-items: center;
      gap: 8px;
      margin-top: 8px;
      flex-wrap: wrap;
    }

    [${BUTTON_ATTR}="1"] {
      appearance: none;
      border: 1px solid rgba(83, 100, 113, 0.45);
      background: transparent;
      color: rgb(113, 118, 123);
      border-radius: 9999px;
      padding: 4px 10px;
      font-size: 13px;
      line-height: 18px;
      cursor: pointer;
      transition: all 0.15s ease;
    }

    [${BATCH_BUTTON_ATTR}="1"] {
      appearance: none;
      border: 1px solid rgba(29, 155, 240, 0.45);
      background: rgba(29, 155, 240, 0.12);
      color: rgb(29, 155, 240);
      border-radius: 9999px;
      padding: 6px 14px;
      font-size: 14px;
      line-height: 20px;
      font-weight: 700;
      cursor: pointer;
      transition: all 0.15s ease;
    }

    [${BATCH_BUTTON_ATTR}="1"]:hover {
      background: rgba(29, 155, 240, 0.18);
      border-color: rgba(29, 155, 240, 0.72);
    }

    [${BUTTON_ATTR}="1"]:hover {
      border-color: rgba(29, 155, 240, 0.75);
      color: rgb(29, 155, 240);
      background: rgba(29, 155, 240, 0.08);
    }

    [${BUTTON_ATTR}="1"][data-state="loading"] {
      opacity: 0.72;
      cursor: progress;
    }

    [${BATCH_BUTTON_ATTR}="1"][data-state="loading"] {
      opacity: 0.72;
      cursor: progress;
    }

    [${BUTTON_ATTR}="1"][data-state="done"] {
      color: rgb(29, 155, 240);
      border-color: rgba(29, 155, 240, 0.55);
    }

    [${BATCH_BUTTON_ATTR}="1"][data-state="done"] {
      color: rgb(255, 255, 255);
      background: rgba(29, 155, 240, 0.88);
      border-color: rgba(29, 155, 240, 0.88);
    }

    [${BUTTON_ATTR}="1"][data-state="error"] {
      color: rgb(244, 33, 46);
      border-color: rgba(244, 33, 46, 0.45);
    }

    [${BATCH_BUTTON_ATTR}="1"][data-state="error"] {
      color: rgb(244, 33, 46);
      border-color: rgba(244, 33, 46, 0.45);
      background: rgba(244, 33, 46, 0.08);
    }

    [${BLOCK_ATTR}="1"] {
      margin-top: 10px;
      padding: 10px 12px;
      border-radius: 12px;
      border: 1px solid rgba(83, 100, 113, 0.28);
      background: rgba(29, 155, 240, 0.08);
      color: inherit;
      font-size: 15px;
      line-height: 1.5;
      white-space: pre-wrap;
      word-break: break-word;
    }

    [${BLOCK_ATTR}="1"] [data-role="title"] {
      display: block;
      margin-bottom: 6px;
      color: rgb(29, 155, 240);
      font-size: 12px;
      font-weight: 700;
      letter-spacing: 0.02em;
      text-transform: uppercase;
    }

    [${BLOCK_ATTR}="1"] [data-role="content"] {
      display: flex;
      flex-direction: column;
      gap: 8px;
    }

    [${BLOCK_ATTR}="1"] p,
    [${BLOCK_ATTR}="1"] blockquote {
      margin: 0;
      white-space: pre-wrap;
      word-break: break-word;
    }

    [${BLOCK_ATTR}="1"] blockquote {
      padding-left: 12px;
      border-left: 3px solid rgba(29, 155, 240, 0.45);
      color: rgba(231, 233, 234, 0.95);
    }
  `);

  function readCookie(name) {
    const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
    return match ? decodeURIComponent(match[1]) : '';
  }

  function readStoredLanguage() {
    const candidates = [
      localStorage.getItem('lang'),
      localStorage.getItem('i18n_redirected'),
      sessionStorage.getItem('lang'),
    ];

    for (const value of candidates) {
      if (typeof value === 'string' && value.trim()) {
        return value.trim();
      }
    }

    return '';
  }

  function getClientLanguage() {
    return (
      state.runtimeHeaders['x-twitter-client-language'] ||
      document.documentElement.lang ||
      readStoredLanguage() ||
      navigator.language ||
      'en'
    ).toLowerCase();
  }

  function getTargetLanguage() {
    const locale = getClientLanguage().replace(/_/g, '-');

    if (locale.startsWith('zh')) {
      return 'zh';
    }

    return locale.split('-')[0] || 'en';
  }

  function toPlainHeaders(input) {
    if (!input) return {};

    if (input instanceof Headers) {
      return Object.fromEntries(input.entries());
    }

    if (Array.isArray(input)) {
      return Object.fromEntries(input);
    }

    return Object.fromEntries(
      Object.entries(input).map(([key, value]) => [key.toLowerCase(), String(value)])
    );
  }

  function captureRuntimeHeaders(headers, url) {
    const normalized = toPlainHeaders(headers);
    if (!Object.keys(normalized).length) return;

    const interestingKeys = [
      'authorization',
      'x-csrf-token',
      'x-twitter-active-user',
      'x-twitter-auth-type',
      'x-twitter-client-language',
      'x-client-transaction-id',
      'x-guest-token',
      'x-twitter-polling',
      'x-twitter-client-version',
    ];

    for (const key of interestingKeys) {
      if (normalized[key]) {
        state.runtimeHeaders[key] = normalized[key];
      }
    }

    if (url.includes('/2/grok/translation.json')) {
      state.lastTranslationTemplate = {
        ...normalized,
      };
    }
  }

  function installNetworkHooks() {
    if (window.__xGrokTranslateHooksInstalled) return;
    window.__xGrokTranslateHooksInstalled = true;

    const originalFetch = window.fetch;
    window.fetch = async function patchedFetch(input, init) {
      try {
        const url =
          typeof input === 'string'
            ? input
            : input instanceof Request
              ? input.url
              : String(input);

        if (url.includes('api.x.com')) {
          captureRuntimeHeaders(input instanceof Request ? input.headers : null, url);
          captureRuntimeHeaders(init?.headers, url);
        }
      } catch (error) {
        console.debug('[X Grok Translate] 抓取 fetch 请求头失败', error);
      }

      return originalFetch.apply(this, arguments);
    };

    const originalOpen = XMLHttpRequest.prototype.open;
    const originalSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
    const originalSend = XMLHttpRequest.prototype.send;

    XMLHttpRequest.prototype.open = function patchedOpen(method, url) {
      this.__xGrokTranslateUrl = typeof url === 'string' ? url : String(url);
      this.__xGrokTranslateHeaders = {};
      return originalOpen.apply(this, arguments);
    };

    XMLHttpRequest.prototype.setRequestHeader = function patchedSetRequestHeader(name, value) {
      if (this.__xGrokTranslateHeaders) {
        this.__xGrokTranslateHeaders[String(name).toLowerCase()] = String(value);
      }
      return originalSetRequestHeader.apply(this, arguments);
    };

    XMLHttpRequest.prototype.send = function patchedSend() {
      try {
        if (String(this.__xGrokTranslateUrl || '').includes('api.x.com')) {
          captureRuntimeHeaders(this.__xGrokTranslateHeaders, this.__xGrokTranslateUrl);
        }
      } catch (error) {
        console.debug('[X Grok Translate] 抓取 XHR 请求头失败', error);
      }

      return originalSend.apply(this, arguments);
    };
  }

  function getHeaders() {
    const csrf = readCookie('ct0');
    const hasAuthToken = Boolean(readCookie('auth_token'));

    if (!csrf) {
      throw new Error('缺少 ct0,当前账号可能未登录。');
    }

    const headers = {
      authorization: `Bearer ${decodeURIComponent(PUBLIC_BEARER)}`,
      'content-type': 'text/plain;charset=UTF-8',
      'x-csrf-token': csrf,
      'x-twitter-active-user': 'yes',
      'x-twitter-auth-type': hasAuthToken ? 'OAuth2Session' : '',
      'x-twitter-client-language': getClientLanguage(),
    };

    if (state.lastTranslationTemplate) {
      Object.assign(headers, state.lastTranslationTemplate);
    }

    Object.assign(headers, state.runtimeHeaders);
    headers['content-type'] = 'text/plain;charset=UTF-8';
    headers['x-csrf-token'] = csrf;
    headers['x-twitter-client-language'] =
      (headers['x-twitter-client-language'] || getClientLanguage()).toLowerCase();

    if (!headers.authorization) {
      throw new Error('还没抓到有效 authorization。请先点一次 X 原生翻译按钮,或刷新后滚动页面再试。');
    }

    return headers;
  }

  function extractTweetId(article) {
    const anchors = article.querySelectorAll('a[href*="/status/"]');
    for (const anchor of anchors) {
      const match = anchor.getAttribute('href')?.match(/\/status\/(\d+)/);
      if (match) return match[1];
    }
    return '';
  }

  function isStatusDetailPage() {
    return /\/status\/\d+/.test(location.pathname);
  }

  function getStatusDetailTweetId() {
    const match = location.pathname.match(/\/status\/(\d+)/);
    return match ? match[1] : '';
  }

  function getPrimaryArticle() {
    const main = document.querySelector('main');
    if (!main) return null;

    const articles = Array.from(main.querySelectorAll('article'));
    if (!articles.length) return null;
    const detailTweetId = getStatusDetailTweetId();
    if (!detailTweetId) return articles[0];

    return (
      articles.find((article) => extractTweetId(article) === detailTweetId) ||
      articles[0]
    );
  }

  function resetAutoTranslateState() {
    state.autoTranslateAll = false;
    state.autoTranslateScheduled = false;
    state.autoTranslateDetailTweetId = '';
  }

  function syncRouteState() {
    if (state.lastPathname === location.pathname) return;
    state.lastPathname = location.pathname;
    resetAutoTranslateState();
  }

  function findTextContainer(article) {
    return (
      article.querySelector('[data-testid="tweetText"]') ||
      article.querySelector('[lang]') ||
      article
    );
  }

  function findActionBar(article) {
    const candidates = [
      '[role="group"]',
      '[data-testid="reply"]',
      '[data-testid="retweet"]',
      '[data-testid="like"]',
    ];

    for (const selector of candidates) {
      const node = article.querySelector(selector);
      if (!node) continue;

      if (selector === '[role="group"]') {
        return node;
      }

      const group = node.closest('[role="group"]');
      if (group) return group;
    }

    return null;
  }

  function findButtonHost(article) {
    const actionBar = findActionBar(article);
    if (actionBar?.parentElement) {
      return actionBar.parentElement;
    }

    const textContainer = findTextContainer(article);
    return textContainer.parentElement || article;
  }

  function ensureTranslationBlock(article) {
    let block = article.querySelector(`[${BLOCK_ATTR}="1"]`);
    if (block) return block;

    block = document.createElement('div');
    block.setAttribute(BLOCK_ATTR, '1');
    block.hidden = true;
    block.innerHTML = '<span data-role="title">Grok 翻译</span><div data-role="content"></div>';

    const textContainer = findTextContainer(article);
    if (textContainer.parentElement) {
      textContainer.insertAdjacentElement('afterend', block);
    } else {
      article.appendChild(block);
    }

    return block;
  }

  function renderTranslation(article, translatedText) {
    const block = ensureTranslationBlock(article);
    const content = block.querySelector('[data-role="content"]');
    if (content) {
      renderFormattedText(content, translatedText);
    }
    block.hidden = false;
  }

  function decodeHtmlEntities(text) {
    const textarea = document.createElement('textarea');
    textarea.innerHTML = text;
    return textarea.value;
  }

  function appendParagraph(container, lines) {
    if (!lines.length) return;
    const paragraph = document.createElement('p');
    paragraph.textContent = lines.join('\n');
    container.appendChild(paragraph);
  }

  function appendQuote(container, lines) {
    if (!lines.length) return;
    const quote = document.createElement('blockquote');
    quote.textContent = lines
      .map((line) => line.replace(/^>\s?/, ''))
      .join('\n');
    container.appendChild(quote);
  }

  function renderFormattedText(container, rawText) {
    const decodedText = decodeHtmlEntities(rawText || '').replace(/\r\n/g, '\n');
    const lines = decodedText.split('\n');

    container.replaceChildren();

    let paragraphBuffer = [];
    let quoteBuffer = [];

    const flushParagraph = () => {
      appendParagraph(container, paragraphBuffer);
      paragraphBuffer = [];
    };

    const flushQuote = () => {
      appendQuote(container, quoteBuffer);
      quoteBuffer = [];
    };

    for (const line of lines) {
      const isQuote = /^>\s?/.test(line);
      const isBlank = line.trim() === '';

      if (isBlank) {
        flushParagraph();
        flushQuote();
        continue;
      }

      if (isQuote) {
        flushParagraph();
        quoteBuffer.push(line);
        continue;
      }

      flushQuote();
      paragraphBuffer.push(line);
    }

    flushParagraph();
    flushQuote();
  }

  function normalizeTranslatedText(payload) {
    if (!payload) return '';

    const queue = [payload];
    const visited = new Set();

    while (queue.length) {
      const current = queue.shift();
      if (!current || typeof current !== 'object' || visited.has(current)) continue;
      visited.add(current);

      for (const key of [
        'translated_text',
        'translation',
        'text',
        'translatedText',
        'result',
        'output_text',
        'body',
      ]) {
        const value = current[key];
        if (typeof value === 'string' && value.trim()) {
          return value.trim();
        }
      }

      for (const value of Object.values(current)) {
        if (typeof value === 'string') continue;
        if (value && typeof value === 'object') {
          queue.push(value);
        }
      }
    }

    return '';
  }

  async function requestTranslation(tweetId) {
    if (state.cache.has(tweetId)) {
      return state.cache.get(tweetId);
    }

    if (state.pending.has(tweetId)) {
      return state.pending.get(tweetId);
    }

    const task = fetch(API_URL, {
      method: 'POST',
      mode: 'cors',
      credentials: 'include',
      headers: getHeaders(),
      body: JSON.stringify({
        content_type: 'POST',
        id: tweetId,
        dst_lang: getTargetLanguage(),
      }),
    })
      .then(async (response) => {
        const rawText = await response.text();
        let payload = null;

        try {
          payload = rawText ? JSON.parse(rawText) : null;
        } catch {
          payload = { rawText };
        }

        if (!response.ok) {
          const message =
            normalizeTranslatedText(payload) ||
            payload?.errors?.[0]?.message ||
            payload?.error ||
            `HTTP ${response.status}`;
          throw new Error(message);
        }

        const translatedText = normalizeTranslatedText(payload);
        if (!translatedText) {
          throw new Error('接口返回成功,但没有拿到可用译文。');
        }

        state.cache.set(tweetId, translatedText);
        return translatedText;
      })
      .finally(() => {
        state.pending.delete(tweetId);
      });

    state.pending.set(tweetId, task);
    return task;
  }

  function updateButtonState(button, stateName, label) {
    button.dataset.state = stateName;
    button.textContent = label;
  }

  async function handleTranslateClick(article, button) {
    const tweetId = article.dataset.xgrokTweetId || extractTweetId(article);
    if (!tweetId) {
      updateButtonState(button, 'error', '未找到帖子 ID');
      return;
    }

    const block = ensureTranslationBlock(article);
    if (!block.hidden && state.cache.has(tweetId)) {
      block.hidden = true;
      updateButtonState(button, 'done', '显示翻译');
      return;
    }

    updateButtonState(button, 'loading', '翻译中...');

    try {
      const translatedText = await requestTranslation(tweetId);
      renderTranslation(article, translatedText);
      updateButtonState(button, 'done', '隐藏翻译');
    } catch (error) {
      console.error('[X Grok Translate]', error);
      updateButtonState(button, 'error', '翻译失败');
      setTimeout(() => {
        updateButtonState(button, 'idle', '翻译');
      }, 2500);
    }
  }

  function scheduleAutoTranslateVisibleArticles() {
    if (!state.autoTranslateAll || state.autoTranslateScheduled || !isStatusDetailPage()) {
      return;
    }

    if (state.autoTranslateDetailTweetId !== getStatusDetailTweetId()) {
      resetAutoTranslateState();
      return;
    }

    state.autoTranslateScheduled = true;

    queueMicrotask(async () => {
      try {
        const articles = getTranslatableArticles();
        for (const article of articles) {
          const tweetId = article.dataset.xgrokTweetId || extractTweetId(article);
          if (!tweetId || state.cache.has(tweetId) || state.pending.has(tweetId)) {
            continue;
          }

          const button = article.querySelector(`[${BUTTON_ATTR}="1"]`);
          if (!button) continue;

          try {
            await handleTranslateClick(article, button);
          } catch (error) {
            console.error('[X Grok Translate] 自动翻译失败', error);
          }
        }
      } finally {
        state.autoTranslateScheduled = false;
      }
    });
  }

  function getTranslatableArticles() {
    const main = document.querySelector('main');
    if (!main) return [];

    return Array.from(main.querySelectorAll('article')).filter((article) => {
      if (!(article instanceof HTMLElement)) return false;
      if (article.getAttribute('aria-label')) return false;
      return Boolean(extractTweetId(article));
    });
  }

  async function runBatchTranslation(button) {
    state.autoTranslateAll = true;
    state.autoTranslateDetailTweetId = getStatusDetailTweetId();
    const articles = getTranslatableArticles();
    if (!articles.length) {
      updateButtonState(button, 'error', '未找到可翻译内容');
      setTimeout(() => updateButtonState(button, 'idle', '翻译全部'), 2500);
      return;
    }

    const articleButtons = articles
      .map((article) => ({
        article,
        button: article.querySelector(`[${BUTTON_ATTR}="1"]`),
      }))
      .filter((item) => item.button);

    if (!articleButtons.length) {
      updateButtonState(button, 'error', '未找到翻译按钮');
      setTimeout(() => updateButtonState(button, 'idle', '翻译全部'), 2500);
      return;
    }

    updateButtonState(button, 'loading', `翻译中 0/${articleButtons.length}`);

    let completed = 0;

    const workers = new Array(Math.min(3, articleButtons.length)).fill(null).map(async () => {
      while (articleButtons.length) {
        const current = articleButtons.shift();
        if (!current) return;

        try {
          await handleTranslateClick(current.article, current.button);
        } catch (error) {
          console.error('[X Grok Translate] 批量翻译失败', error);
        } finally {
          completed += 1;
          updateButtonState(button, 'loading', `翻译中 ${completed}/${completed + articleButtons.length}`);
        }
      }
    });

    await Promise.all(workers);
    updateButtonState(button, 'done', `已翻译 ${completed} 条`);
  }

  function mountBatchButton() {
    if (!isStatusDetailPage()) return;

    const article = getPrimaryArticle();
    if (!article) return;
    if (extractTweetId(article) !== getStatusDetailTweetId()) return;

    const host = findButtonHost(article);
    if (!host) return;

    let wrapper = host.querySelector(`[${HOST_ATTR}="1"]`);
    if (!wrapper) {
      mountButton(article);
      wrapper = host.querySelector(`[${HOST_ATTR}="1"]`);
    }

    if (!wrapper || wrapper.querySelector(`[${BATCH_BUTTON_ATTR}="1"]`)) return;

    const button = document.createElement('button');
    button.type = 'button';
    button.setAttribute(BATCH_BUTTON_ATTR, '1');
    button.dataset.state = 'idle';
    button.textContent = '翻译全部';
    button.addEventListener('click', () => {
      runBatchTranslation(button);
    });

    wrapper.appendChild(button);
  }

  function shouldSkipArticle(article) {
    if (!(article instanceof HTMLElement)) return true;
    if (article.closest('[aria-label*="Timeline: Trending"]')) return true;
    if (article.querySelector(`[${BUTTON_ATTR}="1"]`)) return true;
    return !extractTweetId(article);
  }

  function mountButton(article) {
    if (shouldSkipArticle(article)) return;

    const tweetId = extractTweetId(article);
    article.dataset.xgrokTweetId = tweetId;

    const host = findButtonHost(article);
    if (!host) return;

    let wrapper = host.querySelector(`[${HOST_ATTR}="1"]`);
    if (!wrapper) {
      wrapper = document.createElement('div');
      wrapper.setAttribute(HOST_ATTR, '1');
      host.appendChild(wrapper);
    }

    if (wrapper.querySelector(`[${BUTTON_ATTR}="1"]`)) return;

    const button = document.createElement('button');
    button.type = 'button';
    button.setAttribute(BUTTON_ATTR, '1');
    button.dataset.state = 'idle';
    button.textContent = '翻译';
    button.addEventListener('click', () => {
      handleTranslateClick(article, button);
    });

    wrapper.appendChild(button);
  }

  function scan(root = document) {
    syncRouteState();
    const articles = root.querySelectorAll('article');
    for (const article of articles) {
      mountButton(article);
    }
    mountBatchButton();
    scheduleAutoTranslateVisibleArticles();
  }

  function bootstrapObserver() {
    if (state.observer) {
      state.observer.disconnect();
    }

    state.observer = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        for (const node of mutation.addedNodes) {
          if (!(node instanceof HTMLElement)) continue;

          if (node.matches?.('article')) {
            mountButton(node);
            continue;
          }

          if (node.querySelector?.('article')) {
            scan(node);
          }
        }
      }
    });

    state.observer.observe(document.body, {
      childList: true,
      subtree: true,
    });
  }

  function start() {
    installNetworkHooks();
    scan(document);
    bootstrapObserver();
    window.addEventListener('popstate', () => {
      resetAutoTranslateState();
      setTimeout(() => scan(document), 150);
    });
    window.addEventListener('hashchange', () => {
      resetAutoTranslateState();
      setTimeout(() => scan(document), 150);
    });
  }

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