Use Cases (업데이트: 2026. 6. 2.)

Claude Code로 안전한 Web Scraping 구현하기: Fetch, Playwright, 감사 로그

Claude Code로 안전한 Web Scraping을 구현합니다. robots.txt, Fetch, Playwright, CSV와 감사 로그를 다룹니다.

Claude Code로 안전한 Web Scraping 구현하기: Fetch, Playwright, 감사 로그

코드를 만들기 전에 경계를 정한다

Web scraping은 웹 페이지의 정보를 프로그램으로 읽는 일입니다. 자기 사이트 모니터링, 공개 문서 URL 정리, 소량의 시장 조사, 콘텐츠 QA에는 유용합니다. 하지만 공개 페이지라고 해서 무엇이든 자동 수집해도 된다는 뜻은 아닙니다. Claude Code는 구현을 빠르게 만들기 때문에, 코드를 만들기 전에 먼저 경계를 써야 합니다. 공개 데이터만 처리하고, 이용약관과 robots.txt를 존중하고, 요청 간격을 두고, 적법한 근거 없이 개인정보를 수집하지 않으며, 감사 로그를 남기는 것이 기본입니다.

초보자에게 가장 안전한 순서는 공식 API, RSS, sitemap, CSV export부터 찾는 것입니다. 이런 경로는 HTML보다 구조가 안정적이고 사용 조건도 명확합니다. 합법적이고 작은 범위의 작업이며 더 좋은 구조화 데이터가 없을 때만 HTML을 읽습니다.

이 글은 Claude Code를 구현 도우미로 쓰는 방법을 설명합니다. 로그인 우회, CAPTCHA 우회, bot 보호 회피, 대량 이메일 수집, 제한된 데이터 수집은 다루지 않습니다. 개인정보, 영업 메일, 규제 데이터가 관련되면 목적, 법적 근거, 개인정보 처리방침, 보관 기간, opt-out 경로, 지역별 준수 사항을 먼저 확인해야 합니다.

기준 문서는 공식 자료를 사용하세요. robots.txt 프로토콜은 RFC 9309, 검색 크롤링 설명은 Google robots.txt 문서, 표준 fetch 동작은 MDN Fetch API, 브라우저 자동화 격리는 Playwright Browser contexts를 확인합니다.

추천 흐름

flowchart TD
  A["목적을 하나로 제한"] --> B["API, RSS, sitemap 확인"]
  B --> C["약관과 robots.txt 확인"]
  C --> D{"정적 HTML로 충분한가?"}
  D -->|예| E["Fetch로 한 페이지씩 가져오기"]
  D -->|아니오, 허가됨| F["Playwright로 렌더링 DOM 확인"]
  E --> G["CSV에 URL과 시간 저장"]
  F --> G
  G --> H["사용 전 사람이 샘플 검토"]

이 흐름을 쓰면 Claude Code에 줄 지시도 구체적입니다. “이 사이트를 긁어 와”가 아니라 “허용된 origin의 한 페이지만, 요청 사이 2초 이상 간격을 두고 가져오며, robots.txt가 막으면 중단하고, sourceUrlfetchedAt을 CSV에 남겨라”라고 요청합니다. 경계가 구체적일수록 위험한 루프나 취약한 selector가 줄어듭니다.

Fetch와 Playwright 선택 기준

필요한 정보가 HTML 응답 안에 있으면 fetch를 사용합니다. 정적 문서, 블로그 글, 공개 가격표, 상태 페이지, sitemap에서 얻은 URL 점검은 대체로 Fetch가 맞습니다. 단순한 HTTP 요청이므로 감사하기 쉽고, 브라우저 실행보다 가볍습니다.

브라우저 실행 후에야 내용이 보일 때만 Playwright를 고려합니다. 자기 사이트의 로컬 preview, staging 환경, 허가된 내부 QA가 좋은 예입니다. 브라우저 자동화는 script, cookie, localStorage, 이미지와 client-side 상태를 함께 로드하므로 위험이 큽니다. 작업별로 browser context를 분리해 상태가 섞이지 않게 해야 합니다.

