본문 바로가기
공부 및 정리/기타 정리

Github Action을 통해 좋아하는 블로그의 새 포스팅 자동 알림 만들기

by 스파이펭귄 2024. 5. 29.
728x90

저는 평소 좋아하던 블로그들이 있습니다.

하지만 개인 블로그다보니 자주 글은 올라오지 않아 생각 날때마다 블로그를 찾으며 새 글이 올라오는지 체크합니다.

물론 Tistory 같은 경우 구독을 하면 알림이 오지만(구독 자체가 안되는 경우도 있는것 같지만) Github 블로그나 개인 블로그 같은 경우에는 알림 기능을 블로그에서 제공하지 않으면 새로운 글이 올라왔는지 자주 체크해야하는 불편함이 존재했습니다.

이때 Github Action이라는 녀석을 발견했습니다.

 

Github Action

Github Action은 Github에서 제공하는 CI/CD(Continuous Integration/Continuous Deployment) 도구로 코드를 자동으로 빌드, 테스트 및 배포할 수 있게 도와주는 툴입니다.

갑자기 CI/CD가 나와서 당황스럽지만, 이 녀석은 스케쥴러의 역할을 할 수 있습니다.

즉, Github 자체에서 특정 시간마다 특정 코드를 실행해 줄 수 있다는 것입니다.

마침 최근 크롤링에 대해서 공부하였고, 크롤링과 Github Action을 사용하면 제가 좋아하는 블로그들의 글의 최신 상태를 확인하고 새로운 글이 올라오는 경우 알림을 줄 수 있을 것이라 생각해 Github Action 연습 느낌으로 프로젝트를 만들어 봤습니다.

 

 

타겟 블로그 설정

먼저 블로그들부터 선정했습니다.

1. https://kciter.so/

난해한 프로그래밍 언어 만들어보기라는 글을 읽고 알게된 블로그로 재미있는 컴퓨터 과학 관련 글들을 많이 써주시는 블로그입니다.

2. https://blog.firstpenguine.school/

그 다음으로 AI, 빅데이터 관련 글을 자주 써주시는 퍼스트 펭귄 코딩 스쿨이라는 블로그입니다.

3. https://junia3.github.io/blog

마지막으로 AI 관련 논문을 자주 리뷰해주시는 블로그입니다.

 

 

HTML 분석

타겟 블로그 중 하나인 JunYoung님의 블로그로 예시를 들어보겠습니다.

먼저 우린 최신 글 리스트를 가져올 필요가 있습니다.

위 그림을 확인해보면 blog-card라는 id의 div 태그들이 게시글 리스트임을 확인할 수 있습니다.

이 div 태그는 위처럼 구성되어있습니다. 여기서 우린 제목, 날짜, 링크를 가져오고 싶습니다.

  • 제목: title is-size-4-touch라는 클래스의 h1 태그로부터 가져올 수 있습니다.
  • 날짜: has-text-weight-semibold라는 클래스의 span 태그로부터 가져올 수 있습니다.
  • 링크: 링크의 경우 a태그의 href를 통해 absolute 경로를 가져올 수 있습니다.

 

 

크롤러 만들기

이제 블로그 포스트에서 제목, 날짜, 링크를 크롤링하는 크롤러를 만듭니다.

import requests
from bs4 import BeautifulSoup as bs4
from datetime import datetime
# 웹페이지로부터 HTML을 가져옵니다.
res = requests.get("https://junia3.github.io/blog").text

# BeautifulSoup 객체를 생성합니다.
soup = bs4(res, "html.parser")

# 특정 ID를 가진 요소를 찾습니다.
element_by_name = soup.find_all("div", attrs={"name": "blogcards"})

jy_blog_content_lst = []
for elem in element_by_name:
    link = elem.find('a').get('href')
    title = elem.find('h1', attrs={"class": "title is-size-4-touch"}).get_text()
    date_str = elem.find('span', attrs={"class": "has-text-weight-semibold"}).get_text()
    date = datetime.strptime(date_str, "%B %d, %Y")
    print(title, date, link)

단순히 생각하면 위처럼 추출할 수 있습니다.

이제 이를 일반화 해서 함수로 만들어봅니다.

