mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
[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:
parent
55e565cebb
commit
0d0497719d
4 changed files with 248 additions and 4 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
packages/blue-ribbon-nearby/src/browser-fallback.js
Normal file
199
packages/blue-ribbon-nearby/src/browser-fallback.js
Normal 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
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue