Skip to content

fix(crawlers): 크롤러 사이의 기능 편차 12건을 메운다 - #17

Merged
seungwonme merged 16 commits into
mainfrom
fix/crawler-parity
Aug 10, 2026
Merged

fix(crawlers): 크롤러 사이의 기능 편차 12건을 메운다#17
seungwonme merged 16 commits into
mainfrom
fix/crawler-parity

Conversation

@seungwonme

@seungwonme seungwonme commented Aug 10, 2026

Copy link
Copy Markdown
Owner

TL;DR

크롤러 14종을 20개 축으로 전수 대조해서, 다른 크롤러는 하는데 이 크롤러만 안 하던 것 10건을 고쳤습니다. 검증 중 프로덕션에서 2건이 더 터져 12건이 됐습니다. 새 크롤러·새 소스는 없습니다.

  • 이미지만 올린 글이 DB에 행조차 안 생기던 것 -> 미디어 링크 폴백
  • reddit 링크 글 본문이 38자(submitted by /u/... 껍데기) -> 원문 추출로 1,783~31,924자
  • huggingface comments가 항상 NULL이던 필드 오배치
  • 작성자 빈 행 456개 복구
  • ailabs 중복 182행 제거 (660 -> 478)
  • hnrss 502에 HN이 통째로 0건 되던 것 -> Algolia 폴백
  • HN 서브피드가 DB에 별도 플랫폼으로 갈리던 것 (60행 수리)

이 중 2건은 #14에서 제가 절반만 하고 끝낸 것입니다. 아래 표에 그대로 적었습니다.

고친 것

# 크롤러 무엇이 없었나 결과
1 hackernews, geeknews, producthunt 댓글 파싱이 try 밖 -> 상류 구조가 바뀌면 그 회차 저장 0건 #14가 API형 4종만 고쳤음. feed형 3종으로 넓힘
2 arxiv, huggingface make_retrying_session을 만들어 두고 연결 안 함 #14의 미완성. 이제 429/5xx 재시도
3 huggingface 댓글 미수집 + num_comments= 오타로 comments 항상 NULL SSR 페이로드에서 댓글 수집, 지표 복구
4 db 작성자 빈 행을 upsert가 못 고침 (SET 절 누락) 456행 복구 + backfill_blank_authors()
5 threads, linkedin 이미지만 올린 글에 None 반환 -> 행 자체가 안 생김 미디어 링크 폴백 + content_status="media_link"
6 reddit 링크 글이 껍데기만 저장 (JSON·Atom 두 경로 다) 원문 추출 연결. 38자 -> 1,783~31,924자
7 producthunt, ailabs 상류 고유 id를 버려 URL 병합 / id 체계 변경으로 행 분열 재런치 누락 해소, ailabs 182행 중복 제거
8 전 크롤러 --count 절단이 enrichment 버려질 항목을 추출·댓글 조회하던 낭비 제거
9 arxiv 한 페이지(50건)로 창을 못 덮음 페이징 + 상한 도달 경고
10 everyto 구독자 벽에서 잘린 본문이 완결된 글로 보임 content_status="paywalled" (로그인 연결 안 함 — 사용자 결정)
11 hackernews 피드 3장이 전부 hnrss.org -> 502에 그 회차 몫이 통째로 유실 0건인 피드를 Algolia search_by_date로 다시 채움
12 hackernews 서브피드 이름이 Post.platform으로 새어 DB에 hackernews/show 별도 플랫폼 행 생성 platform 고정 + 서브피드는 source로. 프로덕션 60행 수리
11번을 넣은 이유 (프로덕션에서 두 번 터졌습니다)