def crawling_favorite_blogs(blog_info_lst):
    """
    주어진 사이트 정보를 사용하여 각 사이트의 첫 페이지를 크롤링하고, 게시글의 발행 시간, 제목, 링크를 추출합니다.
    그 후 어제 날짜와 비교해 어제 날짜인 글만 추출합니다. (매일 00시에 이 프로그램을 Github Action으로 돌릴 예정)

    매개변수:
    blog_info_lst (list): blog_tuple로 구성된 리스트.
    - blog_tuple (tuple): 블로그 이름과 blog_dict로 구성된 튜플
        - blog_name (str): 각 블로그의 이름(별명)
        - blog_dict (dict): 각 사이트의 구성 정보를 포함하는 사전. 각 키는 블로그의 별칭이며, 값은 다음 정보를 포함하는 사전입니다:
            - base_url (str): 사이트의 기본 URL.
            - post_path (str): 게시글이 나열된 페이지의 경로.
            - detail_page_is_absolute (boolean): 상세 페이지의 경로가 absolute로 연결 되어있는가에 대한 정보
            - datetime_format (str): 발행 시간의 날짜 형식을 지정하는 문자열.
            - post_info (list): 게시글 리스트를 가져오기 위한 정보를 포함하는 리스트 [태그, 검색 방법, class/id/name 값].
            - title_info (list): 게시글 제목을 찾기 위한 정보를 포함하는 리스트 [태그, 검색 방법, class/id/name 값].
            - link_info (list): 게시글 링크를 찾기 위한 정보를 포함하는 리스트.
            - publish_info (list): 게시글의 발행 시간 정보를 찾기 위한 리스트.
            - need_enter_detail_page_for_publish_date (bool): 게시 날짜 정보를 얻기 위해 상세 페이지에 접근해야 하는지 여부.

    반환 값:
    crawling_result_lst (list): 크롤링한 결과 중 각 사이트별로 타겟 날짜인 포스트만 모은 리스트
        - crawling_result_tuple (tuple): crawling_result_lst의 요소로 각 사이트의 이름과 각 사이트에서 추출한 정보를 담고 있음
            - name (str): 블로그 이름
            - data (list): 타겟 날짜에 맞는 포스트만 모은 정보들 [링크, 제목, 게시 날짜]로 구성

    예제:
    blog_info = ("example_blog", {
                    "base_url": "https://example.com",
                    "post_path": "/posts",
                    "detail_page_is_absolute": False,
                    "datetime_format": "%Y-%m-%d",
                    "posts_info": ["div", "class", "post"],
                    "title_info": ["h1", "class", "post-title"],
                    "link_info": ["a", "name", "post-link"],
                    "publish_info": ["span", "class", "publish-date"],
                    "need_enter_detail_page_for_publish_date": False
                })
    blog_dict_list = [blog_info]
    crawling_favorite_blogs(blog_dict_list)
    """
    crawling_result_lst = []
    today = datetime.now().date() - timedelta(days=1)

    for blog_name, blog_dict in blog_info_lst:
        soup = bs(requests.get(blog_dict["base_url"] + blog_dict["post_path"]).text, "html.parser")
        posts_info = blog_dict["posts_info"]
        link_info = blog_dict["link_info"]
        title_info = blog_dict["title_info"]
        publish_info = blog_dict["publish_info"]
        posts = soup.find_all(posts_info[0], attrs={posts_info[1]:posts_info[2]})
        result_lst = []
        for post in posts:
            if link_info[0] == None:
                # 리스트 자체가 a 태그인 경우...
                link = post.get("href")
            elif link_info[1] == None:
                link = post.find('a').get("href")
            else:
                link = post.find(link_info[0], attrs={link_info[1]:link_info[2]}).get("href")

            if blog_dict["detail_page_is_absolute"] is not None and not blog_dict["detail_page_is_absolute"]:
                link = blog_dict["base_url"] + link

            if title_info[1] == None:
                title = post.find(title_info[0]).get_text()
            else:
                title = post.find(title_info[0], attrs={title_info[1]:title_info[2]}).get_text()

            if blog_dict["need_enter_detail_page_for_publish_date"]:
                detail_soup = bs(requests.get(link).text, "html.parser")
                publish_date_str = detail_soup.find(publish_info[0], attrs={publish_info[1]:publish_info[2]}).get_text()
            else:
                if publish_info[1] == None:
                    publish_date_str = post.find(publish_info[0]).get_text()
                else:
                    publish_date_str = post.find(publish_info[0], attrs={publish_info[1]:publish_info[2]}).get_text()

            publish_date = datetime.strptime(publish_date_str, blog_dict["datetime_format"])
            if today == publish_date.date():
                result_lst.append((link, title, publish_date))

        crawling_result_lst.append((blog_name, result_lst))
    return crawling_result_lst

위와 같은 함수를 만들었습니다.

이때 매개변수로 junia3의 블로그 정보를 넣어주는 경우 아래와 같이 넣어줘야합니다.

