알고리즘 문제를 풀고, 그 코드를 Github에 업로드하고 있다. 하지만 별도의 README 파일을 작성해두지 않아서 굉장히 어수선한 상태이다. 어떤 문제를 풀었고, 또 그 풀이를 어디서 확인할 수 있는지에 대한 정보가 전혀 없기 때문이다.

무성의한 기존의 README...

'이제 정리해야지!'라는 생각에 README 파일을 직접 작성하려고 했다. 하지만 파일이 100개가 넘어서 굉장히 귀찮았다. '그냥 복붙 할까'라는 생각이 잠깐 들기도 했지만, 이왕 하는 거 코드로 한번 구현해 보자라는 생각이 들었다. 

 

README는 어떻게 작성?


README를 통해 '문제 이름', '문제 링크', '풀이 파일 링크', '풀이 성공 여부' 정보를 제공하려고 한다. 현재 파일들의 경로는 baekjoon/solved/*/b1_1004.py(문제 이름)과 같은 형식이고, 여기서 필요한 정보들을 어떻게 얻을 수 있을 지 고민해 봤다.

 

  • 문제 이름, 문제 링크 : 저장된 파일명으로 정확한 정보를 얻을 수 없음 → 크롤링이 필요
  • 풀이 링크 : Github 저장소에 업로드된 파일 주소를 제공하면 됨
  • 풀이 성공여부 : 기존에 있던 retry, solved 폴더로 구분하면 됨

 

고민 결과 문제 정보를 얻기 위한 크롤링이 필요했고, 추가로 풀이 링크의 commit id를 Github의 main으로 일괄 처리하지 않기로 했다. 풀이 링크를 commit id로 관리하면 기존과 다른 새로운 풀이가 업로드됐을 때도, 링크가 겹치지 않게 README에 기록할 수 있기 때문이다.

 

문제 정보 크롤링하기

풀이 파일은 번호만으로 문제를 확인할 수 있는 경우엔 문제 번호를, 그렇지 않은 경우엔 문제 이름으로 저장 돼있다. 문제 이름은 최대한 기존의 것을 유지하려 했고, 공백은 제거된 상태이다. 따라서 현재 백준 풀이는 번호로 나머지 플랫폼 풀이는 공백이 제거된 이름으로 저장돼 있다.

백준 : 번호 또는 이름으로 문제 확인 가능 / 프로그래머스 : 이름으로 확인 가능

 

결국 '번호'와 '공백이 제거된 이름'으로 '실제 문제의 이름'과 '문제의 링크' 정보를 크롤링해야 했다. 이때 번호만으로 크롤링할 수 있는 경우, 파싱 후 문제 이름과 비교할 필요가 없다. 그래서 비동기 처리할 경우, 더 빠르게 크롤링할 수 있을 것으로 판단했고 asyncio를 통해 비동기 처리했다.

 

코드는 아래와 같이 구현했다.

class BaekjoonScraper:
    """
    A web scraper for the Baekjoon Online Judge.
    """
    BASE_URL = "https://www.acmicpc.net"
    API_URL = "https://solved.ac/api/v3"

    @staticmethod
    async def fetch(session: aiohttp.ClientSession, url: str, headers: Dict[str, str]) -> str:
        """
        Sends an HTTP GET request using the specified session, URL, and headers.
        Returns the response body as json.
        """
        try:
            async with session.get(url, headers=headers) as response:
                if response.status == 200:
                    await asyncio.sleep(10)
                    return await response.json()
        except aiohttp.ClientError:
            print(f"Failed to fetch{url}")

    async def get_problem(self, problem_id: str) -> Problem:
        """
        Fetches the details for a single problem, given its ID.
        Returns a Problem object representing the problem.
        """
        api_url = f"{BaekjoonScraper.API_URL}/problem/show?problemId={problem_id}"
        headers = {"Content-Type": "application/json"}
        async with aiohttp.ClientSession() as session:
            response_json = await BaekjoonScraper.fetch(session, api_url, headers)
            # 파일 정보 확인
            problem_title = response_json['titleKo']
            problem_url = f"{BaekjoonScraper.BASE_URL}/problem/{problem_id}"
            return Problem(problem_id, problem_title, problem_url, "baekjoon")

    async def get_problems(self, problem_ids: List[str]) -> List[Problem]:
        """
        Fetches the details for multiple problems, given their IDs.
        Returns a list of Problem objects representing the problems.
        """
        tasks = [self.get_problem(re.sub(r"[^\d]", "", id))
                 for id in problem_ids]
        return await asyncio.gather(*tasks)

 

Git에서 Python 활용하기

풀이 링크에 사용할 commit id가 필요했다. 추가로 README의 문제 작성 순서의 기준이 될, 파일 최초 업로드 시간이 필요했다. 이를 위해 Git 명령어를 파이썬으로 처리하기 위한 방법을 찾아 봤다. 그 결과 GitPython이라는 패키지를 알게 됐고, 활용했다. 

 

이를 통해 아래와 같이 commit id 정보와, 최초 커밋 날짜 정보를 파이썬으로 쉽게 얻을 수 있었다.

GIT_REPO = Repo.init("../")
GIT_BRANCH = GIT_REPO.active_branch
# 풀이 링크에 활용될 정보
GIT_BASE_DIR = f"https://github.com/woodywarhol9/algorithm-practice/blob/{GIT_REPO.head.commit}"
def get_dt(path: str) -> str:
    """
    정렬 기준으로 사용할 최초 커밋 날짜 확인
    """
    dt = GIT_REPO.git.log("--follow", "--pretty=%cd",
                          "--date=format:%Y-%m-%d", path).split("\n")[-1]
    return dt

 

새로운 풀이가 추가되면, README에서 어떻게 처리?


앞선 고민을 바탕으로 README 파일을 파이썬 코드로 작성하는데 성공했다. 하지만 README 작성 시, 폴더 순회가 필요 했고 이 과정은 시간이 꽤 오래 걸린다는 것을 알게 됐다. 그래서 새로운 풀이가 추가 됐을 땐, README를 새로 작성하는 대신 기존 파일을 업데이트하는 방식이 더 좋을 것이라고 판단했다.

 

새로 업로드할 풀이는 보통 commit 된 상태에서, push를 기다리고 있는 파일인 경우가 많다. 따라서 원격 저장소와 로컬 저장소 사이 git diff를 통해 파일 정보를 얻고, 이를 README를 업데이트하는데 활용했다.

# git diff origin/main..main을 통해 새로 커밋된 파일만 확인할 수 있다.
file_paths = GIT_REPO.git.diff([f"origin/main..main"], name_only=True)

 

README 업데이트를 자동화하자


파이썬을 통해 초기 README 파일을 작성하고, 업데이트할 수 있는 코드를 구성했다. 최종적으론 풀이를 업로드하고, 이를 바탕으로 README 업데이트를 자동화하고 싶었다.

 

Github 원격 저장소에 풀이가 push 되는 것은 일종의 Event가 발생한다고 생각할 수 있고, 따라서 Github Actions을 활용하면 README 업데이트 과정을 자동화할 수 있다고 판단했다.

 

이때 push 된 파일이 항상 풀이와 관련된 파일은 아니기에, 확인 후 풀이인 경우에만 README를 업데이트해야 했다. 따라서 Step에 변수를 전달하고 if 문으로 실행 조건을 확인했다. 조건에 활용될 변수는 echo "{name}={value}" >> $GITHUB_OUTPUT 형식으로 전달해야 했고, 파이썬엔 echo가 없어 print를 사용했다.

# README 업데이트 성공 여부 확인
condition = run_main()
# Github Actions의 실행 조건 전달
print(f"DO_UPDATE={condition}")
# README 업데이트 여부 확인
- name: Run update_readme.py
  id: condition
  working-directory: auto-readme # 실행할 폴더 정보
  run: |
    python update_readme.py >> $GITHUB_OUTPUT
# 변수 전달 됐는지 확인
- name: Echo condition Var
  run: echo ${{steps.condition.outputs.DO_UPDATE}}
# 업데이트가 됐다면 commit 진행
- name: Commit changes
  if: ${{steps.condition.outputs.DO_UPDATE == 'True'}}
  run: |
    git config --global user.name 'woodywarhol9'
    git config --global user.email 'woodywarhol9@gmail.com'
    git add -A
    git commit -am "auto-update README.md"
# push
- name: Push changes
  run: |
    git push

 

적용 완료 + 코드 확인


삽질이 꽤 있었지만 결국엔 위와 같은 README가, 문제 풀이 업로드에 따라 자동으로 업데이트 되도록 구현하는데 성공했다. 새로운 플랫폼의 문제를 풀기 전까진 잘 동작할 거 같으니, 잘 써봐야겠다 ㅎㅎ... 구현에 활용한 전체 코드는 아래 링크를 통해서 확인할 수 있다.

 

GitHub - woodywarhol9/algorithm-practice: problem solving using python

problem solving using python. Contribute to woodywarhol9/algorithm-practice development by creating an account on GitHub.

github.com


참고

 

[Git] github actions로 README.md 자동생성하기

최근에 LeetCode를 풀고 있는데, 내가 풀어 둔 문제 목록을 레포지토리 첫 화면에 예쁘게 보여주면 좋겠다는 생각이 들었다. LeetHub처럼 LeetCode의 내 실제 제출 목록을 분석해서 깃헙에 자동으로 올

holika.tistory.com

  • 아이디어와 코드 관련 내용을 많이 참고했다.