이 브랜치 검증으로 프로덕션 데일리를 돌리던 중 hnrss.org가 502를 냈고, HN 세 피드가 동시에 0건이 됐습니다 (run #264 degraded). 재시도 3회로도 못 뚫었습니다.

데일리는 고정 창으로 돌기 때문에 다음 날 창에 그 글이 다시 안 들어옵니다. 그대로 영구 유실입니다.

직후 재측정에서는 count 유무와 무관하게 전부 200이었습니다. 즉 이번 PR의 count=100 변경 탓이 아니라 간헐적 서버 장애이고, 원래 있던 단일 호스트 의존 취약점입니다.

처음엔 폴백 조건을 "세 피드가 전부 0건일 때만"으로 잡았는데, 그게 틀렸습니다. 커밋 직후 프로덕션 재수집에서 newest만 502가 나고 show/ask는 살아 있었고, 살아남은 피드가 있으니 폴백이 안 돌아 30점 이상 글 전량이 그대로 빠졌습니다 (60건만 저장, 정상 회차는 97건). 판정을 피드 단위로 바꿨습니다.

HN은 하루 창에 어느 피드도 0건이 되는 날이 없으므로 0건은 곧 장애 신호이고, 정말 없었다면 Algolia도 0건을 주므로 무해합니다.

실측:

  • 세 장 다 죽었다고 가정 -> 155건 수집
  • newest만 죽었다고 가정 -> 156건 (그중 30점 이상 96건이 폴백 몫)
  • 두 경우 모두 external_id/timestamp 결손 0
기각한 4건과 이유

전수 조사에서 후보로 올라왔지만 넣지 않은 것들입니다.

  • comments_zero_guard를 hackernews/lobsters에도 — 그쪽은 같은 요청이 본문까지 같이 주므로, 댓글 0건이라고 건너뛰면 본문을 잃습니다.
  • 회차 내 중복 제거db.py의 upsert가 이미 접습니다.
  • 나머지 2건도 기존 코드가 이미 다른 방식으로 처리하고 있었습니다.

검증

just lint   10.00/10
just test   Python 505건 + Swift 20건
just e2e    OK

프로덕션 데일리 1회차를 실제로 돌려 확인했습니다 (266건 저장). 11번과 12번은 그 과정에서 잡힌 것입니다 — 로컬 테스트만으로는 둘 다 안 드러났습니다.

12번 수리는 백업 후 실행했고 pragma integrity_check ok, skim doctor --strict exit 0입니다.

ailabs 0건도 같은 회차에 떴는데, 크롤러 문제가 아니라 그 창에 실제로 새 글이 없었습니다 (7일 창으로 찌르면 30건 정상 수집). 다만 doctor의 "14일간 수집되던 플랫폼이 0건" 경고는 ailabs처럼 발행 간격이 넓은 소스에 오탐이 됩니다 — 이 PR 범위 밖이라 남겨 둡니다.

seungwonme added 12 commits August 10, 2026 17:00
PR #14가 API 4종(threads/x/linkedin/reddit)만 고치고 feed 쪽은 그대로 뒀다.
hackernews/geeknews/producthunt는 try가 HTTP 호출까지만이라, 상류 응답 구조가
바뀌면 파싱 예외가 crawl 루프로 올라가 그 회차 게시글이 통째로 저장 0건이 된다.

- hackernews: walk()와 반환 dict 구성을 try 안으로
- geeknews: BeautifulSoup 파싱과 셀렉터를 try 안으로
- producthunt: DOM 순회를 _parse_comment_section으로 분리해 try 안으로.
  조용히 None을 반환하던 것도 경고 한 줄을 남기게 했다

회귀 테스트 3클래스를 추가했다. 수정 전 hackernews 케이스가 실제로
AttributeError로 실패하는 것을 확인했다.
PR #14에서 make_retrying_session을 만들어 놓고 정작 데일리에서 조용히
멈춰 있던 두 소스에는 붙이지 않았다. 단발 요청이라 503 한 번이면 그날
논문이 유실되는데, arxiv는 카테고리당 최신 50건만 받으므로 다음 날 창이
겹쳐도 그 사이 제출분까지 거슬러 가지 못한다.

- arxiv: _fetch_category로 감싸 실패를 None으로 돌린다. feedparser에 URL을
  직접 주면 실패해도 빈 피드를 조용히 주기 때문에, 4개 카테고리 중 하나가
  죽어도 나머지가 정상 카운트를 만들어 0건 회귀 감지에 안 걸렸다
- huggingface: raise_for_status가 crawl 안에 있어 예외가 CLI까지 올라갔다
- enrichment: PDF 요청도 _HTTP_SESSION으로. 한 회차에 arxiv.org/pdf를
  최대 50건 연속으로 때리는 경로다

실측: arxiv 50건, huggingface 49건 정상 수집.
HF 행은 본문을 arXiv HTML/PDF에서 뽑아오므로 같은 논문의 arxiv 행과 사실상
같은 글이다. HF만 갖는 것(저자가 직접 단 코드·데모 링크, 평문 요약, 독자 반응)이
통째로 빠져 HF Daily Papers를 따로 읽을 이유가 남지 않았다.

논문 페이지의 SSR 페이로드(data-target=PaperContent의 data-props)가 댓글을
통째로 담아 준다. 로그인도 토큰도 필요 없고 논문당 요청 1건이다.
latest.raw가 이미 마크다운 원문이라 HTML 파싱도 필요 없다.

- numComments가 목록 응답에 있어 0건 논문은 조회하지 않는다 (판정 비용 0)
- HTTP와 파싱을 함께 try로 감싸 댓글 실패가 논문 저장을 막지 않는다
- 숨김 댓글과 comment가 아닌 노드는 제외한다

별건: _item_to_post가 num_comments= 로 넣고 있어 Post의 extra=allow에
흡수됐고, db.py가 읽는 posts.comments는 항상 None이었다. comments= 로 고쳤다.

실측: 논문 3건 전부 댓글 섹션이 붙고 comments 값이 들어간다.
456행(ailabs/OpenAI News 424, blogs/Phil Schmid 17, blogs/Addy Osmani 15)이
작성자 영구 공백이었다. export/bundle의 front matter, research --fields author,
데스크톱 리더가 전부 빈 값을 찍는다.

두 갈래가 다 필요했다.
- upsert에 author 복구 분기를 넣고 WHERE 가드에도 author 조건을 추가했다.
  SET만 넣으면 WHERE에서 문장이 통째로 걸러져 실행되지 않는다 (title/summary
  복구가 동작하고 author만 안 되던 이유가 이것이다)
- backfill_blank_authors로 옛 행을 채운다. 424행이 크롤 창보다 오래돼
  재크롤될 일이 없어 upsert만으로는 영원히 빈 채로 남는다

값은 fetch_feed가 이미 쓰는 폴백과 같다 (source의 '/' 뒷조각).
프로덕션 실측: 456건 채움, 빈 author 0행. 백업 후 적용.
threads와 linkedin은 본문이 비면 파싱 단계에서 None을 돌려 DB에 행 자체가
안 생겼다. 그래서 그날 그 계정이 무엇을 올렸는지가 digest와 research에서
통째로 빠졌다. x는 이미 사다리(alt text -> media link -> drop)를 갖고 있었다.

- threads: contents가 비면 수집해둔 image_urls를 본문으로 쓴다
- linkedin: 임계를 len < 10에서 빈 값만 drop으로 낮춘다. 10자 임계는
  "꾸준함은 능력이다." 같은 유효한 짧은 글도 잘랐다
- 폴백 본문은 content_status extras로 표시해 소비 시점에 구분되게 한다
- x의 스레드 병합 경로가 세 번째 반환값을 버리던 것도 고쳤다.
  단 스레드 전체가 폴백일 때만 표시한다

곁들여 linkedin _extract_text의 str() 폴백을 막았다. dict에서 텍스트를 못
찾으면 dict repr이 본문이 됐는데, 길이 임계가 우연히 이걸 막고 있어서
임계를 낮추는 순간 표면화된다 (프로덕션에는 아직 0행).
reddit 링크 게시물은 제목 한 줄만 남고 원문 URL조차 소비자에게 닿지 않았다.
hackernews/lobsters/everyto는 defuddle 단발만 타서 3단 사다리를 못 받아
파이프라인에서 가장 약한 추출 경로였다.

reddit:
- JSON listing 경로는 url_overridden_by_dest를 이미지 판별에만 쓰고 버렸다.
  _external_link로 셀프 포스트/이미지/reddit 호스트를 걸러 원문만 남긴다
- Atom 폴백 경로(JSON 403 시)가 더 심했다. strip_html이 [link] 앵커를
  텍스트로 만들며 href를 버려, 본문이 "submitted by /u/x [link] [comments]"
  껍데기가 됐다. href를 살리고 껍데기는 제목으로 바꾼다
- attach_articles가 원문을 추출해 ## Original Article로 잇는다.
  추출 실패는 게시글 저장을 막지 않는다

enrichment:
- 링크 애그리게이터 4종을 _enrich_article_item으로 보낸다
- min_words를 인자로 뚫었다. 60단어 게이트는 짧은 릴리스 노트를 failed로
  떨어뜨려서, 애그리게이터는 20으로 낮춘다
- 추출 실패 + .pdf 링크면 extract_pdf_text로 폴백한다
- 플랫폼 분기를 _extract_for_platform으로 분리했다 (복잡도 게이트 통과용)

실측(r/programming 5건): 링크 게시물 4건의 본문이 38~44자에서
1,783~31,924자로 채워졌다. 그중 1건은 playwright 렌더까지 폴백했다.
producthunt: 피드가 런치별 고유 id(tag:...,2005:Post/1219088)를 주는데
_item_to_post가 버려서 592행 전부 해시 id였다. 해시 id는 db가 URL로 병합하므로
같은 제품 페이지의 두 번째 이후 런치가 아예 저장되지 않았다.

ailabs: RSS guid가 있으면 그걸 쓰고 없으면 URL 기반이라 두 체계가 공존했다.
소스가 RSS/HTML 경로를 오가면 같은 글이 두 행으로 갈라진다. URL 기반으로
고정했다.

dedupe_by_url과 `skim dedupe <platform>`을 추가했다. 본문이 가장 긴 행을
남긴다 - 짧은 쪽이 추출 실패본일 수 있어서다. 행을 지우므로 기본은 미리보기고
--apply를 붙여야 실제로 지운다.

프로덕션 적용(백업 후): ailabs 660 -> 478행, 중복 182건 삭제, 남은 중복 0.
지운 쪽과 남긴 쪽의 본문 길이가 완전히 같은 것을 표본으로 확인했다.
integrity ok.
geeknews/youtube/producthunt/everyto/blogs/ailabs는 조회 창 안의 글 전체를
원문 추출(defuddle/yt-dlp)과 댓글·지표 조회까지 끝낸 뒤에야 CLI가 앞 N건만
남기고 버렸다. N건을 얻자고 수십 건분의 시간과 외부 요청을 치르고, 그 결과물은
DB에 저장조차 되지 않는다.

hackernews/lobsters/arxiv/huggingface가 이미 쓰는 패턴(정렬 직후 items[:count])을
나머지에 옮겼다.

ailabs만 처리가 다르다. 소스별 limit이 이미 count라 소스 9개면 최대 9*count개가
모인다. 소스별 limit은 과수집 방지용으로 두고, dedupe와 정렬 뒤에 전역 상한을
한 번 더 건다.
hackernews: hnrss count 기본값이 20이라 24시간 창에 30점 넘긴 글이 65건
있어도 20건만 들어왔다(2026-08-10 실측). newest를 문서상 최대인 100으로 올린다.

show/ask는 30으로 둔다. 점수 문턱이 없어 셋 다 100으로 두면 한 회차 239건이
되고(종전 60건의 4배) 저품질까지 전부 원문 추출과 댓글 조회를 돈다.
조정 후 실측 97건.

상한에 닿아 창을 다 못 덮으면 경고를 남긴다. 조용히 넘어가면 "그날 HN에
이만큼밖에 없었다"로 읽힌다.

arxiv: arxiv_api_url에 start 오프셋을 뚫고 _entries로 페이지를 흘린다.
한 장(50건)이 실측 4시간25분치라 count를 그보다 크게 잡으면 페이징 없이는
못 채운다. 종료 조건 셋 - count 충족, 빈 페이지, 마지막 항목이 창 밖.
페이지 상한 10, 연속 요청 사이 3초(arXiv 권고).

count가 한 장 안에 들어오는 기본값(50)에서는 요청이 지금과 똑같이 카테고리당
1회다. 볼륨을 늘리는 변경이 아니라 count를 키울 수 있게 하는 변경이다.

조사 리포트의 "hnrss count=40/50/100이 502"는 재측정에서 재현되지 않았다
(30/40/50/100 모두 200). 그래서 Algolia 전면 전환은 하지 않았다.
Every.to 글은 무료 미리보기까지만 저장돼서 본문이 문장 중간에서 끊기고
나머지는 프로모 문구다. 표시가 없으면 research/digest/데스크톱이 반쪽짜리를
완결된 글로 읽고 결론부가 빠진 채 요약한다.

로그인 연결은 하지 않는다(사용자 결정). 유료 구독과 세션 관리가 따라붙는다.
대신 잘린 행에 content_status="paywalled"를 붙여 소비 시점에 구분되게 한다.

판정 문구는 프로덕션 실측이다 ("...to unlock this piece and learn about:").
저장된 68건에 돌려보니 12건이 잘린 글로 잡힌다.

이유는 docs/TODO의 "알려진 한계"에 남겼다.
2026-08-10 전수 조사(크롤러 14종 x 축 20개)에서 나온 것들이다. 같은 결함이
새 크롤러에서 반복되지 않게 계약으로 남긴다.

- 본문이 비면 버리기 전에 폴백을 본다
- 링크 게시물은 원문 URL을 보존하고 추출한다
- 상류가 주는 고유 id를 버리지 않는다. 체계를 바꾸면 백필이 함께 간다
- count는 enrichment 앞에서 자른다
- 창이 한 페이지보다 넓으면 페이징하고, 상한에 닿으면 경고한다
- 불완전한 본문은 content_status로 표시한다

댓글 파싱 try 계약에 feed형 3종이 빠져 있던 이력도 함께 적었다.
huggingface 댓글 행을 표에 추가했다.
HN 피드 세 장이 전부 hnrss.org라 502 한 번에 셋이 같이 0건이 된다.
데일리는 고정 창으로 돌아 다음 날 창에 그 글이 다시 안 들어오므로
그 회차 HN이 그대로 영구 유실이다 (2026-08-10 프로덕션 run #264 degraded).

세 피드가 전부 0건일 때만 Algolia search_by_date로 폴백한다. 한 장이라도
살아 있으면 돌리지 않는다. 중복 요청만 늘어난다.

폴백 항목은 fetch_feed가 만드는 item과 같은 모양으로 맞춘다. external_id는
hnrss guid와 같은 `item?id=<id>` 형태라 두 경로로 들어와도 행이 갈리지 않고,
Algolia가 points/num_comments를 같이 줘서 지표 폴백 요청도 안 나간다.

tags의 콤마는 AND다. 괄호로 싸면 OR이 돼 필터가 통째로 풀린다 (실측).

실측: hnrss를 죽었다고 가정하고 돌려 155건, external_id/timestamp 결손 0.
seungwonme added 4 commits August 10, 2026 18:08
앞 커밋은 "세 피드가 전부 0건일 때만" 폴백했다. 실제 hnrss 502는 피드
단위로 온다. 직후 프로덕션 재수집에서 newest만 502가 나고 show/ask는
살아 있어서 폴백이 안 돌았고, 30점 이상 글 전량이 그대로 빠졌다
(60건만 저장. 정상 회차는 97건).

HN은 하루 창에 어느 피드도 0건이 되는 날이 없다. 0건은 글이 없어서가
아니라 그 피드가 죽어서다. 정말 없었다면 Algolia도 0건을 주므로 무해하다.

실측: newest만 죽었다고 가정하고 돌려 156건 수집, 그중 30점 이상 96건.
external_id/timestamp 결손 0.
fetch_feed는 item["platform"]에 피드 이름(hackernews/show)을 넣고,
_item_to_post가 그걸 Post.platform에 그대로 실었다. db.py는 Post의
platform을 인자보다 우선하므로 DB에 `hackernews/show`, `hackernews/ask`
라는 별도 플랫폼 행이 생긴다.

Show/Ask 피드를 추가한 #14부터 있던 결함인데, hnrss show/ask가 실제로
저장된 회차가 오늘이 처음이라 그동안 안 드러났다. 프로덕션에 60행이
그렇게 갈려 있었고 별도 스크립트로 수리했다 (충돌 2건은 본문이 긴 쪽을
남김, 백업 후 실행, integrity_check ok).

서브피드는 source에 남긴다. blogs가 이미 쓰는 방식이다.
@seungwonme seungwonme changed the title fix(crawlers): 크롤러 사이의 기능 편차 10건을 메운다 fix(crawlers): 크롤러 사이의 기능 편차 12건을 메운다 Aug 10, 2026
@seungwonme
seungwonme merged commit df87120 into main Aug 10, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant