Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions .github/workflows/node.js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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
2 changes: 1 addition & 1 deletion .github/workflows/publish-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ jobs:
- name: executing remote ssh commands using password
uses: appleboy/[email protected]
env:
PORT: ${{ secrets.ENV_PORT }}
PORT: ${{ secrets.PORT }}
with:
host: ${{ secrets.SSH_HOST }}
port: ${{ secrets.SSH_PORT }}
Expand Down
33 changes: 26 additions & 7 deletions src/entities/contest.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, string> = {
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[] = [];
}
55 changes: 24 additions & 31 deletions src/modules/boj/repository.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -17,6 +16,30 @@ describe('BojRepository', () => {
repository = module.get<BojRepository>(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');
Expand All @@ -29,36 +52,6 @@ describe('BojRepository', () => {
});
});

describe('getEndedContests', () => {
it('should return contests', async () => {
const contests = await repository.getEndedContests();
expect(contests).toBeInstanceOf(Array<Contest>);
contests.forEach((contest) => {
expect(contest).toBeInstanceOf(Contest);
});
});
});

describe('getOngoingContests', () => {
it('should return contests', async () => {
const contests = await repository.getOngoingContests();
expect(contests).toBeInstanceOf(Array<Contest>);
contests.forEach((contest) => {
expect(contest).toBeInstanceOf(Contest);
});
});
});

describe('getUpcomingContests', () => {
it('should return contests', async () => {
const contests = await repository.getUpcomingContests();
expect(contests).toBeInstanceOf(Array<Contest>);
contests.forEach((contest) => {
expect(contest).toBeInstanceOf(Contest);
});
});
});

describe('getSSUInfo', () => {
it('should return SSU info', async () => {
const ssuInfo = await repository.getSSUInfo();
Expand Down
131 changes: 61 additions & 70 deletions src/modules/boj/repository.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -56,62 +54,94 @@ export class BojRepository {
return problems;
}

async getEndedContests(): Promise<Contest[]> {
const endedUrl = 'https://www.acmicpc.net/contest/official/list';
async getContestsFromBoj(): Promise<ContestList> {
const url = 'https://www.acmicpc.net/contest/official/list';

const response = cheerio.load(
await this.httpService.axiosRef
.get<string>(endedUrl, {
.get<string>(url, {
headers: {
'User-Agent': this.configService.get<string>('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(<string>rows.eq(i).find('td:nth-child(4) > span').attr('data-timestamp')),
).toISOString();
);
const endDate = new Date(
1000 * parseInt(<string>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<Contest[]> {
const response = await this.otherResponse();

if (response('.col-md-12').length < 6) {
return [];
}

return this.contestsFromOther(response, 3);
}
async getContestsFromCList(): Promise<ContestList> {
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<Contest[]> {
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() {
Expand Down Expand Up @@ -181,10 +211,10 @@ export class BojRepository {
return ranking;
}

async getBaechu() {
async getBaechu(): Promise<Record<string, { badge: string; background: string }>> {
const url = 'https://raw.githubusercontent.com/kiwiyou/baechu/main/db.json';

const data: Record<string, Record<string, string>> = {};
const data: Record<string, { badge: string; background: string }> = {};
await this.httpService.axiosRef.get<Record<string, { badge: string; background: string }>>(url).then((res) => {
for (const contestId in res.data) {
const contest: Record<string, string> = res.data[contestId];
Expand All @@ -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<string>(otherUrl, {
headers: {
'User-Agent': this.configService.get<string>('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<AnyNode>;

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;
}
}
Loading