// ==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 // @downloadURL https://update.greasyfork.icu/scripts/532308/%E8%87%AA%E5%8A%A8%E4%B8%BB%E9%A2%98%E5%88%87%E6%8D%A2.user.js // @updateURL https://update.greasyfork.icu/scripts/532308/%E8%87%AA%E5%8A%A8%E4%B8%BB%E9%A2%98%E5%88%87%E6%8D%A2.meta.js // ==/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); } } })();