브랜드가 도메인을 제공하면 크롤러는 어떤 5개 페이지를 읽지 말아야 하는지에 초점을 맞춘다
요약
본 글은 웹 크롤링 과정에서 어떤 데이터를 수집하고, 더 중요하게는 어떤 실패 상황(failure)을 어떻게 해석하여 사용자에게 전달할지에 대한 심층적인 기술적 접근 방식을 다룹니다. 단순히 데이터를 가져오는 것보다 '무엇을 보내지 않을지' 선택하는 것이 핵심이며, 다양한 오류 유형별로 구체적인 메시지를 제공해야 합니다.
핵심 포인트
- 크롤링은 도메인 분석에서 시작하며, 단순한 데이터 수집 이상의 복잡성을 가집니다.
- HTTP 실패 시, 연결 오류(connection error)와 TLS 오류만 유효하게 구분할 수 있습니다.
- 401, 403 등 특정 HTTP 상태 코드는 '차단됨(blocked)'으로 분류하여 사용자에게 구체적인 피드백을 제공해야 합니다.
- 다양한 failure 유형(invalid, blocked, private 등)을 정의하는 것이 사용자 경험 개선에 필수적입니다.
Nakodo에서 캠페인을 설정하는 것은 하나의 필드, 즉 도메인으로 시작합니다. 이를 통해 저희는 고객님의 사이트를 분석하고 나머지 제품이 작동할 개요(brief)를 작성합니다.
가장 단순한 버전은 다음과 같습니다: 홈페이지를 가져와 태그를 제거하고 텍스트를 모델로 전송하는 여섯 줄의 코드입니다. 하지만 저희가 출시한 것은 이보다 훨씬 복잡하여 약 370줄에 달하며, 추가된 내용 중 거의 대부분은 데이터를 가져오는 것과 관련이 없습니다. 오히려 무엇을 보내지 않을지 선택하는 문제와, 사이트가 열리지 않을 때 뭐라고 말할지에 관한 것입니다.
홈페이지를 요청하는 세 가지 방법
const urls = [`https://${domain}/`, `https://www.${domain}/`, `http://${domain}/`];
많은 사이트들이 이 중 정확히 하나로 응답합니다. 순서는 중요하지 않습니다. 중요한 것은 실패가 의미하는 바입니다:
for (const url of urls) {
if (url.startsWith("http:") && failures.some((f) => f.problem !== "error" && f.problem !== "tls")) break;
...
}
실제 도메인들이 실패하는 것을 관찰하면서 두 가지 규칙을 발견했습니다:
순수 HTTP는 마지막 수단이지, 세 번째 시도가 아닙니다. 만약 https://acme.com이 403 에러를 반환하고 https://www.acme.com이 시간 초과(timed out)되었다고 가정해 봅시다. 이때 포트 80을 시도하는 것은 우리가 조치할 수 있는 아무것도 알려주지 못합니다. 즉, 사이트는 작동하지만 우리를 거부하고 있다는 뜻입니다. 가치가 있어 다운그레이드할 만한 실패는 연결 오류(connection error)와 TLS 오류뿐인데, 왜냐하면 그것들이 진정으로 작동하는 HTTPS가 없는 사이트의 모습이기 때문입니다.
"찾을 수 없음(Not found)"은 모든 시도가 그렇게 말했을 때만 참입니다. 에이펙스 도메인(apex)에서 ENOTFOUND가 발생했지만 www는 정상적으로 해결된 후 거부되는 경우, 이는 오타가 아니라 설정된 사이트의 문제입니다. 이때 "acme.com을 찾을 수 없습니다. 철자를 확인하세요"라고 보고하면 고객은 이미 올바른 철자임에도 불구하고 철자를 확인할 필요를 느끼게 됩니다.
8가지 실패 유형이 필요한 이유: "사이트를 읽을 수 없었습니다"는 쓸모없기 때문입니다
export type CrawlProblem = "invalid" | "not_found" | "blocked" | "private" | "timeout" | "tls" | "empty" | "error";
이 각각은 누군가 무언가를 할 수 있는 문장입니다. 몇 가지 예시를 들자면:
case "private":
return `${domain}은 사설 네트워크 주소를 가리키므로 읽을 수 없습니다.`;
case "tls":
...
근본적인 실패(failure)를 매핑하는 것은 특별한 부분이 없는 하나의 함수이며, HTTP 상태 코드가 의견이 담긴 부분입니다:
if (failure.status !== undefined) {
return [401, 403, 429, 451, 503].includes(failure.status) ? "blocked" : "error";
}
401, 403, 429, 451 및 503은 모두 "서버가 존재하지만 사용자에게 제공하기로 선택하지 않은 경우"를 의미하며, 이는 500과는 고객과의 대화 방식이 다릅니다. "private"는 자체 fetch wrapper가 private 네트워크 주소로 해석하는 것을 거부하면서 발생한 것으로, 별도의 게시물입니다.
"empty"는 가장 자주 유용하게 쓰이는 부분입니다:
const MIN_SITE_TEXT = 300; // 이보다 읽을거리가 적으면 추측에 불과합니다
클라이언트가 렌더링한 마케팅 사이트는 약 네 단어의 HTML만 제공합니다. 이 검사가 없다면 모델은 그 네 단어를 받고, 자신이 아무것도 모르는 회사에 대해 자신감 있는 개요를 작성하며, 고객은 세 화면 후에 이를 알게 됩니다. 300자는 매우 간결한 랜딩 페이지를 통과하기에는 충분히 낮고, 빈 껍데기(empty shell)를 포착하기에는 충분히 높습니다.
어떤 페이지가, 어떤 순서로
우선순위에 따라 정규 표현식 6개가 각각 최대 하나의 페이지에 기여합니다:
const PAGE_KINDS: RegExp[] = [
/(\/about|about-us|our-story|story|who-we-are|mission)(\/|$|\.)/,
/(\/products?|shop|store|collections?|catalog|menu)(\/|$)/,
...
후보 페이지는 먼저 홈페이지 자체의 링크에서, 그다음 사이트맵에서 가져옵니다. 여러 개가 특정 종류에 일치할 경우, 순서 결정(tie breaks)은 다음과 같습니다:
.sort(([pa, a], [pb, b]) =>
a.depth - b.depth || Number(b.onHome) - Number(a.onHome) || pa.length - pb.length)
가장 얕은 깊이(Shallowest wins), 그다음 홈페이지에서 연결되었는지 여부, 그리고 가장 짧은 경로 순입니다. 이 세 가지 휴리스틱(heuristics)이 "사람이 클릭할 만한 것"을 대신하며, 실제로는 깊이(depth) 항이 대부분의 역할을 합니다: /pricing이 /en/business/pricing-2024보다 우선합니다.
페이지는 최대 다섯 페이지가 한계입니다. 토큰 예산 때문이 아니라, 여섯 번째 페이지는 신뢰할 수 있게 경력 페이지나 쿠키 정책 페이지이기 때문에, 그곳에서 추가되는 문장은 모델이 거기에 집착하는 위험보다 가치가 낮습니다.
One Set은 네비게이션이 다섯 번 읽히는 것을 막는다
사이트의 모든 페이지는 헤더, 내비게이션(nav), 푸터, 쿠키 배너를 반복합니다. 원문 텍스트가 다섯 페이지라면 메뉴 복사본이 다섯 개 있다는 뜻이며, 모델에게 메뉴 복사본을 다섯 번 보여주면 그 메뉴가 중요하다는 결론을 내릴 것입니다.
function pageText(lines: string[], seen: Set<string>, maxChars: number): string {
const out: string[] = [];
let length = 0;
...
seen Set은 전체 크롤링에 걸쳐 유지되며, 홈페이지가 먼저 오기 때문에 내비게이션은 홈페이지의 일부로서 정확히 한 번만 읽힙니다. 이 경우 내비게이션은 진정으로 유익합니다. 이후 페이지들은 그 페이지에서 새롭게 추가된 라인들만 받습니다.
예산은 홈페이지에 8,000자, 각 핵심 페이지에 5,000자로 설정되어 있습니다. 놓치기 쉬운 두 번째 차원의 효과가 있습니다: 이 텍스트는 리서치 과정에서 무언가가 작성될 때마다 저장되고 다시 전송되므로, 중복된 크롬(chrome)이 담긴 페이지는 일회성 비용이 아니라 영원히 지속되는 생성당 비용입니다.
고유한 텍스트가 100자 미만인 페이지는 완전히 제외됩니다. 이는 보통 이미지와 반복되는 내비게이션으로 가득 찬 /products 페이지일 것입니다.
사이트맵은 콘텐츠가 아닌 구조이다
우리는 또한 사이트맵(sitemap)을 찾습니다. 왜냐하면 웹사이트의 형태가 그 문장 내용이 말해주지 못하는 무언가를 알려주기 때문입니다. 후보는 먼저 robots.txt에서 나옵니다:
const robots = await tryFetch(`${origin}/robots.txt`);
if (robots?.status === 200) {
for (const m of robots.body.matchAll(/^\s*sitemap:\s*(\S+)/gim)) candidates.push(m[1]);
...
사이트맵 인덱스(sitemap index)의 자식들은 비즈니스를 설명할 가능성이 높은 순서로 정렬됩니다:
const sitemapPreference = (url: string) =>
/page|product|collection|categor|service/i.test(url) ? 0
: /post|blog|news|image|video|author|tag/i.test(url) ? 2 : 1;
최대 세 명의 자녀만. 상점의 제품 사이트맵은 무엇을 판매하는지 알려주고, 뉴스 사이트맵은 블로그가 있다는 것을 알려준다.
그리고 URL들은 나열되는 대신 요약된다. 첫 번째 경로 세그먼트별로 그룹화하고, 가장 큰 40개 섹션을 그 개수와 함께 보여주며, 각 섹션에서 순서대로 최대 150개의 경로를 라운드 로빈(round robin) 방식으로 샘플링한다:
for (let i = 0; sample.length < SITEMAP_PATH_SAMPLE && lists.some((l) => i < l.length); i++) {
for (const list of lists) if (i < list.length && sample.length < SITEMAP_PATH_SAMPLE) sample.push(list[i]);
}
단순히 처음 150개를 가져오는 것이 아니라 라운드 로빈 방식을 사용하는 이유는, 섹션별로 정렬된 4,000개의 URL 사이트맵을 사용하면 샘플 전체가 /blog/ 안에 머무르게 될 수 있기 때문이다.
40개 섹션 제한에는 특정한 근거가 있으며, 주석에 기록되어 있다:
// The biggest sections only: a site with its posts at the top level
// (/post-slug/) has a "section" per URL, thousands of lines that say
// nothing and are sent again with every proposal written from the research.
루트 경로에 예쁜 퍼머링크(permalink)를 가진 WordPress 사이트는 게시물당 하나의
로고 약속(logo promise) 역시 그곳에서는 의도적으로 기다리지 않습니다. 사이트맵보다 먼저 시작되어 가장 마지막에 기다려지며, 핵심 페이지들은 그 사이에 가져와지기 때문에 로고를 가져오는 작업은 전체 다른 것들과 겹칠 뿐 총량에 추가되지는 않습니다.
궁극적인 목적
출력물은 텍스트가 포함된 페이지 목록과 사이트맵 요약으로 구성되며, 이것이 간결한 안내(brief)의 전체 입력값입니다. 위 모든 것은 하나의 아이디어에 봉사합니다. 즉, 모델이 비즈니스에 대해 작성하는 품질은 모델을 호출하기 훨씬 전에, 사용자가 모델 앞에 무엇을 배치했는지에 의해 거의 전적으로 결정된다는 것입니다.
만약 어떤 결과물이 나오는지 보고 싶다면, [Nakodo가 어떻게 작동하는지(how-it-works#brief)의 안내]에서 제품 용어로 설명하고 있으며, [저희 간결한 안내 템플릿 가이드(youtube-sponsorship-brief)]는 크리에이터를 위해 직접 작성할 버전입니다. 리서치 실행은 플랜별로 측정되며, Free에서는 월 3회, Business에서는 최대 150회까지 가능하며, 이는 [가격표]에서 계산되는 모든 것과 함께 나열됩니다. 왜냐하면 다른 사람의 웹사이트를 크롤링하는 것은 우리 둘 다에게 무료 작업이 아니기 때문입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기