264편을 파일로 저장한다 — 두 번 틀린 이야기
Article
앞 편에서 글 하나의 본문을 뽑았다. 이제 264개를 전부 파일로 저장한다.
- ① 웹페이지를 가져온다끝남
- ② 264편을 파일로 저장지금 여기
- ③④ 글을 조각으로 자른다다음
- ⑤⑥ 숫자로 바꿔 검색나중
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(...) | 같은 일을 코드 안에서 |
다음 편에서 긴 글을 짧은 토막으로 자른다.