Study/Python

[Python] 웹 파싱 / 기존 기능 개선

BVM 2023. 8. 11. 17:39

어떤 웹페이지에서 특정 시간 정보를 가져올 일이 간간히 있는데,

지금까진 그냥 눈으로 보고 손으로 데이터를 변환한 후 사용했다.

너무 미련한 짓인 것 같아서 확실히 자동화해야겠다고 생각했다...

해당 기능을 구현해 봇에 올릴 것이다.

1. 어떤 데이터를 받아올 것인가?

FFXIV의 로드스톤엔 점검 관련 공지가 올라온다.

그 공지의 내용에 있는 일정에 대한 정보를 뜯어올 것이다.

[続報]全ワールド メンテナンス作業 終了時間変更のお知らせ(8/8)

 

全ワールド メンテナンス作業 終了時間変更のお知らせ(8/8) | FINAL FANTASY XIV, The Lodestone

全ワールド メンテナンス作業 終了時間変更のお知らせ(8/8)

jp.finalfantasyxiv.com

위 공지에서 시간을 나타내는 아래와 같다.

정규식을 이용해서 일정을 파싱 한 후, 타임스탬프로 변환해서 사용할 것이다.

그리고 패치 등 서버가 전부 다 다운되는 점검 공지엔 무조건 "全ワールド"라는 문구가 들어간다.

타이틀 맨 뒤에 (월/일)이 온다는 점도 확인했다.

2. 클래스 작성

import requests
import re
from bs4 import BeautifulSoup
from datetime import datetime

class Parser:

    def get_recent_maintenance_title(url):
        response = requests.get(url)
        soup = BeautifulSoup(response.content, 'html.parser') if response.status_code == 200 else None

        if not soup:
            return None

        target_prefix = "全ワールド"
        news_list_elements = soup.find_all(class_="news__list")
        current_year = datetime.now().year

        for news_element in news_list_elements:
            title_element = news_element.find(class_="news__list--title")
            title_text = "".join(str(item) for item in title_element.contents if item.name is None).strip()

            if title_text.startswith(target_prefix):
                date_text = title_text.split('(')[-1].replace(')', '')
                month, day = map(int, date_text.split('/')[0:2])
                post_datetime = datetime(current_year, month, day)

                if (datetime.now() - post_datetime).days < 1:
                    return title_text, news_element.find('a')['href']

        return None 

    def get_maint_info():
        target_url = 'https://jp.finalfantasyxiv.com/lodestone/news/category/2'
        recent_title, recent_link = Parser.get_recent_maintenance_title(target_url) or (None, None)
        return recent_title, recent_link

    def get_html_content(url):
        response = requests.get(url)
        return response.content

    def parse_html_content(html_content):
        soup = BeautifulSoup(html_content, "html.parser")
        news_detail_div = soup.find("div", class_="news__detail__wrapper")
        return news_detail_div.get_text()

    def extract_time_info(content):
        time_match = re.search(r"日\s*時:(.*?)頃まで", content, re.DOTALL)
        if time_match:
            return time_match.group(1).strip()
        else:
            return None

    def parse_time_string(time_string):
        match = re.search(r"(\d{4})年(\d{1,2})月(\d{1,2})日", time_string)
        year, month, day = map(int, match.groups())

        match = re.search(r"(\d{1,2}):(\d{1,2})より(\d{1,2}):(\d{1,2})", time_string)
        start_hour, start_minute, end_hour, end_minute = map(int, match.groups())

        start_datetime = datetime(year, month, day, start_hour, start_minute)
        end_datetime = datetime(year, month, day, end_hour, end_minute)

        return int(start_datetime.timestamp()), int(end_datetime.timestamp())

기본적으로 클래스 내에서 사용될 함수들이다.

공지 타이틀을 뽑아오는 부분은 아래와 같이 동작한다.

  1. BeautifulSoup로 html을 파싱해 온다.
  2. 「全ワールド」가 들어간 글을 위에서부터 찾는다.
  3. 찾았다면 스트링을 분해해 날짜를 뽑는다.
  4. 뽑힌 날짜와 현재의 차이가 1일 이상 날 경우 None을 리턴
  5. 1일 이내일 경우 타이틀과 해당 링크 리턴
