Cloudflare Worker 프록시 배포 가이드

도핑 뉴스 모니터 — 네이버·구글 수집 안정화를 위한 커스텀 프록시

무료 (월 10만 요청) 설정 10분 봇 차단 우회 효과적 전 세계 엣지 서버
기존 Worker 재배포 방법 바로가기 ↓ 처음 배포하기 ↓
왜 Cloudflare Worker가 필요한가요?
방법 구글 RSS 네이버 안정성 비용
공개 프록시 (현재) 불안정 자주 차단 낮음 무료
rss2json.com 안정적 미지원 높음 무료(분당10회)
Cloudflare Worker ★ 안정적 안정적 매우 높음 무료(월10만)
현재 이 앱은 구글 RSS → rss2json.com을 우선 사용하고, 나머지(네이버·전문사이트)는 공개 프록시를 시도합니다. Cloudflare Worker를 추가하면 네이버 수집 성공률이 크게 올라갑니다.
1 Cloudflare 계정 만들기
무료 계정 가입

https://dash.cloudflare.com/sign-up 에서 이메일·비밀번호만으로 가입합니다.
신용카드 불필요 — 무료 플랜으로 충분합니다.

2 Workers & Pages → 새 Worker 만들기
대시보드에서 Worker 생성

1. 로그인 후 좌측 메뉴 Workers & Pages 클릭

2. Create applicationCreate Worker 클릭

3. Worker 이름 입력 (예: doping-proxy)

4. Deploy 클릭 (기본 코드로 먼저 배포)

3 Worker 코드 교체 (복사 → 붙여넣기)

배포 후 Edit code 버튼을 클릭해 아래 코드 전체를 붙여넣고 Save and Deploy합니다.

JavaScript — Worker 코드
/**
 * Doping News Monitor — CORS Proxy Worker  v4.0
 * 배포 후 URL: https://doping-proxy.YOUR-SUBDOMAIN.workers.dev
 *
 * ■ 라우트
 *   GET /?url=<enc>
 *       — 일반 CORS 프록시 (Naver HTML, WADA, ITA, iNADO, ITG 크롤링)
 *
 *   GET /naver_api?query=<q>&id=<id>&secret=<sec>[&start=1][&display=100][&sort=date]
 *       — 네이버 Open API 전용 포워딩
 *         Worker가 서버→서버로 호출 → CORS/봇차단 완전 우회
 *
 *   GET /google_rss?url=<enc>
 *       — 구글 뉴스 RSS XML 직접 획득 (v4.0 신규)
 *         Worker 서버 IP로 Google에 요청 → 브라우저 IP 차단 우회
 *         rss2json.com 없이도 구글 뉴스 수집 가능
 *
 * ■ 봇 차단 우회 원리
 *   브라우저 → Worker (CORS 허용) → 대상 서버 (서버간 호출, 봇차단 없음)
 *   - 네이버: X-Naver-Client-Id / X-Naver-Client-Secret 헤더 대신 전달
 *   - 구글:   Cloudflare 서버 IP → Google 뉴스 RSS 직접 호출
 *
 * ■ v4.0 변경사항
 *   - /google_rss 라우트 추가 → 구글 봇차단 완전 우회
 *   - /naver_api: start 파라미터 지원 (페이징 최대 300건)
 *   - handleProxy: Chrome 135 UA + Sec-Fetch 헤더
 */
export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);

    // ── CORS preflight ──────────────────────────────────────────
    if (request.method === "OPTIONS") {
      return new Response(null, { status: 204, headers: corsHeaders() });
    }

    // GET 이외 차단
    if (request.method !== "GET") {
      return jsonError(405, "GET 메서드만 허용됩니다.");
    }

    // ── 라우트: /google_rss — 구글 뉴스 RSS 직접 획득 (v4.0) ────
    if (url.pathname === "/google_rss" || url.pathname === "/google_rss/") {
      return handleGoogleRSS(url.searchParams);
    }

    // ── 라우트: /naver_api ──────────────────────────────────────
    if (url.pathname === "/naver_api" || url.pathname === "/naver_api/") {
      return handleNaverAPI(url.searchParams);
    }

    // ── 라우트: / — 일반 CORS 프록시 ────────────────────────────
    return handleProxy(url.searchParams);
  },
};

