GitHub Star Lists CSV Importer (Optimized)

Import CSV -> create GitHub Star Lists and assign repos. Enhancements: staged progress UI, optional "no auto-star" mode.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

// ==UserScript==
// @name         GitHub Star Lists CSV Importer (Optimized)
// @namespace    https://github.com/
// @version      0.3.2
// @author       blackzero358
// @icon https://github.githubassets.com/images/icons/emoji/unicode/1f4e4.png
// @license      AGPLv3
// @description  Import CSV -> create GitHub Star Lists and assign repos. Enhancements: staged progress UI, optional "no auto-star" mode.
// @match        https://github.com/*
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @connect      api.github.com
// ==/UserScript==

(function () {
  "use strict";

  const STORAGE_KEY_TOKEN = "gh_star_lists_importer_token";
  const STORAGE_KEY_NO_AUTOSTAR = "gh_star_lists_importer_no_autostar";

  // 并发请求数(建议 3-5;过高可能触发 secondary rate limit / abuse detection)
  const MAX_CONCURRENCY = 3;

  // 扫描现有列表 items 的并发(建议 1-2;列表较多时别开太大)
  const LIST_SCAN_CONCURRENCY = 2;

  // 全局状态控制
  let isRunning = false;
  let abortController = null;

  GM_addStyle(`
    .ghsli-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:999999;backdrop-filter:blur(2px);}
    .ghsli-modal{position:fixed;top:5vh;left:50%;transform:translateX(-50%);width:min(1000px,94vw);background:#fff;border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.4);z-index:1000000;display:flex;flex-direction:column;max-height:90vh;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;}
    .ghsli-header{padding:16px 20px;border-bottom:1px solid #eee;display:flex;align-items:center;justify-content:space-between;background:#f6f8fa;border-radius:12px 12px 0 0;}
    .ghsli-title{font-size:16px;font-weight:600;color:#24292f;}
    .ghsli-body{padding:20px;overflow-y:auto;display:flex;flex-direction:column;gap:16px;flex:1;}
    .ghsli-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap;}
    .ghsli-row label{font-size:13px;font-weight:600;color:#24292f;}
    .ghsli-input{font-size:13px;padding:6px 12px;border:1px solid #d0d7de;border-radius:6px;flex:1;min-width:250px;}
    .ghsli-textarea{width:100%;min-height:150px;font-size:12px;padding:8px;border:1px solid #d0d7de;border-radius:6px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;resize:vertical;}
    .ghsli-btn{font-size:13px;padding:6px 16px;border:1px solid rgba(27,31,36,0.15);border-radius:6px;background:#2da44e;color:#fff;cursor:pointer;font-weight:500;transition:.2s;}
    .ghsli-btn:hover{background:#2c974b;}
    .ghsli-btn:disabled{opacity:.5;cursor:not-allowed;background:#94d3a2;}
    .ghsli-btn.secondary{background:#f6f8fa;color:#24292f;}
    .ghsli-btn.secondary:hover{background:#f3f4f6;}
    .ghsli-btn.danger{background:#cf222e;color:#fff;border-color:rgba(27,31,36,0.15);}
    .ghsli-btn.danger:hover{background:#a40e26;}
    .ghsli-log{white-space:pre-wrap;background:#0d1117;color:#c9d1d9;padding:12px;border-radius:6px;font-size:12px;height:250px;overflow-y:auto;border:1px solid #30363d;}
    .ghsli-file-wrap{position:relative;overflow:hidden;display:inline-block;}
    .ghsli-file-input{position:absolute;font-size:100px;opacity:0;right:0;top:0;}
    .log-succ{color:#3fb950;}
    .log-warn{color:#d29922;}
    .log-err{color:#f85149;}
    .log-info{color:#8b949e;}
    .ghsli-progress{height:4px;background:#eee;width:100%;border-radius:2px;overflow:hidden;margin-top:4px;}
    .ghsli-bar{height:100%;background:#2da44e;width:0%;transition:width 0.3s;}
    .ghsli-stage{font-size:12px;color:#57606a;}
  `);

  GM_registerMenuCommand("Import Star Lists (Optimized)", () => openModal());

  function openModal() {
    const existing = document.querySelector(".ghsli-backdrop");
    if (existing) existing.remove();

    const backdrop = document.createElement("div");
    backdrop.className = "ghsli-backdrop";

    const modal = document.createElement("div");
    modal.className = "ghsli-modal";

    const header = document.createElement("div");
    header.className = "ghsli-header";
    header.innerHTML = `
      <div class="ghsli-title">GitHub Star Lists CSV Importer (v0.3.1)</div>
      <button class="ghsli-btn secondary" id="ghsli-close">Close</button>
    `;

    const body = document.createElement("div");
    body.className = "ghsli-body";

    const token = GM_getValue(STORAGE_KEY_TOKEN, "");
    const noAutoStarSaved = !!GM_getValue(STORAGE_KEY_NO_AUTOSTAR, false);

    body.innerHTML = `
      <div class="ghsli-row">
        <label>Token (PAT)</label>
        <input class="ghsli-input" id="ghsli-token" type="password" placeholder="ghp_... / github_pat_..." value="${escapeHtml(token)}" />
        <button class="ghsli-btn secondary" id="ghsli-save-token">Save</button>
      </div>

      <div class="ghsli-row">
        <label><input type="checkbox" id="ghsli-private" /> 新建列表设为私有 (Private)</label>
        <label><input type="checkbox" id="ghsli-overwrite" /> 强制覆盖 (不保留现有其他归属)</label>
        <label title="开启后:不会自动 star 未 star 的仓库;但也意味着它可能无法被加入 Star Lists(GitHub 机制通常要求先 Star)。">
          <input type="checkbox" id="ghsli-no-autostar" ${noAutoStarSaved ? "checked" : ""} />
          只更新目标分类,不自动 Star
        </label>
      </div>

      <div>
        <div class="ghsli-row" style="justify-content:space-between; margin-bottom:8px;">
          <label>CSV 数据 (Category, Repo, URL)</label>
          <div class="ghsli-row" style="gap:8px;">
            <div class="ghsli-file-wrap">
              <button class="ghsli-btn secondary">📂 导入文件...</button>
              <input type="file" id="ghsli-file" class="ghsli-file-input" accept=".csv,.txt" />
            </div>
            <button class="ghsli-btn secondary" id="ghsli-dryrun">Dry Run</button>
            <button class="ghsli-btn" id="ghsli-run">▶ 开始运行</button>
            <button class="ghsli-btn danger" id="ghsli-stop" disabled>⏹ 停止</button>
          </div>
        </div>
        <textarea class="ghsli-textarea" id="ghsli-csv" placeholder="Category,Repository Name,URL\nAI Dev,ollama/ollama,https://github.com/ollama/ollama"></textarea>
      </div>

      <div>
        <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;">
           <label>执行日志</label>
           <div class="ghsli-stage" id="ghsli-stage">阶段:就绪</div>
           <span id="ghsli-status" class="log-info" style="font-size:12px;">就绪</span>
        </div>
        <div class="ghsli-progress" id="ghsli-progress-bg"><div class="ghsli-bar" id="ghsli-progress-bar"></div></div>
        <div class="ghsli-log" id="ghsli-log"></div>
      </div>
    `;

    modal.appendChild(header);
    modal.appendChild(body);
    backdrop.appendChild(modal);
    document.body.appendChild(backdrop);

    const $ = (id) => document.getElementById(id);
    const logger = createLogger();

    $("ghsli-close").onclick = () => {
      if (isRunning) {
        if (!confirm("脚本正在运行中,确定要关闭吗?这不会立即停止后台请求。建议先点击停止。")) return;
      }
      backdrop.remove();
    };

    $("ghsli-save-token").onclick = () => {
      const t = $("ghsli-token").value.trim();
      GM_setValue(STORAGE_KEY_TOKEN, t);
      logger.info("Token 已保存到本地 Tampermonkey 存储。");
    };

    $("ghsli-file").onchange = (e) => {
      const file = e.target.files[0];
      if (!file) return;
      const reader = new FileReader();
      reader.onload = (evt) => {
        $("ghsli-csv").value = evt.target.result;
        logger.info(`已加载文件: ${file.name} (${file.size} bytes)`);
      };
      reader.readAsText(file);
    };

    $("ghsli-dryrun").onclick = () => {
      try {
        const plan = buildPlanFromCsv($("ghsli-csv").value);
        logger.clear();
        logger.info(renderPlan(plan));
      } catch (err) {
        logger.error(`Dry run failed: ${err.message}`);
      }
    };

    $("ghsli-stop").onclick = () => {
      if (!isRunning) return;
      logger.warn("正在停止... 将取消进行中的请求并尽快退出。");
      abortController.abort();
      $("ghsli-stop").disabled = true;
      $("ghsli-stop").textContent = "正在停止...";
    };

    $("ghsli-run").onclick = async () => {
      const runBtn = $("ghsli-run");
      const dryBtn = $("ghsli-dryrun");
      const stopBtn = $("ghsli-stop");

      runBtn.disabled = true;
      dryBtn.disabled = true;
      stopBtn.disabled = false;
      stopBtn.textContent = "⏹ 停止";

      isRunning = true;
      abortController = new AbortController();

      logger.clear();
      logger.setStage("准备");

      try {
        const tokenVal = $("ghsli-token").value.trim();
        if (!tokenVal) throw new Error("请先填写 Token。");
        GM_setValue(STORAGE_KEY_TOKEN, tokenVal);

        const csv = $("ghsli-csv").value;
        const isPrivate = $("ghsli-private").checked;
        const allowOverwrite = $("ghsli-overwrite").checked;
        const noAutoStar = $("ghsli-no-autostar").checked;
        GM_setValue(STORAGE_KEY_NO_AUTOSTAR, noAutoStar);

        const plan = buildPlanFromCsv(csv);
        logger.info(`开始处理: ${plan.categories.size} 个分类, ${plan.repoToCategories.size} 个仓库`);

        logger.setStage("验证 Token");
        logger.updateProgress(0, 1, "验证 Token...");
        logger.log("正在验证 Token...");
        const viewer = await validateToken(tokenVal, abortController.signal);
        logger.info(`Token 有效! 以此用户身份登录: ${viewer.login}`);
        logger.updateProgress(1, 1, "验证完成");

        await runImport({
          token: tokenVal,
          plan,
          isPrivate,
          allowOverwrite,
          noAutoStar,
          logger,
          signal: abortController.signal
        });

        logger.setStage("完成");
        logger.setStatus("完成");
        logger.updateProgress(1, 1, "完成");
      } catch (err) {
        if (err?.name === "AbortError" || !isRunning) {
          logger.warn("操作已由用户手动停止。");
          logger.setStage("已停止");
          logger.setStatus("已停止");
        } else {
          logger.error(`运行出错: ${err.message}`);
          logger.setStage("出错");
          logger.setStatus("出错");
        }
      } finally {
        isRunning = false;
        runBtn.disabled = false;
        dryBtn.disabled = false;
        stopBtn.disabled = true;
        stopBtn.textContent = "⏹ 停止";
      }
    };
  }

  function createLogger() {
    const el = document.getElementById("ghsli-log");
    const statusEl = document.getElementById("ghsli-status");
    const stageEl = document.getElementById("ghsli-stage");
    const bar = document.getElementById("ghsli-progress-bar");

    const append = (msg, cls) => {
      const span = document.createElement("div");
      span.textContent = msg;
      if (cls) span.className = cls;
      el.appendChild(span);
      el.scrollTop = el.scrollHeight;
    };

    return {
      log: (m) => append(m),
      info: (m) => append(m, "log-info"),
      success: (m) => append(m, "log-succ"),
      warn: (m) => append(m, "log-warn"),
      error: (m) => append(m, "log-err"),
      clear: () => {
        el.innerHTML = "";
        bar.style.width = "0%";
        stageEl.textContent = "阶段:就绪";
        statusEl.textContent = "就绪";
      },
      setStage: (stageName) => {
        stageEl.textContent = `阶段:${stageName}`;
      },
      setStatus: (s) => {
        statusEl.textContent = s || "";
      },
      updateProgress: (current, total, msg) => {
        const safeTotal = Math.max(1, total || 1);
        const pct = Math.floor((Math.min(current, safeTotal) / safeTotal) * 100);
        bar.style.width = `${pct}%`;
        statusEl.textContent = `[${Math.min(current, safeTotal)}/${safeTotal}] ${msg || ""}`;
      }
    };
  }

  // --- Core Logic ---

  async function validateToken(token, signal) {
    const q = `query { viewer { login } }`;
    const data = await ghGraphql(token, q, {}, { signal });
    if (!data.viewer) throw new Error("无法获取用户信息,Token 可能无效。");
    return data.viewer;
  }

  async function runImport({ token, plan, isPrivate, allowOverwrite, noAutoStar, logger, signal }) {
    const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
    const checkAbort = () => {
      if (signal.aborted) throw new DOMException("Aborted", "AbortError");
    };

    // 阶段 A:列表准备
    logger.setStage("列表准备");
    logger.log("正在读取现有列表...");
    logger.updateProgress(0, 1, "读取现有列表...");

    const listApi = await detectListApi(token, signal);
    const existingLists = await fetchAllLists(token, listApi, signal);
    checkAbort();

    const nameToList = new Map(existingLists.map((x) => [x.name, { id: x.id, name: x.name }]));

    const neededCats = Array.from(plan.categories).sort();
    logger.updateProgress(0, neededCats.length || 1, "创建缺失列表...");
    let createdCnt = 0;

    for (const name of neededCats) {
      checkAbort();
      if (nameToList.has(name)) {
        createdCnt++;
        logger.updateProgress(createdCnt, neededCats.length || 1, `列表已存在: ${name}`);
        continue;
      }

      logger.log(`创建新列表: ${name}`);
      try {
        const created = await createUserList(token, name, isPrivate, "", signal);
        nameToList.set(created.name, { id: created.id, name: created.name });
        await sleep(250);
      } catch (e) {
        if (e?.name === "AbortError") throw e;
        throw new Error(`创建列表失败 [${name}]: ${e.message}`);
      }

      createdCnt++;
      logger.updateProgress(createdCnt, neededCats.length || 1, `已创建: ${name}`);
    }

    const allLists = Array.from(nameToList.values());
    logger.success(`列表准备完成:共 ${allLists.length} 个列表(含新建)`);

    // 阶段 B:读取仓库信息
    const repos = Array.from(plan.repoToCategories.keys());
    logger.setStage("读取仓库信息");
    logger.log(`正在读取仓库信息(id / viewerHasStarred):${repos.length} 个...`);

    const repoInfoMap = await fetchRepoInfosBulk(token, repos, logger, signal, MAX_CONCURRENCY);
    checkAbort();

    // 统计可用仓库
    let okRepoCount = 0;
    for (const r of repos) if (repoInfoMap.get(r)?.id) okRepoCount++;
    logger.success(`仓库信息读取完成:可处理 ${okRepoCount}/${repos.length} 个`);

    // 阶段 C:扫描现有归属(可选)
    let existingByRepoId = new Map(); // repoId -> Set(listId)
    if (!allowOverwrite) {
      logger.setStage("扫描现有归属");
      const targetRepoIds = new Set();
      for (const repoFull of repos) {
        const info = repoInfoMap.get(repoFull);
        if (info?.id) targetRepoIds.add(info.id);
      }

      logger.log(`正在扫描现有列表归属(用于合并保留其他列表):${allLists.length} 个列表...`);
      const scan = await buildExistingMembershipIndex(token, allLists, targetRepoIds, logger, signal);
      existingByRepoId = scan.repoIdToListIds;
      logger.success(`现有归属扫描完成:命中 ${existingByRepoId.size} 个仓库`);
      checkAbort();
    } else {
      logger.setStage("扫描现有归属");
      logger.warn(`已启用“强制覆盖”:将不会读取/保留仓库现有其他列表归属。`);
      logger.updateProgress(1, 1, "已跳过(强制覆盖)");
    }

    // 阶段 D:更新归属
    logger.setStage("更新归属");
    logger.log(`开始更新:${repos.length} 个仓库(并发 ${MAX_CONCURRENCY})...`);

    let completedCount = 0;
    const total = repos.length;
    logger.updateProgress(0, total || 1, "开始更新...");

    const runWorker = async (repoFull) => {
      checkAbort();

      const cats = plan.repoToCategories.get(repoFull);
      const targetListIds = Array.from(cats)
        .map((c) => nameToList.get(c)?.id)
        .filter(Boolean);

      if (targetListIds.length === 0) {
        logger.warn(`⚠ ${repoFull}: 无有效的目标列表ID(列表创建可能失败),跳过。`);
        return;
      }

      const repoInfo = repoInfoMap.get(repoFull);
      if (!repoInfo?.id) {
        logger.error(`✖ ${repoFull}: 仓库不存在或无权访问(无法读取 repository.id)`);
        return;
      }

      // 重要:不自动 star 模式下,如果仓库未 star,很可能无法加入 Star Lists
      // 我们这里选择“跳过并提示”,避免隐性改变用户意图。
      if (noAutoStar && !repoInfo.viewerHasStarred) {
        logger.warn(`⚠ ${repoFull}: 未 Star,且已启用“只更新分类不自动 Star” -> 跳过(无法可靠加入列表)`);
        return;
      }

      try {
        // Auto-star (optional)
        if (!noAutoStar && !repoInfo.viewerHasStarred) {
          await addStar(token, repoInfo.id, signal);
        }

        // Determine current + final listIds
        const currentListIds = allowOverwrite
          ? []
          : Array.from(existingByRepoId.get(repoInfo.id) || []);

        if (!allowOverwrite) {
          const alreadyHasAll = targetListIds.every((id) => currentListIds.includes(id));
          if (alreadyHasAll) {
            logger.info(`= ${repoFull}: 无需更新(已包含目标分类)`);
            return;
          }
        }

        const finalListIds = allowOverwrite
          ? targetListIds
          : Array.from(new Set([...currentListIds, ...targetListIds]));

        await updateUserListsForItem(token, repoInfo.id, finalListIds, signal);
        logger.success(`✔ ${repoFull}: 已更新到 [${Array.from(cats).join(", ")}]`);
      } catch (e) {
        if (e?.name === "AbortError") throw e;
        logger.error(`✖ ${repoFull}: ${e.message}`);
      }
    };

    await processInBatches(
      repos,
      async (repo) => {
        await runWorker(repo);
        completedCount++;
        logger.updateProgress(completedCount, total || 1, repo);
      },
      MAX_CONCURRENCY,
      checkAbort
    );

    logger.success("更新阶段完成。");
  }

  async function fetchRepoInfosBulk(token, repos, logger, signal, concurrency) {
    const checkAbort = () => {
      if (signal.aborted) throw new DOMException("Aborted", "AbortError");
    };

    const out = new Map();
    let done = 0;
    const total = repos.length;

    logger.updateProgress(0, total || 1, "读取仓库信息...");

    await processInBatches(
      repos,
      async (repoFull) => {
        checkAbort();
        const [owner, name] = repoFull.split("/");
        try {
          const repo = await getRepository(token, owner, name, signal);
          if (!repo) out.set(repoFull, null);
          else out.set(repoFull, { id: repo.id, viewerHasStarred: !!repo.viewerHasStarred });
        } catch (e) {
          if (e?.name === "AbortError") throw e;
          out.set(repoFull, null);
        } finally {
          done++;
          logger.updateProgress(done, total || 1, `读取仓库信息: ${repoFull}`);
        }
      },
      concurrency,
      checkAbort
    );

    return out;
  }

  async function buildExistingMembershipIndex(token, lists, targetRepoIds, logger, signal) {
    const checkAbort = () => {
      if (signal.aborted) throw new DOMException("Aborted", "AbortError");
    };

    const repoIdToListIds = new Map(); // repoId -> Set(listId)
    let scanned = 0;
    const total = lists.length;

    logger.updateProgress(0, total || 1, "扫描列表 items...");

    const scanOneList = async (list) => {
      checkAbort();

      let after = null;
      while (true) {
        checkAbort();
        const q = `
          query($id: ID!, $n: Int!, $after: String) {
            node(id: $id) {
              ... on UserList {
                items(first: $n, after: $after) {
                  nodes { ... on Repository { id } }
                  pageInfo { hasNextPage endCursor }
                }
              }
            }
          }
        `;
        const d = await ghGraphql(token, q, { id: list.id, n: 100, after }, { signal });

        const items = d?.node?.items;
        if (!items) throw new Error(`无法读取列表 items:${list.name}`);

        for (const n of items.nodes || []) {
          const rid = n?.id;
          if (!rid) continue;
          if (!targetRepoIds.has(rid)) continue;

          if (!repoIdToListIds.has(rid)) repoIdToListIds.set(rid, new Set());
          repoIdToListIds.get(rid).add(list.id);
        }

        if (!items.pageInfo?.hasNextPage) break;
        after = items.pageInfo.endCursor;
      }
    };

    await processInBatches(
      lists,
      async (list) => {
        await scanOneList(list);
        scanned++;
        logger.updateProgress(scanned, total || 1, `扫描列表: ${list.name}`);
      },
      LIST_SCAN_CONCURRENCY,
      checkAbort
    );

    return { repoIdToListIds };
  }

  async function processInBatches(items, workerFn, limit, abortCheckFn) {
    const results = [];
    const executing = new Set();

    for (const item of items) {
      abortCheckFn();
      const p = Promise.resolve()
        .then(() => workerFn(item))
        .finally(() => executing.delete(p));
      executing.add(p);
      results.push(p);

      if (executing.size >= limit) {
        await Promise.race(executing);
      }
    }

    await Promise.all(results);
  }

  // --- Data Parsing ---

  function buildPlanFromCsv(csvText) {
    const rows = parseCsv(csvText);
    if (rows.length === 0) throw new Error("CSV 为空");

    const first = rows[0].map((x) => (x || "").trim().toLowerCase());
    const hasHeader =
      first.length >= 3 &&
      (first[0].includes("cat") || first[0].includes("分类")) &&
      first[1].includes("repo") &&
      first[2].includes("url");

    const data = hasHeader ? rows.slice(1) : rows;

    const categories = new Set();
    const repoToCategories = new Map();

    for (const r of data) {
      if (!r || r.length < 2) continue;

      const category = (r[0] || "").trim();
      const repoName = (r[1] || "").trim();
      const url = (r[2] || "").trim();

      if (!category) continue;
      const repo = normalizeRepo(repoName, url);
      if (!repo) continue;

      categories.add(category);
      if (!repoToCategories.has(repo)) repoToCategories.set(repo, new Set());
      repoToCategories.get(repo).add(category);
    }

    if (categories.size === 0) throw new Error("未找到有效分类数据");
    return { categories, repoToCategories };
  }

  function renderPlan(plan) {
    return (
      `计划创建/使用分类 (${plan.categories.size} 个):\n` +
      Array.from(plan.categories).join(", ") +
      `\n\n即将处理仓库 (${plan.repoToCategories.size} 个)...`
    );
  }

  function normalizeRepo(repoName, url) {
    if (repoName && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repoName)) return repoName;
    if (url) {
      const m = url.match(/^https?:\/\/github\.com\/([^\/]+)\/([^\/#?]+)/i);
      if (m) return `${m[1]}/${m[2]}`;
    }
    return null;
  }

  function parseCsv(text) {
    const result = [];
    const lines = text.split(/\r?\n/).filter((l) => l.trim());

    for (let line of lines) {
      const row = [];
      let inQuote = false;
      let token = "";

      for (let i = 0; i < line.length; i++) {
        const char = line[i];

        if (inQuote) {
          if (char === '"') {
            if (line[i + 1] === '"') {
              token += '"';
              i++;
            } else {
              inQuote = false;
            }
          } else {
            token += char;
          }
        } else {
          if (char === '"') inQuote = true;
          else if (char === ",") {
            row.push(token);
            token = "";
          } else token += char;
        }
      }

      row.push(token);
      result.push(row);
    }

    return result;
  }

  function escapeHtml(s) {
    return String(s)
      .replace(/&/g, "&amp;")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;")
      .replace(/"/g, "&quot;");
  }

  // --- GraphQL wrapper ---

  function makeAbortError() {
    return new DOMException("Aborted", "AbortError");
  }

  async function ghGraphql(token, query, variables = {}, { signal } = {}, retryCount = 0) {
    const doRequest = () =>
      new Promise((resolve, reject) => {
        if (signal?.aborted) return reject(makeAbortError());

        let settled = false;
        const req = GM_xmlhttpRequest({
          method: "POST",
          url: "https://api.github.com/graphql",
          headers: {
            "Content-Type": "application/json",
            Accept: "application/vnd.github+json",
            Authorization: `bearer ${token}`,
          },
          data: JSON.stringify({ query, variables }),
          timeout: 20000,
          onload: (resp) => {
            if (settled) return;
            settled = true;

            if (resp.status === 401) return reject(new Error("Token 无效 (401)"));

            if (resp.status === 403 || resp.status === 429) {
              let msg = `Rate Limit/Abuse detected (${resp.status})`;
              try {
                const j = JSON.parse(resp.responseText);
                if (j?.message) msg = j.message;
              } catch {}
              return reject(new Error(msg));
            }

            try {
              const json = JSON.parse(resp.responseText);
              if (json.errors?.length) return reject(new Error(json.errors[0].message));
              resolve(json.data);
            } catch (e) {
              reject(e);
            }
          },
          onerror: () => {
            if (!settled) {
              settled = true;
              reject(new Error("Network Error"));
            }
          },
          ontimeout: () => {
            if (!settled) {
              settled = true;
              reject(new Error("Timeout"));
            }
          },
        });

        if (signal) {
          signal.addEventListener(
            "abort",
            () => {
              if (settled) return;
              settled = true;
              try {
                req.abort();
              } catch {}
              reject(makeAbortError());
            },
            { once: true }
          );
        }
      });

    try {
      return await doRequest();
    } catch (e) {
      if (e?.name === "AbortError") throw e;

      if (retryCount < 2) {
        const isNetError = e.message === "Network Error" || e.message === "Timeout";
        const isRateLimit = /rate|abuse|secondary/i.test(e.message || "");

        if (isNetError || isRateLimit) {
          const delay = isRateLimit ? (retryCount + 1) * 2500 : 1000;
          await new Promise((r) => setTimeout(r, delay));
          return ghGraphql(token, query, variables, { signal }, retryCount + 1);
        }
      }
      throw e;
    }
  }

  // --- Star Lists API ---

  async function detectListApi(token, signal) {
    const fields = ["lists", "userLists"];
    for (const f of fields) {
      try {
        await ghGraphql(token, `query { viewer { ${f}(first:1) { totalCount } } }`, {}, { signal });
        return { viewerField: f };
      } catch (e) {}
    }
    throw new Error("API 探测失败: 无法找到 viewer.lists / viewer.userLists 字段");
  }

  async function fetchAllLists(token, listApi, signal) {
    const out = [];
    let after = null;

    while (true) {
      if (signal?.aborted) throw new DOMException("Aborted", "AbortError");

      const q = `
        query($n:Int!, $after:String) {
          viewer {
            ${listApi.viewerField}(first:$n, after:$after) {
              nodes { id name }
              pageInfo { hasNextPage endCursor }
            }
          }
        }
      `;

      const d = await ghGraphql(token, q, { n: 100, after }, { signal });
      const conn = d.viewer[listApi.viewerField];
      out.push(...(conn.nodes || []));

      if (!conn.pageInfo.hasNextPage) break;
      after = conn.pageInfo.endCursor;
    }

    return out;
  }

  async function createUserList(token, name, isPrivate, description, signal) {
    // CreateUserListPayload 字段是 list,而不是 userList
    const q = `
      mutation($input: CreateUserListInput!) {
        createUserList(input: $input) {
          list { id name }
        }
      }
    `;
    const input = { name, isPrivate };
    if (description) input.description = description;

    const d = await ghGraphql(token, q, { input }, { signal });
    if (!d?.createUserList?.list) throw new Error("createUserList 返回为空(可能是权限或 schema 变更)");
    return d.createUserList.list;
  }

  async function getRepository(token, owner, name, signal) {
    const q = `query($o:String!, $n:String!) { repository(owner:$o, name:$n) { id viewerHasStarred } }`;
    try {
      const d = await ghGraphql(token, q, { o: owner, n: name }, { signal });
      return d.repository;
    } catch (e) {
      if (String(e.message || "").includes("Could not resolve to a Repository")) return null;
      return null;
    }
  }

  async function addStar(token, starrableId, signal) {
    const q = `mutation($input: AddStarInput!) { addStar(input:$input) { starrable { id } } }`;
    return ghGraphql(token, q, { input: { starrableId } }, { signal });
  }

  async function updateUserListsForItem(token, itemId, listIds, signal) {
    const q = `
      mutation($input: UpdateUserListsForItemInput!) {
        updateUserListsForItem(input: $input) { clientMutationId }
      }
    `;
    return ghGraphql(token, q, { input: { itemId, listIds } }, { signal });
  }
})();