Claude Code에는 먼저 Fetch 버전을 만들게 하세요. 정적 HTML로 확인할 수 없다는 근거가 생긴 뒤 Playwright를 추가합니다. 리뷰할 때는 고정 sleep 남발, 로그인 세션 사용, 스타일 class에 의존하는 selector, rate limit 누락, source URL과 timestamp 누락을 확인합니다.

실전 use case

첫 번째는 자기 사이트 모니터링입니다. training page, product page, form, article, canonical URL, title, CTA text가 깨지지 않았는지 확인합니다. 자기 사이트라면 robots.txt와 안정적인 selector를 직접 관리할 수 있습니다. 이 결과를 AI 콘텐츠 운영 자동화콘텐츠 funnel audit에 연결하면 오래된 CTA나 깨진 링크를 더 빨리 고칠 수 있습니다.

두 번째는 공개 문서 URL 수집입니다. 공식 문서, 내부 handbook, 공개 knowledge base의 URL 목록을 만들 수 있습니다. 많은 경우 본문 전체를 저장할 필요는 없습니다. URL, title, 확인 시각, status 정도만 저장해도 검색, 리뷰, 편집 계획에는 충분합니다.

세 번째는 사람이 검토하는 경쟁사 공개 가격 페이지 체크입니다. 공개 가격 페이지를 낮은 빈도로 확인해 plan 이름이나 캠페인 문구 변화를 볼 수 있습니다. 하지만 출력은 그대로 사실이 아닙니다. 가격은 지역, 세금, 통화, 프로모션 조건에 따라 달라집니다. source URL과 timestamp를 남기고, 사람이 샘플을 확인한 뒤 영업 자료나 비교 페이지에 반영합니다.

네 번째는 lead research입니다. 회사명, 공식 웹사이트, 업종, 공개 contact page를 작은 범위로 정리하는 것은 가능할 수 있습니다. 그러나 개인 이메일을 무작위로 수집해 outbound campaign에 넣는 것은 피해야 합니다. outreach가 필요하면 opt-out, 발신자 정보, suppression list, 사람이 확인하는 단계를 둡니다. 수집이 합법적이고 최소화된 뒤에만 Claude Code email automation과 연결하세요.

실패 사례

가장 흔한 실패는 robots.txt와 약관을 읽지 않는 것입니다. robots.txt가 법적 허가 전체를 의미하지는 않지만, 사이트가 공개한 기계 판독 가능한 경계입니다. 약관은 자동 수집, 재사용, 상업적 모니터링을 추가로 제한할 수 있습니다.

이메일을 무작정 저장하는 것도 흔합니다. 공개 페이지에 있어도 개인정보일 수 있습니다. 필요 없으면 수집하지 마세요. 필요하다면 목적, 근거, 보관 기간, 접근 권한, 삭제 절차, opt-out 경로를 기록합니다.

rate limit이 없는 loop도 위험합니다. 수백 페이지를 지연 없이 요청하면 상대방에게 공격처럼 보일 수 있습니다. 작은 batch, 낮은 빈도, 명확한 User-Agent, 제한된 retry, 실패 시 중단이 기본입니다.

취약한 selector도 품질 문제를 만듭니다. .card > div:nth-child(2) 같은 selector는 디자인 변경에 쉽게 깨집니다. 의미 있는 HTML, time[datetime], main h1, 직접 관리하는 data attribute를 우선합니다. 필수 selector가 없으면 조용히 빈 값을 저장하지 말고 실패로 남깁니다.

보호 회피는 요구사항이 아닙니다. Claude Code가 CAPTCHA 우회, login wall scraping, identity rotation, rate-limit 회피를 제안하면 중단하고 승인된 data source로 다시 설계합니다.

민감한 저장도 피해야 합니다. raw HTML, 로그인 후 데이터, token, customer information, personal record를 검토 없는 CSV에 넣지 마세요. 필요한 최소 필드만 저장하고, 더 넓은 방어는 Claude Code security best practices를 함께 봅니다.

복사해서 실행하는 Fetch 예제

다음 스크립트는 Node 18 이상에서 동작합니다. 허용된 한 페이지를 가져오고, robots.txt를 보수적으로 확인하고, 요청 간격을 둔 뒤, 기본 page summary를 CSV와 JSON audit file로 저장합니다.

// scrape-allowed-page.mjs
import { writeFile } from "node:fs/promises";