// ============================================================
// 구글 뉴스 RSS 직접 획득 핸들러 (v4.0 신규)
// GET /google_rss?url=
// ============================================================
async function handleGoogleRSS(params) {
  const rawUrl = params.get("url");
  if (!rawUrl) {
    return jsonError(400, "url 파라미터가 필요합니다.");
  }

  let targetUrl;
  try {
    targetUrl = new URL(decodeURIComponent(rawUrl));
  } catch (e) {
    return jsonError(400, "유효하지 않은 URL: " + e.message);
  }

  // 구글 뉴스 도메인만 허용
  if (targetUrl.hostname !== "news.google.com") {
    return jsonError(403, "news.google.com 도메인만 허용됩니다.");
  }

  try {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), 20000);

    const resp = await fetch(targetUrl.href, {
      signal: controller.signal,
      headers: {
        // Google은 브라우저처럼 보이는 요청 선호
        "User-Agent":
          "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
          "AppleWebKit/537.36 (KHTML, like Gecko) " +
          "Chrome/135.0.0.0 Safari/537.36",
        "Accept":          "application/rss+xml, application/xml, text/xml, */*",
        "Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
        "Accept-Encoding": "gzip, deflate, br",
        "Cache-Control":   "no-cache",
        "Referer":         "https://news.google.com/",
      },
      redirect: "follow",
    });
    clearTimeout(timer);

    if (!resp.ok) {
      return jsonError(resp.status, `Google RSS HTTP ${resp.status}`);
    }

    const body = await resp.text();

    // HTML 페이지가 반환된 경우 (봇 차단) 에러 처리
    const trimmed = body.trim();
    if (/^]/i.test(trimmed)) {
      return jsonError(503, "Google에서 HTML 페이지 반환 (봇 차단). 잠시 후 다시 시도하세요.");
    }

    return new Response(body, {
      status: 200,
      headers: {
        ...corsHeaders(),
        "Content-Type":     "application/xml; charset=utf-8",
        "X-Proxied-Status": String(resp.status),
        "X-Via":            "cf-worker-google-rss",
        "X-Worker-Version": "4.0",
      },
    });
  } catch (err) {
    const isTimeout = err.name === "AbortError";
    return jsonError(
      isTimeout ? 504 : 502,
      isTimeout ? "Google RSS 타임아웃 (20초 초과)" : "Google RSS 요청 실패: " + err.message
    );
  }
}

// ============================================================
// 네이버 Open API 포워딩 핸들러 (서버→서버, CORS 우회)
// ============================================================
async function handleNaverAPI(params) {
  const query   = params.get("query")   || "";
  const id      = params.get("id")      || "";
  const secret  = params.get("secret")  || "";
  const display = params.get("display") || "100";
  const sort    = params.get("sort")    || "date";
  const start   = params.get("start")   || "1";

  if (!query || !id || !secret) {
    return jsonError(400, "query, id, secret 파라미터가 필요합니다.");
  }
  // display 범위 보정 (1~100)
  const safeDisplay = Math.min(100, Math.max(1, parseInt(display, 10) || 100));
  // start 범위 보정 (1~1000)
  const safeStart   = Math.min(1000, Math.max(1, parseInt(start, 10) || 1));

  const apiUrl = "https://openapi.naver.com/v1/search/news.json?" +
    new URLSearchParams({
      query,
      display: String(safeDisplay),
      sort,
      start:   String(safeStart),
    });

  try {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), 30000); // 30초 타임아웃

    const resp = await fetch(apiUrl, {
      signal: controller.signal,
      headers: {
        "X-Naver-Client-Id":     id,
        "X-Naver-Client-Secret": secret,
        "Accept":                "application/json",
        "User-Agent":            "DoingNewsMonitor/3.0 (Cloudflare Worker; server-to-server)",
      },
    });
    clearTimeout(timer);

    const body = await resp.text();

    // 401/403/429는 상태 코드를 그대로 전달 → 브라우저에서 정확한 오류 처리 가능
    return new Response(body, {
      status: resp.status,
      headers: {
        ...corsHeaders(),
        "Content-Type":     "application/json; charset=utf-8",
        "X-Proxied-Status": String(resp.status),
        "X-Via":            "cf-worker-naver-api",
        "X-Worker-Version": "4.0",
      },
    });
  } catch (err) {
    const isTimeout = err.name === "AbortError";
    return jsonError(
      isTimeout ? 504 : 502,
      isTimeout ? "네이버 API 타임아웃 (30초 초과)" : "네이버 API 요청 실패: " + err.message
    );
  }
}