blog_infos = [
        ("junia3", {
            "base_url": "https://junia3.github.io/blog",
            "post_path": "",
            "detail_page_is_absolute": None,
            "datetime_format": "%B %d, %Y",
            "posts_info": ["div", "name", "blogcards"],
            "title_info": ["h1", "class", "title is-size-4-touch"],
            "link_info": ["a", None, None],
            "publish_info": ["span", "class", "has-text-weight-semibold"],
            "need_enter_detail_page_for_publish_date": False
        })
]
crawling_result_lst = crawling_favorite_blogs(blog_infos)

코드가 기니까 하나하나 잘라서 보겠습니다.

먼저 매개변수부터 체크해봅니다.

매개 변수로 넣어주는 변수들은 각 블로그들의 정보가 튜플로 감싸진 리스트로 각 튜플은 각각 다음과 같이 이루어져 있습니다.

  1. 블로그 별명
  2. 블로그 정보 Dictionary

이때 블로그 정보 Dictionary에는 아래와 같은 정보들이 있습니다.

  • base_url (str): 사이트의 기본 URL.
  • post_path (str): 게시글이 나열된 페이지의 경로.
  • detail_page_is_absolute (boolean): 상세 페이지의 경로가 absolute로 연결 되어있는가에 대한 정보
  • datetime_format (str): 발행 시간의 날짜 형식을 지정하는 문자열.
  • post_info (list): 게시글 리스트를 가져오기 위한 정보를 포함하는 리스트 [태그, 검색 방법, class/id/name 값].
  • title_info (list): 게시글 제목을 찾기 위한 정보를 포함하는 리스트 [태그, 검색 방법, class/id/name 값].
  • link_info (list): 게시글 링크를 찾기 위한 정보를 포함하는 리스트.
  • publish_info (list): 게시글의 발행 시간 정보를 찾기 위한 리스트.
  • need_enter_detail_page_for_publish_date (bool): 게시 날짜 정보를 얻기 위해 상세 페이지에 접근해야 하는지 여부.

이후 이 리스트를 돌며 각 블로그별로 크롤링을 시작합니다.

# 1
today = datetime.now().date() - timedelta(days=1)    
for blog_name, blog_dict in blog_info_lst:
    # 2
    soup = bs(requests.get(blog_dict["base_url"] + blog_dict["post_path"]).text, "html.parser")
    # 3
    posts_info = blog_dict["posts_info"]
    link_info = blog_dict["link_info"]
    title_info = blog_dict["title_info"]
    publish_info = blog_dict["publish_info"]
    # 4
    posts = soup.find_all(posts_info[0], attrs={posts_info[1]:posts_info[2]})
    ...
  1. 새로운 게시글임을 나타낼 날짜를 오늘 날짜 - 1일로 설정합니다. (매일 0시에 시작할 것이므로)
  2. 위 코드에서 보이는 것처럼 먼저 블로그 게시글 리스트가 나열된 페이지를 beautiful soup을 사용해 html 코드를 가져옵니다.
  3. 이후 인자로 들어온 blog_dict로부터 크롤링에 사용할 정보를 추출합니다.
  4. 크롤링에 사용할 정보를 기반으로 게시글 리스트를 HTML 코드에서 가져옵니다.

이제 이 게시글 리스트를 for문으로 돌며 제목, 날짜, 링크 정보를 추출합니다.

...
result_lst = []
for post in posts:
  if link_info[0] == None:
      # 리스트 자체가 a 태그인 경우...
      link = post.get("href")
  elif link_info[1] == None:
      link = post.find('a').get("href")
  else:
      link = post.find(link_info[0], attrs={link_info[1]:link_info[2]}).get("href")

  if blog_dict["detail_page_is_absolute"] is not None and not blog_dict["detail_page_is_absolute"]:
      link = blog_dict["base_url"] + link

  if title_info[1] == None:
      title = post.find(title_info[0]).get_text()
  else:
      title = post.find(title_info[0], attrs={title_info[1]:title_info[2]}).get_text()

  if blog_dict["need_enter_detail_page_for_publish_date"]:
      detail_soup = bs(requests.get(link).text, "html.parser")
      publish_date_str = detail_soup.find(publish_info[0], attrs={publish_info[1]:publish_info[2]}).get_text()
  else:
      if publish_info[1] == None:
          publish_date_str = post.find(publish_info[0]).get_text()
      else:
          publish_date_str = post.find(publish_info[0], attrs={publish_info[1]:publish_info[2]}).get_text()

  publish_date = datetime.strptime(publish_date_str, blog_dict["datetime_format"])
  if today == publish_date.date():
      result_lst.append((link, title, publish_date))

crawling_result_lst.append((blog_name, result_lst))

