GitHub 过期仓库筛选器

自动标记或隐藏 GitHub 搜索结果中的过期仓库,支持自定义过期时间来筛选。

// ==UserScript==
// @name         GitHub 过期仓库筛选器
// @namespace    https://gf.qytechs.cn/zh-CN/users/1532235-stanley-ewing
// @version      1.0
// @description  自动标记或隐藏 GitHub 搜索结果中的过期仓库,支持自定义过期时间来筛选。
// @author       cscny
// @match        https://github.com/*
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  // --- Part 1: 配置与样式 ---
  const CONFIG = {
    TARGET_SEARCH_TYPES: ["repositories"],
    DEFAULT_STALE_THRESHOLD_YEARS: 1,
    STALE_OPACITY: 0.55,
  };

  // [THEMED] 主题自适应
  GM_addStyle(`
        /* 核心标记样式 */
        .is-stale-repo {
            opacity: ${CONFIG.STALE_OPACITY} !important;
            transition: all 0.2s ease-in-out;
            padding-left: 16px !important;
            padding-right: 16px !important;
            position: relative;
        }
        .is-stale-repo:hover { opacity: 1 !important; }
        .is-stale-repo::before {
            content: ''; position: absolute; left: 0; top: 50%;
            transform: translateY(-50%); width: 4px; height: calc(100% - 16px);
            border-radius: 4px; background: linear-gradient(160deg, #a966ff, #3c91ff, #ff8966);
        }
        .is-stale-repo::after {
            content: ''; position: absolute; right: 0; top: 50%;
            transform: translateY(-50%); width: 4px; height: calc(100% - 16px);
            border-radius: 4px; background: linear-gradient(160deg, #a966ff, #3c91ff, #ff8966);
        }
        .stale-repo-badge {
            display: inline-flex; align-items: center; margin-left: 8px;
            padding: 1px 7px; font-size: 11px;
            font-weight: 700; color: #cb2431;
            background-color: #fcf0f1; border: 1px solid #cb2431;
            border-radius: 2em; vertical-align: middle; cursor: help;
        }
        .is-stale-repo.hide-mode { display: none !important; }


        

        /* 模态对话框 UI (已适配主题) */
        #marker-settings-dialog-backdrop {
            position: fixed; top: 0; left: 0; width: 100%; height: 100%;
            background-color: var(--color-backdrop-bg, rgba(132, 117, 117, 0.4));
            z-index: 99998; display: flex; align-items: center; justify-content: center;
        }
        #marker-settings-dialog {
            background-color: var(--color-canvas-overlay, #5787cbff);
            color: var(--color-fg-default);
            border: 1px solid var(--color-border-default);
            border-radius: 12px;
            box-shadow: var(--color-shadow-large);
            width: 320px;
            padding: 20px;
            animation: fadeInDialog 0.2s ease-out;
        }
        #marker-settings-dialog h3 {
            margin: 0 0 16px; font-size: 16px; font-weight: 600;
            border-bottom: 1px solid var(--color-border-muted);
            padding-bottom: 8px;
        }
        #marker-settings-dialog .setting-group {
            margin-bottom: 15px;
            border-bottom: 1px solid var(--color-border-muted);
            padding-bottom: 15px;
        }
        #marker-settings-dialog .setting-group:last-of-type {
            border-bottom: none; margin-bottom: 0; padding-bottom: 0;
        }
        #marker-settings-dialog label {
            font-weight: 600; display: block; margin-bottom: 8px; font-size: 14px;
        }
        #marker-settings-dialog .radio-label {
            font-weight: normal; display: flex; align-items: center;
        }
        #marker-settings-dialog input[type="radio"] {
            margin-right: 8px;
        }
        #marker-settings-dialog input[type="number"] {
            width: 100%;
            box-sizing: border-box;
            padding: 5px 12px;
            border-radius: 6px;
            border: 1px solid var(--color-border-default, #ba202fff);
            background-color: var(--color-canvas-inset);
            color: var(--color-fg-default);
        }
        #marker-settings-dialog button {
            width: 100%;
            padding: 8px;
            margin-top: 20px;
            border-radius: 6px;
            border: 1px solid var(--color-btn-primary-border);
            background-color: var(--color-btn-primary-bg);
            color: var(--color-btn-primary-fg);
            font-weight: 600;
            cursor: pointer;
            transition: background-color 0.2s;
        }
        #marker-settings-dialog button:hover {
            background-color: var(--color-btn-primary-hover-bg);
        }
        @keyframes fadeInDialog {
            from { opacity: 0; transform: scale(0.95); }
            to { opacity: 1; transform: scale(1); }
        }
    `);

  // --- Part 2: 核心功能 ---
  const markerModule = {
    staleDate: null,
    currentThreshold: CONFIG.DEFAULT_STALE_THRESHOLD_YEARS,
    currentMode: "mark",

    loadSettings() {
      this.currentMode = GM_getValue("markerMode", "mark");
      this.currentThreshold = GM_getValue(
        "staleThreshold",
        CONFIG.DEFAULT_STALE_THRESHOLD_YEARS
      );
      this.staleDate = new Date(
        new Date().setFullYear(new Date().getFullYear() - this.currentThreshold)
      );
    },
    saveSettings(mode) {
      this.currentMode = mode;
      GM_setValue("markerMode", mode);
      this.applyModeChange();
    },
    applyModeChange() {
      document.querySelectorAll(".is-stale-repo").forEach((repo) => {
        repo.classList.toggle("hide-mode", this.currentMode === "hide");
      });
    },
    updateThreshold(years) {
      this.currentThreshold =
        parseFloat(years) || CONFIG.DEFAULT_STALE_THRESHOLD_YEARS;
      this.staleDate = new Date(
        new Date().setFullYear(new Date().getFullYear() - this.currentThreshold)
      );
      GM_setValue("staleThreshold", this.currentThreshold);
      document
        .querySelectorAll(".is-stale-repo, .processed-v1")
        .forEach((repo) => {
          repo.classList.remove("is-stale-repo", "hide-mode", "processed-v1");
          const badge = repo.querySelector(".stale-repo-badge");
          if (badge) badge.remove();
        });
      intelligentObserver.scanAll();
    },
    parseDate(dateString) {
      if (!dateString) return null;
      try {
        const cleanedString = dateString
          .replace(/^(更新于 on|Updated on|on)\s+/, "")
          .trim();
        let match = cleanedString.match(/(\d{4})年(\d{1,2})月(\d{1,2})日/);
        if (match) {
          const [_, y, m, d] = match;
          return new Date(`${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`);
        }
        const date = new Date(cleanedString);
        if (!isNaN(date.getTime())) {
          return date;
        }
        return null;
      } catch (e) {
        console.error("日期解析失败:", dateString, e);
        return null;
      }
    },
    processRepo(repoItem) {
      if (
        !repoItem ||
        repoItem.nodeType !== 1 ||
        repoItem.classList.contains("processed-v1")
      )
        return;
      repoItem.classList.add("processed-v1");
      const lastListItem = repoItem.querySelector("ul > li:last-of-type");
      const titleContainer = repoItem.querySelector(
        '.search-title, [class*="search-title"]'
      );
      if (!lastListItem || !titleContainer) return;
      const dateElement = lastListItem.querySelector("[title]");
      const titleLink = titleContainer.querySelector("a");
      if (!dateElement || !titleLink) return;
      const updateDate = this.parseDate(dateElement.getAttribute("title"));
      if (updateDate && updateDate < this.staleDate) {
        repoItem.classList.add("is-stale-repo");
        if (
          !titleLink.nextElementSibling ||
          !titleLink.nextElementSibling.classList.contains("stale-repo-badge")
        ) {
          const badge = document.createElement("span");
          badge.className = "stale-repo-badge";
          badge.textContent = " 陈旧 ";
          badge.title = `最后更新于: ${updateDate.toLocaleDateString()}`;
          titleLink.insertAdjacentElement("afterend", badge);
        }
        if (this.currentMode === "hide") {
          repoItem.classList.add("hide-mode");
        }
      }
    },
  };

  // --- Part 3: UI 交互模块 ---
  function openSettingsDialog() {
    if (document.getElementById("marker-settings-dialog-backdrop")) return;
    const backdrop = document.createElement("div");
    backdrop.id = "marker-settings-dialog-backdrop";
    const dialog = document.createElement("div");
    dialog.id = "marker-settings-dialog";
    dialog.innerHTML = `
            <h3>筛选器设置</h3>
            <div class="setting-group">
                <label>显示模式</label>
                <label class="radio-label"><input type="radio" name="marker-mode" value="mark"> 标记陈旧项目</label>
                <label class="radio-label"><input type="radio" name="marker-mode" value="hide"> 隐藏陈旧项目(配合自动翻页脚本最佳)</label>
            </div>
            <div class="setting-group">
                <label for="stale-threshold-input">“陈旧”定义 (年)</label>
                <input type="number" id="stale-threshold-input" step="0.5" min="0.5">
            </div>
            <button id="marker-dialog-close-btn">完成</button>
        `;
    backdrop.appendChild(dialog);
    document.body.appendChild(backdrop);
    dialog.querySelector(
      `input[value="${markerModule.currentMode}"]`
    ).checked = true;
    const thresholdInput = dialog.querySelector("#stale-threshold-input");
    thresholdInput.value = markerModule.currentThreshold;
    backdrop.addEventListener("click", () =>
      document.body.removeChild(backdrop)
    );
    dialog.addEventListener("click", (e) => e.stopPropagation());
    dialog
      .querySelector("#marker-dialog-close-btn")
      .addEventListener("click", () => document.body.removeChild(backdrop));
    dialog.addEventListener("change", (e) => {
      if (e.target.name === "marker-mode") {
        markerModule.saveSettings(e.target.value);
      }
    });
    let debounceTimer;
    thresholdInput.addEventListener("input", () => {
      clearTimeout(debounceTimer);
      debounceTimer = setTimeout(() => {
        const newThreshold = parseFloat(thresholdInput.value);
        if (newThreshold && newThreshold >= 0.1) {
          markerModule.updateThreshold(newThreshold);
        }
      }, 500);
    });
  }
  GM_registerMenuCommand("⚙️ 筛选器设置", openSettingsDialog);

  // --- Part 4: 页面监控模块 ---
  const intelligentObserver = {
    observer: null,

    handleMutations(mutations) {
      if (!this.isRepoSearchPage()) return;
      for (const mutation of mutations) {
        for (const node of mutation.addedNodes) {
          if (node.nodeType !== 1) continue;
          if (
            node.parentElement &&
            node.parentElement.getAttribute("data-testid") === "results-list"
          ) {
            markerModule.processRepo(node);
          }
          const repos = node.querySelectorAll(
            'div[data-testid="results-list"] > div'
          );
          if (repos.length > 0) {
            repos.forEach((repo) => markerModule.processRepo(repo));
          }
        }
      }
    },
    isRepoSearchPage() {
      const urlParams = new URLSearchParams(window.location.search);
      const type = urlParams.get("type");
      return (
        window.location.pathname === "/search" &&
        (CONFIG.TARGET_SEARCH_TYPES.includes(type) || type === null)
      );
    },
    scanAll() {
      if (!this.isRepoSearchPage()) return;
      document
        .querySelectorAll(
          'div[data-testid="results-list"] > div:not(.processed-v1)'
        )
        .forEach((repo) => markerModule.processRepo(repo));
    },
    start() {
      markerModule.loadSettings();

      const targetNode = document.querySelector("main") || document.body;

      this.observer = new MutationObserver((mutations) =>
        this.handleMutations(mutations)
      );
      this.observer.observe(targetNode, { childList: true, subtree: true });

      setTimeout(() => this.scanAll(), 500);
      document.addEventListener("turbo:load", () => {
        setTimeout(() => this.scanAll(), 500);
      });
    },
  };

  // --- 启动脚本 ---
  intelligentObserver.start();
})();

QingJ © 2025

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