Files
whale-town-end-v2/src/business/rankings/rankings.service.ts

437 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { BadGatewayException, Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import axios from 'axios';
import {
DatawhaleCommitStats,
DatawhaleHonorRankingPayload,
DatawhaleMemberRow,
DatawhaleWeeklyCommitsPayload,
RankingCategory,
RankingCategoryId,
RankingEntry,
RankingUser,
} from './rankings.types';
const DATAWHALE_MEMBERS_URL = 'https://mv.datawhale.cc/data/members.json';
const DATAWHALE_WEEKLY_COMMITS_URL = 'https://mv.datawhale.cc/data/commits_weekly.json';
const DATAWHALE_ASSET_BASE_URL = 'https://mv.datawhale.cc/';
const DEFAULT_CATEGORY: RankingCategoryId = 'weekly_commits';
const DEFAULT_LIMIT = 10;
const CATEGORIES: RankingCategory[] = [
{
id: 'weekly_commits',
label: '一周卷王',
title: '一周卷王',
description: '近 7 天 commit 数 + 连续性、多仓库、质量奖励',
icon: '🔥',
},
{
id: 'night_owl',
label: '夜猫榜',
title: '夜猫榜',
description: '深夜提交数量与深夜活跃比例排行',
icon: '🌙',
},
{
id: 'popularity',
label: '人气王',
title: '人气王',
description: 'Followers 与组织仓库 Stars 的综合影响力',
icon: '👑',
},
{
id: 'productive',
label: '多产榜',
title: '多产榜',
description: '参与 Datawhale 组织仓库数量排行',
icon: '🏆',
},
{
id: 'social',
label: '社交达人',
title: '社交达人',
description: 'GitHub Following 数量排行',
icon: '💬',
},
{
id: 'rising',
label: '新星榜',
title: '新星榜',
description: '按仓库数量归一后的潜力新星排行',
icon: '🌠',
},
{
id: 'comprehensive',
label: '综合实力',
title: '综合实力',
description: 'Stars、Followers、仓库数、社交和贡献数综合评分',
icon: '🌟',
},
];
@Injectable()
export class RankingsService implements OnModuleInit {
private readonly logger = new Logger(RankingsService.name);
private members: DatawhaleMemberRow[] = [];
private weeklyCommits: DatawhaleWeeklyCommitsPayload | null = null;
private syncedAt: Date | null = null;
private syncing: Promise<void> | null = null;
async onModuleInit(): Promise<void> {
this.syncNow().catch(error => {
this.logger.warn(`Datawhale荣誉榜启动同步失败${this.errorMessage(error)}`);
});
}
@Cron('15 3 * * *')
async syncDaily(): Promise<void> {
await this.syncNow();
}
async getDatawhaleHonorRanking(
category: RankingCategoryId = DEFAULT_CATEGORY,
limit: number = DEFAULT_LIMIT,
refresh = false,
): Promise<DatawhaleHonorRankingPayload> {
if (refresh || this.members.length === 0) {
await this.syncNow();
}
return this.getCachedPayload(category, limit);
}
async syncNow(): Promise<DatawhaleHonorRankingPayload> {
if (this.syncing) {
await this.syncing;
return this.getCachedPayload(DEFAULT_CATEGORY, DEFAULT_LIMIT);
}
this.syncing = this.fetchAndReplace();
try {
await this.syncing;
} finally {
this.syncing = null;
}
return this.getCachedPayload(DEFAULT_CATEGORY, DEFAULT_LIMIT);
}
private getCachedPayload(
category: RankingCategoryId,
limit: number,
): DatawhaleHonorRankingPayload {
const normalizedCategory = this.normalizeCategory(category);
const normalizedLimit = this.normalizeLimit(limit);
const entries = this.buildEntries(normalizedCategory, normalizedLimit);
return {
source: 'datawhale-members-visualization',
activeCategory: normalizedCategory,
categories: CATEGORIES,
topRankers: entries.slice(0, 3),
rankers: entries.slice(3, normalizedLimit),
myRank: {
rank: null,
score: 0,
reward: 0,
},
total: entries.length,
syncedAt: this.syncedAt ? this.syncedAt.toISOString() : null,
sourceUpdatedAt: this.weeklyCommits?.update_time ?? null,
};
}
private async fetchAndReplace(): Promise<void> {
try {
const [membersResponse, commitsResponse] = await Promise.all([
axios.get(DATAWHALE_MEMBERS_URL, { timeout: 15000 }),
axios.get(DATAWHALE_WEEKLY_COMMITS_URL, { timeout: 15000 }),
]);
if (!Array.isArray(membersResponse.data)) {
throw new BadGatewayException('Datawhale成员接口返回格式异常');
}
const commitsPayload = commitsResponse.data as DatawhaleWeeklyCommitsPayload;
if (!commitsPayload || typeof commitsPayload !== 'object' || !commitsPayload.user_commits) {
throw new BadGatewayException('Datawhale周贡献接口返回格式异常');
}
this.members = membersResponse.data as DatawhaleMemberRow[];
this.weeklyCommits = commitsPayload;
this.syncedAt = new Date();
this.logger.log(`Datawhale荣誉榜同步完成${this.members.length} 位成员`);
} catch (error) {
if (this.members.length > 0) {
this.logger.warn(`Datawhale荣誉榜同步失败继续使用缓存${this.errorMessage(error)}`);
return;
}
throw error;
}
}
private buildEntries(category: RankingCategoryId, limit: number): RankingEntry[] {
const users = category === 'weekly_commits' || category === 'night_owl'
? this.buildCommitDrivenUsers(category)
: this.members.map(member => this.toRankingUser(member, category));
return users
.filter(user => user.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map((user, index) => ({
...user,
rank: index + 1,
}));
}
private buildCommitDrivenUsers(category: RankingCategoryId): RankingUser[] {
const memberById = new Map(
this.members
.map(member => [this.cleanString(member.id), member] as const)
.filter(([id]) => id.length > 0),
);
const userCommits = this.weeklyCommits?.user_commits ?? {};
return Object.entries(userCommits).map(([rawId, commits]) => {
const id = this.cleanString(rawId);
const member = memberById.get(id) ?? { id };
return this.toRankingUser(member, category, commits);
});
}
private toRankingUser(
member: DatawhaleMemberRow,
category: RankingCategoryId,
commitsOverride?: DatawhaleCommitStats,
): RankingUser {
const id = this.cleanString(member.id);
const commits = commitsOverride ?? this.weeklyCommits?.user_commits?.[id] ?? {};
const score = this.scoreMember(member, commits, category);
const domains = this.domainList(member);
return {
id,
name: this.displayName(member),
avatarText: this.avatarText(member),
avatarUrl: this.avatarUrl(member),
githubUrl: this.githubUrl(member),
domain: domains[0] ?? '',
domains,
location: this.cleanString(member.location),
score,
scoreLabel: this.scoreLabel(member, commits, category),
contrib: this.numberValue(member.org_total_contributions) || this.numberValue(commits.total_commits),
answers: this.numberValue(commits.repo_count) || this.numberValue(member.org_repos_count),
likes: this.numberValue(member.org_total_stars),
reward: this.rewardForScore(score),
};
}
private scoreMember(
member: DatawhaleMemberRow,
commits: DatawhaleCommitStats,
category: RankingCategoryId,
): number {
switch (category) {
case 'weekly_commits':
return this.weeklyCommitScore(commits);
case 'night_owl':
return this.nightOwlScore(commits);
case 'popularity':
return Math.round(
this.numberValue(member.followers ?? member.followers_count) * 0.6 +
this.numberValue(member.org_total_stars) * 0.4,
);
case 'productive':
return this.numberValue(member.org_repos_count);
case 'social':
return this.numberValue(member.following);
case 'rising': {
const repoCount = Math.max(this.numberValue(member.org_repos_count), 1);
const bonus = repoCount < 5 ? 1.5 : 1.0;
return Math.round((this.numberValue(member.followers ?? member.followers_count) + this.numberValue(member.org_total_stars)) / repoCount * bonus);
}
case 'comprehensive':
return Math.round(
this.numberValue(member.org_total_stars) * 0.3 +
this.numberValue(member.followers ?? member.followers_count) * 0.25 +
this.numberValue(member.org_repos_count) * 0.2 +
this.numberValue(member.following) * 0.15 +
this.numberValue(member.org_total_contributions) * 0.1,
);
default:
return 0;
}
}
private weeklyCommitScore(commits: DatawhaleCommitStats): number {
let score = this.numberValue(commits.total_commits);
const activeDays = this.numberValue(commits.active_days);
if (activeDays >= 7) {
score += 10;
} else if (activeDays >= 5) {
score += 5;
} else if (activeDays >= 3) {
score += 2;
}
const repoCount = this.numberValue(commits.repo_count);
if (repoCount >= 5) {
score += 5;
} else if (repoCount >= 3) {
score += 3;
} else if (repoCount >= 2) {
score += 1;
}
const avgCommitsPerDay = this.numberValue(commits.avg_commits_per_day);
if (avgCommitsPerDay >= 5) {
score += 8;
} else if (avgCommitsPerDay >= 3) {
score += 5;
} else if (avgCommitsPerDay >= 2) {
score += 2;
}
return Math.round(score);
}
private nightOwlScore(commits: DatawhaleCommitStats): number {
let score = this.numberValue(commits.night_owl_commits) * 2;
const percentage = this.numberValue(commits.night_owl_percentage);
if (percentage >= 50) {
score += 10;
} else if (percentage >= 30) {
score += 5;
} else if (percentage >= 20) {
score += 2;
}
const activeDays = this.numberValue(commits.active_days);
if (activeDays >= 5) {
score += 8;
} else if (activeDays >= 3) {
score += 4;
}
const repoCount = this.numberValue(commits.repo_count);
if (repoCount >= 3) {
score += 3;
} else if (repoCount >= 2) {
score += 1;
}
return Math.round(score);
}
private scoreLabel(
member: DatawhaleMemberRow,
commits: DatawhaleCommitStats,
category: RankingCategoryId,
): string {
switch (category) {
case 'weekly_commits':
return `${this.numberValue(commits.total_commits)} commits`;
case 'night_owl':
return `${this.numberValue(commits.night_owl_commits)} 深夜`;
case 'popularity':
return `${this.numberValue(member.followers ?? member.followers_count)} followers`;
case 'productive':
return `${this.numberValue(member.org_repos_count)} 仓库`;
case 'social':
return `${this.numberValue(member.following)} following`;
case 'rising':
return '活跃度';
case 'comprehensive':
return '综合分';
default:
return '分数';
}
}
private domainList(member: DatawhaleMemberRow): string[] {
const primaryDomain = this.cleanString(member.primary_domain);
const domains = this.cleanString(member.domain)
.split(';')
.map(domain => this.cleanString(domain))
.filter(Boolean);
const result = primaryDomain ? [primaryDomain, ...domains] : domains;
return [...new Set(result)].slice(0, 3);
}
private displayName(member: DatawhaleMemberRow): string {
const name = this.cleanString(member.name);
if (name && !['null', 'undefined', 'none'].includes(name.toLowerCase())) {
return name;
}
return this.cleanString(member.id) || '未知用户';
}
private avatarText(member: DatawhaleMemberRow): string {
const name = this.displayName(member);
return name.length > 0 ? name.slice(0, 1).toUpperCase() : '鲸';
}
private avatarUrl(member: DatawhaleMemberRow): string {
const avatar = this.cleanString(member.avatar);
if (!avatar) {
return '';
}
if (avatar.startsWith('http://') || avatar.startsWith('https://')) {
return avatar;
}
if (avatar.startsWith('/')) {
return `${DATAWHALE_ASSET_BASE_URL.replace(/\/$/, '')}${avatar}`;
}
return `${DATAWHALE_ASSET_BASE_URL}${avatar}`;
}
private githubUrl(member: DatawhaleMemberRow): string {
const github = this.cleanString(member.github);
if (github.startsWith('http')) {
return github;
}
const id = this.cleanString(member.id);
return id ? `https://github.com/${id}` : '';
}
private normalizeCategory(category: RankingCategoryId): RankingCategoryId {
return CATEGORIES.some(item => item.id === category) ? category : DEFAULT_CATEGORY;
}
private normalizeLimit(limit: number): number {
if (!Number.isFinite(limit)) {
return DEFAULT_LIMIT;
}
return Math.min(Math.max(Math.floor(limit), 3), DEFAULT_LIMIT);
}
private rewardForScore(score: number): number {
if (score >= 1000) {
return 80;
}
if (score >= 500) {
return 60;
}
if (score >= 120) {
return 50;
}
if (score >= 50) {
return 40;
}
if (score >= 20) {
return 30;
}
return 20;
}
private numberValue(value: unknown): number {
const parsed = Number(value || 0);
return Number.isFinite(parsed) ? parsed : 0;
}
private cleanString(value: unknown): string {
return String(value ?? '').trim();
}
private errorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
}