def GetMaintTimeStamp():
    # infos[0] = title, infos[1] = link
    infos = Parser.get_maint_info()
    if not infos[1]:
        return None
    url = f'https://jp.finalfantasyxiv.com{infos[1]}'
    html_content = Parser.get_html_content(url)
    content = Parser.parse_html_content(html_content)
    time_string = Parser.extract_time_info(content)
    if time_string:
        start_unix_timestamp, end_unix_timestamp = Parser.parse_time_string(time_string)
        if end_unix_timestamp < datetime.now().timestamp():
            return None
        print(f"Parsed time: {start_unix_timestamp} to {end_unix_timestamp}")
        return start_unix_timestamp, end_unix_timestamp, infos[0], url
    else:
        return None

실제로 명령어에서 호출할 함수.

넘겨받은 링크로 가서 시간 정보를 뽑아온다.

만약 점검 종료 시각이 현재보다 이전이면 None을 리턴한다.

@client.tree.command()
async def maintinfo(interaction: discord.Interaction):
    """공지 관련 정보를 임베드로 작성"""

    lg.info(f"{interaction.user.display_name} request maintinfo()")

    # 0 = Start, 1 = End, 2 = Title, 3 = Link
    time_info = pa.GetMaintTimeStamp()

    if not time_info:
        embed = discord.Embed(title="점검 일정이 없습니다!", colour=discord.Colour.dark_red())
        embed.set_thumbnail(url="https://cdn.discordapp.com/attachments/1138398345065414657/1138398369929244713/0001061.png")
        embed.add_field(name="", value="현재 확인할 수 있는 점검 공지가 없습니다.\n무언가 문제가 있다면 공식 로드스톤을 참고해 주세요.")
        await interaction.response.send_message(embed=embed)
        return

    output = Translator.Translate(time_info[2])

    embed = discord.Embed(title=time_info[2], url=time_info[3], description=output, colour=discord.Colour.dark_blue())
    embed.set_thumbnail(url="https://cdn.discordapp.com/attachments/1138398345065414657/1138398369929244713/0001061.png")
    embed.add_field(name="일정", value=f'시작 : <t:{time_info[0]}:F> \n종료 : <t:{time_info[1]}:F> \n\n<t:{time_info[1]}:R>', inline=False)

    lg.info(f'공지 링크 : {time_info[3]}')

넘겨받은 데이터로 임베드를 작성해 응답한다.

3. 출력 확인

잘 되니 다행이다.

4. 개선 사항들

A. 환율 정보 테이블

여러 화폐의 환율을 한방에 보고 싶어서 만들기로 했다.

기존 방식이 번거로웠던 것도 있고.

아래는 클래스 내부에서 실제로 데이터를 처리할 함수이다.

def exchCurList(src, amount):
    dst_currencies = ['usd', 'krw', 'jpy', 'eur', 'gbp', 'cny', 'try', 'ars', 'twd', 'mnt']
    exchange_rates = {}
    for dst in dst_currencies:
        try:
            request = requests.get(f"https://cdn.jsdelivr.net/gh/fawazahmed0/currency-api@1/latest/currencies/{src}/{dst}.min.json")
            result = request.json()
            total = result[f'{dst}'] * amount
            total = '{:,.2f}'.format(total)
            exchange_rates[dst] = total
        except:
            lg.error("Something went wrong while processing exchCur()!!")
            return None

    return exchange_rates

내가 확인할 필요가 있는 여러 화폐들을 배열처럼 저장하고 환율 데이터를 받아온 후, 딕셔너리 형태로 저장한다.

굳이 딕셔너리로 할 필요는 없었지만 하다보니 그리 됐다...

