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 | null = null; async onModuleInit(): Promise { this.syncNow().catch(error => { this.logger.warn(`Datawhale荣誉榜启动同步失败:${this.errorMessage(error)}`); }); } @Cron('15 3 * * *') async syncDaily(): Promise { await this.syncNow(); } async getDatawhaleHonorRanking( category: RankingCategoryId = DEFAULT_CATEGORY, limit: number = DEFAULT_LIMIT, refresh = false, ): Promise { if (refresh || this.members.length === 0) { await this.syncNow(); } return this.getCachedPayload(category, limit); } async syncNow(): Promise { 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 { 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); } }