自动主题切换

根据用户设定时间段, 自动切换已适配网站的黑白主题

// ==UserScript==
// @name         自动主题切换
// @namespace    airbash/Rocy-June/AutoDarkMode
// @homepage     https://github.com/AirBashX/UserScript
// @version      25.04.24.01
// @description  根据用户设定时间段, 自动切换已适配网站的黑白主题
// @author       airbash / Rocy-June
// @match        *://*/*
// @icon
// @run-at       document-body
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @license      GPL-3.0
// ==/UserScript==

(function () {
  "use strict";

  const debug_mode = false;
  let debug_force_toggle = false;

  // 日志函数
  const log = console.log.bind(console, "[AutoDarkMode Script]");
  const warn = console.warn.bind(console, "[AutoDarkMode Script]");
  const error = console.error.bind(console, "[AutoDarkMode Script]");
  const debug = (() =>
    debug_mode
      ? console.error.bind(console, "[AutoDarkMode Script] [Debug Mode]")
      : () => {})();

  debug("Debug mode is on");

  // 失败检测计数器
  let fail_count = 0;
  // 失败检查时间间隔, 单位: 毫秒
  let fail_check_time = 10000;

  const sim_events = {
    pointer_down: () =>
      new PointerEvent("pointerdown", {
        bubbles: true,
        cancelable: true,
        pointerType: "mouse",
      }),
    key: (is_down, key, modifiers = {}) => {
      const key_codes = {
        control: "ControlLeft",
        alt: "AltLeft",
        shift: "ShiftLeft",
        meta: "MetaLeft",
        enter: "Enter",
      };

      const key_code =
        key_codes[key.toLowerCase()] || `Key${key.toUpperCase()}`;

      const {
        ctrl = false,
        meta = false,
        shift = false,
        alt = false,
        bubbles = true,
        cancelable = true,
      } = modifiers;

      return new KeyboardEvent(is_down ? "keydown" : "keyup", {
        key: key.toUpperCase(),
        code: key_code,
        ctrlKey: ctrl,
        metaKey: meta,
        shiftKey: shift,
        altKey: alt,
        bubbles,
        cancelable,
      });
    },
  };

  log("脚本开始运行");

  // 设置对象
  const settings = {
    light_time: "08:00",
    dark_time: "18:00",
    light_menu_id: null,
    dark_menu_id: null,
    debug_toggle_id: null,
  };

  // 初始化设置
  initSettings();

  // 所有适配的站点
  /* 
    {
      "domain (站点域名匹配, 防止后续匹配项过多, 提高加载速度)": [{
        url: 匹配站点正则,
        check: 检查当前主题函数(返回 "dark" 或 "light"),
        toggle: 切换主题函数(可选, 为 null 时 toLight 和 toDark 函数会被调用),
        toLight: 切换到明亮主题函数(可选, toggle 为 null 时必需设定),
        toDark: 切换到黑夜主题函数(可选, toggle 为 null 时必需设定),
        fastCheckTime: 未成功检查 / 切换主题前的检查主题间隔时间(可选, 为 null 时默认 1 秒),
        afterCheckTime: 无需切换或切换成功后, 再次检查主题间隔时间(可选, 为 null 时默认 10 秒),
      }]
    }

    tip: 
      如果新增站点有新的顶级域名, 
      记得同步增加到 sites 下方的 domain_suffixes  
  */
  const fastCheckDefaultTime = debug_mode ? 3000 : 200;
  const afterCheckDefaultTime = debug_mode ? 3600000 : 10000;
  const sites = {
    // Pixiv
    "pixiv.net": [
      {
        url: /^https?:\/\/.*?pixiv\.net.*/,
        check: () =>
          html().getAttribute("data-theme") === "dark" ? "dark" : "light",
        toLight: () => {
          $single(".gtm-darkmode-toggle-on-user-menu-to-light").click();
        },
        toDark: () => {
          $single(".gtm-darkmode-toggle-on-user-menu-to-dark").click();
        },
      },
    ],
    // 风车动漫
    "fengchedmp.com": [
      {
        url: /^https?:\/\/.*?fengchedmp\.com.*/,
        check: () =>
          $single("#cssFile").getAttribute("href").includes("black")
            ? "dark"
            : "light",
        toLight: () => {
          $single("i.icon-rijian").click();
        },
        toDark: () => {
          $single("i.icon-yejian").click();
        },
      },
    ],
    // Wikipedia
    "wikipedia.org": [
      {
        url: /^https?:\/\/.*?wikipedia\.org.*/,
        check: () =>
          html().classList.contains("skin-theme-clientpref-night")
            ? "dark"
            : "light",
        toLight: () => {
          $single("#skin-client-pref-skin-theme-value-day").click();
        },
        toDark: () => {
          $single("#skin-client-pref-skin-theme-value-night").click();
        },
      },
    ],
    // ChatGPT
    "chatgpt.com": [
      {
        url: /^https?:\/\/.*?chatgpt\.com.*/,
        check: () => (html().classList.contains("dark") ? "dark" : "light"),
        toLight: async () => {
          $single(
            "#conversation-header-actions>button:last-child"
          ).dispatchEvent(sim_events.pointer_down());
          (
            await $singleAsync("div[data-radix-popper-content-wrapper]")
          ).style.opacity = 0;

          $single("div[data-testid=settings-menu-item]").click();
          (
            await $singleAsync("div[data-testid=modal-settings]")
          ).style.opacity = 0;

          $single(
            "div.flex.items-center.justify-between button[role=combobox]"
          ).dispatchEvent(sim_events.pointer_down());
          (
            await $singleAsync("div[data-radix-popper-content-wrapper]")
          ).style.opacity = 0;

          $single(
            "div[data-radix-select-viewport] div[role=option]:nth-child(3)"
          ).click();
          $single("button[data-testid=close-button]").click();
        },
        toDark: async () => {
          $single(
            "#conversation-header-actions>button:last-child"
          ).dispatchEvent(sim_events.pointer_down());
          (
            await $singleAsync("div[data-radix-popper-content-wrapper]")
          ).style.opacity = 0;

          $single("div[data-testid=settings-menu-item]").click();
          (
            await $singleAsync("div[data-testid=modal-settings]")
          ).style.opacity = 0;

          $single(
            "div.flex.items-center.justify-between button[role=combobox]"
          ).dispatchEvent(sim_events.pointer_down());
          (
            await $singleAsync("div[data-radix-popper-content-wrapper]")
          ).style.opacity = 0;

          $single(
            "div[data-radix-select-viewport] div[role=option]:nth-child(2)"
          ).click();
          $single("button[data-testid=close-button]").click();
        },
      },
    ],
    // GitHub
    "github.com": [
      {
        url: /^https?:\/\/.*?github\.com.*/,
        check: () =>
          html().getAttribute("data-color-mode") === "dark" ? "dark" : "light",
        toLight: async () => {
          document.body.dispatchEvent(
            sim_events.key(true, "k", { ctrl: true, shift: true })
          );
          const command_container = $single("#command-palette-pjax-container");
          command_container.style.opacity = 0;

          const command_palette = $single(
            "input[aria-controls=command-palette-page-stack]"
          );
          command_palette.value = ">switch theme";

          (
            await $singleAsync("div[role=listbox]>command-palette-item>span")
          ).click();

          await waitForAsync(() => {
            return $single("svg[aria-label=Loading]").parentNode.hasAttribute(
              "hidden"
            );
          });

          command_palette.value = "default light";

          (
            await $selectSingleAsync(
              "div[role=listbox]>command-palette-item>span",
              (e) => {
                return e.innerText.toLowerCase().includes("default light");
              }
            )
          ).click();
          command_container.style.opacity = 1;
        },
        toDark: async () => {
          document.body.dispatchEvent(
            sim_events.key(true, "k", { ctrl: true, shift: true })
          );
          const command_container = $single("#command-palette-pjax-container");
          command_container.style.opacity = 0;

          const command_palette = $single(
            "input[aria-controls=command-palette-page-stack]"
          );
          command_palette.value = ">switch theme";

          (
            await $singleAsync("div[role=listbox]>command-palette-item>span")
          ).click();

          await waitForAsync(() => {
            return $single("svg[aria-label=Loading]").parentNode.hasAttribute(
              "hidden"
            );
          });

          command_palette.value = "default dark";

          (
            await $selectSingleAsync(
              "div[role=listbox]>command-palette-item>span",
              (e) => {
                return e.innerText.toLowerCase().includes("default dark");
              }
            )
          ).click();
          command_container.style.opacity = 1;
        },
      },
    ],
  };

  const domain_suffixes = [
    ".com.cn",
    ".com",
    ".org",
    ".net",
    ".edu",
    ".gov",
    ".cn",
  ];

  // 匹配到的域名设置
  const no_www_domain = getRootDomain(location.hostname);

  const domain_setting = sites[no_www_domain];
  if (!domain_setting) {
    log(`未找到适配的站点: ${no_www_domain}`);
    return;
  }

  // 匹配到的网站设置
  const site_setting = domain_setting.find((s) => s.url.test(location.href));
  if (!site_setting) {
    log(`未找到当前页面的适配操作`);
    return;
  }

  // 加载完成后开始检查主题
  addEventListener("load", async () => {
    const timer = new Timer(
      checkFunc,
      site_setting.afterCheckTime || afterCheckDefaultTime
    );

    await checkFunc();

    timer.start();

    async function checkFunc() {
      try {
        await checkTheme();
        fail_count = 0;
        timer.delay = site_setting.afterCheckTime || afterCheckDefaultTime;
        log("检查/操作完成, 切换到慢速模式");
      } catch (ex) {
        if (debug_mode) {
          debug("检查/操作失败", ex);
          return;
        }

        fail_count++;
        if (
          fail_count >=
          fail_check_time / (site_setting.fastCheckTime || fastCheckDefaultTime)
        ) {
          timer.delay = site_setting.afterCheckTime || afterCheckDefaultTime;
          error("失败超出时长限制, 切换到慢速模式");
          return;
        }

        timer.delay = site_setting.fastCheckTime || fastCheckDefaultTime;
        error("检查/操作失败, 切换到高速模式", ex);
      }
    }
  });

  // 查找元素, 用法类似于 jQuery
  function $single(selector) {
    return document.querySelector(selector);
  }

  /*
    类似 jQuery 的元素查找函数

    适用于连续操作中可能影响用户操作体验的场景, 该方式会尽可能在渲染的同一帧内找到该 DOM 元素,
    以缩短元素变更对用户交互的干扰时间 (如弹窗在显示帧立即点击关闭按钮)
    
    与 singleTimerAsync 不同的是, 本函数每一帧都会进行查询, 因此可能对性能造成一定影响
    如果操作不会显著影响用户体验, 则推荐使用 singleTimerAsync
  */
  async function $singleAsync(selector, timeout = 1000) {
    const start = Date.now();
    while (Date.now() - start < timeout) {
      const ele = $single(selector);
      if (ele) {
        return ele;
      }

      await nextFrame();
    }

    throw new Error("Timeout");
  }

  /*
    类似 jQuery 的元素查找函数
    
    适用于不会改变页面结构、或不影响用户操作体验的场景
    使用自定义 Timer 以固定间隔查询元素, 性能开销较小, 但查找速度相对较慢

    与 singleAsync 不同的是, 该函数通过节流方式降低性能消耗,
    更适合异步检查某些静态区域元素是否已加载完成等情况
  */
  function $singleTimerAsync(selector, interval = 100, timeout = 1000) {
    return promise(async (resolve, reject) => {
      const start = Date.now();
      const timer = new Timer(() => {
        const ele = $single(selector);
        if (ele) {
          resolve(ele);
          timer.stop();
          return;
        }
        if (Date.now() - start >= timeout) {
          reject(new Error("Timeout"));
          timer.stop();
          return;
        }
      }, interval);
      timer.start(true);
    });
  }

  // 用于查找无法单纯使用 CSS 选择器的复杂元素
  async function $selectSingleAsync(selector, filter, timeout = 1000) {
    const start = Date.now();
    while (Date.now() - start < timeout) {
      const eles = $all(selector);
      if (eles.length) {
        var selected = Array.from(eles).filter(filter);
        if (selected.length) {
          return selected[0];
        }
      }

      await nextFrame();
    }

    throw new Error("Timeout");
  }

  function $all(selector) {
    return document.querySelectorAll(selector);
  }

  function html() {
    return document.documentElement;
  }

  // 初始化设置
  function initSettings() {
    log("初始化设置");

    settings.light_time = GM_getValue("light_time", settings.light_time);
    settings.dark_time = GM_getValue("dark_time", settings.dark_time);

    refreshMenuCommand();
  }

  // 获取根域名
  function getRootDomain(domain) {
    for (const suffix of domain_suffixes) {
      if (!domain.endsWith(suffix)) {
        continue;
      }

      const index = domain.lastIndexOf(".", suffix.length);
      return domain.substring(index + 1);
    }
  }

  // 检查当前时间是否需要切换主题
  async function checkTheme() {
    log("开始检查主题");

    const light_minutes = timeToMinutes(settings.light_time);
    const dark_minutes = timeToMinutes(settings.dark_time);

    let now = nowMinutes();

    const current_theme = site_setting.check();
    log(`当前主题:${current_theme}`);

    if (debug_mode && debug_force_toggle) {
      debug("强制切换主题");
      if (
        now >= light_minutes &&
        now < dark_minutes &&
        current_theme === "light"
      ) {
        now = dark_minutes;
        debug("将当前时间设置为黑夜时间");
      } else if (
        (now >= dark_minutes || now < light_minutes) &&
        current_theme === "dark"
      ) {
        now = light_minutes;
        debug("将当前时间设置为明亮时间");
      }
    }

    if (now >= light_minutes && now < dark_minutes) {
      if (current_theme === "light") {
        log("当前时间为明亮主题时间, 无需切换");
        return;
      }
      if (site_setting.toggle) {
        log("切换到明亮主题 - toggle");
        await site_setting.toggle();
        log("切换完成");
        return;
      }
      if (site_setting.toLight) {
        log("切换到明亮主题 - toLight");
        await site_setting.toLight();
        log("切换完成");
        return;
      }

      error("切换到明亮主题 - 未指定切换函数");
    } else {
      if (current_theme === "dark") {
        log("当前时间为黑夜主题时间, 无需切换");
        return;
      }
      if (site_setting.toggle) {
        log("切换到黑夜主题 - toggle");
        await site_setting.toggle();
        log("切换完成");
        return;
      }
      if (site_setting.toDark) {
        log("切换到黑夜主题 - toDark");
        await site_setting.toDark();
        log("切换完成");
        return;
      }

      error("切换到黑夜主题 - 未指定切换函数");
    }
  }

  // 刷新菜单
  function refreshMenuCommand() {
    log("刷新脚本菜单");

    if (settings.light_menu_id) {
      GM_unregisterMenuCommand(settings.light_menu_id);
    }
    if (settings.dark_menu_id) {
      GM_unregisterMenuCommand(settings.dark_menu_id);
    }
    if (settings.debug_toggle_id) {
      GM_unregisterMenuCommand(settings.debug_toggle_id);
    }

    settings.light_menu_id = GM_registerMenuCommand(
      `设置明亮时间 (${settings.light_time})`,
      () => setTimePrompt("light_time", "明亮时间")
    );
    settings.dark_menu_id = GM_registerMenuCommand(
      `设置黑夜时间 (${settings.dark_time})`,
      () => setTimePrompt("dark_time", "黑夜时间")
    );
    if (debug_mode) {
      settings.debug_toggle_id = GM_registerMenuCommand(
        `调试模式: 强制切换主题`,
        async () => {
          debug_force_toggle = true;
          await checkTheme();
        }
      );
    }
  }

  // 设置时间提示框
  function setTimePrompt(key, label) {
    log(`设置${label}提示框`);

    const old_val = GM_getValue(key, settings[key]);

    log(`旧${label}:${old_val}`);

    const new_val = prompt(`设置${label}(格式 HH:mm):`, old_val);
    log(`用户输入: ${new_val}`);
    if (!new_val) {
      return;
    }
    if (
      !new_val ||
      !/^(?:[0-9]|1[0-9]|2[0-3])[::](?:[0-9]|[0-5][0-9])/.test(new_val)
    ) {
      alert('格式不正确, 时间格式为 "08:00"');
      return;
    }
    const standard_new_val = new_val.replace(":", ":");

    log(`新${label}:${standard_new_val}`);

    const tmp = standard_new_val;
    settings[key] = standard_new_val;
    if (
      timeToMinutes(settings.light_time) > timeToMinutes(settings.dark_time)
    ) {
      log("黑夜时间不能早于明亮时间");

      settings[key] = tmp;
      alert("黑夜时间不能早于明亮时间");
      return;
    }

    GM_setValue(key, standard_new_val);

    log(`${label} 已设置为:${standard_new_val}`);

    alert(`${label} 已设置为:${standard_new_val}`);

    refreshMenuCommand();
  }

  // 将时间字符串转为分钟
  function timeToMinutes(time) {
    const [hour, minute] = time.split(":").map(Number);
    return hour * 60 + minute;
  }

  // 获取当前时间的分钟数
  function nowMinutes() {
    const now = new Date();
    return now.getHours() * 60 + now.getMinutes();
  }

  // 将函数加入下一轮事件循环
  function nextTick(func) {
    return Promise.resolve().then(() => {
      func();
    });
  }

  // 等待下一帧
  function nextFrame(func) {
    return new Promise((resolve) => {
      requestAnimationFrame((timestamp) => {
        func?.(timestamp);
        resolve(timestamp);
      });
    });
  }

  // 异步函数返回 Promise
  function promise(func) {
    return new Promise((resolve, reject) => {
      try {
        func(resolve, reject);
      } catch (ex) {
        reject(ex);
      }
    });
  }

  // 等待指定时间
  function waitTimeAsync(time) {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve();
      }, time);
    });
  }

  /*
    等待某个元素达到某个状态

    适用于连续操作中可能影响用户操作体验的场景, 该方式会在渲染的每一帧内检测该 DOM 元素的状态,
    以缩短元素变更对用户交互的干扰时间 (如弹窗在显示帧立即点击关闭按钮)
    
    与 waitForTimerAsync 不同的是, 本函数每一帧都会进行查询, 因此可能对性能造成一定影响
    如果操作不会显著影响用户体验, 则推荐使用 waitForTimerAsync
  */
  async function waitForAsync(detector, timeout = 1000) {
    if (!detector) throw new Error("detector can not be null.");

    const start = Date.now();
    while (Date.now() - start < timeout) {
      if (detector()) {
        return true;
      }

      await nextFrame();
    }

    return false;
  }

  /* 等待某个元素达到某个状态
    
    适用于不会改变页面结构、或不影响用户操作体验的场景
    使用自定义 Timer 以固定间隔查询元素, 性能开销较小, 但查找速度相对较慢

    与 waitForAsync 不同的是, 该函数通过节流方式降低性能消耗,
    更适合异步检查某些静态区域元素是否已加载完成等情况
  */
  function waitForTimerAsync(detector, interval = 100, timeout = 1000) {
    if (!detector) throw new Error("detector can not be null.");

    return promise(async (resolve, reject) => {
      const start = Date.now();
      const timer = new Timer(() => {
        if (detector()) {
          resolve(true);
          timer.stop();
          return;
        }
        if (Date.now() - start >= timeout) {
          reject(new Error("Timeout"));
          timer.stop();
          return;
        }
      }, interval);
      timer.start(true);
    });
  }

  // 自建定时器
  class Timer {
    #timer = null;
    #func = null;
    delay = 0;

    constructor(func, delay) {
      this.#func = func;
      this.delay = delay;
    }

    start(immediate = false) {
      this.stop();

      let start_new = async () => {
        if (immediate) {
          await this.#func();
        }

        this.#timer = setTimeout(async () => {
          await this.#func();
          this.start();
        }, this.delay);
      };
      start_new();
    }

    stop() {
      clearTimeout(this.#timer);
    }
  }
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址