주 1회 자동으로 영상을 만드는 봇이 있습니다. 사람이 소재를 주지 않습니다. 공개 기록에서 후보를 긁어와, 하나를 골라 대본을 쓰고, 검수를 통과하면 발행합니다.
지난주 이 봇이 아무것도 만들지 않았습니다. 로그에는 이렇게 남아 있었습니다.
[discover] 후보 94개
skip: ...
skip: ...
[discover] 적합 후보 없음(이번 회)후보를 94개나 봤는데 하나도 적합하지 않았다는 뜻으로 읽었습니다. 소재 고갈이구나, 카테고리를 늘려야겠구나 생각했습니다.
그 94개는 그 회차에 전부 영구 제외됐습니다. 하나도 판정받지 않은 채로요.
당신의 파이프라인은 "이건 아니다"와 "이건 못 읽었다"를 같은 칸에 쓰고 있습니까?
숫자 세 개
원장을 열어 봤습니다.
| 항목 | 값 |
|---|---|
| 원장에 기록된 제외 | 922건 |
| 로그에 거부 사유가 남은 것 | 8건 |
| 실제로 발행한 롱폼 | 4편 |
922에서 8과 4를 빼면 910입니다. 대략 900건이 판정이 난 적 없이 제외돼 있었습니다. 봇은 매주 "적합 후보 없음"을 찍으면서, 그 회차에 훑은 후보를 전부 태우고 있었습니다. 종료 코드가 0인데 산출물이 없던 예전 사례와 같은 계열입니다 — 다만 이번엔 로그도 정상으로 보였습니다.
다섯 줄
문제는 루프의 순서였습니다.
seen.add(s); seen.add(title) # 시도한 건 재시도 방지
ext = extract(title)
if len(ext) < 500:
continue # ← 스크린도 안 했는데 원장에 이미 박혔다seen.add가 본문을 가져오는 줄보다 위에 있습니다. 그래서 이 순서로 흘러갑니다.
- 후보를 원장에 "다뤘음"으로 기록한다
- 본문을 가져온다
- 본문이 짧으면 넘어간다
3번에서 넘어간 후보는 1번에서 이미 죽어 있습니다. 다음 회차에도, 그 다음 회차에도 다시 뽑히지 않습니다.
그리고 extract()가 이렇게 생겼습니다.
def extract(title):
try:
...
return p.get("extract", "")
except Exception:
return "" # 네트워크 실패도 빈 문자열예외를 삼키고 빈 문자열을 돌려줍니다. 그러면 위키가 429를 던진 것과 문서가 실제로 부실한 것이 위쪽 코드에서 똑같이 len(ext) < 500으로 보입니다. 타임아웃 한 번에 멀쩡한 소재가 "문서 부실"로 영구 제외됩니다.
이 버그는 이미 한 번 고쳤습니다
여기가 오늘 가장 뜨끔한 부분입니다.
형제 봇이 있습니다. 다른 채널, 다른 주제, 같은 코드 계보입니다. 8월에 그 봇에서 똑같은 버그를 찾아 고쳤습니다. 그때 수치가 이랬습니다 — 원장 309건 중 305건이 판정 없이 소각, 실제 편수 5편.
고치고 나서 그 봇만 고쳤습니다. 이 봇은 보지 않았습니다.
단서는 눈앞에 있었습니다. 이 봇의 HTTP 요청 헤더가 이랬습니다.
UA = {"User-Agent": "aftermath-docbot/1.0 (...)"}형제 봇의 이름입니다. "이 파일은 저기서 복사됐다"는 유일한 표시가 코드 안에 박혀 있었는데, 정작 저기서 고친 버그가 여기 남아 있는 걸 아무도 확인하지 않았습니다.
여기서 규칙 하나가 나옵니다. 같은 조상에서 갈라진 봇은, 한쪽을 고칠 때 나머지를 grep해야 합니다. 복사한 코드는 버그도 복사합니다.
당신이라면?
당신 앞에 900건이 태워진 원장이 있습니다. 어느 쪽을 먼저 하겠습니까?
- 카테고리를 늘린다 — 후보 풀을 키워서 소진을 늦춘다. 원인은 그대로다.
- 원장을 비운다 — 전부 되살린다. 사유 있는 거부까지 되살아나 매주 같은 후보를 다시 스크린한다.
- 제외의 근거를 나눈다 — 판정된 것과 못 읽은 것을 갈라, 후자만 되살린다.
세 번째가 답인데, 이게 되려면 원장에 사유가 남아 있어야 합니다. 남아 있지 않았습니다. 그래서 복구 기준을 밖에서 찾아야 했습니다 — 산출물(발행분·큐·거부 폴더)과 로그에 사유가 찍힌 건만 남기고, 나머지는 원장에서 뺐습니다. 922 → 22건.
고친 모양
수정은 네 갈래입니다.
- 수집 실패를 빈 값으로 바꾸지 않습니다.
FetchFailed예외를 따로 만들어, 못 받아온 것은 원장에 기록하지 않고 다음 회차에 다시 후보로 올립니다. - 429는 실패가 아니라 신호입니다.
Retry-After를 존중해 최대 3회 재시도합니다. - 스크린 호출 자체가 실패한 것도 태우지 않습니다. 판정 도구가 죽은 건 주제의 결함이 아닙니다.
- 사유가 있는 거부만 영구 제외하고, 그 사유를 별도 파일에 남깁니다. 다음에 이런 일이 생기면 원장 안에서 갈라낼 수 있습니다.
그리고 회귀 테스트 세 개를 붙였습니다. 이름 자체가 규칙입니다.
test_fetch_failure_does_not_burn_topic
test_merit_rejection_is_recorded_and_burned
test_screen_call_failure_does_not_burn_topic돌려 봤습니다. 후보가 94개에서 475개가 됐고, 봇이 소재 하나를 골라 큐에 넣었습니다.
자가진단 세 줄
지금 당신의 자동화에 대고 물어볼 것들입니다.
- 제외 원장에 사유 칸이 있습니까? 없으면 "판정된 제외"와 "사고로 인한 제외"를 나중에 구분할 수 없습니다.
- 입력을 가져오는 함수가 예외를 삼켜 빈 값을 돌려줍니까? 그렇다면 상류의 장애가 하류에서 "데이터 품질 문제"로 위장됩니다.
- "이미 처리했음" 표시를 처리 전에 찍습니까, 후에 찍습니까? 전에 찍으면 그 사이 모든 실패가 영구 제외가 됩니다.
솔직한 부분
이 봇은 아직 돈을 벌지 않습니다. 900건을 태운 대가가 얼마인지도 정확히는 모릅니다 — 태워진 후보 중 몇 개가 실제로 좋은 영상이 됐을지는 세어볼 방법이 없습니다.
확실한 건 하나입니다. 8월에 형제 봇을 고칠 때 grep을 한 번 더 돌렸으면 오늘 이 글은 없었습니다. 그 grep은 30초짜리였습니다.