const USER_AGENT = "ClaudeCodeLabAuditBot/1.0 (+https://example.com/bot-info)";
const BOT_TOKEN = "ClaudeCodeLabAuditBot";
const targetUrl = new URL(process.env.SCRAPE_URL ?? "https://example.com/");
const allowedOrigins = (process.env.ALLOWED_ORIGINS ?? "https://example.com")
  .split(",")
  .map((value) => new URL(value.trim()).origin);
const delayMs = Number.parseInt(process.env.REQUEST_DELAY_MS ?? "2000", 10);

if (!allowedOrigins.includes(targetUrl.origin)) {
  throw new Error(`Blocked by allowlist: ${targetUrl.origin}`);
}

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function fetchText(url, accept) {
  await sleep(delayMs);
  return fetch(url, {
    headers: {
      "user-agent": USER_AGENT,
      accept,
    },
  });
}

async function loadRobots(origin) {
  const robotsUrl = new URL("/robots.txt", origin);
  const response = await fetchText(robotsUrl, "text/plain");
  if (response.status === 404) {
    return { url: robotsUrl.toString(), status: response.status, text: null };
  }
  if (!response.ok) {
    throw new Error(`robots.txt check failed: HTTP ${response.status}`);
  }
  return {
    url: robotsUrl.toString(),
    status: response.status,
    text: await response.text(),
  };
}

function parseRobots(text) {
  const groups = [];
  let agents = [];
  let rules = [];

  function commit() {
    if (agents.length > 0) {
      groups.push({ agents, rules });
    }
    agents = [];
    rules = [];
  }

  for (const rawLine of text.split(/\r?\n/)) {
    const cleaned = rawLine.split("#")[0].trim();
    if (!cleaned) continue;
    const separator = cleaned.indexOf(":");
    if (separator === -1) continue;

    const field = cleaned.slice(0, separator).trim().toLowerCase();
    const value = cleaned.slice(separator + 1).trim();

    if (field === "user-agent") {
      if (rules.length > 0) commit();
      agents.push(value.toLowerCase());
      continue;
    }

    if ((field === "allow" || field === "disallow") && agents.length > 0) {
      rules.push({ type: field, path: value });
    }
  }

  commit();
  return groups;
}

