Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/blue-ribbon-browser-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"blue-ribbon-nearby": patch
---

Add a changeset for the rebrowser-playwright browser fallback release.
20 changes: 20 additions & 0 deletions blue-ribbon-nearby/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,32 @@ console.log(result.items);
- 공식 Blue Ribbon nearby 결과를 최소 1개 이상 찾았거나, 프록시 미설정 등의 이유로 결과를 가져올 수 없다는 이유와 다음 질문을 제시했다.
- 결과를 거리순으로 짧게 정리했다.

## 브라우저 fallback (봇 차단 우회)

bluer.co.kr이 자동화 접근을 차단(403)할 경우, `rebrowser-playwright`가 설치되어 있으면 실제 Chrome 브라우저를 통해 자동으로 fallback한다.

### 조건

- `rebrowser-playwright`가 설치되어 있어야 한다: `npm install rebrowser-playwright`
- Google Chrome이 시스템에 설치되어 있어야 한다
- headed 모드로 동작한다 (디스플레이 환경 필요)

### 동작 방식

1. 기존 fetch 요청이 403을 반환하면 자동으로 브라우저 fallback 활성화
2. stealth 패치 적용 (webdriver 제거, plugins/languages 스푸핑 등)
3. 실제 Chrome으로 zone 카탈로그 또는 nearby API를 호출
4. 결과를 기존 파이프라인에 그대로 전달

별도 설정 없이 `rebrowser-playwright`만 설치하면 자동으로 작동한다. 설치되어 있지 않으면 기존처럼 403 에러를 그대로 던진다.

## Failure modes

- 위치 문자열이 공식 zone 과 잘 매칭되지 않을 수 있다.
- 같은 키워드가 여러 상권에 걸치면 추가 확인이 필요하다.
- Blue Ribbon 사이트가 구조/파라미터를 바꾸면 zone 파싱 또는 nearby endpoint 가 깨질 수 있다.
- 프록시의 `BLUE_RIBBON_SESSION_ID` 가 만료(30일)되면 갱신이 필요하다.
- 브라우저 fallback은 headed 모드 전용이므로 서버(CI) 환경에서는 동작하지 않는다.

## Notes

Expand Down
60 changes: 55 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion packages/blue-ribbon-nearby/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@
"bluer"
],
"scripts": {
"lint": "node --check src/index.js && node --check src/parse.js && node --check test/index.test.js",
"lint": "node --check src/index.js && node --check src/parse.js && node --check src/browser-fallback.js && node --check test/index.test.js",
"test": "node --test"
},
"optionalDependencies": {
"rebrowser-playwright": ">=1.0.0"
}
}
199 changes: 199 additions & 0 deletions packages/blue-ribbon-nearby/src/browser-fallback.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
/**
* Browser-based fallback for Blue Ribbon nearby search.
*
* bluer.co.kr이 자동화 접근을 차단(403)할 때, rebrowser-playwright + stealth 패치로
* 실제 Chrome 브라우저를 통해 zone 카탈로그와 nearby 검색 결과를 가져온다.
*
* 조건:
* - rebrowser-playwright가 설치되어 있어야 한다 (optional dependency)
* - Google Chrome이 시스템에 설치되어 있어야 한다
* - headed 모드 (디스플레이 필요)
*
* 사용:
* const { browserFetchZoneCatalog, browserFetchNearby } = require("./browser-fallback");
* const html = await browserFetchZoneCatalog();
* const json = await browserFetchNearby(params);
*/

const BASE_URL = "https://www.bluer.co.kr";
const SEARCH_ZONE_URL = `${BASE_URL}/search/zone`;
const RESTAURANTS_MAP_URL = `${BASE_URL}/restaurants/map`;

let _chromium = null;

async function loadChromium() {
if (_chromium) return _chromium;

try {
const mod = await import("rebrowser-playwright");
_chromium = mod.chromium;
return _chromium;
} catch {
throw new Error(
"rebrowser-playwright가 설치되어 있지 않습니다. " +
"브라우저 fallback을 사용하려면: npm install rebrowser-playwright"
);
}
}

