현재 다니는 회사에 적응도 끝났고, 바쁜 일정도 어느정도 해소되었다.
진행하던 운동 기록 사이드 프로젝트도 피드백을 받아 수정 보완 단계로 접어들었고, 릴리즈를 앞두고 있다.
조금 시간이 나서 드디어 PS를 다시 할 수 있게 되었다.
(올해는 4월까지 단 한문제도 풀지 못했다...)
내가 일자별로 푼 문제들을 간단하게 문제 제목과 링크, 난이도를 파악할 수 있게 README.md에 업데이트 할 필요를 느꼈고
이제 어느정도 사용할 줄 알게 된 깃 액션을 통해 자동화를 시킬 수 있었다.
아래는 최종 결과물이며, 두 가지 이유로 포스팅을 남긴다.
(2024. 06. 05 업데이트 - 이전달의 데이터들은 다 접은글로 넘어가고 현재 달의 문제풀이 목록만 보여짐)
1.
나처럼 PS를 풀며 백준허브나 릿허브를 이용할 때, 메인 레포에 자동으로 문제 로그를 간단하게 남기고 싶은 분들을 위해.
2.
링크 처리를 커밋을 감지해 푸시되는 문제에 대한 README.md파일의 문제 링크를 받아서 진행하는 것이 베스트였으나 실패했다.
링크의 디코딩에 실패해서 해당 경로의 README.md의 경로를 인식하지 못해 링크를 solved.ac API로 받아왔는데
이는 문제 이름이 많을 경우 정확하지 않을 수 있다.
사용법은 다소 간단한데, README의 업데이트를 자동화해 줄 로직을 포함한 소스 코드와 액션 워크플로우 설정 코드만 있으면 된다.
간단히 설명하자면, 백준허브는 커밋 메세지에 -BaekjoonHub가 붙어서, 이를 감지해서 소스 코드를 실행시켰다.
필자는 현재 Node진영에서 개발하고 있기 때문에, 노드를 활용했으며
필요한 커밋만 감지해서 사용했다.
문제의 코드 (사용에는 지장이 없음)
const fs = require('fs');
const execSync = require('child_process').execSync;
const axios = require('axios');
const readmePath = 'README.md';
//README에 백준 난이도 이미지를 첨부하기 위해 사용
//실제 디렉토리에 백준 난이도 뱃지를 이미지로 저장해서 사용중입니다.
const getDifficultyIconPath = (level) => {
const difficultyLevels = {
'Bronze V': 1,
'Bronze IV': 2,
'Bronze III': 3,
'Bronze II': 4,
'Bronze I': 5,
'Silver V': 6,
'Silver IV': 7,
'Silver III': 8,
'Silver II': 9,
'Silver I': 10,
'Gold V': 11,
'Gold IV': 12,
'Gold III': 13,
'Gold II': 14,
'Gold I': 15,
'Platinum V': 16,
'Platinum IV': 17,
'Platinum III': 18,
'Platinum II': 19,
'Platinum I': 20,
'Diamond V': 21,
'Diamond IV': 22,
'Diamond III': 23,
'Diamond II': 24,
'Diamond I': 25,
'Ruby V': 26,
'Ruby IV': 27,
'Ruby III': 28,
'Ruby II': 29,
'Ruby I': 30
};
return `<div align="center"><img src="https://github.com/mag123c/Codingtest/blob/main/icon/${difficultyLevels[level] || 0}.svg" /></div>`;
};
//백준허브의 푸시인지 감지. 현재 LEETCODE 플랫폼은 이용중이지 않아서, 차후 추가해서 사용하면 됩니다.
const getCommitMessages = () => {
const output = execSync('git log -1 --pretty=%B').toString().trim();
if (!output.includes('-BaekjoonHub')) {
console.error('This commit is not from BaekjoonHub.');
process.exit(1);
}
const problemInfoMatch = output.match(/\[(.*?)\] Title: (.*?), Time:/);
if (!problemInfoMatch) {
console.error('Commit message format is incorrect.');
process.exit(1);
}
const problemLevel = problemInfoMatch[1];
const problemTitle = problemInfoMatch[2];
return { problemLevel, problemTitle };
};
const { problemLevel, problemTitle } = getCommitMessages();
//현재 커밋에 포함된 파일 경로와 파일명을 로그에 출력하는 함수
//파일 경로를 받아오는 데는 성공하였으나, 한글과 특수문자의 디코딩이 되지 않아 푸시된 README를 인식하지 못하는 에러가 있습니다.
//콘솔에 찍어보면 \312\443\531........../Title: \345\...... 형태의 README.md파일과 문제를 푼 언어의 코드파일이 감지는 되지만 디코딩이 되지 않는 이슈가 있습니다.
const logUploadedFiles = () => {
try {
const output = execSync('git diff-tree --no-commit-id --name-only -r HEAD').toString().trim();
const files = output.split('\n');
console.log('Uploaded files:');
files.forEach(file => console.log(file));
return files;
} catch (error) {
console.error('Failed to get uploaded files from the current commit.');
process.exit(1);
}
};
const decodeFilePath = (filePath) => {
try {
return decodeURIComponent(filePath.replace(/\\x/g, '%'));
} catch (error) {
console.error('Failed to decode file path:', filePath, error);
return filePath;
}
};
const uploadedFiles = logUploadedFiles().map(decodeFilePath);
//위의 logUploadedFiles에서 경로를 받아오면 베스트이지만
//현재 이슈 해결을 하지 못해 임시로 solved.ac API를 사용해서, 문제 제목으로 조회하여 문제 번호를 가져와서 링크에 사용하고 있습니다.
//같은 문제 제목일 경우, 맨 앞의 문제를 가져오기 때문에 정확하지 않을 수 있습니다.
//크롤링을 통해 https://www.acmicpc.net/search#q=${문제이름}&c=Problems 으로 사용하려고 하였으나
//403이 발생하였고 확인 결과 현재 스타트링크에서는, 스크래핑은 차단하고 있다고 합니다.
const fetchProblemLink = async (title) => {
try {
const reqUrl = `https://solved.ac/api/v3/search/problem?query=${title}&page=1`;
const { data } = await axios.get(reqUrl);
return `https://www.acmicpc.net/problem/${data.items[0].problemId}`;
} catch (error) {
console.error('Failed to fetch problem link:', error);
return null;
}
};
const updateReadme = async () => {
let content = '';
const newEntry = {
date: new Date().toISOString().slice(0, 10),
title: problemTitle,
level: problemLevel
};
const problemLink = await fetchProblemLink(problemTitle);
if (fs.existsSync(readmePath)) {
content = fs.readFileSync(readmePath, 'utf8');
}
const tableHeader = `
| # | 날짜 | 문제 | 난이도 |
|:---:|:---:|:---:|:---:|
`;
let tableContent = '';
const tableStartIndex = content.indexOf(tableHeader);
let index = 1;
if (tableStartIndex !== -1) {
tableContent = content.slice(tableStartIndex + tableHeader.length).trim();
const existingEntries = tableContent.split('\n').filter(entry => entry.startsWith('|'));
index = existingEntries.length + 1;
tableContent = existingEntries.join('\n');
}
const newTableRow = `| ${index} | ${newEntry.date} | [${newEntry.title}](${problemLink}) | ${getDifficultyIconPath(newEntry.level)} |`;
const newContent = content.slice(0, tableStartIndex + tableHeader.length).trim() + `\n${tableContent}\n${newTableRow}\n`;
fs.writeFileSync(readmePath, newContent);
};
const logReadmeContents = () => {
uploadedFiles.forEach(file => {
console.log("File :", file);
const decodedFile = decodeFilePath(file);
console.log("Decode : ", decodedFile);
if (decodedFile.endsWith('README.md')) {
console.log(`Contents of ${decodedFile}:`);
try {
const readmeContent = fs.readFileSync(decodedFile, { encoding: 'utf8' });
console.log(readmeContent);
} catch (error) {
console.error(`Error reading ${decodedFile}: ${error.message}`);
}
}
});
};
(async () => {
await updateReadme();
logReadmeContents();
})();
(2024. 06. 05 업데이트 - 이전달의 데이터들은 다 접은글로 넘어가고 현재 달의 문제풀이 목록만 보여짐)
import * as axios from 'axios';
import { existsSync, readFileSync } from 'fs';
const readmePath = 'test.md';
const getDifficultyIconPath = (level) => {
const difficultyLevels = {
'Bronze V': 1,
'Bronze IV': 2,
'Bronze III': 3,
'Bronze II': 4,
'Bronze I': 5,
'Silver V': 6,
'Silver IV': 7,
'Silver III': 8,
'Silver II': 9,
'Silver I': 10,
'Gold V': 11,
'Gold IV': 12,
'Gold III': 13,
'Gold II': 14,
'Gold I': 15,
'Platinum V': 16,
'Platinum IV': 17,
'Platinum III': 18,
'Platinum II': 19,
'Platinum I': 20,
'Diamond V': 21,
'Diamond IV': 22,
'Diamond III': 23,
'Diamond II': 24,
'Diamond I': 25,
'Ruby V': 26,
'Ruby IV': 27,
'Ruby III': 28,
'Ruby II': 29,
'Ruby I': 30
};
return `<div align="center"><img src="https://github.com/mag123c/Codingtest/blob/main/icon/${difficultyLevels[level] || 0}.svg" /></div>`;
};
const getCommitMessages = () => {
const output = "[Bronze III] Title: 웰컴 키트, Time: 152 ms, Memory: 15976 KB -BaekjoonHub"
// const output = execSync('git log -1 --pretty=%B').toString().trim();
if (!output.includes('-BaekjoonHub')) {
console.error('This commit is not from BaekjoonHub.');
process.exit(1);
}
const problemInfoMatch = output.match(/\[(.*?)\] Title: (.*?), Time:/);
if (!problemInfoMatch) {
console.error('Commit message format is incorrect.');
process.exit(1);
}
const problemLevel = problemInfoMatch[1];
const problemTitle = problemInfoMatch[2];
return { problemLevel, problemTitle };
};
const { problemLevel, problemTitle } = getCommitMessages();
const fetchProblemLink = async (title) => {
try {
const reqUrl = `https://solved.ac/api/v3/search/problem?query=${title}&page=1`;
const { data } = await axios.get(reqUrl);
return `https://www.acmicpc.net/problem/${data.items[0].problemId}`;
} catch (error) {
console.error('Failed to fetch problem link:', error);
return null;
}
};
const updateReadme = async () => {
let content = '';
const defaultDiv = '<div align="center">\n\n';
const newEntry = {
date: new Date('2024-07-01').toISOString().slice(0, 10).replace(/-/g, '.'),
title: problemTitle,
level: problemLevel
};
const problemLink = await fetchProblemLink(problemTitle);
if (existsSync(readmePath)) {
content = readFileSync(readmePath, 'utf8');
}
let curContent = content.replace(/<details[\s\S]*?<\/details>/gi, '').split("\n\n\n\n")[1];
const detailsRegex = /<details[\s\S]*?<\/details>/gi;
let detailsContent = content.match(detailsRegex) || [];
if (detailsContent.length > 0) {
detailsContent = detailsContent.map(detail => `${detail}\n\n`);
}
const curDate = parseLastDate(curContent);
const curIdx = parseLastIdx(curContent);
const tableHeader = `
| # | 날짜 | 문제 | 난이도 |
|:---:|:---:|:---:|:---:|
`;
//달이 바꼈을 경우 기존 데이터는 접은글로
if (newEntry.date.slice(5, 7) != curDate.slice(5, 7)) {
const updateContent = `<details>\n<summary>${curDate.slice(0, 7)} 풀이 목록</summary>\n${curContent}\n</details>\n\n`;
const newTableRow = `| ${1} | ${newEntry.date} | [${newEntry.title}](${problemLink}) | ${getDifficultyIconPath(newEntry.level)} |`;
content = defaultDiv + detailsContent + updateContent + tableHeader + newTableRow + "\n";
}
else {
const newTableRow = `| ${+curIdx + 1} | ${newEntry.date} | [${newEntry.title}](${problemLink}) | ${getDifficultyIconPath(newEntry.level)} |`;
curContent = curContent + "\n" + newTableRow + "\n";
content = defaultDiv + detailsContent + curContent;
console.log(content);
}
writeFileSync(readmePath, content);
};
function parseLastDate(content) {
const lines = content.split('\n');
return lines[lines.length - 1].split("|")[2].trim();
}
function parseLastIdx(content) {
const lines = content.split('\n');
return lines[lines.length - 1].split("|")[1].trim();
}
updateReadme();
name: Update README
on:
push:
branches:
- main
paths-ignore:
- README.md
- .github/**
- update-readme.js
jobs:
update-readme:
if: "!contains(github.event.head_commit.message, '[skip ci]')"
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 2 # 충분한 히스토리를 가져오기 위해 fetch-depth를 2로 설정
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: |
echo '{}' > package.json
npm install axios
- name: Update README.md
run: node update-readme.js
- name: Commit and push changes
env:
GITHUB_TOKEN: ${{ secrets.토큰을 사용하시는 경우 repo > secrets의 토큰이름 }}
run: |
git config --global user.email "깃허브 이메일"
git config --global user.name "깃허브 닉네임"
git remote set-url origin https://x-access-token:${{ secrets.토큰 }}@github.com/${{ github.repository }}.git
git add README.md
git commit -m "Update README.md [skip ci]"
git push origin HEAD:main
테스트를 위해 수많은 브론즈 문제들이 희생되었지만, 해결되지 못한 문제..
다시 돌아와서, 문제점에 대해 설명하자면
git diff의 세부 명령어를 통해 커밋에서 변경된 파일을 가져오는데는 성공했으나
아래처럼, 내가 아는 선에서 어떤 시도를 해도 디코딩이 되지 않았다.
복기 해보자면.
1.
git diff의 세부 명령어로 단순 파일 경로를 가져와서 fs.readFileSync - 실패
(git diff-tree --no-commit-id --name-only -r HEAD)
2.
git show HEAD:${file} 로 직접 파일 내용을 가져오기 - 실패
3.
1. 번의 HEAD에 직접 커밋 ID를 넣어봄 - 실패
4.
1. 3. 번 과정에 디코딩 시도 - 실패
5.
4. 를 수행하여 2. 명령어 시도 - 실패
6.
iconv-lite 사용 - 실패
7.
Buffer로 인코딩 변환 - 실패
계속 PS를 풀면서 경로를 제대로 파싱하는 시도를 통해 언젠간 해결해내고 말리라..
2023.04 ~ 백엔드 개발자의 기록
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!