function escapeRegExp(value) {
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function pathMatches(pattern, path) {
  if (!pattern) return false;
  const exact = pattern.endsWith("$");
  const normalized = exact ? pattern.slice(0, -1) : pattern;
  const source = `^${escapeRegExp(normalized).replace(/\\\*/g, ".*")}${exact ? "$" : ""}`;
  return new RegExp(source).test(path);
}

function isAllowedByRobots(robotsText, url) {
  if (robotsText === null) {
    return process.env.ALLOW_WITHOUT_ROBOTS === "true";
  }

  const groups = parseRobots(robotsText);
  const bot = BOT_TOKEN.toLowerCase();
  const exactGroups = groups.filter((group) =>
    group.agents.some((agent) => agent !== "*" && bot.includes(agent)),
  );
  const fallbackGroups = groups.filter((group) => group.agents.includes("*"));
  const selectedGroups = exactGroups.length > 0 ? exactGroups : fallbackGroups;
  const rules = selectedGroups.flatMap((group) => group.rules);
  const targetPath = `${url.pathname}${url.search}`;
  let winner = null;

  for (const rule of rules) {
    if (!pathMatches(rule.path, targetPath)) continue;
    const length = rule.path.replace(/[*$]/g, "").length;
    if (!winner || length > winner.length || (length === winner.length && rule.type === "allow")) {
      winner = { type: rule.type, length };
    }
  }

  return winner ? winner.type === "allow" : true;
}

function normalizeText(value) {
  return value
    .replace(/<script[\s\S]*?<\/script>/gi, " ")
    .replace(/<style[\s\S]*?<\/style>/gi, " ")
    .replace(/<[^>]*>/g, " ")
    .replace(/&amp;/g, "&")
    .replace(/&lt;/g, "<")
    .replace(/&gt;/g, ">")
    .replace(/&quot;/g, '"')
    .replace(/&#39;/g, "'")
    .replace(/\s+/g, " ")
    .trim();
}

function firstMatch(html, pattern) {
  const match = html.match(pattern);
  return match ? normalizeText(match[1]) : "";
}

function extractPageSummary(html) {
  const metaMatch =
    html.match(/<meta\s+[^>]*name=["']description["'][^>]*content=["']([^"']*)["'][^>]*>/i) ??
    html.match(/<meta\s+[^>]*content=["']([^"']*)["'][^>]*name=["']description["'][^>]*>/i);

  return {
    title: firstMatch(html, /<title[^>]*>([\s\S]*?)<\/title>/i),
    h1: firstMatch(html, /<h1[^>]*>([\s\S]*?)<\/h1>/i),
    metaDescription: metaMatch ? normalizeText(metaMatch[1]) : "",
    linkCount: [...html.matchAll(/<a\s+[^>]*href=["'][^"']+["']/gi)].length,
  };
}

function csvEscape(value) {
  const text = String(value ?? "");
  return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}

const robots = await loadRobots(targetUrl.origin);
if (!isAllowedByRobots(robots.text, targetUrl)) {
  throw new Error(`Blocked by robots.txt: ${targetUrl.toString()}`);
}

const response = await fetchText(targetUrl, "text/html");
if (!response.ok) {
  throw new Error(`Page fetch failed: HTTP ${response.status}`);
}

const html = await response.text();
const fetchedAt = new Date().toISOString();
const row = {
  sourceUrl: targetUrl.toString(),
  fetchedAt,
  ...extractPageSummary(html),
};
const headers = ["sourceUrl", "fetchedAt", "title", "h1", "metaDescription", "linkCount"];
const csv = [headers.join(","), headers.map((header) => csvEscape(row[header])).join(",")].join("\n");

await writeFile("scrape-output.csv", `${csv}\n`, "utf8");
await writeFile(
  "scrape-audit.json",
  JSON.stringify(
    {
      checkedAt: fetchedAt,
      userAgent: USER_AGENT,
      robotsUrl: robots.url,
      robotsStatus: robots.status,
      allowedOrigins,
      sourceUrl: row.sourceUrl,
    },
    null,
    2,
  ),
  "utf8",
);

console.log(`Saved scrape-output.csv for ${row.sourceUrl}`);

PowerShell에서는 $env:SCRAPE_URL="https://your-domain.example/page"; $env:ALLOWED_ORIGINS="https://your-domain.example"; node scrape-allowed-page.mjs처럼 실행합니다. 결과가 단순한 한 줄 CSV와 JSON audit file인 이유는 검토 가능성을 높이기 위해서입니다.

자기 사이트에만 Playwright 사용

아래 예제는 외부 보호를 우회하는 코드가 아니라, 자기 사이트나 local preview에서 렌더링 후 selector가 존재하는지 확인하는 코드입니다.

// check-own-site-selectors.mjs
import { writeFile } from "node:fs/promises";
import { chromium } from "playwright";

const target = process.env.LOCAL_PREVIEW_URL ?? "http://127.0.0.1:4321/blog/claude-code-web-scraping/";
const allowedPrefixes = [
  "http://127.0.0.1:",
  "http://localhost:",
  "https://claudecodelab.com/",
];

if (!allowedPrefixes.some((prefix) => target.startsWith(prefix))) {
  throw new Error(`Playwright check is limited to owned or local pages: ${target}`);
}

const browser = await chromium.launch();
const context = await browser.newContext({
  userAgent: "ClaudeCodeLabAuditBot/1.0 local-preview-check",
});
const page = await context.newPage();

await page.goto(target, { waitUntil: "domcontentloaded" });

const checks = [
  { name: "article title", selector: "main h1, article h1" },
  { name: "updated date", selector: "time, [data-updated-date]" },
  { name: "main article", selector: "main article, article" },
];
const results = [];

for (const check of checks) {
  const locator = page.locator(check.selector);
  const count = await locator.count();
  const firstText = count > 0 ? ((await locator.first().textContent()) ?? "").trim().slice(0, 120) : "";
  results.push({ ...check, count, firstText });
}

await writeFile(
  "selector-audit.json",
  JSON.stringify({ target, checkedAt: new Date().toISOString(), results }, null, 2),
  "utf8",
);

await context.close();
await browser.close();

const missing = results.filter((result) => result.count === 0);
if (missing.length > 0) {
  throw new Error(`Missing selectors: ${missing.map((result) => result.name).join(", ")}`);
}

console.log(`Saved selector-audit.json for ${target}`);

browser context는 cookie, localStorage, permission을 분리합니다. 승인되지 않은 실제 로그인 세션을 자동화에 넘기지 마세요.

Claude Code 프롬프트

다음처럼 요청하면 더 안전합니다.

allowlist origin만 대상으로 하는 one-page scraper를 추가하세요. 먼저 공식 API, RSS, sitemap이 있는지 README에 기록하세요. HTML fetch 전 robots.txt를 확인하고, CSV에는 sourceUrlfetchedAt을 저장하세요. email address, personal name, authenticated data, secret은 수집하지 마세요. CAPTCHA, login wall, bot protection, rate limit을 우회하지 마세요. request throttling과 blocked path 중단을 넣고 JavaScript 파일의 node --check 결과를 보여 주세요.

Claude Code가 법적 경계를 대신 판단하지 않게 만드는 것이 핵심입니다. 실행 전에는 사람이 diff, target URL, 저장 field, 요청 간격, 삭제 절차, sample output을 확인해야 합니다.

운영 체크리스트

  • API, RSS, sitemap, export를 먼저 확인했다.
  • 페이지가 공개되어 있고 목적이 약관에 어긋나지 않는다.
  • robots.txt를 존중하고 audit log에 남긴다.
  • origin allowlist와 작은 batch를 사용한다.
  • delay, 제한된 retry, stop-on-error가 있다.
  • User-Agent에 목적이나 연락처를 적는다.
  • source URL, fetch timestamp, method, robots status를 저장한다.
  • semantic selector나 직접 관리하는 data attribute를 우선한다.
  • 개인정보, secret, 로그인 데이터, raw HTML을 기본 저장하지 않는다.
  • business decision 전에 사람이 sample을 검토한다.

CSV를 spreadsheet로 열면 CSV injection도 고려해야 합니다. 웹에서 온 문자열은 신뢰하지 말고, 보안 리뷰와 content automation, outreach control에 연결하세요.

교육과 상담

코드 자체는 어렵지 않습니다. 어려운 부분은 무엇을 수집하지 않을지, 허용된 수집임을 어떻게 증명할지, 변경을 어떻게 검토하고 오래된 output을 어떻게 삭제할지입니다. ClaudeCodeLab은 Claude Code 교육 및 상담을 통해 CLAUDE.md 규칙, Playwright 검사, CSV audit log, human approval step을 팀 workflow로 정리할 수 있습니다.

혼자 시작한다면 한 페이지, 한 번, 공개 데이터만으로 시작하세요. 페이지 수를 늘리기 전에 audit trail, failure behavior, review step을 먼저 준비해야 합니다.

정리

Claude Code로 안전한 Web scraping을 만들 때 출발점은 selector가 아니라 경계입니다. API와 sitemap을 우선하고, 정적 공개 페이지는 Fetch로 충분합니다. Playwright는 자기 소유이거나 허가된 동적 페이지에만 사용합니다. URL, 시간, robots 상태, User-Agent는 항상 기록합니다.

피해야 할 것도 명확합니다. 약관 무시, 이메일 무작위 수집, rate-limit 우회, silent selector failure, 보호 회피, 민감 데이터 dump는 하지 않습니다. Claude Code는 구현을 빠르게 하지만, 목적, 출처, 시각, 삭제 경로를 사람이 설명할 수 있어야 실무에 넣을 수 있습니다.

Masa가 이 흐름을 실제로 시험했을 때, 첫 버전을 한 페이지, 한 줄 CSV, 한 개 JSON audit file로 제한하니 리뷰가 훨씬 쉬웠습니다. 특히 sourceUrlfetchedAt은 가격 페이지와 자기 사이트 콘텐츠를 나중에 설명할 때 유용했습니다. 반대로 처음부터 여러 페이지를 모으는 prototype은 selector 실패와 정책 누락을 추적하기 어려워 다시 작성해야 했습니다.

#Claude Code #web scraping #Fetch API #Playwright #robots.txt
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.