// ============================================================
// 일반 CORS 프록시 핸들러 (HTML/XML 크롤링)
// ============================================================
async function handleProxy(params) {
  const rawUrl = params.get("url");

  if (!rawUrl) {
    return jsonError(400, "url 파라미터가 필요합니다.");
  }

  let parsedUrl;
  try {
    parsedUrl = new URL(decodeURIComponent(rawUrl));
  } catch (e) {
    return jsonError(400, "유효하지 않은 URL: " + e.message);
  }

  // https 전용 (http 차단)
  if (parsedUrl.protocol !== "https:") {
    return jsonError(403, "https URL만 허용됩니다.");
  }

  // 허용 도메인 목록
  const ALLOWED_HOSTS = [
    "search.naver.com",
    "m.search.naver.com",
    "news.naver.com",
    "n.news.naver.com",
    "openapi.naver.com",
    "news.google.com",
    "www.wada-ama.org",
    "ita.sport",
    "www.ita.sport",
    "inado.org",
    "www.inado.org",
    "www.insidethegames.biz",
    "api.rss2json.com",
  ];
  const isAllowed = ALLOWED_HOSTS.some(
    h => parsedUrl.hostname === h || parsedUrl.hostname.endsWith("." + h)
  );
  if (!isAllowed) {
    return jsonError(403, "허용되지 않은 호스트: " + parsedUrl.hostname);
  }

  try {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), 25000); // 25초 타임아웃

    const resp = await fetch(parsedUrl.href, {
      signal:   controller.signal,
      redirect: "follow",
      headers: {
        // 최신 Chrome 135 UA — 봇 차단율 감소
        "User-Agent":
          "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
          "AppleWebKit/537.36 (KHTML, like Gecko) " +
          "Chrome/135.0.0.0 Safari/537.36",
        "Accept":
          "text/html,application/xhtml+xml,application/xml;q=0.9," +
          "image/avif,image/webp,*/*;q=0.8",
        "Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
        "Accept-Encoding": "gzip, deflate, br",
        "Cache-Control":   "no-cache",
        "Pragma":          "no-cache",
        "Referer":         "https://www.naver.com/",
        // Sec-Fetch 헤더: 일반 브라우저 요청처럼 보이게
        "Sec-Fetch-Dest":  "document",
        "Sec-Fetch-Mode":  "navigate",
        "Sec-Fetch-Site":  "none",
        "Sec-Fetch-User":  "?1",
        "Upgrade-Insecure-Requests": "1",
      },
    });
    clearTimeout(timer);

    const contentType = resp.headers.get("content-type") || "text/plain; charset=utf-8";
    const body = await resp.text();

    return new Response(body, {
      status: resp.status,
      headers: {
        ...corsHeaders(),
        "Content-Type":     contentType,
        "X-Proxied-Status": String(resp.status),
        "X-Via":            "cf-worker-proxy",
        "X-Worker-Version": "4.0",
      },
    });
  } catch (err) {
    const isTimeout = err.name === "AbortError";
    return jsonError(
      isTimeout ? 504 : 502,
      isTimeout ? "프록시 타임아웃 (25초 초과)" : "프록시 요청 실패: " + err.message
    );
  }
}

// ============================================================
// 공통 유틸
// ============================================================
function corsHeaders() {
  return {
    "Access-Control-Allow-Origin":  "*",
    "Access-Control-Allow-Methods": "GET, OPTIONS",
    "Access-Control-Allow-Headers":
      "Content-Type, Accept, X-Naver-Client-Id, X-Naver-Client-Secret",
    "Access-Control-Max-Age": "86400",
  };
}

function jsonError(status, message) {
  return new Response(
    JSON.stringify({ error: message, status }),
    {
      status,
      headers: {
        ...corsHeaders(),
        "Content-Type":     "application/json; charset=utf-8",
        "X-Worker-Version": "4.0",
      },
    }
  );
}
ALLOWED_HOSTS 목록에 없는 도메인은 403으로 차단됩니다. 필요 시 배열에 추가하세요.
4 Worker URL 확인 및 앱에 등록
Worker URL 찾기

배포 완료 후 Worker 상세 페이지에 URL이 표시됩니다:

https://doping-proxy.YOUR-SUBDOMAIN.workers.dev

이 URL을 복사합니다.

여기에 Worker URL을 입력하고 저장하면 앱에 바로 적용됩니다
5 동작 확인
브라우저에서 직접 테스트

아래 URL을 브라우저에서 열어 네이버 검색 결과 HTML이 표시되면 성공입니다:

https://doping-proxy.YOUR-SUBDOMAIN.workers.dev/?url=https%3A%2F%2Fsearch.naver.com%2Fsearch.naver%3Fwhere%3Dnews%26query%3D%EB%8F%84%ED%95%91

앱 수집 화면에서 수집 로그에 "✅ [네이버] 프록시1 성공" 등이 뜨면 정상입니다.

