8000 feat: 올리브영 브라우저 없는 HTTP 중계 추가 by hmmhmmhm · Pull Request #234 · hmmhmmhm/daiso-mcp · GitHub
[go: up one dir, main page]

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
37 changes: 37 additions & 0 deletions docs/oliveyoung-http-relay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# 올리브영 HTTP 중계

브라우저를 실행하지 않고 맥에서 공식 조회 API를 호출하는 선택 모드입니다. 기존 REST/MCP/CLI와 중계 인증·캐시·호출 한도·원장을 유지합니다. 맥의 전원·네트워크·중계 프로세스는 계속 필요합니다. 기존 GUI LaunchAgent의 로그인 시작 조건까지 변경하는 기능은 아닙니다.

## 실행 설정

Python3 표준 라이브러리만 사용하며 별도 pip 패키지나 쿠키 파일은 필요하지 않습니다. 아래 두 값을 기존 비공개 relay.env에 추가합니다.

```sh
OY_RELAY_TRANSPORT=http
OY_HTTP_PYTHON=/opt/homebrew/bin/python3
```

Python 경로는 설치 환경에 맞춥니다. 설정을 생략하면 기존 browser 모드이며, http 모드가 실패해도 자동으로 브라우저를 띄우지 않습니다. 운영 토큰이나 개인 프로필은 Python 프로세스에 전달하지 않습니다.

## 확인할 기능

- 상품 검색: product-search-v3
- 매장 검색: find-store
- 상품 식별자 조회: stock-goods-info-v3
- 매장별 재고: stock-stores

중계 health에서 `transport: http`, `browserRequired: false`, `pages: 0`, `state: ready`를 확인합니다. ready는 실행 준비 상태이며 공식 서비스의 모든 요청 성공을 보장하는 표시는 아닙니다. 실제 상품·매장·재고도 함께 검증해야 합니다.

고정 공식 주소와 네 경로만 허용합니다. 리다이렉트, HTTP200이 아닌 응답, 잘못된 JSON/성공 형식, 응답 용량 초과 및 시간 초과는 실패로 반환합니다. 차단 HTML을 재고0으로 바꾸지 않습니다. 네트워크 읽기15초, 자식 프로세스18초, 응답2MiB 상한이며 별도 외부 요청 재시도는 하지 않습니다.

## 전환·되돌리기

원장과 비밀 설정을 먼저 권한600인 비공개 위치에 백업합니다. 진행 요청이 없을 때 기존 중계를 정상 종료하고 소유 브라우저의 종료를 확인한 뒤 코드와 설정을 반영합니다. quota.json이나 인증 값을 초기화하지 않습니다.

HTTP 조회가 차단되면 원인을 확인하고 `OY_RELAY_TRANSPORT=browser`로 변경해 중계를 재시작할 수 있습니다. 브라우저 모드에는 기존 GUI 환경과 Playwright 설치가 필요합니다. 쿠키나 브라우저 인증을 HTTP 모드에 복사하여 재사용하지 않습니다.

## 2026-10-06 실측

같은 맥에서 쿠키 없는 Python urllib는 네 API의 HTTP200/SUCCESS를 반환했고 Node fetch·Node https·기본 curl은 403을 받았습니다. Python도 필수 헤더를 제거하면403이었습니다. JSON 공백/문자 인코딩 변경은 원인이 아니었으며 정확한 차단 기준을 TLS만으로 확정하지 않습니다.

브라우저 중계와 동일 조건에서 상품 코드·상세 식별자·매장 코드·실제 재고 필드를 대조했습니다. 명동2가점 핸드크림 일반9/오늘드림7, 선크림 일반54/오늘드림43 및 픽업/취급 상태가 일치했습니다. 이 수량은 확인 당시 관측값이며 이후 변할 수 있습니다. HTTP 성공 여부는 제공자 정책·네트워크·클라이언트 버전에 따라 달라질 수 있으므로 장기간 무차단을 보장하지 않습니다.
14 changes: 14 additions & 0 deletions docs/superpowers/plans/2026-10-06-oliveyoung-http.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# 올리브영 HTTP 중계 구현 계획

**Goal:** 기존 맥 중계에서 브라우저 없이 네 API 조회.
**Architecture:** 고정 Python urllib helper와 Node lifecycle을 추가하고 start의 명시적인 http/browser 선택만 변경.
**Tech Stack:** Node execFile, Python3 stdlib, Vitest, unittest.

