GitHub Starred Repos Exporter

导出指定用户的 Star 仓库列表及 Readme,生成 CSV。使用 API 获取列表,HTML 抓取内容以避开 API 限制。

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Для установки этого скрипта вам необходимо установить расширение, такое как Tampermonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

// ==UserScript==
// @name         GitHub Starred Repos Exporter
// @namespace    http://tampermonkey.net/
// @version      1.3
// @description  导出指定用户的 Star 仓库列表及 Readme,生成 CSV。使用 API 获取列表,HTML 抓取内容以避开 API 限制。
// @author       blackzero358
// @license      AGPLv3
// @icon https://github.githubassets.com/images/icons/emoji/unicode/1f4e5.png
// @match        https://github.com/*
// @connect      api.github.com
// @connect      github.com
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// ==/UserScript==

(function() {
    'use strict';

    // ================= 配置区域 =================
    const CONFIG = {
        // 并发数:建议 3-5,过高可能会触发 GitHub 的 429 Too Many Requests
        concurrency: 4,
        // 是否包含 Readme 内容 (如果只需列表可设为 false,速度极快)
        includeReadme: true
    };
    // ===========================================

    // UI 样式注入
    const style = document.createElement('style');
    style.innerHTML = `
        #gh-export-btn {
            position: fixed; bottom: 20px; right: 20px; z-index: 9999;
            background: #2ea44f; color: white; border: none; padding: 10px 20px;
            border-radius: 6px; cursor: pointer; font-weight: bold;
            box-shadow: 0 4px 12px rgba(0,0,0,0.15); font-family: sans-serif;
            transition: transform 0.2s;
        }
        #gh-export-btn:hover { transform: scale(1.05); background: #2c974b; }
        #gh-export-btn:disabled { background: #94d3a2; cursor: not-allowed; }
        #gh-export-status {
            position: fixed; bottom: 70px; right: 20px; z-index: 9999;
            background: #24292f; color: #fff; padding: 10px; border-radius: 6px;
            font-size: 12px; display: none; max-width: 300px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.2);
        }
    `;
    document.head.appendChild(style);

    // 创建按钮和状态栏
    const btn = document.createElement('button');
    btn.id = 'gh-export-btn';
    btn.innerText = '📥 导出 Star 数据';
    document.body.appendChild(btn);

    const statusBox = document.createElement('div');
    statusBox.id = 'gh-export-status';
    document.body.appendChild(statusBox);

    // 更新状态显示的辅助函数
    function log(msg) {
        statusBox.style.display = 'block';
        statusBox.innerText = msg;
        console.log(`[Export] ${msg}`);
    }

    // 主逻辑
    btn.onclick = async () => {
        const username = prompt("请输入要导出的 GitHub 用户名:", "");
        if (!username) return;

        btn.disabled = true;
        btn.innerText = '⏳ 正在获取列表...';

        try {
            // 第一步:获取所有 Star 的仓库列表 (使用 API)
            const repos = await fetchAllStarred(username);

            if (repos.length === 0) {
                alert("未找到 Star 的仓库或用户不存在。");
                resetUI();
                return;
            }

            // 第二步:并行抓取 Readme (使用 HTML Parsing)
            if (CONFIG.includeReadme) {
                btn.innerText = `0/${repos.length} 正在抓取 Readme...`;
                await processQueue(repos, CONFIG.concurrency, (completed, total) => {
                    btn.innerText = `⏳ ${completed}/${total} 抓取中...`;
                    log(`进度: ${completed}/${total} | 刚刚完成: ${repos[completed-1]?.name}`);
                });
            }

            // 第三步:生成并下载 CSV
            downloadCSV(repos, `${username}_starred_repos.csv`);
            log("✅ 导出完成!");
            alert(`导出成功!共 ${repos.length} 个仓库。`);

        } catch (e) {
            console.error(e);
            alert(`发生错误: ${e.message}`);
        } finally {
            resetUI();
        }
    };

    function resetUI() {
        btn.disabled = false;
        btn.innerText = '📥 导出 Star 数据';
        setTimeout(() => { statusBox.style.display = 'none'; }, 5000);
    }

    // ----------------------------------------------------------------
    // 核心功能函数
    // ----------------------------------------------------------------

    // 1. 获取所有 Star 列表 (处理分页)
    async function fetchAllStarred(username) {
        let page = 1;
        let allRepos = [];
        const perPage = 100; // API 允许的最大单页数量

        while (true) {
            log(`正在获取 API 列表第 ${page} 页...`);
            // 使用 fetch,因为这是同源(api.github.com)请求,或者简单的 GET
            // 注意:如果在非登录状态下频繁调用可能会触发 60次/小时 限制
            // 这里使用了 fetch,如果浏览器里已经登录了 GitHub,通常会带上 cookie 或者是作为未认证请求
            const response = await fetch(`https://api.github.com/users/${username}/starred?per_page=${perPage}&page=${page}`, {
                headers: { 'Accept': 'application/vnd.github.v3+json' }
            });

            if (!response.ok) throw new Error(`API 请求失败: ${response.status}`);

            const data = await response.json();
            if (data.length === 0) break;

            // 提取关键字段,准备数据对象
            const pageRepos = data.map(repo => ({
                name: repo.name,
                full_name: repo.full_name,
                url: repo.html_url,
                description: repo.description || "",
                readme: "Pending..." // 占位符
            }));

            allRepos = allRepos.concat(pageRepos);

            // 如果返回数量少于 perPage,说明是最后一页
            if (data.length < perPage) break;
            page++;
        }
        return allRepos;
    }

    // 2. 任务队列处理器 (控制并发)
    async function processQueue(items, concurrency, onProgress) {
        let index = 0;
        let completed = 0;

        const worker = async () => {
            while (index < items.length) {
                const currentIdx = index++;
                const repo = items[currentIdx];

                try {
                    // 抓取 Readme
                    const readmeText = await fetchReadmeFromHTML(repo.url);
                    repo.readme = readmeText;
                } catch (err) {
                    repo.readme = `[Error: ${err.message}]`;
                }

                completed++;
                if (onProgress) onProgress(completed, items.length);
            }
        };

        const workers = [];
        for (let i = 0; i < concurrency; i++) {
            workers.push(worker());
        }
        return Promise.all(workers);
    }

    // 3. 通过 HTML 抓取 Readme (避开 API Rate Limit)
    function fetchReadmeFromHTML(repoUrl) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "GET",
                url: repoUrl,
                onload: function(response) {
                    if (response.status !== 200) {
                        resolve("无法访问仓库页面");
                        return;
                    }
                    // 解析 HTML
                    const parser = new DOMParser();
                    const doc = parser.parseFromString(response.responseText, "text/html");

                    // GitHub Readme 通常在 article.markdown-body 标签内
                    const readmeNode = doc.querySelector('article.markdown-body') || doc.querySelector('.markdown-body');

                    if (readmeNode) {
                        // 获取纯文本,去除多余空白
                        // 如果你想要 Markdown 源码,这里需要解析 'Raw' 按钮的链接再请求一次,成本较高
                        // 这里返回纯文本预览
                        resolve(readmeNode.innerText.trim().substring(0, 5000)); // 限制长度防止 CSV 爆炸
                    } else {
                        resolve("无 Readme 或无法解析");
                    }
                },
                onerror: function(err) {
                    resolve("网络请求错误");
                }
            });
        });
    }

    // 4. CSV 生成与下载
    function downloadCSV(data, filename) {
        // CSV 头部
        const headers = ["Repository Name", "URL", "Description", "Readme Content"];

        // 处理 CSV 转义 (处理双引号、换行符)
        const escapeCSV = (str) => {
            if (str == null) return "";
            str = String(str);
            // 将双引号替换为两个双引号
            str = str.replace(/"/g, '""');
            // 如果包含逗号、双引号或换行符,则用双引号包裹
            if (str.search(/("|,|\n)/g) >= 0) {
                str = `"${str}"`;
            }
            return str;
        };

        const rows = data.map(repo => {
            return [
                escapeCSV(repo.full_name),
                escapeCSV(repo.url),
                escapeCSV(repo.description),
                escapeCSV(repo.readme) // Readme 内容可能很长
            ].join(",");
        });

        const csvContent = "\uFEFF" + [headers.join(","), ...rows].join("\n"); // 添加 BOM 防止乱码
        const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
        const link = document.createElement("a");
        if (link.download !== undefined) {
            const url = URL.createObjectURL(blob);
            link.setAttribute("href", url);
            link.setAttribute("download", filename);
            link.style.visibility = 'hidden';
            document.body.appendChild(link);
            link.click();
            document.body.removeChild(link);
        }
    }

})();