• 幻雪

    该脚本全程vibe,通过关键词,标签,筛选你关心的话题,命中后浏览器直接弹通知

    弹出通知后点击可以跳转到话题

    图片

    图片```
    // ==UserScript==
    // @name LINUX DO 话题订阅推送
    // @namespace https://linux.do/
    // @version 1.0.0
    // @description 按关键词 / 分类 / 标签订阅 LINUX DO 话题,命中后浏览器通知,点击直达话题。
    // @match https://linux.do/*
    // @run-at document-idle
    // @grant GM_getValue
    // @grant GM_setValue
    // @grant GM_addStyle
    // @grant GM_notification
    // @grant GM_openInTab
    // @noframes
    // ==/UserScript==

    (() => {
    "use strict";

    / ---------------- 存储键与默认值 ---------------- /

    const K = Object.freeze({
    enabled: "ld-push-enabled",
    subscribeAll: "ld-push-subscribe-all",
    onlyNew: "ld-push-only-new",
    matchMode: "ld-push-match-mode",
    keywords: "ld-push-keywords",
    exclude: "ld-push-exclude",
    categories: "ld-push-categories",
    tags: "ld-push-tags",
    interval: "ld-push-interval",
    catchup: "ld-push-catchup",
    seen: "ld-push-seen-ids",
    lastCheck: "ld-push-last-check",
    total: "ld-push-total",
    catCache: "ld-push-cat-cache",
    });

    const DEFAULTS = Object.freeze({
    enabled: true,
    subscribeAll: false,
    onlyNew: true,
    matchMode: "any",
    interval: 90, // 秒
    catchup: 30, // 分钟(离线后最多补推多久内的话题)
    seenCap: 1000,
    burstCap: 5, // 单次检查最多逐条推送数,超出合并为一条摘要
    });

    const TOPIC_URL = (topic) =>
    `https://linux.do/t/${
    topic.slug && /^[\w-]+$/.test(topic.slug) ? topic.slug : "topic"
    }/${topic.id}`;

    / ---------------- 样式 ---------------- /

    GM_addStyle(`
    #ld-push-root {
    --ldp-accent: #3b7ddd;
    --ldp-bg: #fbfcfd;
    --ldp-panel: #f0f3f5;
    --ldp-text: #26343b;
    --ldp-muted: #66757d;
    --ldp-border: rgb(42 59 64 / 14%);
    position: fixed; z-index: 1300;
    right: max(20px, env(safe-area-inset-right));
    bottom: max(84px, calc(env(safe-area-inset-bottom) + 64px));
    font-family: "Source Han Sans SC","Noto Sans CJK SC","Microsoft YaHei UI",sans-serif;
    color: var(--ldp-text); font-size: 13px;
    }
    @media (prefers-color-scheme: dark) {
    #ld-push-root {
    --ldp-bg: #182229; --ldp-panel: #223038;
    --ldp-text: #eef3f5; --ldp-muted: #a7b6bd;
    --ldp-border: rgb(220 233 237 / 16%);
    }
    }
    #ld-push-root .ldp__trigger {
    width: 50px; height: 50px; border-radius: 15px; cursor: pointer;
    border: 1px solid var(--ldp-border); background: var(--ldp-bg);
    color: var(--ldp-accent); font-size: 21px;
    display: grid; place-items: center; position: relative;
    box-shadow: 0 14px 38px rgb(15 25 30 / 16%);
    transition: transform .15s ease, box-shadow .15s ease;
    }
    #ld-push-root .ldp__trigger:hover { transform: translateY(-2px); }
    #ld-push-root .ldp__dot {
    position: absolute; top: 7px; right: 7px; width: 9px; height: 9px;
    border-radius: 50%; background: #36b37e;
    box-shadow: 0 0 0 2px var(--ldp-bg);
    }
    #ld-push-root .ldp__panel {
    position: absolute; right: 0; bottom: 60px;
    width: min(360px, calc(100vw - 24px));
    max-height: min(640px, calc(100vh - 40px));
    overflow-y: auto; overscroll-behavior: contain;
    border: 1px solid var(--ldp-border); border-radius: 20px;
    background: var(--ldp-bg); padding: 12px;
    box-shadow: 0 24px 64px rgb(15 25 30 / 20%);
    display: grid; gap: 10px;
    }
    #ld-push-root .ldp__panel[hidden] { display: none; }
    #ld-push-root header {
    display: flex; justify-content: space-between; align-items: center;
    padding-bottom: 8px; border-bottom: 1px solid var(--ldp-border);
    }
    #ld-push-root header strong { font-size: 15px; display: block; }
    #ld-push-root header span { color: var(--ldp-muted); font-size: 11px; }
    #ld-push-root .ldp__close {
    width: 30px; height: 30px; border: 0; border-radius: 10px; cursor: pointer;
    background: transparent; color: var(--ldp-muted); font-size: 20px;
    }
    #ld-push-root .ldp__close:hover { background: var(--ldp-panel); color: var(--ldp-text); }
    #ld-push-root .ldp__section {
    display: grid; gap: 8px; padding: 10px;
    border: 1px solid var(--ldp-border); border-radius: 14px;
    background: color-mix(in srgb, var(--ldp-panel) 55%, transparent);
    }
    #ld-push-root .ldp__title { font-weight: 600; font-size: 12px; color: var(--ldp-muted); }
    #ld-push-root .ldp__row {
    display: flex; justify-content: space-between; align-items: center; gap: 12px;
    }
    #ld-push-root .ldp__row small { display: block; color: var(--ldp-muted); font-size: 11px; margin-top: 2px; }
    #ld-push-root input[type="checkbox"] { width: 17px; height: 17px; accent-color: var(--ldp-accent); flex: 0 0 auto; }
    #ld-push-root .ldp__field { display: grid; gap: 5px; }
    #ld-push-root .ldp__field > span { font-size: 12px; color: var(--ldp-muted); display: flex; justify-content: space-between; align-items: center; }
    #ld-push-root textarea, #ld-push-root input[type="text"],
    #ld-push-root input[type="number"], #ld-push-root select {
    width: 100%; box-sizing: border-box; padding: 7px 9px;
    border: 1px solid var(--ldp-border); border-radius: 10px;
    background: var(--ldp-bg); color: var(--ldp-text); font: inherit; resize: vertical;
    }
    #ld-push-root input[type="range"] { width: 100%; accent-color: var(--ldp-accent); }
    #ld-push-root .ldp__cats {
    display: grid; grid-template-columns: 1fr 1fr; gap: 4px 8px;
    max-height: 150px; overflow-y: auto; padding: 6px;
    border: 1px solid var(--ldp-border); border-radius: 10px; background: var(--ldp-bg);
    }
    #ld-push-root .ldp__cats label { display: flex; gap: 6px; align-items: center; font-size: 12px; cursor: pointer; }
    #ld-push-root .ldp__cats input { width: 14px; height: 14px; }
    #ld-push-root .ldp__mini {
    border: 1px solid var(--ldp-border); background: var(--ldp-panel); color: var(--ldp-text);
    border-radius: 8px; padding: 2px 8px; font-size: 11px; cursor: pointer;
    }
    #ld-push-root .ldp__actions { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 7px; }
    #ld-push-root .ldp__actions button {
    min-height: 34px; border: 1px solid var(--ldp-border); border-radius: 11px;
    background: var(--ldp-panel); color: var(--ldp-text); cursor: pointer; font: inherit;
    }
    #ld-push-root .ldp__actions button:first-child { background: var(--ldp-accent); border-color: transparent; color: #fff; }
    #ld-push-root .ldp__actions button:hover { filter: brightness(1.06); }
    #ld-push-root .ldp__status {
    display: block; min-height: 16px; font-size: 11px; line-height: 1.5; color: var(--ldp-muted);
    }
    #ld-push-root .ldp__status[data-kind="error"] { color: #c0392b; }
    #ld-push-root .ldp__status[data-kind="ok"] { color: #2e8b57; }
    #ld-push-root .ldp__hint { margin: 0; font-size: 11px; color: var(--ldp-muted); }
    `);

    / ---------------- 工具函数 ---------------- /

    const bool = (v, d) => (typeof v === "boolean" ? v : d);
    const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
    const parseList = (v) =>
    String(v ?? "")
    .split(/[\n,,、;;]+/)
    .map((s) => s.trim().toLowerCase())
    .filter(Boolean);
    const idArray = (v) =>
    (Array.isArray(v) ? v : [])
    .map((n) => Number(n))
    .filter((n) => Number.isInteger(n) && n > 0);
    const escapeHtml = (s) =>
    String(s).replace(/[&<>"']/g, (c) => ({
    "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
    }[c]));
    const timeStr = (ts) => new Date(ts).toLocaleTimeString("zh-CN", { hour12: false });

    function loadSettings() {
    return {
    enabled: bool(GM_getValue(K.enabled), DEFAULTS.enabled),
    subscribeAll: bool(GM_getValue(K.subscribeAll), DEFAULTS.subscribeAll),
    onlyNew: bool(GM_getValue(K.onlyNew), DEFAULTS.onlyNew),
    matchMode: GM_getValue(K.matchMode) === "all" ? "all" : "any",
    keywordsRaw: String(GM_getValue(K.keywords, "")),
    keywords: parseList(GM_getValue(K.keywords, "")),
    excludeRaw: String(GM_getValue(K.exclude, "")),
    exclude: parseList(GM_getValue(K.exclude, "")),
    categoryIds: idArray(GM_getValue(K.categories, [])),
    tagsRaw: String(GM_getValue(K.tags, "")),
    tags: parseList(GM_getValue(K.tags, "")),
    intervalSec: clamp(Number(GM_getValue(K.interval, DEFAULTS.interval)) || DEFAULTS.interval, 30, 300),
    catchupMin: clamp(Number(GM_getValue(K.catchup, DEFAULTS.catchup)) || DEFAULTS.catchup, 1, 240),
    };
    }

    function getCatCache() {
    const c = GM_getValue(K.catCache, null);
    return c && Array.isArray(c.list) ? c : { ts: 0, list: [] };
    }

    async function fetchCategories() {
    const res = await fetch("/categories.json?include_subcategories=true", {
    credentials: "same-origin",
    headers: { accept: "application/json" },
    });
    if (!res.ok) throw new Error(分类获取失败:HTTP ${res.status});
    const data = await res.json();
    const list = [];
    for (const c of data?.category_list?.categories ?? []) {
    list.push({ id: c.id, name: String(c.name ?? c.id), parent: null });
    for (const s of c.subcategory_list ?? []) {
    list.push({ id: s.id, name: ${c.name} / ${s.name}, parent: c.id });
    }
    }
    const cache = { ts: Date.now(), list };
    GM_setValue(K.catCache, cache);
    return cache;
    }

    function expandCategoryIds(selected, cache) {
    const set = new Set(selected);
    for (const c of cache.list) {
    if (c.parent && set.has(c.parent)) set.add(c.id); // 选中父分类 ⇒ 含子分类
    }
    return set;
    }

    function topicMatches(topic, s, catIdSet) {
    const title = String(topic.title ?? "").toLowerCase();
    if (s.exclude.some((k) => title.includes(k))) return false;
    if (s.subscribeAll) return true;
    const checks = [];
    if (s.keywords.length) checks.push(s.keywords.some((k) => title.includes(k)));
    if (s.categoryIds.length) checks.push(catIdSet.has(topic.category_id));
    if (s.tags.length) {
    const tags = Array.isArray(topic.tags)
    ? topic.tags.map((t) => String(t).toLowerCase())
    : [];
    checks.push(s.tags.some((t) => tags.includes(t)));
    }
    if (!checks.length) return false; // 未配置任何筛选 ⇒ 不推送,防安装即轰炸
    return s.matchMode === "all" ? checks.every(Boolean) : checks.some(Boolean);
    }

    / ---------------- 通知 ---------------- /

    const hasGM = typeof GM_notification === "function";

    function openTopic(url) {
    try {
    if (typeof GM_openInTab === "function") {
    GM_openInTab(url, { active: true, insert: true });
    return;
    }
    } catch { / fall through / }
    window.open(url, "_blank", "noopener");
    }

    function ensureWebPermission() {
    if (hasGM || !("Notification" in window)) return;
    if (Notification.permission === "default") {
    Notification.requestPermission().catch(() => {});
    }
    }

    function pushNotify({ title, body, icon, url, tag }) {
    if (hasGM) {
    try {
    GM_notification({
    title, text: body, image: icon, tag, timeout: 10000,
    onclick: () => openTopic(url),
    });
    return;
    } catch { / fall through to web notification / }
    }
    if (!("Notification" in window) || Notification.permission !== "granted") return;
    const n = new Notification(title, { body, icon, tag });
    n.onclick = () => { window.focus(); openTopic(url); n.close(); };
    }

    function absolutize(path) {
    if (!path) return null;
    if (path.startsWith("http")) return path;
    if (path.startsWith("//")) return "https:" + path;
    return "https://linux.do&quot; + path;
    }

    / ---------------- 状态显示 ---------------- /

    let statusEl = null;
    function setStatus(msg, kind = "info") {
    if (!statusEl) return;
    statusEl.textContent = msg;
    statusEl.dataset.kind = kind;
    }

    / ---------------- 核心检查逻辑 ---------------- /

    async function checkNow(manual = false) {
    const s = loadSettings();
    const startedAt = Date.now();

    const res = await fetch("/latest.json", {
    credentials: "same-origin",
    headers: { accept: "application/json" },
    });
    if (res.status === 429 || res.status === 403) {
    throw new Error(被限流(HTTP ${res.status}),已自动降速);
    }
    if (!res.ok) throw new Error(请求失败:HTTP ${res.status});
    const data = await res.json();

    // 多标签页护栏:另一个页面刚检查过则跳过
    const lastCheckBefore = Number(GM_getValue(K.lastCheck, 0)) || 0;
    if (!manual && lastCheckBefore >= startedAt - 500) {
    return { skipped: true };
    }

    const topics = data?.topic_list?.topics ?? [];
    const users = new Map((data?.users ?? []).map((u) => [u.id, u]));

    // 首次运行:静默播种,避免存量话题轰炸
    if (!lastCheckBefore) {
    const seenSeed = topics.map((t) => t.id).slice(-DEFAULTS.seenCap);
    GM_setValue(K.seen, seenSeed);
    GM_setValue(K.lastCheck, Date.now());
    return { seeded: true, count: topics.length };
    }

    const cache = getCatCache();
    const catIdSet = expandCategoryIds(s.categoryIds, cache);
    const catName = new Map(cache.list.map((c) => [c.id, c.name]));

    // 读取最新 seen(多标签页合并)后再判定
    const seen = new Set(idArray(GM_getValue(K.seen, [])));
    const cutoff = Math.max(lastCheckBefore, startedAt - s.catchupMin * 60000);
    const hits = [];

    for (const t of topics) {
    if (seen.has(t.id)) continue;
    seen.add(t.id);
    const created = Date.parse(t.created_at ?? 0) || 0;
    const bumped = Date.parse(t.bumped_at ?? t.created_at ?? 0) || 0;
    const inWindow = s.onlyNew ? created > cutoff : bumped > cutoff;
    if (!inWindow) continue;
    if (!topicMatches(t, s, catIdSet)) continue;
    hits.push(t);
    }

    // 先写存储(占坑),再发通知,避免并发重复
    GM_setValue(K.seen, [...seen].slice(-DEFAULTS.seenCap));
    GM_setValue(K.lastCheck, Date.now());

    for (const t of hits.slice(0, DEFAULTS.burstCap)) {
    const posterId = t.posters?.[0]?.user_id;
    const avatar = absolutize(
    users.get(posterId)?.avatar_template?.replace("{size}", "96"),
    );
    const cat = catName.get(t.category_id);
    pushNotify({
    title: t.title || 话题 #${t.id},
    body: ${cat ? 【${cat}】 : ""}${t.posts_count != null ? ${t.posts_count} 回复 : ""} · 点击打开话题,
    icon: t.image_url || avatar || undefined,
    url: TOPIC_URL(t),
    tag: ld-topic-${t.id},
    });
    }
    if (hits.length > DEFAULTS.burstCap) {
    const extra = hits.slice(DEFAULTS.burstCap);
    pushNotify({
    title: LINUX DO:另有 ${extra.length} 条命中话题,
    body: extra.map((t) => t.title).filter(Boolean).slice(0, 3).join(" / "),
    url: TOPIC_URL(extra[0]),
    tag: "ld-topic-burst",
    });
    }

    if (hits.length) {
    GM_setValue(K.total, (Number(GM_getValue(K.total, 0)) || 0) + hits.length);
    }
    return { hits: hits.length, scanned: topics.length };
    }

    / ---------------- 调度 ---------------- /

    let timer = null;
    let running = false;
    let backoff = 1;

    function schedule(ms) {
    clearTimeout(timer);
    timer = setTimeout(tick, ms);
    }

    async function tick() {
    const s = loadSettings();
    if (!s.enabled) { schedule(20000); return; } // 停用时低频空转等重新启用
    if (running) { schedule(5000); return; }
    running = true;
    try {
    const r = await checkNow(false);
    backoff = 1;
    if (r?.seeded) {
    setStatus(已初始化(跳过存量 ${r.count} 条),开始监听, "ok");
    } else if (!r?.skipped) {
    const total = Number(GM_getValue(K.total, 0)) || 0;
    setStatus(
    ${timeStr(Date.now())} 已检查 ${r.scanned} 条 · 本次命中 ${r.hits} · 累计推送 ${total} · 每 ${s.intervalSec}s,
    r.hits ? "ok" : "info",
    );
    }
    } catch (e) {
    backoff = Math.min(backoff * 2, 8);
    setStatus(${timeStr(Date.now())} ${e?.message ?? "检查失败"}(${backoff} 倍降速), "error");
    } finally {
    running = false;
    schedule(s.intervalSec 1000 backoff + Math.random() * 2000); // 抖动防多标签撞车
    }
    }

    / ---------------- 面板 UI ---------------- /

    function renderCats(host) {
    const box = host.querySelector("[data-p-cats]");
    if (!box) return;
    const cache = getCatCache();
    const selected = new Set(idArray(GM_getValue(K.categories, [])));
    if (!cache.list.length) {
    box.innerHTML = <span style="color:var(--ldp-muted);font-size:12px">尚未加载,点「刷新分类」拉取</span>;
    return;
    }
    box.innerHTML = cache.list.map((c) => `
    <label title="${escapeHtml(c.name)}">
    <input type="checkbox" data-p-cat="${c.id}" ${selected.has(c.id) ? "checked" : ""}>
    <span>${escapeHtml(c.name)}</span>
    </label>`).join("");
    }

    function mountUI() {
    if (document.getElementById("ld-push-root") || !document.body) return;
    const s = loadSettings();

    const host = document.createElement("div");
    host.id = "ld-push-root";
    host.innerHTML = `
    <button class="ldp__trigger" type="button" aria-expanded="false" title="话题订阅推送">
    🔔<span class="ldp__dot" data-p-dot ${s.enabled ? "" : "hidden"}></span>
    </button>
    <section class="ldp__panel" hidden role="dialog" aria-label="话题订阅推送设置">
    <header>
    <div><strong>话题订阅推送</strong><span>命中关键词 / 分类 / 标签时通知</span></div>
    <button class="ldp__close" type="button" data-p-close aria-label="关闭">×</button>
    </header>

    <div class="ldp__section">
    <label class="ldp__row"><span><b>启用推送</b><small>保持任一 LINUX DO 页面打开即可</small></span>
    <input type="checkbox" data-p-enabled ${s.enabled ? "checked" : ""}></label>
    <label class="ldp__row"><span><b>订阅全部新话题</b><small>开启后忽略下方筛选条件</small></span>
    <input type="checkbox" data-p-all ${s.subscribeAll ? "checked" : ""}></label>
    <label class="ldp__row"><span><b>仅推送新发布</b><small>回帖顶起的老话题不推送</small></span>
    <input type="checkbox" data-p-onlynew ${s.onlyNew ? "checked" : ""}></label>
    </div>

    <div class="ldp__section">
    <div class="ldp__title">筛选条件(话题类型)</div>
    <label class="ldp__field"><span>命中方式</span>
    <select data-p-mode>
    <option value="any" ${s.matchMode === "any" ? "selected" : ""}>任一条件命中即推送</option>
    <option value="all" ${s.matchMode === "all" ? "selected" : ""}>已填条件需全部命中</option>
    </select></label>
    <label class="ldp__field"><span>关键词(每行一个,匹配标题,不区分大小写)</span>
    <textarea data-p-keywords rows="3" placeholder="抽奖&#10;优惠码&#10;GPT">${escapeHtml(s.keywordsRaw)}</textarea></label>
    <label class="ldp__field"><span>排除词(逗号分隔,命中则不推)</span>
    <input type="text" data-p-exclude value="${escapeHtml(s.excludeRaw)}" placeholder="已截止, 抽奖专用"></label>
    <div class="ldp__field"><span>分类(不勾选 = 不按分类筛选)
    <button class="ldp__mini" type="button" data-p-refresh-cats>刷新分类</button></span>
    <div class="ldp__cats" data-p-cats></div></div>
    <label class="ldp__field"><span>标签(逗号分隔)</span>
    <input type="text" data-p-tags value="${escapeHtml(s.tagsRaw)}" placeholder="ai, 羊毛, 资讯"></label>
    </div>

    <div class="ldp__section">
    <div class="ldp__title">运行</div>
    <label class="ldp__field"><span>轮询间隔 <b data-p-interval-label>${s.intervalSec}s</b></span>
    <input type="range" min="30" max="300" step="15" value="${s.intervalSec}" data-p-interval></label>
    <label class="ldp__field"><span>补推窗口(分钟):离线重开最多补推多久内的话题</span>
    <input type="number" min="1" max="240" value="${s.catchupMin}" data-p-catchup></label>
    <div class="ldp__actions">
    <button type="button" data-p-check>立即检查</button>
    <button type="button" data-p-test>测试通知</button>
    <button type="button" data-p-reset>清空记录</button>
    </div>
    </div>

    <output class="ldp__status" data-p-status>等待首次检查…</output>
    <p class="ldp__hint">设置仅保存在本浏览器;点击通知直达话题。</p>
    </section>`;
    document.body.append(host);

    const $q = (sel) => host.querySelector(sel);
    const panel = $q(".ldp__panel");
    const trigger = $q(".ldp__trigger");
    statusEl = $q("[data-p-status]");

    const setOpen = (open) => {
    panel.hidden = !open;
    trigger.setAttribute("aria-expanded", String(open));
    };
    trigger.addEventListener("click", () => setOpen(panel.hidden));
    $q("[data-p-close]").addEventListener("click", () => setOpen(false));
    document.addEventListener("pointerdown", (e) => {
    if (!panel.hidden && !host.contains(e.target)) setOpen(false);
    });
    document.addEventListener("keydown", (e) => {
    if (e.key === "Escape" && !panel.hidden) setOpen(false);
    });

    $q("[data-p-enabled]").addEventListener("change", (e) => {
    GM_setValue(K.enabled, e.target.checked);
    $q("[data-p-dot]").hidden = !e.target.checked;
    if (e.target.checked) { ensureWebPermission(); schedule(300); }
    setStatus(e.target.checked ? "已启用,开始监听" : "已停用", "info");
    });
    $q("[data-p-all]").addEventListener("change", (e) =>
    GM_setValue(K.subscribeAll, e.target.checked));
    $q("[data-p-onlynew]").addEventListener("change", (e) =>
    GM_setValue(K.onlyNew, e.target.checked));
    $q("[data-p-mode]").addEventListener("change", (e) =>
    GM_setValue(K.matchMode, e.target.value === "all" ? "all" : "any"));
    $q("[data-p-keywords]").addEventListener("change", (e) =>
    GM_setValue(K.keywords, e.target.value));
    $q("[data-p-exclude]").addEventListener("change", (e) =>
    GM_setValue(K.exclude, e.target.value));
    $q("[data-p-tags]").addEventListener("change", (e) =>
    GM_setValue(K.tags, e.target.value));
    $q("[data-p-interval]").addEventListener("input", (e) => {
    const v = clamp(Number(e.target.value) || 90, 30, 300);
    $q("[data-p-interval-label]").textContent = ${v}s;
    GM_setValue(K.interval, v);
    });
    $q("[data-p-catchup]").addEventListener("change", (e) => {
    const v = clamp(Number(e.target.value) || 30, 1, 240);
    e.target.value = String(v);
    GM_setValue(K.catchup, v);
    });

    $q("[data-p-cats]").addEventListener("change", () => {
    const ids = [...host.querySelectorAll("[data-p-cat]:checked")]
    .map((i) => Number(i.dataset.pCat));
    GM_setValue(K.categories, ids);
    });
    $q("[data-p-refresh-cats]").addEventListener("click", async (e) => {
    e.target.disabled = true;
    setStatus("正在拉取分类列表…");
    try {
    const cache = await fetchCategories();
    renderCats(host);
    setStatus(分类已更新(${cache.list.length} 个), "ok");
    } catch (err) {
    setStatus(err.message, "error");
    } finally {
    e.target.disabled = false;
    }
    });

    $q("[data-p-check]").addEventListener("click", async () => {
    setStatus("正在检查…");
    try {
    const r = await checkNow(true);
    setStatus(
    r.seeded
    ? 已初始化(跳过存量 ${r.count} 条)
    : 检查完成:命中 ${r.hits} 条,
    r.hits ? "ok" : "info",
    );
    } catch (err) {
    setStatus(err.message, "error");
    }
    });
    $q("[data-p-test]").addEventListener("click", () => {
    ensureWebPermission();
    pushNotify({
    title: "LINUX DO 订阅推送测试",
    body: "看到这条通知说明推送正常,点击可打开最新页",
    url: "https://linux.do/latest&quot;,
    tag: "ld-push-test",
    });
    setStatus("测试通知已发送(无反应请检查通知权限)", "info");
    });
    $q("[data-p-reset]").addEventListener("click", () => {
    GM_setValue(K.seen, []);
    GM_setValue(K.total, 0);
    GM_setValue(K.lastCheck, 0);
    setStatus("已清空:下次检查将重新播种(不补推存量)", "ok");
    });

    renderCats(host);
    if (!getCatCache().list.length) {
    fetchCategories().then(() => renderCats(host)).catch(() => {});
    }
    }

    / ---------------- 启动 ---------------- /

    function boot() {
    mountUI();
    ensureWebPermission();
    schedule(2000 + Math.random() * 3000);

    document.addEventListener("visibilitychange", () => {
    if (document.hidden) return;
    const s = loadSettings();
    const last = Number(GM_getValue(K.lastCheck, 0)) || 0;
    if (s.enabled && Date.now() - last >= s.intervalSec * 1000) schedule(500);
    });

    new MutationObserver(() => {
    if (!document.getElementById("ld-push-root")) {
    statusEl = null;
    mountUI();
    }
    }).observe(document.body, { childList: true });
    }

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

    主楼
  • yourui

    感谢佬友分享,我拿走试试了,应该非常实用

    #1
  • salsal101

    不吃429警告吗 多标签90s一次

    #2

发表回复

登录后回复