- [ ] tests/relay/http-lifecycle.test.ts tests/relay/transport-lifecycle.test.ts tests/relay/oliveyoung-http-python.test.ts와 Python helper 오프라인 fixture 테스트에서 성공·403·redirect·size·malformed·timeout·invalid path·close를 먼저 실패시킨다. 실행: npx vitest run tests/relay/http-lifecycle.test.ts tests/relay/transport-lifecycle.test.ts tests/relay/oliveyoung-http-python.test.ts; python3 tests/relay/test_oliveyoung_http.py.
- [ ] scripts/relay/oliveyoung-http.py에 고정 경로/헤더/200 SUCCESS 검증 및2MiB/15초 제한을 구현한다.
- [ ] scripts/relay/http-lifecycle.ts에 execFile18초 제한, 비밀 없는 환경, ready/active/closed health를 구현하고 위 테스트를 통과시킨다.
- [ ] scripts/relay/transport-lifecycle.ts에 모드선택을 추가하고 start.ts를 연결한다. http는 browser import/launch를 하지 않으며 기존 browser default 유지. 알 수 없는 모드 거부.
- [ ] vitest.config.ts coverage 대상에 새 TypeScript 모듈을 추가하고 npm run check; npm run test:coverage; npm run build; npm audit 실행. 새로운 측정 대상도100%.
- [ ] docs/oliveyoung-http-relay.md에 운영 설정·실측·실패/rollback 조건을 기록하고 독립 리뷰를 수행한다.
- [ ] PR 생성·CI확인·병합 후 원장과 인증을 보존하여 맥 runtime에 코드와 OY_RELAY_TRANSPORT/http 및 OY_HTTP_PYTHON을 반영. 사전 백업과 진행 요청0·소유 브라우저 종료 확인.
- [ ] 운영 REST/MCP/공개CLI 상품·매장·재고 일치 및 최종health/http/pages0을 검증하고 프로젝트 기록·완료 알림을 남긴다. Worker/CLI 코드 변화는 없어 새CLI 버전 발행은 필요하지 않다.
13 changes: 13 additions & 0 deletions docs/superpowers/specs/2026-10-06-oliveyoung-http-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# 올리브영 브라우저 없는 맥 중계

목표: 기존 상품·매장·상세·매장재고 계약을 유지하며 브라우저 없는 실행을 지원한다.

실측: 같은 맥의 Python3 urllib는 쿠키 없이 네 API SUCCESS, Node fetch/https와 curl은 403. 최소 헤더는 Python도 403. 헤더가 필요하며 HTTP 클라이언트에 따라 차이가 있다. TLS만 원인이라고 확정하지 않는다.

선택: 기존 Mac 중계의 수송만 Python 표준 HTTP 클라이언트로 교체하는 명시적인 OY_RELAY_TRANSPORT=http 옵션. 기본 browser는 유지하여 기존 설치 호환 및 롤백 제공. 자동 브라우저 fallback은 하지 않는다. Worker 직접 전환은 같은 환경 검증 근거가 부족하고, 자동 fallback은 브라우저 제거 목표를 충족하지 않는다.

Node가 고정 Python helper를 실행한다. 외부 Python 패키지·쿠키·개인 프로필 없음. 고정 origin과 네 path만 허용, HTTP200/SUCCESS 및 객체 data만 허용. 리다이렉트 거부, 응답2MiB/네트워크15초/프로세스18초 상한. 비밀 환경 변수를 자식에게 전달하지 않는다. 오류는 재고0이 아닌 기존502로 반환한다.

start.ts는 mode에 맞는 lifecycle을 선택하고 HTTP일 때 브라우저 모듈을 로드하지 않는다. 기존 인증/캐시/coalescing/직렬처리/원장/한도/로그는 유지한다. health는 transport/http, pages0, browserRequiredfalse 및 상태를 표시한다. 실행 Python 경로는 OY_HTTP_PYTHON으로 지정한다.

