
Crawlee와 Apify를 사용하여 SEO 경쟁사 분석 Actor 구축하기
요약
Crawlee와 Apify를 활용하여 경쟁사의 콘텐츠 구조를 분석하고 콘텐츠 격차(Content Gap)를 식별하는 Actor 구축 방법을 설명합니다. 웹 페이지의 불규칙한 헤딩 구조와 노이즈를 정제하여 구조화된 JSON 데이터를 생성하는 과정을 다룹니다.
핵심 포인트
- Crawlee와 Apify를 이용한 자동화된 SEO 경쟁사 분석 도구 구축
- 웹 페이지의 불규칙한 헤딩 구조 및 노이즈 제거 기술
- 추출된 데이터를 정규화하여 공유 섹션 및 누락된 주제 식별
- 수동 분석의 한계를 극복하는 확장 가능한 크롤링 워크플로우
저는 고객의 작은 블로그를 관리하며 "최고의 프로젝트 관리 도구" 또는 "Windows OS 대안"과 같은 쿼리에 대해 상위 노출되는 비교 기사들을 정기적으로 게시합니다. 시간이 흐르면서 한 가지 패턴을 발견했습니다. 저보다 상위에 랭킹된 경쟁사들이 반드시 더 많은 주제를 다루고 있는 것은 아니며, 단지 콘텐츠를 다르게 구조화하고 있다는 점이었습니다. 저는 그들이 제가 다루지 않은 어떤 내용을 정확히 다루고 있는지 이해하고 싶었습니다.
처음에는 각 페이지를 수동으로 열고, 헤딩(heading)을 스캔하고, 메모를 하는 것이 본능적인 방법이었습니다. 한두 페이지 정도는 효과가 있었습니다. 하지만 확장성이 없었습니다. 그래서 저는 제 페이지와 경쟁사 페이지에서 헤딩 구조를 추출하고, 이를 정제 및 정규화(normalizing)하며, 공유된 주제, 누락된 범위, 고유 섹션을 보여주는 구조화된 JSON 출력을 생성하여 저 대신 비교를 수행하는 Crawlee Actor를 구축했습니다.
이 글에서는 제가 이를 어떻게 구축했는지, 그 과정에서 무엇이 고장 났는지, 그리고 어떻게 매월 일정에 맞춰 실행할 수 있을 만큼 신뢰할 수 있는 결과물을 얻게 되었는지에 대해 설명합니다.
사전 요구 사항 (Prerequisites)
함께 따라오기 위해서는 다음이 필요합니다:
- Node.js v18 이상
- Apify CLI 설치 (
npm install -g apify-cli) - JavaScript 및 async/await에 대한 기본적인 익숙함
- Apify 계정 (무료 티어도 괜찮습니다)
Actor가 하는 일
이 가이드를 마칠 때쯤, 여러분은 다음과 같은 기능을 가진 Crawlee Actor를 갖게 될 것입니다:
- 주어진 주제를 대상으로 내 페이지와 경쟁사 페이지를 크롤링(Crawls)
- 실제 기사 콘텐츠에서 구조화된 헤딩(H2, H3, H4)을 추출
- 위젯, 사이드바 및 삽입된 요소에서 노이즈 제거
- 헤딩을 비교 가능한 값으로 정규화(Normalizes)
- 헤딩에서 의미 있는 엔티티(entities) 추출
- 내 페이지를 경쟁사와 비교
- 다음과 같은 구조화된 JSON 출력 반환:
- 공유된 섹션 (shared sections)
- 누락된 주제 (missing topics)
- 고유한 범위 (unique coverage)
이 출력물은 경쟁사 페이지를 단 하나도 수동으로 열지 않고도 콘텐츠 격차 분석(content gap analysis)을 위한 사용 가능한 기준점을 제공합니다.
이 문제가 보기보다 어려운 이유
처음에는 페이지를 비교하는 것이 간단해 보입니다. 내 페이지에서 헤딩(headings)을 추출하고, 경쟁사 페이지에서 헤딩을 추출한 뒤, 그것들을 비교하면 된다고 생각하기 때문입니다. 하지만 실제로 해보면 이 방식은 금방 무너집니다.
실제 웹 페이지들은 깔끔하지 않습니다. 실제 콘텐츠와 추출을 방해하는 관련 없는 요소들이 뒤섞여 있습니다. 제가 겪었던 가장 흔한 문제들은 다음과 같습니다:
- 일관되지 않은 구조 (Inconsistent structure): 어떤 페이지는 도구 목록을 H2 헤딩 아래에 나열하는 반면, 다른 페이지는 H3를 사용하거나 심지어 일반 텍스트를 사용하기도 합니다. 사이트 간에 계층 구조(hierarchy)를 신뢰할 수 없습니다.
- 삽입되거나 스타일이 적용된 콘텐츠 (Injected or styled content): 일부 사이트는 헤딩 요소에 스타일이나 스크립트를 직접 삽입합니다. 깨끗한 텍스트 대신
.css-19a5n3-link{color:#0a0a23}와 같은 CSS 조각이나 깨진 문자열을 추출하게 됩니다. - 모호한 헤딩 (Ambiguous headings): "팀을 위한 최고의 옵션" 또는 "최고의 선택"과 같은 헤딩은 무엇을 가리키는지 명확하게 식별하지 못합니다. 추가적인 처리 없이는 비교 용도로 사용할 수 없습니다.
- 동일한 개념에 대한 서로 다른 표현 (Different wording for the same concept): 한 페이지는 "Linux Mint"를 사용하고, 다른 페이지는 "Mint Linux"를 사용할 수 있습니다. 정규화 (normalization) 없이는 이들이 서로 다른 항목으로 나타납니다.
단순히 원시 데이터 추출 (raw extraction) 단계에서 멈춘다면, 결과물은 노이즈가 많고 오해의 소지가 생깁니다. 진짜 도전 과제는 스크래핑 (scraping) 자체가 아니라, 실제 기사 콘텐츠를 격리하고, 관련 없는 섹션을 제거하며, 헤딩을 정규화하고, 의미 있는 엔티티 (entities)를 추출하며, 페이지 간에 데이터를 비교 가능하게 만드는 것입니다.
프로젝트 설정 (Project setup)
먼저 새로운 Crawlee 프로젝트를 생성하고 필요한 의존성 (dependencies)을 설치하는 것으로 시작했습니다. 처음부터 시작한다면 다음과 같이 입력하세요:
npx crawlee create seo-content-gap-analyzer
cd seo-content-gap-analyzer
이미 프로젝트가 있다면, Crawlee를 설치하세요:
npm install crawlee
이 워크플로우는 Actor로 실행되도록 설계되었으므로, Apify SDK도 설치했습니다:
npm install apify
모든 주요 로직은 단일 엔트리 파일 (entry file)에 위치합니다:
src/main.js
이것이 시작하는 데 필요한 모든 설정이었습니다.
Actor 입력 정의 (Defining the Actor input)
Actor에는 두 가지 주요 입력값이 필요합니다: 내 페이지(분석하고자 하는 페이지)와 경쟁사 페이지(비교용으로 사용)입니다. 또한 주제 레이블(topic label)과 몇 가지 선택적 설정도 포함했습니다. 다음은 Windows OS alternatives에 대한 입력 예시입니다:
{
"type": "comparison",
"topic": "windows os alternatives",
...
입력 항목 상세 (Input breakdown)
- type: 출력 모드를 정의합니다. 여기서는 비교 워크플로우 (comparison workflow)를 실행합니다.
- topic: 분석 내용이 무엇인지 설명하는 레이블입니다.
- myPage: 평가하고자 하는 페이지입니다.
- competitors: 동일한 주제로 순위가 매겨진 URL 목록입니다. 이들은 비교 기준점 (comparison baseline) 역할을 합니다.
- maxRequests (선식): 크롤러가 처리할 페이지 수를 제한합니다.
- debug (선택 사항): 실행 중 추가적인 로깅 (logging)을 활성화합니다.
런타임 (runtime) 시, Actor는 내 페이지와 각 경쟁사 페이지를 크롤링하고, 모든 페이지에서 헤딩 구조 (heading structures)를 추출한 다음, 모든 요소를 비교하여 중복되는 부분과 차이점 (gaps)을 식별합니다. 이러한 입력 구조는 워크플로우를 유연하게 유지하며 다양한 주제에 재사용할 수 있게 합니다. 입력값에 대한 유효성 검사 (validation)나 UI를 추가하고 싶다면, Actor input schema specification에서 설정 방법을 확인할 수 있습니다.
크롤링 전략 (Crawling strategy)
이 워크플로우를 위해 저는 CheerioCrawler를 사용했습니다. 이는 의도적인 선택이었습니다. 저는 페이지와 상호작용하거나, 버튼을 클릭하거나, 동적인 사용자 흐름 (dynamic user flows)을 처리하려는 것이 아닙니다. 목표는 기사 페이지에서 구조화된 콘텐츠를 가능한 한 효율적으로 추출하는 것입니다. CheerioCrawler는 다음과 같은 이점을 제공합니다:
- 빠른 HTML 파싱 (parsing)
- 낮은 리소스 사용량
- 단순한 DOM 순회 (traversal)
- 콘텐츠를 정제하고 처리하기에 충분한 제어권
Playwright와 같은 브라우저 기반 크롤러를 사용하는 것은 이 사용 사례에서 실질적인 가치를 더하지 않으면서 복잡성과 비용만 증가시킬 것입니다.
실제 비교 페이지들을 다루면서 간단한 데모에서는 나타나지 않는 예외 케이스(edge cases)들이 빠르게 드러났습니다. 주요 과제는 크롤링 그 자체라기보다, 일관적이지 않고 깊게 중첩된 HTML 구조를 처리하는 것이었습니다. 어떤 페이지들은 도구들을 깔끔하게 노출했지만, 다른 페이지들은 스타일링 레이어 및 편집 콘텐츠와 섞인 채 컴포넌트 기반 레이아웃 내부에 도구들을 임베드(embed)해 두었습니다.
크롤러의 신뢰성을 확보하기 위해 저는 세 가지 영역에 집중했습니다:
- 텍스트 정규화 (Text normalization): 프로세싱 전 CSS 파편과 표현상의 아티팩트(artifacts)를 제거하기 위함
- 엔티티 필터링 (Entity filtering): 실제 도구를 점수, 기능 및 편집 섹션으로부터 분리하기 위함
- 유연한 매칭 (Flexible matching): 페이지 전반에 걸친 명칭의 변형을 처리하기 위함
이러한 조정 사항들은 크롤러가 작동하는 상위 수준의 방식은 바꾸지 않았지만, 추출된 데이터의 품질을 크게 향상시켰습니다.
각 페이지에서 실제로 필요한 것
저는 페이지 전체를 스크래핑하는 것이 아닙니다. 저는 오직 메인 기사 콘텐츠, 헤딩 구조(H2, H3, H4), 그리고 해당 헤딩 내부의 의미 있는 텍스트에만 관심이 있습니다. 그 외의 모든 것은 노이즈입니다.
const crawler = new CheerioCrawler({
maxRequestsPerCrawl: 10,
async requestHandler({ request, $, response }) {
...
제가 테스트한 세 개의 페이지에 대한 초기 추출 결과는 다음과 같습니다:
비교 스냅샷 추가하기
이 시점에서 각 페이지는 구조화된 헤딩 (headings)을 반환하고 있었습니다. 이는 유용하지만 아직 비교 가능한 수준은 아니었습니다. 전체적인 관점을 얻기 위해, 저는 모든 페이지를 하나의 스냅샷 (snapshot)으로 집계하는 비교 레이어 (comparison layer)를 도입했습니다. 목표는 추출 (extraction) 방식을 변경하는 것이 아니라, 여러 페이지에 걸쳐 무엇이 나타나는지, 내 페이지에서 무엇이 누락되었는지, 그리고 무엇이 고유한지를 요약하는 것입니다.
const result = {
type: "comparison",
topic,
...
저는 uniqueList() 바로 아래에 헬퍼 함수 (helper function)를 추가했습니다:
function compareHeadings(myPageData, competitorsData) {
const myItems = uniqueList(
[...myPageData.h3List, ...(myPageData.h4List || [])]
...
이것은 크롤러 (crawler) 자체를 변경하지는 않습니다. 추출된 데이터 위에 스냅샷 레이어를 추가하는 것뿐입니다. 즉, 내 페이지가 나머지 페이지들과 어떻게 비교되는지를 보여주는 하나의 요약본을 만드는 것입니다. 크롤링이 완료되면, 데이터셋 (dataset)을 로드하고 결과를 myPage와 competitor 항목으로 분리합니다:
const { items } = await dataset.getData({ limit: 1000 });
const myPageData = items.find((item) => item.pageType === 'myPage');
...
그런 다음 비교를 실행하고 스냅샷을 저장합니다:
if (myPageData && competitorsData.length > 0) {
const comparison = compareHeadings(myPageData, competitorsData);
...
이를 통해 해당 주제에 대한 첫 번째 글로벌 뷰 (global view)를 얻을 수 있습니다: sharedHeadings는 내 페이지와 경쟁사 간의 중복을 보여주고, myPageOnlyHeadings는 내 페이지에서만 다루는 콘텐츠를 보여주며, competitorOnlyHeadings는 경쟁사는 다루지만 내 페이지에는 없는 콘텐츠를 보여줍니다.
다른 주제로 크롤러 테스트하기
이 시점에서 크롤러는 하나의 데이터셋에 대해 작동했습니다. 다음 단계는 주제가 바뀌었을 때도 로직이 유지되는지 확인하는 것이었습니다. 코드를 수정하는 대신, 입력값만 변경했습니다. 이번에는 이미 북마크해 두었던 실제 프로젝트 관리 (project management) 페이지들을 사용했습니다:
{
"topic": "프로젝트 관리 도구 (project management tools)",
"myPage": "https://www.learn-dev-tools.blog/best-legal-project-management-software/",
...
개별 페이지 추출 결과는 다음과 같았습니다:
[

AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기



