mirror of
https://github.com/khairul169/github-leaderboard.git
synced 2025-04-28 15:39:31 +07:00
290 lines
7.6 KiB
TypeScript
290 lines
7.6 KiB
TypeScript
import * as cheerio from "cheerio";
|
|
import { intval } from "./utils";
|
|
import dayjs from "dayjs";
|
|
|
|
const GITHUB_URL = "https://github.com";
|
|
const GITHUB_API_URL = "https://api.github.com";
|
|
|
|
const selectors = {
|
|
user: {
|
|
name: "h1.vcard-names > span.vcard-fullname",
|
|
avatar: ".js-profile-editable-replace img.avatar-user",
|
|
location: "li[itemprop='homeLocation'] span",
|
|
followers: ".js-profile-editable-area a[href$='?tab=followers'] > span",
|
|
following: ".js-profile-editable-area a[href$='?tab=following'] > span",
|
|
achievement: "img.achievement-badge-sidebar",
|
|
},
|
|
|
|
repo: {
|
|
list: "div#user-repositories-list li",
|
|
listForked: ':contains("Forked")',
|
|
listLanguage: "span[itemprop='programmingLanguage']",
|
|
listStars: "a[href$='stargazers']",
|
|
listForks: "a[href$='forks']",
|
|
langList: ".Layout-sidebar h2:contains('Languages')",
|
|
},
|
|
};
|
|
|
|
const github = {
|
|
async getUser(username: string) {
|
|
const response = await this.fetch(username);
|
|
const $ = cheerio.load(response);
|
|
|
|
const name = $(selectors.user.name).text().trim();
|
|
const avatar = $(selectors.user.avatar).attr("src");
|
|
const location = $(selectors.user.location).text().trim();
|
|
const followers = intval($(selectors.user.followers).text().trim());
|
|
const following = intval($(selectors.user.following).text().trim());
|
|
const achievements = [] as { name: string; image?: string }[];
|
|
|
|
$(selectors.user.achievement).each((_i, el) => {
|
|
const name = $(el).attr("alt")?.split(" ")[1] || "";
|
|
const image = $(el).attr("src");
|
|
achievements.push({ name, image });
|
|
});
|
|
|
|
return {
|
|
name: name || username,
|
|
avatar,
|
|
username,
|
|
location,
|
|
followers,
|
|
following,
|
|
achievements,
|
|
};
|
|
},
|
|
|
|
async getRepositories(
|
|
username: string,
|
|
params?: Partial<GetRepositoriesParams>
|
|
) {
|
|
const response = await this.fetch(username, {
|
|
params: {
|
|
tab: "repositories",
|
|
type: "public",
|
|
...params,
|
|
},
|
|
});
|
|
const $ = cheerio.load(response);
|
|
let repositories = [] as {
|
|
name: string;
|
|
uri: string;
|
|
language: string;
|
|
stars: number;
|
|
forks: number;
|
|
lastUpdate: Date;
|
|
}[];
|
|
|
|
$(selectors.repo.list).each((_i, el) => {
|
|
const isForked = $(el).find(selectors.repo.listForked).length > 0;
|
|
if (isForked) return;
|
|
|
|
const name = $(el).find("h3 > a").text().trim();
|
|
const language = $(el).find(selectors.repo.listLanguage).text().trim();
|
|
const stars = intval($(el).find(selectors.repo.listStars).text().trim());
|
|
const forks = intval($(el).find(selectors.repo.listForks).text().trim());
|
|
const lastUpdate = $(el).find("relative-time").attr("datetime");
|
|
|
|
repositories.push({
|
|
name,
|
|
uri: `${username}/${name}`,
|
|
language,
|
|
stars,
|
|
forks,
|
|
lastUpdate: dayjs(lastUpdate).toDate(),
|
|
});
|
|
});
|
|
|
|
const prevPage = intval(
|
|
$("a.prev_page")
|
|
.attr("href")
|
|
?.match(/page=(\d+)/)?.[1]
|
|
);
|
|
const nextPage = intval(
|
|
$("a.next_page")
|
|
.attr("href")
|
|
?.match(/page=(\d+)/)?.[1]
|
|
);
|
|
|
|
if (params?.fetchAll && nextPage > 1 && nextPage < 10) {
|
|
try {
|
|
const nextPageRes = await this.getRepositories(username, {
|
|
...params,
|
|
page: nextPage,
|
|
});
|
|
if (nextPageRes.repositories?.length > 0) {
|
|
repositories = [...repositories, ...nextPageRes.repositories];
|
|
}
|
|
} catch (err) {
|
|
//
|
|
}
|
|
}
|
|
|
|
return { repositories, prevPage, nextPage };
|
|
},
|
|
|
|
async getRepoDetails(repo: string) {
|
|
const response = await this.fetch(repo);
|
|
const $ = cheerio.load(response);
|
|
|
|
const languages = [] as { lang: string; amount: number }[];
|
|
|
|
$(selectors.repo.langList)
|
|
.parent()
|
|
.find("ul > li > a")
|
|
.each((_i, el) => {
|
|
const lang = $(el).children().eq(1).text().trim();
|
|
const percentage = $(el).children().eq(2).text().trim();
|
|
const amount = parseFloat(percentage?.replace(/[^0-9.]/, "")) || 0;
|
|
languages.push({ lang, amount });
|
|
});
|
|
|
|
return { languages };
|
|
},
|
|
|
|
async getRepoContributors(repo: string, options?: Partial<FetchOptions>) {
|
|
const response = await this.fetch(`repos/${repo}/stats/contributors`, {
|
|
...options,
|
|
ghApi: true,
|
|
headers: { accept: "application/json", ...(options?.headers || {}) },
|
|
});
|
|
|
|
if (!Array.isArray(response)) {
|
|
throw new Error("Invalid response: " + JSON.stringify(response));
|
|
}
|
|
|
|
const result = response
|
|
.map((item: any) => {
|
|
const { author, total, weeks } = item;
|
|
let additions = 0;
|
|
let deletions = 0;
|
|
let commits = 0;
|
|
|
|
weeks.forEach((week: any) => {
|
|
additions += week.a || 0;
|
|
deletions += week.d || 0;
|
|
commits += week.c || 0;
|
|
});
|
|
|
|
return { author, total, additions, deletions, commits };
|
|
})
|
|
.sort((a, b) => b.total - a.total);
|
|
|
|
return result;
|
|
},
|
|
|
|
async getAllData(username: string, options?: Partial<GetAllDataOptions>) {
|
|
const user = await this.getUser(username);
|
|
const repositories = [] as (Repository & {
|
|
languages: Language[];
|
|
contributors: Contributors;
|
|
})[];
|
|
|
|
const _repos = await this.getRepositories(username, {
|
|
sort: "stargazers",
|
|
fetchAll: true,
|
|
});
|
|
|
|
const repoCount = Math.min(
|
|
_repos.repositories.length,
|
|
options?.maxRepo || Number.POSITIVE_INFINITY
|
|
);
|
|
|
|
for (let idx = 0; idx < repoCount; idx++) {
|
|
const repo = _repos.repositories[idx];
|
|
const [details, contributors] = await Promise.all([
|
|
this.getRepoDetails(repo.uri),
|
|
this.getRepoContributors(repo.uri),
|
|
]);
|
|
|
|
repositories.push({
|
|
...repo,
|
|
languages: details.languages,
|
|
contributors,
|
|
});
|
|
}
|
|
|
|
return { user, repositories };
|
|
},
|
|
|
|
async fetch<T = any>(path: string, options?: Partial<FetchOptions>) {
|
|
const url = new URL(
|
|
"/" + path,
|
|
options?.ghApi ? GITHUB_API_URL : GITHUB_URL
|
|
);
|
|
|
|
if (options?.params) {
|
|
Object.entries(options.params).forEach(([key, value]) => {
|
|
url.searchParams.append(key, value as string);
|
|
});
|
|
}
|
|
|
|
const headers = {
|
|
"User-Agent":
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36",
|
|
...(options?.headers || {}),
|
|
};
|
|
|
|
if (options?.xhr) {
|
|
headers["X-Requested-With"] = "XMLHttpRequest";
|
|
}
|
|
|
|
const init = {
|
|
method: "GET",
|
|
headers,
|
|
referrer: options?.referrer || GITHUB_URL,
|
|
};
|
|
|
|
const res = await fetch(url, init);
|
|
if (!res.ok) {
|
|
throw new Error(res.statusText);
|
|
}
|
|
|
|
const type = res.headers.get("Content-Type");
|
|
|
|
if (type?.includes("application/json")) {
|
|
return res.json() as T;
|
|
}
|
|
|
|
return res.text();
|
|
},
|
|
};
|
|
|
|
type FetchOptions = {
|
|
xhr: boolean;
|
|
ghApi: boolean;
|
|
params: any;
|
|
headers: any;
|
|
referrer: string;
|
|
};
|
|
|
|
type GetRepositoriesParams = {
|
|
page: string | number;
|
|
sort: "stargazers" | "name" | null;
|
|
fetchAll: boolean;
|
|
};
|
|
|
|
type GetAllDataOptions = {
|
|
maxRepo: number;
|
|
};
|
|
|
|
export type GithubUser = Awaited<ReturnType<typeof github.getUser>>;
|
|
|
|
export type Repository = Awaited<
|
|
ReturnType<typeof github.getRepositories>
|
|
>["repositories"][number];
|
|
|
|
export type Language = Awaited<
|
|
ReturnType<typeof github.getRepoDetails>
|
|
>["languages"][number];
|
|
|
|
export type Contributors = Awaited<
|
|
ReturnType<typeof github.getRepoContributors>
|
|
>;
|
|
|
|
export type Contributor = NonNullable<Contributors>[number];
|
|
|
|
export type Achievement = GithubUser["achievements"][number];
|
|
|
|
export default github;
|