무료 플랜 한도: 월 100,000 요청 / 하루 약 3,300회. 키워드 5개 × 포털 2개 × 페이지 3개 = 1회 수집 시 약 30 요청. 하루 100회 수집해도 3,000 요청 — 충분합니다.
기존 Worker 코드 교체 · 재배포 방법 (kada-news 기준)
이미 Worker가 배포되어 있는 경우
아래 절차를 따르면 URL은 그대로 유지되고 코드만 교체됩니다. Worker를 새로 만들 필요가 없습니다.

현재 위치 확인 (아키텍처 탭)

지금 보이는 화면은 아키텍처(Architecture) 탭입니다.
코드를 편집하려면 Edit code 버튼 또는 코드 편집기 탭으로 이동해야 합니다.

도메인 1 Workers 0 ← 현재 여기: kada-news (아키텍처) 바인딩 0
A Cloudflare 대시보드에서 Worker 열기
  1. 브라우저에서 dash.cloudflare.com 접속 후 로그인
  2. 왼쪽 사이드바에서 Workers & Pages 클릭
  3. 목록에서 kada-news (또는 본인 Worker 이름) 클릭
  4. 상단 탭 중 Edit code 버튼 또는 </> 코드 탭 클릭
팁: 아키텍처 탭 상단 오른쪽에 있는 Edit code 버튼을 찾아 클릭하세요.
B 기존 코드 전체 삭제 후 새 코드 붙여넣기
  1. 코드 편집기 창이 열리면 Ctrl+A (Mac: ⌘+A) 로 전체 선택
  2. Delete 또는 Backspace 로 기존 코드 전부 삭제
  3. 아래 코드 복사 버튼을 클릭해 새 코드를 복사
  4. 편집기 빈 화면에 Ctrl+V (Mac: ⌘+V) 로 붙여넣기
기존 코드를 완전히 삭제한 뒤 붙여넣어야 합니다. 일부만 바꾸면 오류가 발생합니다.
C 저장 및 배포
  1. 코드 편집기 오른쪽 상단의 Deploy 버튼 클릭
  2. "Your Worker has been deployed" 메시지가 뜨면 성공
  3. Worker URL은 변경되지 않으므로 앱 설정을 다시 입력할 필요 없음
버튼 위치
편집기 우측 상단 또는 하단
단축키 없음
반드시 버튼 클릭으로 배포
소요 시간
수 초 ~ 30초 이내
D 배포 확인 (브라우저 테스트)

배포 후 아래 버튼으로 새 라우트가 정상 동작하는지 즉시 확인하세요.

/google_rss 라우트 테스트 (v4.0 신규)
아래 URL을 브라우저에서 열어 XML 피드가 표시되면 성공
Worker URL을 아래에 입력하면 테스트 링크가 생성됩니다
/naver_api 라우트 테스트
JSON 응답이 오면 성공 (API 키 없어도 400 오류로 정상 응답)
Worker URL 입력 (저장 + 테스트 링크 생성)
재배포 후에도 오류가 발생한다면? (클릭해서 펼치기)
❌ 500 Internal Server Error
코드가 완전히 교체되지 않았을 가능성. Ctrl+A 후 삭제 → 붙여넣기를 다시 시도하세요.
⚠️ 404 Not Found (/google_rss)
v4.0 코드가 배포되지 않은 것입니다. 이 페이지의 코드를 복사해 재배포하세요.
⚠️ 코드 편집기가 보이지 않음
아키텍처 탭이 선택된 상태입니다. 탭 목록에서 코드(Code) 또는 Edit code 탭을 찾으세요.
ℹ️ Deploy 버튼이 비활성화됨
코드를 수정하면 버튼이 활성화됩니다. 코드를 1글자라도 변경 후 다시 시도하세요.
ℹ️ Wrangler CLI 대안 (고급)
터미널에서 npx wrangler deploy worker.js --name kada-news 를 실행해도 배포 가능합니다.
자주 묻는 질문
개인 정보가 노출되나요?

Worker는 URL만 중계합니다. 로그인 정보나 개인 정보는 전송되지 않습니다. Worker 코드도 직접 관리하므로 안전합니다.

네이버가 여전히 차단되면?

네이버는 주기적으로 크롤러 대응을 강화합니다. 이 경우 Worker의 User-AgentReferer 헤더를 최신 Chrome 버전으로 업데이트해 보세요.

Worker를 삭제하고 싶으면?

Cloudflare 대시보드 → Workers & Pages → 해당 Worker → Settings → Delete에서 삭제할 수 있습니다. 앱 설정에서도 Worker URL을 초기화하세요.

메인 페이지로 돌아가기