[FEAT] blue-ribbon-nearby: rebrowser-playwright 기반 브라우저 fallback 추가

bluer.co.kr이 자동화 접근을 차단(403)할 때,
rebrowser-playwright가 설치되어 있으면 실제 Chrome 브라우저를 통해
자동으로 fallback하여 zone 카탈로그와 nearby 검색을 수행한다.

- browser-fallback.js: stealth 패치 적용 브라우저 모듈 (8개 패치)
- index.js: fetchZoneCatalog/fetchNearbyMap에서 403 시 자동 fallback
- package.json: rebrowser-playwright를 optionalDependencies로 추가
- SKILL.md: 브라우저 fallback 사용법 안내

rebrowser-playwright 미설치 시 기존 동작과 완전히 동일하다.
This commit is contained in:
TKman 2026-04-09 13:33:52 +09:00
commit 0d0497719d
4 changed files with 248 additions and 4 deletions

View file

@ -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

View file

@ -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"
}
}

View file

@ -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
};

View file

@ -6,6 +6,12 @@ const {
parseZoneCatalogHtml
} = require("./parse");
const {
browserFetchNearby,
browserFetchZoneCatalog,
isBrowserFallbackAvailable
} = require("./browser-fallback");
const DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org";
const DEFAULT_BROWSER_HEADERS = {
@ -160,8 +166,16 @@ function buildNearbySearchParams(options = {}) {
}
async function fetchZoneCatalog(options = {}) {
const html = await fetchText(SEARCH_ZONE_URL, options);
return parseZoneCatalogHtml(html);
try {
const html = await fetchText(SEARCH_ZONE_URL, options);
return parseZoneCatalogHtml(html);
} catch (error) {
if (error.statusCode === 403 && isBrowserFallbackAvailable()) {
const html = await browserFetchZoneCatalog();
return parseZoneCatalogHtml(html);
}
throw error;
}
}
async function fetchNearbyMap(params, options = {}) {
@ -173,7 +187,14 @@ async function fetchNearbyMap(params, options = {}) {
}
}
return fetchJson(url.toString(), options);
try {
return await fetchJson(url.toString(), options);
} catch (error) {
if (error.statusCode === 403 && isBrowserFallbackAvailable()) {
return browserFetchNearby(params);
}
throw error;
}
}
function sortNearbyItems(items) {
@ -343,6 +364,7 @@ module.exports = {
buildNearbySearchParams,
fetchZoneCatalog,
findZoneMatches,
isBrowserFallbackAvailable,
normalizeNearbyItem,
parseZoneCatalogHtml,
searchNearbyByCoordinates,