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);
        }
    }

})();