블로그

2026년 8월 15일 · 10분 읽기

Go 웹 본문 추출: density 파서로 article 태그를 믿지 않기

Go로 링크에서 본문만 뽑는 추출기를 만들면서 시맨틱 태그를 믿지 않게 된 경위입니다. article 태그 23개 안에 345자 위젯만 있고 본문 9,400자가 밖에 있었습니다.

  • Go
  • 백엔드
  • 콘텐츠 추출
  • HTML 파싱

2026년 8월 23일 업데이트

동아일보 기사에서 본문 대신 345자짜리 위젯이 뽑혔습니다.

원인은 <article> 태그를 믿은 것이었습니다. 그 페이지의 렌더된 DOM에는 <article>이 23개 있었습니다. 위젯 단위로 흘려 놓은 것이고, 실제 본문 9,400자는 그 밖에 있었습니다.

시맨틱 태그가 본문을 가리킨다는 가정이 틀렸습니다.

외부 API 없이 웹 본문 추출기를 만들었습니다.

한눈에 보기

문제 동아일보 기사에서 본문 대신 345자짜리 위젯이 뽑혔습니다.
결정 외부 API 없이 웹 본문 추출기를 만들었습니다.
결과 규칙을 추가하는 조건을 주석에 남겼습니다.
제약 한계도 분명합니다.

엔드포인트는 하나이고 상한이 셋입니다

계약이 단순합니다. URL을 받아 markdown을 돌려줍니다.

// platform/backend/internal/api/reader.go:13-39
func registerReader(g *echo.Group) {
    g.POST("…", func(c echo.Context) error {
        // body: {"url": "..."}
        // 200: {url, title, site, kind, markdown, hero_image_url, truncated}
    })
}

kind가 있는 것이 눈에 걸릴 수 있습니다. 페이지가 기사인지, 피드인지, 소셜 게시물인지 구분합니다. 소셜 게시물은 제목이 없고 본문이 짧으므로 다르게 다뤄야 합니다.

남의 서버를 부르는 코드에는 상한이 필요합니다. 셋을 박았습니다.

