모바일

테일러 스위프트 상점 페이지 크롤링하는 어플리케이션 만들기 - (1)

Patti Smith 2024. 7. 25.

Sad Story

한국인 스위프티로 사는 건 고난하다... 가사 해석도 해야 하고, 떡밥도 주워먹어야 하고, 인터뷰도 해석해야 하고, 바이럴 탄 게시물을 해석하려면 미국 밈도 알아야 함.. 하지만 뭣보다 힘든 건.....

사인 씨디를 놓치는 것이다..

사인 시디는 당연한 소리지만 진짜 순식간에 품절된다. 이번엔 뜨는 경우도 많았지만 너무 불시에 반짝 떴다 사라진다....그때 난 잔다고....

물론 시디 앨범에 대한 재고 알림을 주는 봇이 있다. 하지만 말그대로 봇이다.. 디스코드나 트위터로 알려주긴하지만 다른 알림에 묻히거나 알람처럼 요란스럽게 짖어대지도 않는다 잠든 나를 깨울 만큼 강력한 알람이 필요햇다.... 

 

사인 시디를 향하여!!!

특정 사이트의 정보를 뽑아내고 싶으면 우선 그 웹이 open api를 제공하는지 확인한다. 하지만 네이버나 구글 같은 유명한 곳이 아니라면 제공해주지 않는 경우가 대부분이므로 사람들은(나는) 웹을 크롤링해서 정보를 얻어야 한다.

 

웹 크롤링을 하는 방법은 검색하면 아주 친절하게 수십 개의 게시물이 나온다. 하지만 블로그를 무턱대고 따라하기 전에 주의해야 할 것이 있음. 반드시 robots.txt를 확인해야 한다는 것임.

https://www.크롤링하려는 웹사이트 주소/robots.txt

robots.txt는 크롤링하려는 사이트 주소에다 저렇게 robots.txt만 붙여주면 확인할 수 있다.

 

robots.txt는 나 같은 사람들이 봇을 만들 때 여긴 크롤링하지 말아주셈 or 크롤링할 때 몇 초 이하로는 요청하지 않았으면 좋겠다거나.. 하는 권고안이 적혀있다. 의무는 아닌데 (예의있게) 하지 말기.....

금지 규약이지만 뜻밖의 정보도 많다 나는 스토어 robots.txt 상단에 떡하니 Shopify 이커머스 사이트 사용하고 있다는 정보를 확인했다. 그리고 Shopify 홈페이지에는 사이트 크롤러가 어떻게 웹사이트를 크롤링하면 좋을지 가이드 라인을 제공하고 있었다! (야호) 

 

How To Use a Site Crawler To Improve Your Ecommerce Site (2023) - Shopify

Want to see your website how Google sees it? Learn how to use a site crawler and the principles behind it.

www.shopify.com

  • 주기적인 사이트맵 확인하려면 사이트맵의 <lastmod> 태그를 확인하여 변경 사항이 있는지 파악한다.
  • 크롤링한 모든 제품의 URL과 마지막 수정 날짜를 저장하는 데이터베이스를 만든다.
  • 사이트맵의 각 URL을 데이터베이스와 비교한다. 
    • 새로운 URL이 있거나 기존 URL의 <lastmod> 값이 변경되었다면 해당 페이지를 크롤링한다.
    • 데이터베이스에 없는 새로운 URL을 발견하면 새 상품으로 간주하고 정보를 수집한다.

여기서 사이트 맵이 뭐냐면

  • The sitemap is simply a basic HTML file containing a listing of all the important pages on a site when it is intended for users. (의도된 접근-크롤링-할 때 이정표를 보여준다)
  • The sitemap, sometimes referred to as a sitemap.xml file, aids in the indexing of all pages on the website by search engine crawlers. Even though a site map does not ensure that a crawler will visit every page of a website, most search engines suggest using them. (서치엔진은 크롤러가 웹의 모든 페이지를 방문하지 않으니 서치 엔진은 이 이정표를 사용함)

설명을 들을 수록 내가 봇이 된 느낌이다. 누구는 사이트맵을 만들어 친절하게 알려주는 한편 나는 챗지피티에게 왜 안되냐며 막말을 쏘아댓는데... 

 

실제 사이트 맵의 링크를 확인하면 <lastmod>를 확인할 수 있다.

딴 얘기지만 이렇게 친절하게 사이트 맵을 제공하거나 가이드라인이 제공되지 않았다면 다른 게시물을 찾아보길 바란다... robots.txt가 있는 줄 모르고 논문까지 찾아보긴 했는데 참고가 될까 싶어 하나 남겨둔다

Predictive Crawling for Commercial Web Content (storage.googleapis.com)  <- 딥러닝을 통한 예측적인 크롤링을 하는 방법이다.

 

아키텍쳐 구성

서버리스 Lambda

크롤링하여 알림을 주는 간단한 기능이기 때문에 람다함수를 사용하기로 했다.

 

AWS Pricing Calculator

 

calculator.aws

람다 요금

특히 30초 간격으로 요청하면 한달에 (2 * 60 * 24 * 30 =)86400 회 요청하며, 람다 요청시간인 10초까지 포함해서 봐도 프리티어다. 문제 없이 쓸 수 있겠다고 판단!

 

