diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 89db640..42970e1 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -19,6 +19,7 @@ jobs: node-version: [ 23.x ] # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + steps: - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} @@ -34,13 +35,15 @@ jobs: echo "BOJ_USER_AGENT=$BOJ_USER_AGENT" >> .env echo "MONGODB_URI=$MONGODB_URI" >> .env echo "SOLVEDAC_TOKEN=$SOLVEDAC_TOKEN" >> .env + echo "CLIST_API_KEY=$CLIST_API_KEY" >> .env env: - PORT: ${{ secrets.ENV_PORT }} - BOJ_AUTO_LOGIN: ${{ secrets.ENV_BOJ_AUTO_LOGIN }} - BOJ_ONLINE_JUDGE: ${{ secrets.ENV_BOJ_ONLINE_JUDGE }} - BOJ_USER_AGENT: ${{ secrets.ENV_BOJ_USER_AGENT }} - MONGODB_URI: ${{ secrets.ENV_MONGODB_URI }} - SOLVEDAC_TOKEN: ${{ secrets.ENV_SOLVEDAC_TOKEN }} + PORT: ${{ secrets.PORT }} + BOJ_AUTO_LOGIN: ${{ secrets.BOJ_AUTO_LOGIN }} + BOJ_ONLINE_JUDGE: ${{ secrets.BOJ_ONLINE_JUDGE }} + BOJ_USER_AGENT: ${{ secrets.BOJ_USER_AGENT }} + MONGODB_URI: ${{ secrets.MONGODB_URI }} + SOLVEDAC_TOKEN: ${{ secrets.SOLVEDAC_TOKEN }} + CLIST_API_KEY: ${{ secrets.CLIST_API_KEY }} - run: npm ci - run: npm run build --if-present - run: npm test diff --git a/.github/workflows/publish-deploy.yml b/.github/workflows/publish-deploy.yml index eafa3a8..dabd310 100644 --- a/.github/workflows/publish-deploy.yml +++ b/.github/workflows/publish-deploy.yml @@ -66,7 +66,7 @@ jobs: - name: executing remote ssh commands using password uses: appleboy/ssh-action@v1.0.3 env: - PORT: ${{ secrets.ENV_PORT }} + PORT: ${{ secrets.PORT }} with: host: ${{ secrets.SSH_HOST }} port: ${{ secrets.SSH_PORT }} diff --git a/src/entities/contest.entity.ts b/src/entities/contest.entity.ts index 4022ef6..cb79bcd 100644 --- a/src/entities/contest.entity.ts +++ b/src/entities/contest.entity.ts @@ -41,23 +41,42 @@ export class Contest { venue: string, name: string, url: string, - startTime: string, - endTime: string, + startTime: Date, + endTime: Date, badge?: string, background?: string, ) { this.venue = venue; this.name = name; this.url = url; - this.startTime = startTime; - this.endTime = endTime; + this.startTime = startTime.toISOString(); + this.endTime = endTime.toISOString(); this.badge = badge; this.background = background; } + + static fromCList(data: { event: string; start: string; end: string; href: string; resource_id: number }) { + return new Contest( + clistMap[data.resource_id] ?? 'Unknown', + data.event, + data.href, + new Date(data.start + '.000Z'), + new Date(data.end + '.000Z'), + ); + } } +const clistMap: Record = { + 1: 'Codeforces', + 25: 'USACO', + 86: 'ICPC', + 141: 'ICPC', + 93: 'AtCoder', + 102: 'LeetCode', +}; + export class ContestList { - ended: Contest[]; - ongoing: Contest[]; - upcoming: Contest[]; + ended: Contest[] = []; + ongoing: Contest[] = []; + upcoming: Contest[] = []; } diff --git a/src/modules/boj/repository.spec.ts b/src/modules/boj/repository.spec.ts index ae87e80..2e5f1e1 100644 --- a/src/modules/boj/repository.spec.ts +++ b/src/modules/boj/repository.spec.ts @@ -1,4 +1,3 @@ -import { Contest } from '@entities/contest.entity'; import { HttpModule } from '@nestjs/axios'; import { ConfigModule } from '@nestjs/config'; import { Test } from '@nestjs/testing'; @@ -17,6 +16,30 @@ describe('BojRepository', () => { repository = module.get(BojRepository); }); + describe('Get BOJ contests', () => { + it('should return ContestList', async () => { + const contests = await repository.getContestsFromBoj(); + expect(contests).toHaveProperty('ended'); + expect(contests.ended).toBeInstanceOf(Array); + expect(contests).toHaveProperty('upcoming'); + expect(contests.upcoming).toBeInstanceOf(Array); + expect(contests).toHaveProperty('ongoing'); + expect(contests.ongoing).toBeInstanceOf(Array); + }, 10000); + }); + + describe('Get CList contests', () => { + it('should return ContestList', async () => { + const contests = await repository.getContestsFromCList(); + expect(contests).toHaveProperty('ended'); + expect(contests.ended).toBeInstanceOf(Array); + expect(contests).toHaveProperty('upcoming'); + expect(contests.upcoming).toBeInstanceOf(Array); + expect(contests).toHaveProperty('ongoing'); + expect(contests.ongoing).toBeInstanceOf(Array); + }, 10000); + }); + describe('getUserProblems', () => { it('should return problems', async () => { const problems = await repository.getUserProblems('w8385'); @@ -29,36 +52,6 @@ describe('BojRepository', () => { }); }); - describe('getEndedContests', () => { - it('should return contests', async () => { - const contests = await repository.getEndedContests(); - expect(contests).toBeInstanceOf(Array); - contests.forEach((contest) => { - expect(contest).toBeInstanceOf(Contest); - }); - }); - }); - - describe('getOngoingContests', () => { - it('should return contests', async () => { - const contests = await repository.getOngoingContests(); - expect(contests).toBeInstanceOf(Array); - contests.forEach((contest) => { - expect(contest).toBeInstanceOf(Contest); - }); - }); - }); - - describe('getUpcomingContests', () => { - it('should return contests', async () => { - const contests = await repository.getUpcomingContests(); - expect(contests).toBeInstanceOf(Array); - contests.forEach((contest) => { - expect(contest).toBeInstanceOf(Contest); - }); - }); - }); - describe('getSSUInfo', () => { it('should return SSU info', async () => { const ssuInfo = await repository.getSSUInfo(); diff --git a/src/modules/boj/repository.ts b/src/modules/boj/repository.ts index 12a5f0e..0c990c8 100644 --- a/src/modules/boj/repository.ts +++ b/src/modules/boj/repository.ts @@ -1,10 +1,8 @@ -import { Contest } from '@entities/contest.entity'; +import { Contest, ContestList } from '@entities/contest.entity'; import { HttpService } from '@nestjs/axios'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as cheerio from 'cheerio'; -import { Cheerio } from 'cheerio'; -import { AnyNode } from 'domhandler'; import * as process from 'node:process'; @Injectable() @@ -56,62 +54,94 @@ export class BojRepository { return problems; } - async getEndedContests(): Promise { - const endedUrl = 'https://www.acmicpc.net/contest/official/list'; + async getContestsFromBoj(): Promise { + const url = 'https://www.acmicpc.net/contest/official/list'; const response = cheerio.load( await this.httpService.axiosRef - .get(endedUrl, { + .get(url, { headers: { 'User-Agent': this.configService.get('BOJ_USER_AGENT'), }, }) - .then((res) => res.data), ); - const contests: Contest[] = []; + const contests: ContestList = new ContestList(); const rows = response( 'body > div.wrapper > div.container.content > div.row > div:nth-child(2) > div > table > tbody > tr', ); for (let i = 0; i < rows.length; i++) { - if (rows.eq(i).find('td:nth-child(6)').text() !== '종료') { - continue; - } - const venue = 'BOJ Open'; const name = rows.eq(i).find('td:nth-child(1) > a').text(); const url = 'https://www.acmicpc.net' + rows.eq(i).find('td:nth-child(1) > a').attr('href'); const startDate = new Date( 1000 * parseInt(rows.eq(i).find('td:nth-child(4) > span').attr('data-timestamp')), - ).toISOString(); + ); const endDate = new Date( 1000 * parseInt(rows.eq(i).find('td:nth-child(5) > span').attr('data-timestamp')), - ).toISOString(); - - contests.push(new Contest(venue, name, url, startDate, endDate)); + ); + + const contest = new Contest(venue, name, url, startDate, endDate); + if (endDate < new Date()) { + contests.ended.push(contest); + } else if (new Date() < startDate) { + contests.upcoming.push(contest); + } else { + contests.ongoing.push(contest); + } } return contests; } - async getOngoingContests(): Promise { - const response = await this.otherResponse(); - - if (response('.col-md-12').length < 6) { - return []; - } - - return this.contestsFromOther(response, 3); - } + async getContestsFromCList(): Promise { + const url = 'https://clist.by/api/v4/contest/'; + const headers = { + Authorization: `${process.env.CLIST_API_KEY}`, + }; + const params = { + resource_id__in: '1, 25, 86, 141, 93, 102', + order_by: '-start', + }; - async getUpcomingContests(): Promise { - const response = await this.otherResponse(); + const response = await this.httpService.axiosRef.get<{ + objects: { + event: string; + start: string; + end: string; + href: string; + resource_id: number; + }[]; + }>(url, { + headers: headers, + params: params, + }); - const rowIndex = response('.col-md-12').length === 6 ? 5 : 3; + const clist: { + event: string; + start: string; + end: string; + href: string; + resource_id: number; + }[] = response.data.objects; + + const contests: ContestList = new ContestList(); + for (const contest of clist) { + const startDate = new Date(contest.start); + const endDate = new Date(contest.end); + + if (endDate < new Date()) { + contests.ended.push(Contest.fromCList(contest)); + } else if (new Date() < startDate) { + contests.upcoming.push(Contest.fromCList(contest)); + } else { + contests.ongoing.push(Contest.fromCList(contest)); + } + } - return this.contestsFromOther(response, rowIndex); + return contests; } async getSSUInfo() { @@ -181,10 +211,10 @@ export class BojRepository { return ranking; } - async getBaechu() { + async getBaechu(): Promise> { const url = 'https://raw.githubusercontent.com/kiwiyou/baechu/main/db.json'; - const data: Record> = {}; + const data: Record = {}; await this.httpService.axiosRef.get>(url).then((res) => { for (const contestId in res.data) { const contest: Record = res.data[contestId]; @@ -197,43 +227,4 @@ export class BojRepository { return data; } - - private async otherResponse() { - const otherUrl = 'https://www.acmicpc.net/contest/other/list'; - - return cheerio.load( - await this.httpService.axiosRef - .get(otherUrl, { - headers: { - 'User-Agent': this.configService.get('BOJ_USER_AGENT'), - Cookie: 'bojautologin=' + process.env.BOJ_AUTO_LOGIN + ';', - }, - }) - .then((res) => res.data), - ); - } - - private contestsFromOther(response: any, rowIndex: number): Contest[] { - const contests: Contest[] = []; - - const rows = response( - `body > div.wrapper > div.container.content > div.row > div:nth-child(${rowIndex}) > div > table > tbody > tr`, - ) as Cheerio; - - for (let i = 0; i < rows.length; i++) { - const venue = rows.eq(i).find('td:nth-child(1)').text().trim(); - const name = rows.eq(i).find('td:nth-child(2)').text().trim(); - const url = rows.eq(i).find('td:nth-child(2) > a').attr('href') ?? ''; // `null` 방지 - const startTime = new Date( - 1000 * Number(rows.eq(i).find('td:nth-child(3) > span').attr('data-timestamp') ?? 0), - ).toISOString(); - const endTime = new Date( - 1000 * Number(rows.eq(i).find('td:nth-child(4) > span').attr('data-timestamp') ?? 0), - ).toISOString(); - - contests.push(new Contest(venue, name, url, startTime, endTime)); - } - - return contests; - } } diff --git a/src/modules/boj/service.ts b/src/modules/boj/service.ts index ea7de62..2ef5da6 100644 --- a/src/modules/boj/service.ts +++ b/src/modules/boj/service.ts @@ -1,4 +1,4 @@ -import { ContestList } from '@entities/contest.entity'; +import { Contest } from '@entities/contest.entity'; import { Injectable } from '@nestjs/common'; import { BojRepository } from './repository'; @@ -12,48 +12,24 @@ export class BojService { } async getContests() { - const contests: ContestList = new ContestList(); const baechu = await this.bojRepository.getBaechu(); - contests.ended = await this.bojRepository.getEndedContests(); - for (const contest of contests.ended) { - if (contest.venue !== 'BOJ Open' || !contest.url) { - continue; - } + const contests = await this.bojRepository.getContestsFromCList(); - if (!contest.url.includes('/')) { - continue; - } - const contestId = contest.url.split('/').pop(); - if (contestId === undefined) continue; - contest.badge = baechu[contestId]?.badge; - contest.background = baechu[contestId]?.background; + const bojContests = await this.bojRepository.getContestsFromBoj(); + for (const contest of bojContests.upcoming) { + contests.upcoming.push(this.adjustBojContestBaechu(contest, baechu)); } - - contests.ongoing = await this.bojRepository.getOngoingContests(); - for (const contest of contests.ongoing) { - if (contest.venue !== 'BOJ Open' || !contest.url) { - continue; - } - - const contestId = contest.url.split('/').pop(); - if (contestId === undefined) continue; - contest.badge = baechu[contestId]?.badge; - contest.background = baechu[contestId]?.background; + for (const contest of bojContests.ended) { + contests.ended.push(this.adjustBojContestBaechu(contest, baechu)); } - - contests.upcoming = await this.bojRepository.getUpcomingContests(); - for (const contest of contests.upcoming) { - if (contest.venue !== 'BOJ Open' || !contest.url) { - continue; - } - - const contestId = contest.url.split('/').pop(); - if (contestId === undefined) continue; - contest.badge = baechu[contestId]?.badge; - contest.background = baechu[contestId]?.background; + for (const contest of bojContests.ongoing) { + contests.ongoing.push(this.adjustBojContestBaechu(contest, baechu)); } + contests.upcoming.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()); + contests.ended.sort((a, b) => new Date(b.endTime).getTime() - new Date(a.endTime).getTime()); + contests.ongoing.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()); return contests; } @@ -64,4 +40,18 @@ export class BojService { async getSSURanking(page: number) { return await this.bojRepository.getSSURanking(page); } + + private adjustBojContestBaechu(contest: Contest, baechu: Record) { + if (contest.venue !== 'BOJ Open' || !contest.url) { + return contest; + } + + const contestId = contest.url.split('/').pop(); + if (contestId === undefined) return contest; + + contest.badge = baechu[contestId]?.badge; + contest.background = baechu[contestId]?.background; + + return contest; + } }