@client.tree.command()
@app_commands.describe(src='Source Currency. Default : USD', amount='Amount of Source Currency. Default : 1')
async def ratetable(interaction: discord.Interaction, src: str=None, amount: app_commands.Range[float, 0, None]=None):
    """Show the exchange rate table."""

    await interaction.response.defer()

    # Set default values if not provided
    src = src.lower() if src else 'usd'
    amount = amount if amount else 1

    try:
        # Get exchange rates
        result = ex.exchCurList(src, amount)
        if result is None:
            raise ValueError("No exchange rates found for the given source and amount.")
        exchange_rates = list(result.values())

        # Create an embed message with the exchange rates
        embed = discord.Embed(title="**Exchange Rate**", colour=discord.Colour.dark_green())
        embed.description = f'Exchange rates for **[ {amount} {src.upper()} ]**'
        embed.set_thumbnail(url="https://cdn.discordapp.com/attachments/1138398345065414657/1138816034049105940/gil.png")
        embed.add_field(name="", value="", inline=False)  # Padding

        # Add fields for each currency
        currencies = ['USD', 'KRW', 'JPY', 'EUR', 'GBP', 'CNY', 'TRY', 'ARS', 'TWD', 'MNT']
        flags = [':flag_us:', ':flag_kr:', ':flag_jp:', ':flag_eu:', ':flag_gb:', ':flag_cn:', ':flag_tr:', ':flag_ar:', ':flag_tw:', ':flag_mn:']
        for flag, currency, rate in zip(flags, currencies, exchange_rates):
            embed.add_field(name=f'{flag} {currency}', value=rate, inline=True)

        embed.add_field(name="", value="", inline=False)  # Padding
        embed.add_field(name="", value="Powered by [fawazahmed0/currency-api](https://github.com/fawazahmed0/currency-api)", inline=False)
        embed.set_footer(text=f'{datetime.datetime.now().strftime("%Y-%m-%d")} 기준')

        # Send embed message
        await interaction.followup.send(embed=embed)

    except:
        lg.error("Something went wrong while processing ratetable()")
        embed = rs.error("처리 중 오류가 발생했습니다. USD, KRW, JPY와 같은 제대로 된 통화코드를 입력했는지 확인하세요.")
        await interaction.followup.send(embed=embed, ephemeral=True)

인자에 어떤 입력도 없다면 1 USD 로 계산한다.

받아온 데이터를 EmbedField 형태로 정리한다.

반복적인 작업은 반복문으로 간소화한다.

A-1. 결과

데스크탑 환경에선 잘 보인다마는...

모바일 환경에선 3개씩 끊는 게 아니라 inline = False처럼 하나씩 끊어서 나온다.

다른 봇들도 그렇게 나오는 것을 보면 디스코드 자체의 사양인 듯싶다.

B. 임베드 리스폰스

그냥 쌩 메세지로 보내는 건 보기에 좋지 않은 것 같아 모든 반응을 임베드로 하게 했다.

from datetime import datetime
import discord

class Response:

    @staticmethod
    def create_embed(title, description, color):
        embed = discord.Embed(
            title = title,
            description = description,
            colour = color
        )
        embed.set_footer(text=f'현재 시각 : {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
        return embed

    @staticmethod
    def general(title, content):
        return Response.create_embed(title, content, discord.Colour.dark_blue())

    @staticmethod
    def info(content):
        return Response.create_embed(":large_blue_diamond: **INFO**", content, discord.Colour.dark_blue())

    @staticmethod
    def error(content):
        return Response.create_embed(":warning: **ERROR**", content, discord.Colour.red())

임베드를 그때그때 코드 상에 만들 순 없으니 클래스로 양식을 만들었다.

embed = rs.general(title=f'{value} {imp} **IN METRIC**', content=f'**{resVal:.2f} {resMet}**')
await interaction.response.send_message(embed=embed, ephemeral=True)

위와 같은 느낌으로 임베드를 작성해 넘겨주게 된다.

B-1. 결과

훨씬 보기 좋은 것 같다.

푸터의 시간을 GMT+9로 바꾸는 것은 조만간 할 것이다.