您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Shows timer for next chapter when 2+ chapters are available
// ==UserScript== // @name Comick Chapter Timer // @namespace https://github.com/GooglyBlox // @version 1.0 // @description Shows timer for next chapter when 2+ chapters are available // @author GooglyBlox // @match https://comick.io/* // @grant none // @license MIT // ==/UserScript== (function () { 'use strict'; const API_BASE = 'https://api.comick.fun'; let currentURL = location.href; let pageObserver = null; let bodyObserver = null; let processedSlugs = new Set(); const elementProcessed = new WeakSet(); const elementTimers = new Map(); const slugControllers = new Map(); const debounce = (fn, wait = 200) => { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), wait); }; }; const scheduleLight = (fn) => { if ('requestIdleCallback' in window) { requestIdleCallback(fn, { timeout: 500 }); } else { requestAnimationFrame(fn); } }; (function () { const wrap = (type) => { const orig = history[type]; return function () { const res = orig.apply(this, arguments); window.dispatchEvent(new Event('spa:navigation')); return res; }; }; history.pushState = wrap('pushState'); history.replaceState = wrap('replaceState'); window.addEventListener('popstate', () => window.dispatchEvent(new Event('spa:navigation'))); })(); function cancelInFlight() { for (const [, controller] of slugControllers) { try { controller.abort(); } catch {} } slugControllers.clear(); for (const [, data] of elementTimers) { if (data && data.intervalId) clearInterval(data.intervalId); } elementTimers.clear(); } function onRouteChange() { const newURL = location.href; if (newURL === currentURL) return; currentURL = newURL; cancelInFlight(); processedSlugs = new Set(); teardownObservers(); scheduleLight(() => { observePageRoot(); debouncedScan(); }); } window.addEventListener('spa:navigation', onRouteChange); setInterval(() => { if (location.href !== currentURL) onRouteChange(); }, 800); function extractSlugFromHref(href) { if (!href) return null; const m = href.match(/\/comic\/([^\/\?#]+)/); return m ? m[1] : null; } function extractCurrentChapter(element) { const spans = element.querySelectorAll('span'); for (const span of spans) { const t = span.textContent || ''; if (t.includes('Current')) { const m = t.match(/Current\s+(\d+)/); if (m) return parseInt(m[1], 10); } } return null; } async function getComicData(slug, signal) { try { const res = await fetch(`${API_BASE}/comic/${slug}?tachiyomi=true`, { signal }); if (!res.ok) return null; return await res.json(); } catch { return null; } } async function getChapters(hid, signal) { try { const res = await fetch(`${API_BASE}/comic/${hid}/chapters?lang=en&chap-order=1&limit=300`, { signal }); if (!res.ok) return null; return await res.json(); } catch { return null; } } function deduplicateChapters(chapters) { const map = new Map(); for (const c of chapters) { const num = parseFloat(c.chap); if (Number.isNaN(num)) continue; const ex = map.get(num); if (!ex) { map.set(num, c); } else { const exTime = ex.publish_at ? new Date(ex.publish_at) : new Date(0); const curTime = c.publish_at ? new Date(c.publish_at) : new Date(0); if (curTime > exTime) map.set(num, c); } } return Array.from(map.values()); } function findNextUnpublishedChapter(chapters) { const now = new Date(); const unique = deduplicateChapters(chapters); const upcoming = unique.filter(c => c.publish_at && new Date(c.publish_at) > now); if (upcoming.length === 0) return null; upcoming.sort((a, b) => new Date(a.publish_at) - new Date(b.publish_at)); return upcoming[0]; } function getHighestChapterNumber(chapters) { const unique = deduplicateChapters(chapters); if (unique.length === 0) return 0; const highest = unique.reduce((h, cur) => { const hn = parseFloat(h.chap) || 0; const cn = parseFloat(cur.chap) || 0; return cn > hn ? cur : h; }); return parseFloat(highest.chap) || 0; } function createTimerElement(targetTime, hostElement) { const timerDiv = document.createElement('div'); timerDiv.className = 'mt-3 pr-2'; timerDiv.innerHTML = ` <a class="btn w-full text-center text-xs px-0 border-none" style="pointer-events: none;"> <div class="text-orange-600 dark:text-orange-400"> <p> <span class="hours">00</span><span class="divider">:</span><span class="minutes">00</span><span class="divider">:</span><span class="seconds">00</span> </p> </div> </a> `; const update = () => { const now = new Date(); const diff = targetTime - now; if (diff <= 0) { timerDiv.innerHTML = ` <div class="flex items-center h-8"> <span class="btn w-full text-center text-xs px-0 border-none text-green-600">Available Now</span> </div> `; const data = elementTimers.get(hostElement); if (data && data.intervalId) { clearInterval(data.intervalId); elementTimers.set(hostElement, { ...data, intervalId: null }); } return; } const hours = Math.floor(diff / (1000 * 60 * 60)); const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); const seconds = Math.floor((diff % (1000 * 60)) / 1000); const hEl = timerDiv.querySelector('.hours'); const mEl = timerDiv.querySelector('.minutes'); const sEl = timerDiv.querySelector('.seconds'); if (hEl) hEl.textContent = String(hours).padStart(2, '0'); if (mEl) mEl.textContent = String(minutes).padStart(2, '0'); if (sEl) sEl.textContent = String(seconds).padStart(2, '0'); }; update(); const id = setInterval(update, 1000); elementTimers.set(hostElement, { intervalId: id, node: timerDiv }); return timerDiv; } function clearTimerForElement(el) { const data = elementTimers.get(el); if (!data) return; if (data.intervalId) clearInterval(data.intervalId); elementTimers.delete(el); } function trackElementRemoval(root) { if (bodyObserver) return; bodyObserver = new MutationObserver((mutations) => { for (const m of mutations) { m.removedNodes && m.removedNodes.forEach((n) => { if (!(n instanceof Element)) return; if (elementTimers.has(n)) clearTimerForElement(n); for (const [el] of elementTimers) { if (!root.contains(el)) clearTimerForElement(el); } }); } }); bodyObserver.observe(document.body, { childList: true, subtree: true }); } async function processComicElement(element) { try { if (elementProcessed.has(element)) return; elementProcessed.add(element); const links = element.querySelectorAll('a[href*="/comic/"]'); if (links.length === 0) return; let comicLink = null; for (const link of links) { if (!/\/chapter\//.test(link.href)) { comicLink = link; break; } } if (!comicLink) return; const slug = extractSlugFromHref(comicLink.href); if (!slug) return; const currentChapter = extractCurrentChapter(element); if (currentChapter == null) return; if (processedSlugs.has(slug)) return; processedSlugs.add(slug); const controller = new AbortController(); slugControllers.set(slug, controller); const comicData = await getComicData(slug, controller.signal); if (!comicData || !comicData.comic) { slugControllers.delete(slug); return; } const chaptersData = await getChapters(comicData.comic.hid, controller.signal); slugControllers.delete(slug); if (!chaptersData || !chaptersData.chapters) return; const highest = getHighestChapterNumber(chaptersData.chapters); const diff = highest - currentChapter; if (diff >= 2) { const nextUnpublished = findNextUnpublishedChapter(chaptersData.chapters); if (nextUnpublished) { const publishTime = new Date(nextUnpublished.publish_at); const existing = element.querySelector('.mt-3.pr-2'); if (existing) { clearTimerForElement(element); const timer = createTimerElement(publishTime, element); existing.replaceWith(timer); } else { clearTimerForElement(element); const timer = createTimerElement(publishTime, element); element.appendChild(timer); } } } } catch {} } function collectCandidateCards(root = document) { const set = new Set(); root.querySelectorAll('div[style*="translateX"]').forEach((el) => set.add(el)); root.querySelectorAll('a[href*="/comic/"]').forEach((a) => { const card = a.closest('div'); if (card) set.add(card); }); root.querySelectorAll('span').forEach((s) => { const t = s.textContent || ''; if (t.includes('Current')) { const card = s.closest('div'); if (card) set.add(card); } }); return Array.from(set); } function scanForComics() { const cards = collectCandidateCards(document); for (const card of cards) { let hasCurrent = false; for (const span of card.querySelectorAll('span')) { const t = span.textContent || ''; if (t.includes('Current')) { hasCurrent = true; break; } } if (hasCurrent) processComicElement(card); } } const debouncedScan = debounce(() => scheduleLight(scanForComics), 150); function getPageRoot() { const candidates = [ document.querySelector('main#main'), document.querySelector('main'), document.getElementById('__next'), document.body, ]; return candidates.find(Boolean) || document.body; } function observePageRoot() { const root = getPageRoot(); if (pageObserver) { try { pageObserver.disconnect(); } catch {} } pageObserver = new MutationObserver((mutations) => { const relevant = mutations.some((m) => Array.from(m.addedNodes).some((n) => { if (!(n instanceof Element)) return false; if (n.matches('div[style*="translateX"]')) return true; if (n.querySelector && n.querySelector('div[style*="translateX"]')) return true; if (n.querySelector && n.querySelector('a[href*="/comic/"]')) return true; if ((n.textContent || '').includes('Current')) return true; return false; }) ); if (relevant) debouncedScan(); }); pageObserver.observe(root, { childList: true, subtree: true }); trackElementRemoval(root); } function teardownObservers() { if (pageObserver) { try { pageObserver.disconnect(); } catch {} pageObserver = null; } } document.addEventListener('visibilitychange', () => { if (document.hidden) { for (const [, data] of elementTimers) { if (data && data.intervalId) clearInterval(data.intervalId); } for (const [el, data] of elementTimers) { elementTimers.set(el, { ...data, intervalId: null }); } } else { debouncedScan(); } }); if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { observePageRoot(); debouncedScan(); }); } else { observePageRoot(); debouncedScan(); } setInterval(() => debouncedScan(), 30000); })();