검증: Python helper의 allowlist/redirect/status/envelope/size/error를 오프라인 fixture로 테스트하고 Node 실행·상태·종료를 검증. 실환경 네 API3회 및 상품2종의 실제 매장재고를 기존 브라우저와 대조. 전체 check/coverage100/build/audit 통과 후 PR 병합, 맥 runtime 백업·원장 보존·종료 확인 후 http 모드 전환. REST/MCP/게시CLI/health 및 소유 브라우저 없음 확인. 장기 차단 없음은 보장하지 않는다.
103 changes: 103 additions & 0 deletions scripts/relay/http-lifecycle.ts
F438
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/** 고정 Python 헬퍼의 시작·요청·종료를 브라우저 없이 관리합니다. */
import { execFile, type ChildProcess } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import type { BrowserRunner } from './oliveyoung.js';
const LIMIT = 2 * 1024 * 1024;
export function createHttpLifecycle() {
const python = process.env.OY_HTTP_PYTHON || 'python3';
const helper = fileURLToPath(new URL('./oliveyoung-http.py', import.meta.url));
// 토큰·프록시·Python 설정은 전달하지 않고 실행 파일 탐색 경로만 허용합니다.
const env = { PATH: process.env.PATH || '/usr/bin:/bin' };
let state: 'starting' | 'ready' | 'closed' = 'starting';
const isClosed = () => state === 'closed';
let child: ChildProcess | undefined;
let pending: Promise<string> | undefined;
let starting: Promise<void> | undefined;
let active = false;
let calls = 0;
const execute = (check: boolean, input?: string) => {
pending = new Promise<string>((resolve, reject) => {
child = execFile(
python,
['-I', helper, ...(check ? ['--check'] : [])],
{
env,
timeout: 18000,
killSignal: 'SIGKILL',
maxBuffer: LIMIT,
encoding: 'utf8',
},
(error, stdout) => {
child = undefined;
if (error || isClosed()) reject(new Error('HTTP relay request failed'));
else resolve(stdout);
},
);
// 출력 파이프 종료는 콜백에서 처리하고 입력 오류는 비밀 없이 보고합니다.
child.stdin!.on('error', () => child?.kill('SIGKILL'));
child.stdin!.end(input);
});
return pending;
};
const start = async () => {
if (isClosed()) throw new Error('HTTP lifecycle closed');
if (state === 'ready') return;
starting ??= (async () => {
const output = await execute(true);
try {
if (JSON.parse(output)?.ready !== true) throw new Error();
} catch {
throw new Error('HTTP relay request failed');
}
if (isClosed()) throw new Error('HTTP lifecycle closed');
state = 'ready';
})();
await starting;
};
const run: BrowserRunner = async (path, body) => {
if (isClosed()) throw new Error('HTTP lifecycle closed');
if (state !== 'ready') throw new Error('HTTP lifecycle not ready');
if (active) throw new Error('HTTP lifecycle already active');
active = true;
calls++;
try {
const input = JSON.stringify({ path, body });
if (Buffer.byteLength(input) > LIMIT) throw new Error();
const result = JSON.parse(await execute(false, input));
if (
!result ||
result.status !== 'SUCCESS' ||
!result.data ||
typeof result.data !== 'object' ||
Array.isArray(result.data)
)
throw new Error();
return result;
} catch {
throw new Error('HTTP relay request failed');
} finally {
active = false;
}
};
return {
start,
run,
async close() {
state = 'closed';
child?.kill('SIGKILL');
await pending?.catch(() => undefined);
},
status: () => ({
state,
transport: 'http',
browserRequired: false,
pages: 0,
active,
calls,
totalCalls: calls,
rotations: 0,
rssBytes: 0,
reason: state === 'closed' ? 'shutdown' : 'http',
}),
};
}
78 changes: 78 additions & 0 deletions scripts/relay/oliveyoung-http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""쿠키와 리다이렉트 없이 네 개의 공식 조회 경로만 호출합니다."""
import json
import sys
import urllib.request

ORIGIN = 'https://www.oliveyoung.co.kr'
PATHS = frozenset([
'/oystore/api/storeFinder/find-store',
'/oystore/api/stock/product-search-v3',
'/oystore/api/stock/stock-goods-info-v3',
'/oystore/api/stock/stock-stores',
])
LIMIT = 2 * 1024 * 1024
HEADERS = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36',
'Origin': ORIGIN,
'Referer': ORIGIN + '/',
'Accept-Language': 'ko-KR,ko;q=0.9',
}

class RelayError(Exception):
"""상위 응답 본문을 노출하지 않는 고정 오류입니다."""

class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None

def fetch(path, body):
if not isinstance(path, str) or path not in PATHS or not isinstance(body, dict):
raise RelayError('Invalid request')
try:
payload = json.dumps(body, ensure_ascii=False, allow_nan=False).encode('utf-8')
if len(payload) > LIMIT:
raise RelayError('Invalid request')
request = urllib.request.Request(ORIGIN + path, data=payload, headers=HEADERS, method='POST')
# 환경 프록시와 리다이렉트를 모두 차단합니다.
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
with opener.open(request, timeout=15) as response:
if response.status != 200:
raise RelayError('Upstream unavailable')
raw = response.read(LIMIT + 1)
if len(raw) > LIMIT:
raise RelayError('Upstream unavailable')
result = json.loads(raw)
if not isinstance(result, dict) or result.get('status') != 'SUCCESS' or not isinstance(result.get('data'), dict):
raise RelayError('Upstream unavailable')
return result
except Exception:
raise RelayError('Upstream unavailable') from None

def main():
if sys.argv[1:] == ['--check']:
print('{"ready":true}')
return 0
try:
if sys.argv[1:]:
raise RelayError('Invalid request')
raw = sys.stdin.buffer.read(LIMIT + 1)
if len(raw) > LIMIT:
raise RelayError('Invalid request')
request = json.loads(raw)
if not isinstance(request, dict) or set(request) != {'path', 'body'}:
raise RelayError('Invalid request')
result = fetch(request['path'], request['body'])
output = json.dumps(result, ensure_ascii=False, separators=(',', ':'), allow_nan=False).encode('utf-8')
if len(output) > LIMIT:
raise RelayError('Upstream unavailable')
sys.stdout.buffer.write(output)
return 0
except Exception:
print('HTTP relay request failed', file=sys.stderr)
return 1

if __name__ == '__main__':
sys.exit(main())
17 changes: 11 additions & 6 deletions scripts/relay/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ import { Readable } from 'node:stream';
import { pathToFileURL } from 'node:url';
import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { createBrowserLifecycle } from './lifecycle.js';
import { launchGuardedBrowser } from './supervisor.js';
import { createTransportLifecycle } from './transport-lifecycle.js';
import { createFileQuota } from './quota.js';
import { createOliveyoungRelay } from './oliveyoung.js';

Expand All @@ -31,9 +30,15 @@ async function main() {
60000,
);
heartbeat.unref();
const lifecycle = createBrowserLifecycle(
() => launchGuardedBrowser(join(stateDir, 'browser-owner.json')),
(outcome) => log.append({ stage: 'lifecycle', operation: 'browser', outcome }),
const lifecycle = await createTransportLifecycle(
process.env.OY_RELAY_TRANSPORT,
stateDir,
(outcome) =>
log.append({
stage: 'lifecycle',
operation: process.env.OY_RELAY_TRANSPORT || 'browser',
outcome,
}),
);
try {
await lifecycle.start();
Expand Down Expand Up @@ -101,7 +106,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
main().catch(async () => {
logger?.append({ stage: 'process', outcome: 'startup-failed' });
await logger?.flush();
console.error('올리브영 릴레이 시작 실패: 토큰, 포트 및 GUI 브라우저 접속을 확인하세요.');
console.error('올리브영 릴레이 시작 실패: 토큰, 포트 및 전송 환경을 확인하세요.');
process.exitCode = 1;
});
}
20 changes: 20 additions & 0 deletions scripts/relay/transport-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/** HTTP 경로에서는 Playwright 모듈 자체를 불러오지 않습니다. */
import { join } from 'node:path';
export async function createTransportLifecycle(
mode: string | undefined,
stateDir: string,
onEvent?: (reason: string) => void | Promise<void>,
) {
if (mode === 'http') {
const { createHttpLifecycle } = await import('./http-lifecycle.js');
return createHttpLifecycle();
}
if (mode !== undefined && mode !== 'browser')
throw new Error('OY_RELAY_TRANSPORT 값이 잘못되었습니다.');
const { createBrowserLifecycle } = await import('./lifecycle.js');
const { launchGuardedBrowser } = await import('./supervisor.js');
return createBrowserLifecycle(
() => launchGuardedBrowser(join(stateDir, 'browser-owner.json')),
onEvent,
);
}
Loading
Loading
0