마찬가지로 크롤링에 사용할 정보를 기반으로 게시 날짜, 제목, 링크를 추출합니다.

이때 아까 설정한 today와 게시 날짜가 동일한 경우 result_lst에 추가합니다.

이렇게 모든 새로운 게시글들을 [(블로그 별명, 새 게시글 리스트)] 형태로 crawling_result_lst에 추가하고 리턴합니다.

 

 

Git Issue로 올리기

Github Action을 통해 스케쥴링을 만들어 알림을 주는 대표적인 방법이 바로 Git Issue를 만드는 것입니다. watching 중인 레포지토리에 새로운 Git Issue이 발생시 메일이 발송되기 때문에 알림으로는 아주 제격입니다.

def publish_git_issue(crawling_result_lst):
    """
    매개변수:
    crawling_result_lst (list): 크롤링한 결과 중 각 사이트별로 타겟 날짜인 포스트만 모은 리스트
        - crawling_result_tuple (tuple): crawling_result_lst의 요소로 각 사이트의 이름과 각 사이트에서 추출한 정보를 담고 있음
            - name (str): 블로그 이름
            - data (list): 타겟 날짜에 맞는 포스트만 모은 정보들 [링크, 제목, 게시 날짜]로 구성

    """
    GITHUB_TOKEN = os.environ['GITHUB_TOKEN']
    REPO_NAME = "favorite-blog-crawling"
    repo = get_github_repo(GITHUB_TOKEN, REPO_NAME)
    total_new_post_blogs = 0
    body = "| 블로그 이름 | 제목 | 게시날짜 |\n" + "| - | - | - |\n"
    for blog_name, blog_posts in crawling_result_lst:
        if len(blog_posts) > 0:
            total_new_post_blogs += 1
            for post in blog_posts:
                body += f"| {blog_name} | [{post[1]}]({post[0]}) | {post[2]} |\n"

    if total_new_post_blogs > 0:
        title = f"{total_new_post_blogs}개의 블로그에서 새로운 포스트가 게시되었습니다."
        upload_github_issue(repo, title, body)

아까 결과로 얻는 crawling_result_lst를 인자로 받아 markdown 형식으로 이쁘게 정리 후 전체 블로그 중 1개 이상의 블로그 포스트가 새로 올라온 경우 Git Issue를 만듭니다.

이때 os.environ에서 GITHUB_TOKEN을 받는데 이는 Github Action 실행시 환경 변수로 github Token을 추가할 수 있습니다. 조금 있다가 보도록 하겠습니다.

 

Github Action으로 자동화하기

Github Action은 레포지토리의 Actions에 존재합니다.

Actions로 가면 위와 같이 뜰텐데 여기서 저는 Python Application을 사용했습니다.

이렇게 하면 레포지토리 코드로 .github/workflows/action.yml를 생성할 수 있습니다.

name: 매일 블로그 포스트 확인하기

on:
  push:
    branches:
      - main
  schedule:
    - cron: "0 0 * * *"

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v4
    - name: Set up Python 3.10
      uses: actions/setup-python@v3
      with:
        python-version: "3.10"
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
    - name: run crawling
      run: |
        python crawling.py
      env: 
        GITHUB_TOKEN: ${{ secrets.GITHUBTOKEN }}

저는 위 스크립트 코드를 추가해주었습니다.

위 코드는 간단히 보면 main 브랜치에 push 시 시작되거나 아니면 매일 0시에 실행됩니다.

그리고 jobs가 중요한데, 레포지토리 내의 requirements.txt를 통해 설치하고, python carwling.py를 통해 크롤링 코드를 실행합니다.

이때 환경 변수로 GITHUB_TOKEN에 secrets.GITHUBTOKEN을 넣어주는 것을 확인할 수 있습니다. 이것이 아까 얘기한 Github Action에서 환경변수 추가해주는 부분입니다.

그렇다면 secrets는 어디서 설정할까요?

레포지토리의 settings로 가서 Security에서 Secrets and variables를 확인하면 Secret 값을 추가할 수 있습니다.

위처럼 매일 블로그들의 새 글 확인 스케쥴링을 Github 자체에서 할 수 있고, 새 글이 게시되는 경우 위와 같이 Github Issue가 발행되며 테이블 형식으로 이쁘게 보여주도록 하였습니다.

 

앞으로 시간이 날때마다 좋아하는 블로그들의 HTML 코드를 분석해 추가해야겠습니다. 헤헤헤헤ㅔ헤ㅔㅎ

깃허브 레포지토리는 https://github.com/7xxogre/favorite-blog-crawling

Reference

728x90