github-discover 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # github-discover-cli
2
+
3
+ [中文](./README_zh.md) | English
4
+
5
+ A lightweight CLI tool to discover **newly created and fast-growing** open-source repositories and trending topics on GitHub.
6
+
7
+ Uses the GitHub Search API to filter high-star repos, track growth trends, and surface tech hotspots by day, week, month, or year.
8
+
9
+ ## Features
10
+
11
+ - **`trending`** — Smart ranking by stars-per-day, highlighting truly fast-rising projects
12
+ - **`popular`** — Newly created high-star repos within a given time period, sorted by star count
13
+ - **`topic`** — Trending topics across popular repos, scored by frequency and total stars
14
+ - Filter by **programming language**, with output as a **terminal table** or **JSON**
15
+
16
+ ## Installation
17
+
18
+ Requires [Node.js](https://nodejs.org/) **18+**.
19
+
20
+ ```bash
21
+ git clone https://github.com/coderyi/github-discover-cli.git
22
+ cd github-discover-cli
23
+ npm install && npm run build
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ### `trending` — Growth Trends
29
+
30
+ Ranks repos by stars-per-day (with age smoothing), rather than raw star count.
31
+
32
+ ```bash
33
+ github-discover trending # fastest growing repos recently
34
+ github-discover trending -p monthly -l Python # monthly, Python only
35
+ ```
36
+
37
+ ### `popular` — Popular New Repos
38
+
39
+ ```bash
40
+ github-discover popular # popular repos from the past day
41
+ github-discover popular -p weekly -l Rust # past week, Rust only
42
+ github-discover popular -p monthly -n 10 --json # monthly, top 10, JSON output
43
+ ```
44
+
45
+ ### `topic` — Topic Insights
46
+
47
+ Aggregates frequent topic tags from popular repos, scored by occurrence count and total associated stars.
48
+
49
+ ```bash
50
+ github-discover topic # recent trending topics
51
+ github-discover topic -p yearly -n 20 # yearly, top 20
52
+ ```
53
+
54
+ ## Options
55
+
56
+ All subcommands support the following options:
57
+
58
+ | Option | Short | Description | Default |
59
+ | --- | --- | --- | --- |
60
+ | `--period <period>` | `-p` | Time window: `daily` \| `weekly` \| `monthly` \| `yearly` | `daily` |
61
+ | `--limit <number>` | `-n` | Number of results (1–100) | `50` (topic: `30`) |
62
+ | `--language <lang>` | `-l` | Programming language filter | none |
63
+ | `--min-stars <number>` | `-s` | Minimum star threshold | auto by period \* |
64
+ | `--json` | — | Output as JSON | `false` |
65
+
66
+ \* `--min-stars` defaults: daily `60` · weekly `500` · monthly `4000` · yearly `10000`
67
+
68
+ ## Output Examples
69
+
70
+ **Table mode (default):**
71
+
72
+ | Name | Stars | Lang | Created | URL | Description |
73
+ | ------------ | ----- | ---------- | ---------- | ------------------------------- | ----------- |
74
+ | owner1/repo1 | 1234 | TypeScript | 2026-03-09 | https://github.com/owner1/repo1 | description |
75
+
76
+ **JSON mode** — pipe to `jq` for further processing:
77
+
78
+ ```bash
79
+ github-discover trending --json | jq '.[].full_name'
80
+ ```
81
+
82
+
83
+
84
+ ## License
85
+
86
+ MIT
package/README_zh.md ADDED
@@ -0,0 +1,85 @@
1
+ # github-discover-cli
2
+
3
+ 中文 | [English](./README.md)
4
+
5
+ 一个轻量级命令行工具,帮助你发现 GitHub 上**新创建且快速增长**的开源仓库和热门话题。
6
+
7
+ 通过 GitHub Search API,按日 / 周 / 月 / 年维度筛选高星仓库、追踪增长趋势、洞察技术热点。
8
+
9
+ ## 功能亮点
10
+
11
+ - **增长趋势** (`trending`) — 基于「日均星标数」智能排序,突出真正快速崛起的项目
12
+ - **热门仓库** (`popular`) — 指定时间段内新创建的高星仓库,按星标数排序
13
+ - **话题洞察** (`topic`) — 热门仓库中的高频话题,综合频率与星标数评分排名
14
+ - 支持按**编程语言**过滤,输出**终端表格**或 **JSON**
15
+
16
+ ## 安装
17
+
18
+ 需要 [Node.js](https://nodejs.org/) **18+**
19
+
20
+ ```bash
21
+ git clone https://github.com/coderyi/github-discover-cli.git
22
+ cd github-discover-cli
23
+ npm install && npm run build
24
+ ```
25
+
26
+ ## 使用方法
27
+
28
+ ### `trending` — 增长趋势
29
+
30
+ 使用「日均星标数」(带平滑因子)衡量增长速度,区别于简单的星标排序。
31
+
32
+ ```bash
33
+ github-discover trending # 近期增长最快的仓库
34
+ github-discover trending -p monthly -l Python # 月度,限定 Python
35
+ ```
36
+
37
+ ### `popular` — 热门新仓库
38
+
39
+ ```bash
40
+ github-discover popular # 过去一天的热门仓库
41
+ github-discover popular -p weekly -l Rust # 过去一周,限定 Rust
42
+ github-discover popular -p monthly -n 10 --json # 月度,前 10 个,JSON 输出
43
+ ```
44
+
45
+ ### `topic` — 话题洞察
46
+
47
+ 统计热门仓库中的高频话题标签,综合出现次数和关联星标总数评分。
48
+
49
+ ```bash
50
+ github-discover topic # 近期热门话题
51
+ github-discover topic -p yearly -n 20 # 年度,前 20 个
52
+ ```
53
+
54
+ ## 命令参考
55
+
56
+ 所有子命令均支持以下选项:
57
+
58
+ | 选项 | 简写 | 说明 | 默认值 |
59
+ | --- | --- | --- | --- |
60
+ | `--period <period>` | `-p` | 时间维度:`daily` \| `weekly` \| `monthly` \| `yearly` | `daily` |
61
+ | `--limit <number>` | `-n` | 返回结果数量(1–100) | `50`(topic 为 `30`) |
62
+ | `--language <lang>` | `-l` | 编程语言过滤 | 不限 |
63
+ | `--min-stars <number>` | `-s` | 最低星标数阈值 | 按时间维度自动设定 \* |
64
+ | `--json` | — | 以 JSON 格式输出 | `false` |
65
+
66
+ \* `--min-stars` 默认值:daily `60` · weekly `500` · monthly `4000` · yearly `10000`
67
+
68
+ ## 输出示例
69
+
70
+ **表格模式(默认):**
71
+
72
+ | Name | Stars | Lang | Created | URL | Description |
73
+ | ------------ | ----- | ---------- | ---------- | ------------------------------- | ----------- |
74
+ | owner1/repo1 | 1234 | TypeScript | 2026-03-09 | https://github.com/owner1/repo1 | description |
75
+
76
+ **JSON 模式** 可配合 `jq` 做进一步处理:
77
+
78
+ ```bash
79
+ github-discover trending --json | jq '.[].full_name'
80
+ ```
81
+
82
+
83
+ ## 许可证
84
+
85
+ MIT
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "github-discover",
3
+ "version": "0.1.0",
4
+ "description": "A minimal CLI to discover newly created popular GitHub repositories by day/week/month.",
5
+ "bin": {
6
+ "github-discover": "dist/cli.js"
7
+ },
8
+ "type": "commonjs",
9
+ "main": "dist/cli.js",
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "start": "node dist/cli.js",
13
+ "dev": "ts-node src/cli.ts"
14
+ },
15
+ "keywords": [
16
+ "github",
17
+ "cli",
18
+ "discover",
19
+ "repositories"
20
+ ],
21
+ "author": "",
22
+ "license": "ISC",
23
+ "dependencies": {
24
+ "commander": "^13.1.0",
25
+ "undici": "^6.23.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^25.4.0",
29
+ "typescript": "^5.9.3"
30
+ }
31
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from "commander";
4
+ import { runPopularCommand } from "./commands/popular";
5
+ import { runTrendingCommand } from "./commands/trending";
6
+ import { runTopicCommand } from "./commands/topic";
7
+
8
+ const program = new Command();
9
+
10
+ program
11
+ .name("github-discover")
12
+ .description(
13
+ "Discover newly created popular GitHub repositories and trending topics (daily/weekly/monthly/yearly)."
14
+ )
15
+ .version("0.1.0");
16
+
17
+ program
18
+ .command("trending")
19
+ .description(
20
+ "List trending repositories based on lifetime stars per day, adjusted by repository age."
21
+ )
22
+ .option(
23
+ "-p, --period <period>",
24
+ "time period for repository creation window: daily | weekly | monthly | yearly",
25
+ "daily"
26
+ )
27
+ .option(
28
+ "-n, --limit <number>",
29
+ "maximum number of repositories to return (1-100)",
30
+ "50"
31
+ )
32
+ .option(
33
+ "-l, --language <language>",
34
+ "optional programming language filter, e.g. TypeScript"
35
+ )
36
+ .option(
37
+ "-s, --min-stars <number>",
38
+ "minimum star count threshold (defaults: daily 60, weekly 500, monthly 4000, yearly 10000)"
39
+ )
40
+ .option("--json", "output JSON instead of a table", false)
41
+ .action(async (options) => {
42
+ try {
43
+ await runTrendingCommand(options);
44
+ } catch (err) {
45
+ const error = err as Error;
46
+ console.error("Error:", error.message);
47
+ process.exitCode = 1;
48
+ }
49
+ });
50
+
51
+ program
52
+ .command("popular")
53
+ .description("List newly created popular repositories for a given period.")
54
+ .option(
55
+ "-p, --period <period>",
56
+ "time period: daily | weekly | monthly | yearly",
57
+ "daily"
58
+ )
59
+ .option(
60
+ "-n, --limit <number>",
61
+ "maximum number of repositories to return (1-100)",
62
+ "50"
63
+ )
64
+ .option(
65
+ "-l, --language <language>",
66
+ "optional programming language filter, e.g. TypeScript"
67
+ )
68
+ .option(
69
+ "-s, --min-stars <number>",
70
+ "minimum star count threshold (defaults: daily 60, weekly 500, monthly 4000, yearly 10000)"
71
+ )
72
+ .option("--json", "output JSON instead of a table", false)
73
+ .action(async (options) => {
74
+ try {
75
+ await runPopularCommand(options);
76
+ } catch (err) {
77
+ const error = err as Error;
78
+ console.error("Error:", error.message);
79
+ process.exitCode = 1;
80
+ }
81
+ });
82
+
83
+ program
84
+ .command("topic")
85
+ .description(
86
+ "List trending repository topics for a given period based on frequency and stars."
87
+ )
88
+ .option(
89
+ "-p, --period <period>",
90
+ "time period: daily | weekly | monthly | yearly",
91
+ "daily"
92
+ )
93
+ .option(
94
+ "-n, --limit <number>",
95
+ "maximum number of topics to return (1-100)",
96
+ "30"
97
+ )
98
+ .option(
99
+ "-l, --language <language>",
100
+ "optional programming language filter, e.g. TypeScript"
101
+ )
102
+ .option(
103
+ "-s, --min-stars <number>",
104
+ "minimum star count threshold (defaults: daily 60, weekly 500, monthly 4000, yearly 10000)"
105
+ )
106
+ .option("--json", "output JSON instead of a table", false)
107
+ .action(async (options) => {
108
+ try {
109
+ await runTopicCommand(options);
110
+ } catch (err) {
111
+ const error = err as Error;
112
+ console.error("Error:", error.message);
113
+ process.exitCode = 1;
114
+ }
115
+ });
116
+
117
+ program.parseAsync(process.argv).catch((err) => {
118
+ const error = err as Error;
119
+ console.error("Error:", error.message);
120
+ process.exit(1);
121
+ });
122
+
@@ -0,0 +1,68 @@
1
+ import { GitHubClient } from "../github/client";
2
+ import { searchNewPopularRepositories } from "../github/repoSearch";
3
+ import { getStartDate, Period } from "../utils/dateRange";
4
+ import { printRepositories } from "../utils/output";
5
+
6
+ interface PopularCliOptions {
7
+ period?: string;
8
+ limit?: string;
9
+ language?: string;
10
+ minStars?: string;
11
+ json?: boolean;
12
+ }
13
+
14
+ export async function runPopularCommand(
15
+ options: PopularCliOptions
16
+ ): Promise<void> {
17
+ const periodInput = (options.period ?? "daily").toLowerCase();
18
+ const validPeriods: Period[] = ["daily", "weekly", "monthly", "yearly"];
19
+ const defaultMinStarsByPeriod: Record<Period, number> = {
20
+ daily: 60,
21
+ weekly: 500,
22
+ monthly: 4000,
23
+ yearly: 10000,
24
+ };
25
+
26
+ if (!validPeriods.includes(periodInput as Period)) {
27
+ throw new Error(
28
+ `Invalid period "${options.period}". Expected one of: daily, weekly, monthly, yearly.`
29
+ );
30
+ }
31
+
32
+ const period = periodInput as Period;
33
+
34
+ const limit = clampToInt(options.limit ?? "50", 1, 100, "limit");
35
+ const minStars = clampToInt(
36
+ options.minStars ?? String(defaultMinStarsByPeriod[period]),
37
+ 1,
38
+ Number.MAX_SAFE_INTEGER,
39
+ "min-stars"
40
+ );
41
+
42
+ const startDate = getStartDate(period);
43
+ const client = new GitHubClient();
44
+
45
+ const repos = await searchNewPopularRepositories(client, {
46
+ startDate,
47
+ minStars,
48
+ language: options.language,
49
+ limit,
50
+ });
51
+
52
+ printRepositories(repos, { json: Boolean(options.json) });
53
+ }
54
+
55
+ function clampToInt(
56
+ valueStr: string,
57
+ min: number,
58
+ max: number,
59
+ label: string
60
+ ): number {
61
+ const value = Number.parseInt(valueStr, 10);
62
+ if (Number.isNaN(value)) {
63
+ throw new Error(`Invalid number for ${label}: "${valueStr}".`);
64
+ }
65
+ const clamped = Math.min(Math.max(value, min), max);
66
+ return clamped;
67
+ }
68
+
@@ -0,0 +1,85 @@
1
+ import { GitHubClient } from "../github/client";
2
+ import {
3
+ searchRepositoriesForTopics,
4
+ TopicOptionsInternal,
5
+ } from "../github/repoSearch";
6
+ import { aggregateTopicStats } from "../github/topicStats";
7
+ import { getStartDate, Period } from "../utils/dateRange";
8
+ import { printTopicStats } from "../utils/output";
9
+
10
+ interface TopicCliOptions {
11
+ period?: string;
12
+ limit?: string;
13
+ language?: string;
14
+ minStars?: string;
15
+ json?: boolean;
16
+ }
17
+
18
+ export async function runTopicCommand(
19
+ options: TopicCliOptions
20
+ ): Promise<void> {
21
+ const periodInput = (options.period ?? "daily").toLowerCase();
22
+ const validPeriods: Period[] = ["daily", "weekly", "monthly", "yearly"];
23
+
24
+ const defaultMinStarsByPeriod: Record<Period, number> = {
25
+ daily: 60,
26
+ weekly: 500,
27
+ monthly: 4000,
28
+ yearly: 10000,
29
+ };
30
+
31
+ if (!validPeriods.includes(periodInput as Period)) {
32
+ throw new Error(
33
+ `Invalid period "${options.period}". Expected one of: daily, weekly, monthly, yearly.`
34
+ );
35
+ }
36
+
37
+ const period = periodInput as Period;
38
+
39
+ const limit = clampToInt(options.limit ?? "30", 1, 100, "limit");
40
+ const minStars = clampToInt(
41
+ options.minStars ?? String(defaultMinStarsByPeriod[period]),
42
+ 1,
43
+ Number.MAX_SAFE_INTEGER,
44
+ "min-stars"
45
+ );
46
+
47
+ const startDate = getStartDate(period);
48
+ const client = new GitHubClient();
49
+
50
+ const searchOptions: TopicOptionsInternal = {
51
+ startDate,
52
+ minStars,
53
+ language: options.language,
54
+ limit,
55
+ };
56
+
57
+ const { totalCount, repos } = await searchRepositoriesForTopics(
58
+ client,
59
+ searchOptions
60
+ );
61
+
62
+
63
+ const stats = aggregateTopicStats(repos, limit);
64
+
65
+ if (stats.length === 0) {
66
+ return;
67
+ }
68
+
69
+ printTopicStats(stats, { json: Boolean(options.json) });
70
+ }
71
+
72
+ function clampToInt(
73
+ valueStr: string,
74
+ min: number,
75
+ max: number,
76
+ label: string
77
+ ): number {
78
+ const value = Number.parseInt(valueStr, 10);
79
+ if (Number.isNaN(value)) {
80
+ throw new Error(`Invalid number for ${label}: "${valueStr}".`);
81
+ }
82
+ const clamped = Math.min(Math.max(value, min), max);
83
+ return clamped;
84
+ }
85
+
@@ -0,0 +1,76 @@
1
+ import { GitHubClient } from "../github/client";
2
+ import { searchLifetimeFastGrowingRepositories } from "../github/repoSearch";
3
+ import { Period } from "../utils/dateRange";
4
+ import { printRepositories } from "../utils/output";
5
+
6
+ interface TrendingCliOptions {
7
+ period?: string;
8
+ limit?: string;
9
+ language?: string;
10
+ minStars?: string;
11
+ json?: boolean;
12
+ }
13
+
14
+ export async function runTrendingCommand(
15
+ options: TrendingCliOptions
16
+ ): Promise<void> {
17
+ const periodInput = (options.period ?? "daily").toLowerCase();
18
+ const validPeriods: Period[] = ["daily", "weekly", "monthly", "yearly"];
19
+
20
+ const windowDaysByPeriod: Record<Period, number> = {
21
+ daily: 7,
22
+ weekly: 28,
23
+ monthly: 90,
24
+ yearly: 730,
25
+ };
26
+
27
+ const defaultMinStarsByPeriod: Record<Period, number> = {
28
+ daily: 60,
29
+ weekly: 500,
30
+ monthly: 4000,
31
+ yearly: 10000,
32
+ };
33
+
34
+ if (!validPeriods.includes(periodInput as Period)) {
35
+ throw new Error(
36
+ `Invalid period "${options.period}". Expected one of: daily, weekly, monthly, yearly.`
37
+ );
38
+ }
39
+
40
+ const period = periodInput as Period;
41
+
42
+ const limit = clampToInt(options.limit ?? "50", 1, 100, "limit");
43
+ const minStars = clampToInt(
44
+ options.minStars ?? String(defaultMinStarsByPeriod[period]),
45
+ 1,
46
+ Number.MAX_SAFE_INTEGER,
47
+ "min-stars"
48
+ );
49
+
50
+ const windowDays = windowDaysByPeriod[period];
51
+ const client = new GitHubClient();
52
+
53
+ const repos = await searchLifetimeFastGrowingRepositories(client, {
54
+ windowDays,
55
+ minStars,
56
+ language: options.language,
57
+ limit,
58
+ });
59
+
60
+ printRepositories(repos, { json: Boolean(options.json) });
61
+ }
62
+
63
+ function clampToInt(
64
+ valueStr: string,
65
+ min: number,
66
+ max: number,
67
+ label: string
68
+ ): number {
69
+ const value = Number.parseInt(valueStr, 10);
70
+ if (Number.isNaN(value)) {
71
+ throw new Error(`Invalid number for ${label}: "${valueStr}".`);
72
+ }
73
+ const clamped = Math.min(Math.max(value, min), max);
74
+ return clamped;
75
+ }
76
+
package/src/config.ts ADDED
@@ -0,0 +1,8 @@
1
+ export function getGitHubToken(): string | undefined {
2
+ return process.env.GITHUB_TOKEN;
3
+ }
4
+
5
+ export function getUserAgent(): string {
6
+ return "github-discover-cli/0.1.0";
7
+ }
8
+
@@ -0,0 +1,78 @@
1
+ import { getGitHubToken, getUserAgent } from "../config";
2
+
3
+ export interface GitHubRequestParams {
4
+ [key: string]: string | number | boolean | undefined;
5
+ }
6
+
7
+ export interface GitHubErrorDetails {
8
+ status: number;
9
+ message: string;
10
+ documentation_url?: string;
11
+ }
12
+
13
+ export class GitHubClient {
14
+ private readonly baseUrl = "https://api.github.com";
15
+
16
+ async request<T>(path: string, params: GitHubRequestParams = {}): Promise<T> {
17
+ const url = new URL(this.baseUrl + path);
18
+
19
+ Object.entries(params).forEach(([key, value]) => {
20
+ if (value === undefined) return;
21
+ url.searchParams.set(key, String(value));
22
+ });
23
+
24
+ const headers: Record<string, string> = {
25
+ "User-Agent": getUserAgent(),
26
+ Accept: "application/vnd.github+json",
27
+ };
28
+
29
+ const token = getGitHubToken();
30
+ if (token) {
31
+ headers.Authorization = `token ${token}`;
32
+ }
33
+
34
+ if (typeof fetch === "undefined") {
35
+ throw new Error(
36
+ "Global fetch is not available. Please run this CLI with Node.js 18+ where fetch is built-in."
37
+ );
38
+ }
39
+
40
+ const response = await fetch(url.toString(), {
41
+ method: "GET",
42
+ headers,
43
+ });
44
+
45
+ const text = await response.text();
46
+ let json: any;
47
+ try {
48
+ json = text ? JSON.parse(text) : undefined;
49
+ } catch {
50
+ json = undefined;
51
+ }
52
+
53
+ if (!response.ok) {
54
+ const status = response.status;
55
+ const message =
56
+ (json && (json.message as string)) ||
57
+ `GitHub API request failed with status ${status}`;
58
+
59
+ if (status === 401 || status === 403) {
60
+ const hint = getGitHubToken()
61
+ ? "Check that your GITHUB_TOKEN is valid and has sufficient scopes."
62
+ : "You may be hitting anonymous rate limits. Set GITHUB_TOKEN to increase limits.";
63
+ throw new Error(`${message} (${status}). ${hint}`);
64
+ }
65
+
66
+ if (status === 422) {
67
+ throw new Error(
68
+ `${message} (${status}). Please check your search parameters.`
69
+ );
70
+ }
71
+
72
+ throw new Error(`${message} (${status}).`);
73
+ }
74
+
75
+ return json as T;
76
+ }
77
+ }
78
+
@@ -0,0 +1,175 @@
1
+ import { GitHubClient } from "./client";
2
+
3
+ export interface Repo {
4
+ name: string;
5
+ full_name: string;
6
+ html_url: string;
7
+ stargazers_count: number;
8
+ language: string | null;
9
+ description: string | null;
10
+ created_at: string;
11
+ topics?: string[];
12
+ }
13
+
14
+ interface SearchRepositoriesResponse<T = Repo> {
15
+ total_count: number;
16
+ items: T[];
17
+ }
18
+
19
+ export interface DiscoverOptionsInternal {
20
+ startDate: string;
21
+ minStars: number;
22
+ language?: string;
23
+ limit: number;
24
+ }
25
+
26
+ export interface GrowthOptionsInternal {
27
+ windowDays: number;
28
+ minStars: number;
29
+ language?: string;
30
+ limit: number;
31
+ }
32
+
33
+ export interface TopicOptionsInternal {
34
+ startDate: string;
35
+ minStars: number;
36
+ language?: string;
37
+ limit: number;
38
+ }
39
+
40
+ export async function searchNewPopularRepositories(
41
+ client: GitHubClient,
42
+ options: DiscoverOptionsInternal
43
+ ): Promise<Repo[]> {
44
+ const { startDate, minStars, language, limit } = options;
45
+
46
+ const qualifiers: string[] = [
47
+ `created:>=${startDate}`,
48
+ `stars:>=${minStars}`,
49
+ ];
50
+
51
+ if (language) {
52
+ qualifiers.push(`language:${language}`);
53
+ }
54
+
55
+ const q = qualifiers.join(" ");
56
+
57
+ const perPage = Math.min(Math.max(limit, 1), 100);
58
+
59
+ const data = await client.request<SearchRepositoriesResponse>(
60
+ "/search/repositories",
61
+ {
62
+ q,
63
+ sort: "stars",
64
+ order: "desc",
65
+ per_page: perPage,
66
+ }
67
+ );
68
+
69
+ return data.items.slice(0, limit);
70
+ }
71
+
72
+ export interface TopicSearchResult {
73
+ totalCount: number;
74
+ repos: Repo[];
75
+ }
76
+
77
+ export async function searchRepositoriesForTopics(
78
+ client: GitHubClient,
79
+ options: TopicOptionsInternal
80
+ ): Promise<TopicSearchResult> {
81
+ const { startDate, minStars, language } = options;
82
+
83
+ const qualifiers: string[] = [`created:>=${startDate}`, `stars:>=${minStars}`];
84
+
85
+ if (language) {
86
+ qualifiers.push(`language:${language}`);
87
+ }
88
+
89
+ const q = qualifiers.join(" ");
90
+
91
+ const data = await client.request<SearchRepositoriesResponse>(
92
+ "/search/repositories",
93
+ {
94
+ q,
95
+ sort: "stars",
96
+ order: "desc",
97
+ per_page: 100,
98
+ }
99
+ );
100
+
101
+ return {
102
+ totalCount: data.total_count,
103
+ repos: data.items,
104
+ };
105
+ }
106
+
107
+ export async function searchLifetimeFastGrowingRepositories(
108
+ client: GitHubClient,
109
+ options: GrowthOptionsInternal
110
+ ): Promise<Repo[]> {
111
+ const { windowDays, minStars, language, limit } = options;
112
+
113
+ const startDate = getDateNDaysAgo(windowDays);
114
+
115
+ const qualifiers: string[] = [
116
+ `created:>=${startDate}`,
117
+ `stars:>=${minStars}`,
118
+ ];
119
+
120
+ if (language) {
121
+ qualifiers.push(`language:${language}`);
122
+ }
123
+
124
+ const q = qualifiers.join(" ");
125
+
126
+ const data = await client.request<SearchRepositoriesResponse>(
127
+ "/search/repositories",
128
+ {
129
+ q,
130
+ sort: "stars",
131
+ order: "desc",
132
+ per_page: 100,
133
+ }
134
+ );
135
+
136
+ const items = data.items;
137
+
138
+ const MS_PER_DAY = 1000 * 60 * 60 * 24;
139
+ const SMOOTHING_DAYS = 30;
140
+ const now = Date.now();
141
+
142
+ items.sort((a, b) => {
143
+ const ageDaysARaw = Math.max(
144
+ (now - new Date(a.created_at).getTime()) / MS_PER_DAY,
145
+ 1
146
+ );
147
+ const ageDaysBRaw = Math.max(
148
+ (now - new Date(b.created_at).getTime()) / MS_PER_DAY,
149
+ 1
150
+ );
151
+
152
+ const ageDaysAEff = ageDaysARaw + SMOOTHING_DAYS;
153
+ const ageDaysBEff = ageDaysBRaw + SMOOTHING_DAYS;
154
+
155
+ const growthA = a.stargazers_count / ageDaysAEff;
156
+ const growthB = b.stargazers_count / ageDaysBEff;
157
+
158
+ return growthB - growthA;
159
+ });
160
+
161
+ return items.slice(0, limit);
162
+ }
163
+
164
+ function getDateNDaysAgo(days: number): string {
165
+ const now = new Date();
166
+ const start = new Date(now);
167
+ start.setDate(now.getDate() - days);
168
+
169
+ const year = start.getFullYear();
170
+ const month = String(start.getMonth() + 1).padStart(2, "0");
171
+ const day = String(start.getDate()).padStart(2, "0");
172
+
173
+ return `${year}-${month}-${day}`;
174
+ }
175
+
@@ -0,0 +1,56 @@
1
+ import type { Repo } from "./repoSearch";
2
+
3
+ export interface TopicStat {
4
+ topic: string;
5
+ score: number;
6
+ repoCount: number;
7
+ starSum: number;
8
+ }
9
+
10
+ export function aggregateTopicStats(
11
+ repos: Repo[],
12
+ limit: number
13
+ ): TopicStat[] {
14
+ const topicMap = new Map<string, { repoCount: number; starSum: number }>();
15
+
16
+ for (const repo of repos) {
17
+ if (!repo.topics || repo.topics.length === 0) continue;
18
+
19
+ const uniqueTopics = new Set(
20
+ repo.topics
21
+ .map((t) => t.trim())
22
+ .filter((t) => t.length > 0)
23
+ );
24
+
25
+ for (const topic of uniqueTopics) {
26
+ const current = topicMap.get(topic) ?? { repoCount: 0, starSum: 0 };
27
+ current.repoCount += 1;
28
+ current.starSum += repo.stargazers_count;
29
+ topicMap.set(topic, current);
30
+ }
31
+ }
32
+
33
+ const stats: TopicStat[] = [];
34
+
35
+ for (const [topic, { repoCount, starSum }] of topicMap.entries()) {
36
+ const score =
37
+ repoCount * Math.log10(Math.max(starSum + 10, 1)); // 防止 log10(0)
38
+
39
+ stats.push({
40
+ topic,
41
+ score,
42
+ repoCount,
43
+ starSum,
44
+ });
45
+ }
46
+
47
+ stats.sort((a, b) => {
48
+ if (b.score !== a.score) return b.score - a.score;
49
+ if (b.repoCount !== a.repoCount) return b.repoCount - a.repoCount;
50
+ return a.topic.localeCompare(b.topic);
51
+ });
52
+
53
+ const safeLimit = Math.max(1, Math.min(limit, stats.length));
54
+ return stats.slice(0, safeLimit);
55
+ }
56
+
@@ -0,0 +1,30 @@
1
+ export type Period = "daily" | "weekly" | "monthly" | "yearly";
2
+
3
+ export function getStartDate(period: Period): string {
4
+ const now = new Date();
5
+ const start = new Date(now);
6
+
7
+ switch (period) {
8
+ case "daily":
9
+ start.setDate(now.getDate() - 2);
10
+ break;
11
+ case "weekly":
12
+ start.setDate(now.getDate() - 7);
13
+ break;
14
+ case "monthly":
15
+ start.setDate(now.getDate() - 30);
16
+ break;
17
+ case "yearly":
18
+ start.setDate(now.getDate() - 365);
19
+ break;
20
+ default:
21
+ start.setDate(now.getDate() - 2);
22
+ }
23
+
24
+ const year = start.getFullYear();
25
+ const month = String(start.getMonth() + 1).padStart(2, "0");
26
+ const day = String(start.getDate()).padStart(2, "0");
27
+
28
+ return `${year}-${month}-${day}`;
29
+ }
30
+
@@ -0,0 +1,141 @@
1
+ import type { Repo } from "../github/repoSearch";
2
+ import type { TopicStat } from "../github/topicStats";
3
+
4
+ export interface OutputOptions {
5
+ json: boolean;
6
+ }
7
+
8
+ export function printRepositories(
9
+ repos: Repo[],
10
+ options: OutputOptions
11
+ ): void {
12
+ const filteredRepos = repos.filter((r) => {
13
+ const langRaw = r.language ?? "";
14
+ const lang = langRaw.trim();
15
+ if (!lang || lang === "-") {
16
+ return false;
17
+ }
18
+ const lower = lang.toLowerCase();
19
+ return lower !== "html" && lower !== "shell";
20
+ });
21
+
22
+ if (options.json) {
23
+ const minimal = filteredRepos.map((r) => ({
24
+ name: r.name,
25
+ full_name: r.full_name,
26
+ html_url: r.html_url,
27
+ stars: r.stargazers_count,
28
+ language: r.language,
29
+ created_at: r.created_at,
30
+ description: r.description,
31
+ }));
32
+ console.log(JSON.stringify(minimal, null, 2));
33
+ return;
34
+ }
35
+
36
+ if (filteredRepos.length === 0) {
37
+ return;
38
+ }
39
+
40
+ const truncate = (text: string, maxLength: number): string =>
41
+ text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`;
42
+
43
+ const rows = filteredRepos.map((r) => {
44
+ const lang = r.language ?? "-";
45
+ const created = r.created_at.slice(0, 10);
46
+ const description = r.description ? truncate(r.description, 150) : "-";
47
+ return {
48
+ name: r.full_name,
49
+ stars: String(r.stargazers_count),
50
+ lang,
51
+ created,
52
+ url: r.html_url,
53
+ description,
54
+ };
55
+ });
56
+
57
+ const headers = ["Name", "Stars", "Lang", "Created", "URL", "Description"];
58
+
59
+ const colWidths = headers.map((header, index) =>
60
+ Math.max(
61
+ header.length,
62
+ ...rows.map((row) => Object.values(row)[index].length)
63
+ )
64
+ );
65
+
66
+ const formatRow = (cols: string[]): string =>
67
+ cols
68
+ .map((col, i) => col.padEnd(colWidths[i], " "))
69
+ .join(" | ");
70
+
71
+ console.log(formatRow(headers));
72
+ console.log(
73
+ formatRow(
74
+ headers.map((h, i) => "".padEnd(colWidths[i], "-"))
75
+ )
76
+ );
77
+
78
+ for (const row of rows) {
79
+ console.log(
80
+ formatRow([
81
+ row.name,
82
+ row.stars,
83
+ row.lang,
84
+ row.created,
85
+ row.url,
86
+ row.description,
87
+ ])
88
+ );
89
+ }
90
+ }
91
+
92
+ export function printTopicStats(
93
+ stats: TopicStat[],
94
+ options: OutputOptions
95
+ ): void {
96
+ if (options.json) {
97
+ const minimal = stats.map((s) => ({
98
+ topic: s.topic,
99
+ }));
100
+ console.log(JSON.stringify(minimal, null, 2));
101
+ return;
102
+ }
103
+
104
+ if (stats.length === 0) {
105
+ return;
106
+ }
107
+
108
+ const rows = stats.map((s, index) => ({
109
+ rank: String(index + 1),
110
+ topic: s.topic,
111
+ }));
112
+
113
+ const headers = ["Rank", "Topic"];
114
+
115
+ const colWidths = headers.map((header, index) =>
116
+ Math.max(
117
+ header.length,
118
+ ...rows.map((row) => Object.values(row)[index].length)
119
+ )
120
+ );
121
+
122
+ const formatRow = (cols: string[]): string =>
123
+ cols
124
+ .map((col, i) => col.padEnd(colWidths[i], " "))
125
+ .join(" | ");
126
+
127
+ console.log(formatRow(headers));
128
+ console.log(
129
+ formatRow(
130
+ headers.map((h, i) => "".padEnd(colWidths[i], "-"))
131
+ )
132
+ );
133
+
134
+ for (const row of rows) {
135
+ console.log(
136
+ formatRow([row.rank, row.topic])
137
+ );
138
+ }
139
+ }
140
+
141
+
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "CommonJS",
5
+ "moduleResolution": "Node",
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "skipLibCheck": true,
12
+ "resolveJsonModule": true,
13
+ "lib": ["ES2020", "DOM"]
14
+ },
15
+ "include": ["src"]
16
+ }
17
+