학습 자료

264편을 파일로 저장한다 — 두 번 틀린 이야기


Article

앞 편에서 글 하나의 본문을 뽑았다. 이제 264개를 전부 파일로 저장한다.

전체 여섯 편 중 지금
  1. ① 웹페이지를 가져온다끝남
  2. ② 264편을 파일로 저장지금 여기
  3. ③④ 글을 조각으로 자른다다음
  4. ⑤⑥ 숫자로 바꿔 검색나중

1. 제목이 264개 전부 똑같이 나왔다

제목을 h1 에서 가져왔더니 이렇게 저장됐다.

저장된 파일들
  • # 디코드랩
  • # 디코드랩
  • # 디코드랩
  • …264개 전부

페이지 맨 위 로고도 <h1> 이고, select_one 은 먼저 나오는 것을 준다. 제목은 <head> 안의 og:title 에서 가져온다.

03_find_title.py
import requests
from bs4 import BeautifulSoup

soup = BeautifulSoup(
    requests.get("https://www.dcodelab.kr/p/git-21-branch", timeout=30).text,
    "html.parser",
)

# enumerate — 몇 번째인지를 같이 준다
for i, h in enumerate(soup.select("h1")):
    print(f"h1[{i}]  {h.get_text(strip=True)[:50]}")

# find — 태그 이름과 속성으로 찾는다
og = soup.find("meta", property="og:title")

# meta 는 안에 글자가 없고 content 속성에 값이 있다
print("og:title :", og.get("content"))
터미널
  • h1[0] 디코드랩
  • h1[1] 브랜치란 무엇인가 — 원본은 그대로 두고 딴 데서 해봅니다
  • og:title : 브랜치란 무엇인가 — 원본은 그대로 두고 딴 데서 해봅니다

og:title 은 링크를 공유했을 때 뜨는 제목이다. 화면에 안 보이는 값이라 디자인이 바뀌어도 잘 안 변한다.

실제로 쓸 때는 og 가 없는 페이지도 있으니 확인하고 꺼낸다.

og = soup.find("meta", property="og:title")
title = og.get("content") if og else ""

2. 윈도우에서 한글이 깨진다

터미널
  • UnicodeEncodeError: 'cp949' codec can't encode character '\u2014'

윈도우 터미널은 기본이 cp949 인데 거기에는 긴 줄표()가 없다. 파일은 encoding="utf-8" 을 줘서 멀쩡하고, 죽는 건 화면에 찍는 순간이다. 둘 중 하나로 푼다.

# 방법 1 — 이 실행에만 적용한다
set PYTHONIOENCODING=utf-8
python crawl.py
# 방법 2 — 코드 맨 위에 넣는다
import sys
sys.stdout.reconfigure(encoding="utf-8")

3. 전체 코드

crawl.py
import re
import time
import pathlib
import requests
from bs4 import BeautifulSoup
from markdownify import markdownify as to_md

SITEMAP = "https://www.dcodelab.kr/sitemap.xml"
OUT = pathlib.Path("posts")


def post_urls():
    """사이트맵에서 글 주소만 골라 목록으로 돌려준다."""
    xml = requests.get(SITEMAP, timeout=30).text
    return [u for u in re.findall(r"<loc>(.*?)</loc>", xml) if "/p/" in u]


def fetch(url):
    """글 하나를 받아 딕셔너리로 돌려준다. 못 받으면 None."""
    soup = BeautifulSoup(requests.get(url, timeout=30).text, "html.parser")

    body = soup.select_one("div.prose")
    if body is None:
        return None           # 구조가 바뀌었다

    for junk in body.select("button, svg, script, style"):
        junk.decompose()

    og = soup.find("meta", property="og:title")

    return {
        # 주소 맨 뒤 조각이 글 이름이다
        "slug": url.rstrip("/").split("/")[-1],
        "title": og.get("content") if og else "",
        "url": url,
        "md": to_md(str(body), heading_style="ATX").strip(),
    }