async function createStealthContext(chromium) {
const browser = await chromium.launch({
headless: false,
channel: "chrome",
args: [
"--disable-blink-features=AutomationControlled",
"--no-sandbox"
]
});

const context = await browser.newContext({
viewport: { width: 1280, height: 800 },
locale: "ko-KR",
extraHTTPHeaders: {
"Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7"
}
});

await context.addInitScript(() => {
delete Object.getPrototypeOf(navigator).webdriver;

if (!window.chrome) window.chrome = {};
if (!window.chrome.runtime) {
window.chrome.runtime = {
PlatformOs: { MAC: "mac", WIN: "win", ANDROID: "android", CROS: "cros", LINUX: "linux" },
PlatformArch: { ARM: "arm", X86_32: "x86-32", X86_64: "x86-64" }
};
}

Object.defineProperty(navigator, "plugins", {
get: () => {
const arr = [
{ name: "Chrome PDF Plugin", filename: "internal-pdf-viewer", description: "Portable Document Format" },
{ name: "Chrome PDF Viewer", filename: "mhjfbmdgcfjbbpaeojofohoefgiehjai", description: "" },
{ name: "Native Client", filename: "internal-nacl-plugin", description: "" }
];
arr.__proto__ = PluginArray.prototype;
return arr;
}
});

Object.defineProperty(navigator, "languages", {
get: () => ["ko-KR", "ko", "en-US", "en"]
});

const originalQuery = window.navigator.permissions.query;
window.navigator.permissions.query = (parameters) =>
parameters.name === "notifications"
? Promise.resolve({ state: Notification.permission })
: originalQuery(parameters);
});

return { browser, context };
}

/**
* 브라우저로 zone 카탈로그 HTML을 가져온다.
* @returns {Promise<string>} /search/zone 페이지의 HTML
*/
async function browserFetchZoneCatalog() {
const chromium = await loadChromium();
const { browser, context } = await createStealthContext(chromium);

try {
const page = await context.newPage();
const response = await page.goto(SEARCH_ZONE_URL, {
waitUntil: "domcontentloaded",
timeout: 30000
});

if (!response || !response.ok()) {
throw new Error(`zone 카탈로그 요청 실패: HTTP ${response?.status()}`);
}

return await page.content();
} finally {
await browser.close();
}
}

/**
* 브라우저로 nearby 검색 JSON을 가져온다.
* @param {Object} params - URL search params (distance, isAround, ribbon, etc.)
* @returns {Promise<Object>} nearby 검색 결과 JSON
*/
async function browserFetchNearby(params) {
const chromium = await loadChromium();
const { browser, context } = await createStealthContext(chromium);

try {
const page = await context.newPage();

// zone 페이지를 먼저 방문하여 세션 쿠키와 CSRF 토큰 획득
await page.goto(SEARCH_ZONE_URL, {
waitUntil: "domcontentloaded",
timeout: 30000
});

// CSRF 토큰 추출
const csrf = await page.evaluate(() => {
const meta = document.querySelector('meta[name="_csrf"]');
return meta ? meta.getAttribute("content") : null;
});

if (!csrf) {
throw new Error("CSRF 토큰을 찾을 수 없습니다.");
}

// nearby API를 page context에서 fetch로 호출 (same-origin 쿠키 자동 전송)
const url = new URL(RESTAURANTS_MAP_URL);
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== "") {
url.searchParams.set(key, String(value));
}
}

const result = await page.evaluate(async ({ apiUrl, csrfToken }) => {
const response = await fetch(apiUrl, {
headers: {
"accept": "application/json, text/plain;q=0.9,*/*;q=0.8",
"x-requested-with": "XMLHttpRequest",
"x-csrf-token": csrfToken
},
credentials: "same-origin"
});

if (!response.ok) {
return { error: true, status: response.status, text: await response.text().catch(() => "") };
}

return response.json();
}, { apiUrl: url.toString(), csrfToken: csrf });

if (result.error) {
throw new Error(`nearby API 요청 실패: HTTP ${result.status}`);
}

return result;
} finally {
await browser.close();
}
}

/**
* 브라우저 fallback이 사용 가능한지 확인한다.
* @returns {boolean}
*/
function isBrowserFallbackAvailable() {
try {
require.resolve("rebrowser-playwright");
return true;
} catch {
return false;
}
}

module.exports = {
browserFetchNearby,
browserFetchZoneCatalog,
isBrowserFallbackAvailable
};
Loading
Loading