// platform/backend/internal/reader/reader.go:30-31
const maxBody = 4 << 20      // 4MiB까지만 읽습니다
const minBodyRunes = 80      // 80자보다 짧으면 본문으로 인정하지 않습니다
// reader.go:147-152
client.Timeout = 10 * time.Second   // 10초 안에 못 받으면 포기합니다
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
    if len(via) >= 3 {              // 리다이렉트는 세 번까지
        return statusError(http.StatusGatewayTimeout, "UPSTREAM_TIMEOUT", "too many redirects", nil)
    }
    if err := e.validateURL(req.Context(), req.URL); err != nil {

눈여겨볼 것은 마지막 줄입니다. 리다이렉트를 따라갈 때마다 주소를 다시 검사합니다.

첫 주소만 검사하면 SSRF를 막지 못합니다. 정상 주소를 주고 그 서버가 http://10.0.0.1로 리다이렉트하게 만들면 됩니다. 서버가 자기 내부망을 대신 긁어 주게 되는 문제입니다.

포트를 80과 443으로 묶고, DNS를 해석한 뒤 나온 IP가 예약 대역이면 거부합니다. 도메인 이름만 보고 판정하지 않습니다.

사이트별 규칙 표를 두지 않기로 했습니다

한국 뉴스 포털은 본문 컨테이너가 특이합니다. 네이버는 #dic_area#newsct_article을 씁니다.

그래서 사이트별 선택자 표를 만들었습니다. 호스트 이름으로 찾아 그 컨테이너에서 본문을 뽑는 구조입니다.

지웠습니다. 주석에 이유가 적혀 있습니다.

// reader.go:626-635
// There is deliberately no per-site container table here. One was written for
// Naver (#dic_area / #newsct_article) on the assumption that Korean news
// portals need special-casing, but running five byte-for-byte captures of live
// articles through the extractor with and without it produced identical output
// — same title, same body, same length — because the density fallback already
// finds the same node. A lookup table that changes nothing is a maintenance
// cost, so the generic path is the only path.

라이브 기사 다섯 개를 바이트 단위로 캡처해 두 경로로 돌렸습니다. 표가 있을 때와 없을 때의 출력이 완전히 같았습니다. 제목도, 본문도, 길이도 같았습니다.

일반 경로가 이미 같은 노드를 찾고 있었습니다.

표를 넣어도 라이브 기사 다섯 개의 출력은 같았습니다

표가 없어도 결과가 같다는 것을 확인하기 전에는 그 표가 일하는 줄 알았습니다.

이런 코드는 지우기 어렵습니다. 지우면 무언가 깨질 것 같습니다. 사이트가 마크업을 바꾸면 표를 고쳐야 할 것 같습니다.

실제로는 유지 비용만 냅니다. 사이트가 바뀌면 표를 고쳐야 하는데, 표가 없으면 고칠 것도 없습니다.

규칙을 추가하는 조건을 주석에 남겼습니다.

Add a rule only when a real capture proves the fallback misses.

실제 캡처가 일반 경로의 실패를 증명할 때만 규칙을 넣습니다. "특수 케이스가 있을 것 같다"는 예상으로는 넣지 않습니다.

후보를 경쟁시켜 본문을 고릅니다

시맨틱 태그를 믿지 않으면 무엇으로 고를까요.

<article>이 하나라도 있으면 그 안에서 고르는 방식이 처음 구현이었습니다. 동아일보에서 실패했습니다.

// reader.go:663-670 (주석)
// 후보를 경쟁시킨다. 이전에는 <article>이 하나라도 있으면 그 안에서만
// 골랐다. 동아일보의 렌더된 DOM은 <article>을 위젯 단위로 23개 흘리고 본문
// 9,400자는 그 밖에 있어서, 345자짜리 위젯이 본문으로 뽑혔다(실측).

지금은 모든 후보에 점수를 주고 경쟁시킵니다. 후보가 될 수 있는 태그는 article, main, section, div 넷입니다. body는 제외합니다. 길이로는 항상 이기고 페이지 껍데기를 항상 포함합니다.

점수는 세 값을 곱합니다.

점수 = 보이는 글자 수 × 문단 가중 × 링크 할인
문단 가중 = 0.3 + 0.7 × (p 안의 글자 비율)
링크 할인 = max(0, 1 − 1.2 × (a 안의 글자 비율))

두 계수에 각각 근거가 있습니다.

링크 계수 1.2는 1을 넘습니다. 일부러 그렇게 뒀습니다. Markdown으로 바꾸면 링크가 많은 노드가 보이는 글자보다 훨씬 부풀기 때문입니다. 앵커마다 URL이 따라옵니다. 네이버 한 페이지의 껍데기는 보이는 글자가 7,213자인데 Markdown으로는 16,977자가 됐고, 그 페이지의 기사는 2,000자 남짓이었습니다. 링크 비중을 0.8로 깎아도 껍데기가 여전히 1등이었습니다. 캡처해 둔 페이지들에서 기사가 이기기 시작하는 값이 1.2였습니다.

문단 하한 0.3은 반대 방향의 보정입니다. 산문은 문단에 삽니다. <p> 안에 있는 글자가 많은 컨테이너가 본문일 가능성이 높습니다. 그런데 네이버는 기사를 <br>로 그리고 React로 만든 사이트는 맨 <div>에 글자를 넣습니다. 실측한 문단 비율이 0.10과 0.25였습니다. 문단을 요구 조건으로 두면 이 둘이 0점이 됩니다.

두 계수를 정한 근거를 실측값으로 놓으면 이렇습니다.

계수 그 값을 정한 실측
링크 할인 1.2 네이버 껍데기 보이는 글자 7,213자 → Markdown 16,977자, 같은 페이지 기사 약 2,000자. 0.8에서는 껍데기가 1등
문단 하한 0.3 네이버 <br> 기사 문단 비율 0.10, React 사이트 0.25. 요구 조건으로 두면 둘 다 0점

두 값이 반대 방향으로 일합니다. 링크 계수는 껍데기를 눌러야 하니 세게 잡고, 문단 하한은 문단 없는 본문을 살려야 하니 0으로 내리지 않습니다. 한쪽만 조정하면 다른 쪽이 무너집니다.

점수 방식 무엇이 이기는가
길이만 네비게이션, 헤드라인 목록
시맨틱 태그만 345자 위젯 (동아일보 사례)
길이 × 문단 가중 × 링크 할인 본문

분자와 분모를 다른 함수로 세었습니다

이 점수에서 한 번 크게 틀렸습니다.

글자 수는 정리 함수를 거쳐 세고 링크 글자는 원본에서 셌습니다. 두 값의 기준이 달라서 비율이 1을 넘었습니다. 그러면 링크 할인이 0이 되고 점수도 0이 됩니다.

동아일보의 3,936자 <main>이 297자 댓글 위젯에게 졌습니다. 진짜 본문이 0점이었습니다.

글자 수와 링크 글자를 한 번의 순회에서 같은 기준으로 셌습니다. 분자와 분모가 다른 문자열을 세면 링크 비율이 1을 넘을 수 있습니다.

길이가 여전히 주항입니다

연합뉴스에는 짧은 요약용 <article>과 긴 본문 컨테이너가 있습니다. 요약이 시맨틱하게 더 정확한 태그를 쓰지만 짧아서 집니다.

주석이 이 성질을 인정하고 있습니다.

연합뉴스의 짧은 story-summary <article>이 긴 articleWrap에게 지는 성질은 그대로다 — 길이가 여전히 점수의 주항이다.

본문을 원하는 것이므로 이 경우 긴 쪽이 맞습니다. 다만 이것이 우연이 아니라 설계의 결과라는 것을 적어 뒀습니다.

노이즈 제거 순서가 결과를 바꿉니다

HTML에서 버릴 것이 두 종류입니다. UI 컨트롤과 숨겨진 요소, 그리고 링크 목록입니다.

두 제거를 같은 자리에서 하지 않습니다.

// reader.go:642-645
// UI-control/hidden pruning runs on the whole document, before scoring —
// see pruneNoise's comment for why that is safe here but not for
// pruneLinkNoise below.
pruneNoise(doc)

root := bestBodyNode(doc)   // 점수를 매겨 승자를 뽑습니다

// reader.go:676-678
// Link-list pruning is coarse enough to shift which container wins, so it
// only runs after the winner is fixed
pruneLinkNoise(root)

첫 번째는 점수 매기기 전에 전체 문서에 적용합니다. 버튼과 숨겨진 div는 어느 컨테이너에 있든 본문이 아닙니다.

두 번째는 승자가 정해진 뒤에 그 안에서만 합니다. 링크 목록 제거는 거칠어서 어느 컨테이너가 이길지를 바꿀 수 있습니다.

순서를 바꾸면 링크가 많은 본문이 통째로 사라져 다른 컨테이너가 이깁니다. 정리와 선택의 순서가 결과를 결정합니다.

본문이 JSON으로 오는 사이트가 있습니다

단일 페이지 앱 형태의 CMS는 기사를 JSON으로 보내고 브라우저가 DOM을 만듭니다. 서버가 받는 마크업에는 헤드라인만 있고 본문이 없습니다.

이 경우 density 파서가 할 일이 없습니다. 본문이 HTML에 없습니다.

한 가지를 예외로 다뤘습니다.

// reader.go:681-692
// Single-page CMSes ship the article as JSON and let the browser build the
// DOM, so the markup we receive has a headline and no story. Arc XP is the
// one worth handling: its payload is a typed array, so there is no guessing
// about what is body text, and one rule covers every publisher on it
// (chosun.com among them). This is a CMS shape, not a per-host selector.
if len([]rune(md)) < minBodyRunes {
    if embedded := arcBody(body); len([]rune(embedded)) >= minBodyRunes {
        md = embedded
    }
}

Arc XP를 다룬 이유가 둘입니다.

첫째, payload가 타입이 있는 배열입니다. 어느 항목이 본문 텍스트인지 추측할 필요가 없습니다.

둘째, 규칙 하나가 그 CMS를 쓰는 모든 퍼블리셔를 덮습니다. chosun.com이 그중 하나입니다.

이것이 앞서 지운 사이트별 표와 다른 점입니다. 표는 호스트 이름을 키로 했고 사이트 하나씩 덮었습니다. 이 규칙은 CMS의 형태를 키로 하고 여러 사이트를 덮습니다.

예외를 둘 때 그 예외가 무엇을 키로 하는지 봐야 합니다.

그래도 안 되면 og:description을 씁니다

본문이 전부 껍데기인 페이지에도 요약은 있습니다.

// reader.go:693-697
// A page whose body is all chrome still has a usable summary in og:description;
// prefer that over returning boilerplate as if it were the article.
if desc := firstMeta(doc, "og:description"); len([]rune(md)) < minBodyRunes && desc != "" {
    md = cleanLines(desc)
}

껍데기를 기사인 것처럼 돌려주는 것보다 짧은 요약이 낫습니다.

실패에 이름을 붙였습니다

본문 추출이 실패하는 경우가 여러 가지입니다. 전부 "실패"로 돌려주면 호출자가 대응할 수 없습니다.

특히 두 경우가 위험합니다.

// reader.go:698-707
// A challenge page and a deleted-post screen both have bodies, so the length
// checks pass and either would be returned as if it were the article. Name
// them instead: the caller can tell the user why, and a retry policy can
// skip a wall while still retrying a transient failure.
if isBotWall(md, title) {
    return Result{}, statusError(http.StatusForbidden, "BLOCKED_BY_SITE", ...)
}
if isDeadPage(md, title) {
    return Result{}, statusError(http.StatusNotFound, "PAGE_NOT_FOUND", ...)
}

봇 차단 페이지와 삭제된 글 페이지는 본문이 있습니다. 길이 검사를 통과합니다.

이름을 붙이지 않으면 "Access Denied. Please verify you are human."이 기사 본문으로 저장됩니다. 사용자는 자기가 저장한 링크의 본문이 그것인 줄 압니다.

이름을 붙이면 두 가지가 가능해집니다.

실패 이름 호출자가 하는 일
BLOCKED_BY_SITE (403) 사용자에게 사이트가 막았다고 말합니다. 재시도하지 않습니다
PAGE_NOT_FOUND (404) 페이지가 사라졌다고 말합니다. 재시도하지 않습니다
UPSTREAM_TIMEOUT (504) 일시적 실패입니다. 재시도합니다
EXTRACT_FAILED (422) 본문을 못 찾았습니다. 재시도해도 같습니다

재시도 정책이 이 이름으로 갈립니다. 벽은 건너뛰고 일시적 실패는 다시 시도합니다.

판정은 문구와 길이 상한으로 합니다

두 판정 모두 문구 목록과 글자 수 상한을 함께 봅니다. 문구만 보면 그 표현이 들어간 진짜 기사가 걸립니다.

판정 문구 상한
봇 차단 11개 3,000자
죽은 페이지 5개 1,500자

문구는 실측에서 나왔습니다. 구글 검색을 실제 브라우저로 열었을 때는 "비정상적인 트래픽을 감지했습니다"가 704자로 왔고, 정적 경로로는 "액세스하는 데 문제가 있으면"이 185자로 왔습니다. 삭제된 X 게시물은 "게시물을 찾을 수 없음"이 203자로 오면서 제목도 같은 문장이었습니다.

상한이 있어야 봇 차단 문구를 인용한 기사가 차단으로 판정되지 않습니다. 3,000자를 넘는 문서는 문구가 있어도 통과시킵니다.

한계도 분명합니다. 문구는 한국어와 영어뿐입니다. 일본어나 프랑스어로 된 차단 페이지는 지나갑니다. 목록으로 판정하는 방식은 목록에 없는 것을 못 잡습니다.

Markdown 변환은 라이브러리를 씁니다

본문 노드를 골랐으면 Markdown으로 바꿔야 합니다. 직접 만들지 않았습니다.

의존성이 둘입니다. golang.org/x/net/html로 파싱하고, html-to-markdown으로 변환합니다. 변환기에는 base, commonmark, table 플러그인을 붙였습니다.

외부 추출 API를 쓰지 않은 것과 변환 라이브러리를 쓴 것은 다른 결정입니다.

직접 만듦 라이브러리
어느 노드가 본문인가 직접
HTML을 Markdown으로 라이브러리
본문 추출 전체 외부 API를 쓰지 않음

본문을 고르는 일은 우리 데이터에 맞춰 계속 고쳐야 합니다. 한국 뉴스 포털의 마크업이 우리 문제입니다.

HTML을 Markdown으로 바꾸는 일은 표준에 가깝습니다. <strong>**가 되는 규칙은 우리 사정과 무관합니다.