def main(limit=None):
    OUT.mkdir(exist_ok=True)   # exist_ok=True — 이미 있어도 에러 내지 마

    urls = post_urls()
    print(f"글 주소 {len(urls)}개")
    urls = urls[:limit]

    ok, fail = 0, []
    for i, url in enumerate(urls, 1):
        try:
            post = fetch(url)
        except Exception as e:
            # 한 건이 실패해도 나머지는 계속 받는다
            post = None
            print(f"  [에러] {url} - {e}")

        if post is None or not post["md"]:
            fail.append(url)
            continue

        text = f'# {post["title"]}\n\n<{post["url"]}>\n\n{post["md"]}\n'
        (OUT / f'{post["slug"]}.md').write_text(text, encoding="utf-8")

        ok += 1
        if i % 25 == 0:
            print(f"  {i}/{len(urls)}")

        time.sleep(0.3)        # 요청 사이에 쉰다. 안 쉬면 공격과 구분이 안 된다

    print(f"\n성공 {ok}건, 실패 {len(fail)}건")
    for u in fail[:10]:
        print("  실패:", u)


if __name__ == "__main__":
    main(limit=5)              # 5개로 확인하고, 괜찮으면 None 으로 바꾼다

limit=5 로 먼저 돌리고 생긴 파일을 열어본다. 제목이 로고로 들어온 걸 알아챈 게 이 단계다. 확인이 끝나면 limit=None 으로 전체를 받는다. 0.3초 × 264 = 약 3분.

4. 결과

터미널 — 전체 실행
  • 글 주소 264개
  • 25/264
  • 250/264
  • 성공 264건, 실패 0건
04_check_result.py
import pathlib

# glob("*.md") — 이름이 .md 로 끝나는 파일 전부
posts = sorted(pathlib.Path("posts").glob("*.md"))

# p.stem — 확장자를 뺀 파일 이름
sizes = {p.stem: len(p.read_text(encoding="utf-8")) for p in posts}
total = sum(sizes.values())

print(f"파일 {len(posts)}개")
print(f"총 {total:,}자, 평균 {total // len(posts):,}자")

# 정렬한 뒤 양 끝을 본다. 평균만 보면 분포를 놓친다
by_size = sorted(sizes.items(), key=lambda kv: kv[1])
print("가장 짧은 글:", by_size[0])
print("가장 긴 글  :", by_size[-1])
터미널
  • 파일 264개
  • 총 1,328,418자, 평균 5,031자
  • 가장 짧은 글: ('low-risk-collaboration', 801)
  • 가장 긴 글 : ('ai-eng-06-measuring-chunks', 15619)

132만 자가 모였고, 길이가 801자부터 15,619자까지 제각각이다. 19배 차이다. 이 들쭉날쭉함이 다음 편의 출발점이다.

받아온 파일에서 표와 순서도는 글자만 남는다. 화면의 모양은 CSS 가 만든 것이라 그렇고, 뜻은 남아 있어서 검색에는 큰 지장이 없다.

이번 편에 나온 것

정리
쓴 것하는 일
soup.find("meta", property="og:title")메타 태그를 고른다. 제목은 여기서
태그.get("content")속성 값을 꺼낸다 (글자가 아니라 속성일 때)
enumerate(목록, 1)「몇 번째인지」를 같이 준다. 1부터
url.rstrip("/").split("/")[-1]주소 맨 뒤 조각 = 글 이름
pathlib.Path("posts")경로를 다룬다. / 로 이어붙인다
.mkdir(exist_ok=True)폴더를 만든다. 있으면 그냥 넘어간다
.write_text(s, encoding="utf-8")파일로 쓴다
.glob("*.md")이름이 .md 로 끝나는 파일 전부
try / except한 건이 실패해도 나머지를 계속한다
time.sleep(0.3)요청 사이에 쉰다
{:,}숫자에 천 단위 쉼표
PYTHONIOENCODING=utf-8화면 출력 한글 깨짐
sys.stdout.reconfigure(...)같은 일을 코드 안에서

다음 편에서 긴 글을 짧은 토막으로 자른다.