데이터 베이스 선택

 

사용 사례에 따른 S3 vs DynamoDb 가격 비교 - LG CNS

1. 서론 Amazon S3는 AWS에서 가장 많이 사용되고 있는 대표적인 스토리지 서비스입니다.특히 S3는 AWS에서 제공하는 스토리지 서비스 중 가장 저렴한 것으로 널리 알려져 있습니다.그런데 “AWS에서 S

www.lgcns.com

 

RDS같이 복잡한 쿼리를 작성할 일은 전혀 없고 url만 넣으면 되기 때문에 noSQL로 작업, 중요한 건 s3를 할지 dynamodb를 할지 고민이었다. 상품의 개수는 250개 남짓하며, 쓰기 작업은 많아봐야 10개 정도다. 관건은 읽기 작업이다. 30초마다 읽기 작업이 일어나는데다, 하루에 3~4번 쓰기 작업이 이루어진다. s3도 고민해봤지만 s3의 경우 많은 데이터를 '저장'하는 데에 초점이 맞춰져 있다. 그에 비해 dynamoDB는 read/write 작업에 탁월하다. 짧은 시간 빈번한 요청(크롤링)을 해야 하는 지금 상황에 딱 맞다!

 

하지만 매번 대략 30초 ~ 1분 가량 url의 모든 데이터를 비교하는 것이 부담스러웠다. 그래서 페이지가 변경됐는지 확인하고 변경됐을 경우에만 db의 읽기 작업을 진행하려고 했으나...

 

1. 사이트 헤더 확인하기

처음 시도는 응답헤더를 확인하는 것이었다. 헤더에는 <etags>라는 태그가 있는데, 이 etags를 통해 캐시된 리소스가 변경되었는지 여부를 쉽게 확인할 수 있다. 하지만 문제는..... 서버가 여러 개면 etags도 여러 개다.... 이것 말고 사이트의 변경 여부를 확인할 수 있는데 헤더에 <last-Modified>라는 태그가 있다. 안타깝게도 이 태그는 내가 크롤링하려는 사이트에 존재하지 않는다.

사이트 맵의 헤더

 

2. 사이트맵의 <lastMod>확인하기

가이드 라인에 제시했든 사이트 맵에는 <lastMod>를 통해 최근 업데이트된 상품을 알 수 있다고 했다. 하지만 문제가 있었다. 이 lastMod의 값이 거의 10초 꼴로 변경되는 것이다. 이래서야 사이트에 새 상품이 등록됐는지 확인할 수 없다. (ㅠㅠ)

products 사이트맵의 xml

 

결국 처음 하던 것처럼 주기적으로 db를 검색하기로 결정했다. 사실 데이터가 그렇게 많지 않아서 프리 티어로도 가능하다.

 

아키텍쳐 설계

아키텍쳐

구성은 간단하다.

  1. Taylor swift Store의 사이트맵을 람다가 주기적으로 관찰한다.
  2. 사이트맵의 product에서 가져온 상품의 url을 dynamo DB에 insert한다.
  3. 매번 크롤링하며 새로운 url이 생기면 FCM에 보내고, 없는 url이라면 404페이지인지 확인하여 데이터베이스에서 삭제한다.

참고로 aws 설정을 위해 이동빈 님의 유튜브를 참고했다. 람다 설정하는 데에 큰 도움이 되어 람다를 처음 다루는 사람이라면 꼭 보길 추천 ㅇ.ㅇ)b

lambda function

이제 람다 실제 함수를 짜보자. 주석을 달아놨기에 동작 개괄만 설명한다.

  1. 사이트맵으로 product 페이지에 들어가 상품의 url을 가져온다.
  2. 모든 상품의 url을 가져왔다면 db와 비교를 한다.
  3. 현재 url이 이미 db에 있다면 무시
    • 현재 url과 저장된 url에 없다면 새 상품에 추가한다(fcm 알람)
    • 저장된 url이 현재 url에 없다면 db에서 삭제한다. 이때 404페이지가 뜨는지 확인해야 함

조금 신경 썼던 점은 1) db에서 모든 url를 로드하고 현재 url과 비교할지 2)매 url마다 db와 비교할지였다. 나는 데이터 개수가 작아 람다 함수의 기본 메모리(128mb)에 비해 매우 작다(37KB) 기본 메모리에 차지하는 비율이 0.01퍼도 되지 않는다. 게다가 dynamoDB에서 한 번 읽을 때마다 4kb당 1 RCU가 든다. 이부분은 지피티가 잘 설명해줘서 같이 첨부한다. 계산에 따르면 1번이 훨 빠르고 싸다. 1번으로 진행했다.

더보기

DynamoDB 쿼리 비용 분석

DynamoDB 읽기 비용 계산

  1. 읽기 용량 단위 (RCU, Read Capacity Unit):
    • DynamoDB는 하나의 읽기 용량 단위로 최대 4KB의 데이터를 Eventually Consistent 읽기로 처리합니다.
    • Strongly Consistent 읽기의 경우 최대 4KB의 데이터를 1 RCU로 처리합니다.
  2. 데이터 크기 및 읽기 용량 단위 계산:
    • 총 데이터 크기: 37KB
    • 4KB 당 1 RCU 필요
    • 필요 RCU: 37KB4KB≈9.25\frac{37KB}{4KB} \approx 9.25 RCU (Eventually Consistent 읽기 기준)
  3. 시간당 비용:
    • 한 번의 읽기에 9.25 RCU 필요.
    • 30초마다 읽기: 시간당 120번 읽기.
    • 시간당 RCU 사용량: 9.25×120=1,1109.25 \times 120 = 1,110 RCU
  4. 비용:
    • RCU 비용: $0.00013 per RCU-hour (AWS Free Tier 사용량 제외)
    • 시간당 비용: 1,110×0.00013=0.14431,110 \times 0.00013 = 0.1443 USD
    • 월 비용 (24시간, 30일 기준): 0.1443×24×30≈1040.1443 \times 24 \times 30 \approx 104 USD

Lambda 메모리 사용 비용 분석

  1. Lambda 메모리 설정:
    • 기본 메모리: 128MB
    • 37KB 데이터는 Lambda 메모리의 약 0.028% 사용.
  2. Lambda 호출 비용:
    • Lambda 호출 비용은 메모리 설정과 실행 시간에 따라 다릅니다.
    • 128MB 메모리 설정에서 1ms 실행 시간당 비용: $0.0000000021
    • 예를 들어, 각 Lambda 함수 호출이 100ms 걸린다고 가정.
  3. 비용:
    • 30초마다 호출: 시간당 120번 호출.
    • 시간당 비용: 120×100ms×0.0000000021×128MB≈0.0032120 \times 100ms \times 0.0000000021 \times 128MB \approx 0.0032 USD
    • 월 비용 (24시간, 30일 기준): 0.0032×24×30≈2.300.0032 \times 24 \times 30 \approx 2.30 USD

비용 절감 계산

  • DynamoDB 읽기 비용: 월 약 104 USD
  • Lambda 메모리 사용 비용: 월 약 2.30 USD
import boto3
import requests
import xml.etree.ElementTree as ET
from botocore.exceptions import ClientError
import json

# dynamodb setting
dynamodb = boto3.resource('dynamodb')
PRODUCT_TABLE_NAME = 'taylor-swift-store-db'
MAIN_SITEMAP_URL = 'https://store.taylorswift.com/sitemap.xml'

# dynamoDB의 상품 목록 불러오기
def get_stored_products():
    table = dynamodb.Table(PRODUCT_TABLE_NAME)
    response = table.scan()
    return {item['url']: item for item in response.get('Items', [])}

# 사이트맵 url 추출
def get_products(sitemap_url):
    response = requests.get(sitemap_url)
    root = ET.fromstring(response.content)
    products = []
    for url in root.findall('.//{http://www.sitemaps.org/schemas/sitemap/0.9}url'):
        loc = url.find('{http://www.sitemaps.org/schemas/sitemap/0.9}loc').text
        if loc.startswith('https://store.taylorswift.com/products/'):
            title = url.find('.//{http://www.google.com/schemas/sitemap-image/1.1}title')
            products.append({
                'url': loc,
                'name': title.text if title is not None else 'Unknown Product Name'
            })
    return products

# url 유효성 검정
def check_url_exists(url):
    try:
        response = requests.head(url, allow_redirects=True)
        return response.status_code == 200
    except requests.RequestException:
        return False

def lambda_handler(event, context):
    response = requests.get(MAIN_SITEMAP_URL)
    root = ET.fromstring(response.content)

    # 상품 목록이 담긴 xml parsing
    products_sitemap_url = next(url.text for url in root.findall('.//{http://www.sitemaps.org/schemas/sitemap/0.9}loc') 
                                if 'sitemap_products' in url.text)

    # 현재 상품 목록 불러오기
    current_products = get_products(products_sitemap_url)

    # 저장된 상품 목록 불러오기
    stored_products = get_stored_products()

    # 현재 상품과 저장된 상품을 비교하여 새로운 url 확인
    new_products = [p for p in current_products if p['url'] not in stored_products]

    # 404페이지가 뜨는 url은 제거
    removed_products = []
    for url, product in stored_products.items():
        if url not in {p['url'] for p in current_products}:
            if not check_url_exists(url):  # 404 페이지 확인
                removed_products.append(product)

    # 쓰기 작업 (새 상품은 추가, 삭제된 상품은 db에서 제거)
    table = dynamodb.Table(PRODUCT_TABLE_NAME)
    with table.batch_writer() as batch:
        for product in new_products: # 새 상품은 추가
            batch.put_item(Item=product)
        for product in removed_products: # 삭제된 상품은 제거
            batch.delete_item(Key={'url': product['url']})

    return {
        'statusCode': 200,
        'body': json.dumps({
            'message': f'Found {len(new_products)} new products and removed {len(removed_products)} products',
            'new_products': new_products,
            'removed_products': removed_products
        })
    }

 

 

길이 글어져 다음 게시글에 이어서 설명한다

댓글