diff --git a/docs/superpowers/plans/2026-07-28-cu-app-user-agent-recovery.md b/docs/superpowers/plans/2026-07-28-cu-app-user-agent-recovery.md new file mode 100644 index 00000000..284e0867 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-cu-app-user-agent-recovery.md @@ -0,0 +1,121 @@ +# CU App User-Agent Recovery Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** PocketCU 공식 앱 User-Agent 표식을 사용해 CU 재고 직접 조회를 복구하고 불필요한 Zyte 폴백을 중단한다. + +**Architecture:** 기존 `requestCuJson`의 direct-first 구조와 degraded 오류 계약은 유지한다. CU JSON 요청 공통 헤더에만 공식 앱 표식이 포함된 Android WebView User-Agent를 추가해 CU upstream 식별 조건을 충족한다. + +**Tech Stack:** TypeScript, Vitest, Hono, Cloudflare Workers, GitHub Actions + +--- + +### Task 1: CU 공식 앱 User-Agent 회귀 테스트 + +**Files:** + +- Modify: `tests/services/cu/client.test.ts` + +- [ ] **Step 1: 실패하는 테스트 작성** + +`primeCuStockDisplay` describe에 다음 테스트를 추가한다. + +```ts +it('공식 앱 식별자가 포함된 User-Agent를 전송한다', async () => { + mockFetch.mockResolvedValue(new Response(JSON.stringify({ areaList: [] }))); + + await primeCuStockDisplay(); + + const options = mockFetch.mock.calls[0][1] as RequestInit; + expect(new Headers(options.headers).get('user-agent')).toMatch(/;BGFCU$/); +}); +``` + +- [ ] **Step 2: 단일 테스트 파일로 RED 확인** + +Run: + +```bash +npx vitest run tests/services/cu/client.test.ts --maxWorkers=1 --no-file-parallelism +``` + +Expected: 새 테스트가 현재 User-Agent 부재로 실패한다. + +### Task 2: 최소 구현과 순차 검증 + +**Files:** + +- Modify: `src/services/cu/client.ts` +- Test: `tests/services/cu/client.test.ts` + +- [ ] **Step 1: CU 공통 헤더에 앱 User-Agent 추가** + +`CU_DEFAULT_HEADERS`에 다음 값을 추가한다. + +```ts +'User-Agent': + 'Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.0.0 Mobile Safari/537.36;BGFCU', +``` + +- [ ] **Step 2: 단일 테스트 파일로 GREEN 확인** + +Run: + +```bash +npx vitest run tests/services/cu/client.test.ts --maxWorkers=1 --no-file-parallelism +``` + +Expected: PASS. + +- [ ] **Step 3: 품질 검사를 한 번에 하나씩 실행** + +Run in order: + +```bash +npm run format:check +npm run lint +npm run lint:biome +npm run typecheck +npm run check:source-lines +npm test -- --maxWorkers=1 --no-file-parallelism +npm run build +``` + +Expected: 모든 명령이 종료 코드 0이고 경고·실패가 없다. + +- [ ] **Step 4: 구현 커밋** + +```bash +git add src/services/cu/client.ts tests/services/cu/client.test.ts docs/superpowers/plans/2026-07-28-cu-app-user-agent-recovery.md +git commit -m "fix: CU 공식 앱 요청으로 재고 조회 복구" +``` + +### Task 3: 배포와 운영 확인 + +**Files:** + +- No source file changes + +- [ ] **Step 1: 현재 브랜치를 push** + +```bash +git push origin main +``` + +- [ ] **Step 2: GitHub Actions를 순차 확인** + +`gh run list`와 `gh run watch`로 push가 시작한 CI 및 Cloudflare 배포가 성공할 때까지 확인한다. + +- [ ] **Step 3: 운영 CU 재고 확인** + +Run: + +```bash +curl -fsS 'https://mcp.aka.page/api/cu/inventory?keyword=%EC%82%BC%EA%B0%81%EA%B9%80%EB%B0%A5&storeCheck=false&size=3' +``` + +Expected: HTTP 200이며 `inventory.available=true`, `inventory.totalCount>0`, 상품 배열이 비어 있지 않다. + +- [ ] **Step 4: 운영 헬스체크 확인** + +운영 헬스체크에서 `cu.inventory` 상태가 `ok`이고 CU 재고 관련 degraded/failure가 없는지 확인한다. diff --git a/docs/superpowers/specs/2026-07-28-cu-app-user-agent-recovery-design.md b/docs/superpowers/specs/2026-07-28-cu-app-user-agent-recovery-design.md new file mode 100644 index 00000000..0826fded --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-cu-app-user-agent-recovery-design.md @@ -0,0 +1,46 @@ +# CU 공식 앱 User-Agent 기반 재고 조회 복구 설계 + +## 배경 + +CU 재고 API 직접 호출과 Zyte 폴백이 각각 `403 Request Blocked`, `520 Website Ban`으로 실패한다. 사용자 소유 Android 단말의 PocketCU 5.3.6 앱과 공개 웹 자산을 확인한 결과, 공식 앱은 WebView 기본 User-Agent 끝에 `;BGFCU`를 붙인다. + +동일 요청을 비교한 운영 재현 결과는 다음과 같다. + +- Android User-Agent만 사용: HTTP 403 +- 공식 앱 표식 `;BGFCU`를 추가: HTTP 200, `resp_cd=0000` +- 세션 쿠키, DeviceId, 로그인 토큰: 성공에 필요하지 않음 + +따라서 결제나 Zyte 키 문제가 아니라, CU가 공식 앱 요청을 식별하는 User-Agent 규칙을 기존 클라이언트가 반영하지 못한 것이 원인이다. + +## 접근 방식 비교 + +1. **공식 앱 User-Agent 표식만 추가 — 채택** + - 최소 변경이며 직접 호출을 복구한다. + - 단말·계정 식별값을 다루지 않는다. + - 직접 호출 성공 시 Zyte 사용량이 발생하지 않는다. +2. 재고 화면을 먼저 열어 세션 쿠키를 생성 + - 추가 요청과 쿠키 관리가 필요하지만 실측상 성공 조건이 아니다. +3. Zyte 브라우저 호출로 강제 전환 + - 비용과 지연이 증가하고 현재 Website Ban도 해결하지 못한다. + +## 구현 + +`src/services/cu/client.ts`의 CU JSON 요청 공통 헤더에 일반 Android WebView 형식의 User-Agent와 공식 앱 표식 `;BGFCU`를 추가한다. 특정 단말 ID, 쿠키, Authorization은 추가하지 않는다. 기존 direct-first 및 Zyte fallback 흐름은 변경하지 않는다. + +## 오류 처리 + +직접 호출이 향후 다시 400/403/429로 차단될 때만 기존 Zyte 폴백을 사용한다. upstream 차단이 계속되면 현재의 degraded 응답 계약을 그대로 유지한다. + +## 테스트와 완료 조건 + +1. 회귀 테스트는 CU 재고 직접 요청의 User-Agent가 `;BGFCU`로 끝나는지 검증한다. +2. 해당 테스트가 구현 전 실패하고 구현 후 통과해야 한다. +3. CU 클라이언트 테스트, 전체 테스트, lint, 타입 검사, 빌드를 한 번에 하나씩 실행한다. +4. 배포 후 운영 `/api/cu/inventory`가 `available: true`, 상품 결과를 반환하는지 확인한다. +5. 운영 헬스체크에서 `cu.inventory`가 더 이상 degraded가 아닌지 확인한다. + +## 범위 제외 + +- PocketCU 로그인 토큰 또는 사용자 단말 정보 수집 +- CU 외 서비스의 User-Agent 변경 +- 불필요한 Zyte 브라우저 자동화 diff --git a/src/services/cu/client.ts b/src/services/cu/client.ts index ae9f4cf7..c4991a4e 100644 --- a/src/services/cu/client.ts +++ b/src/services/cu/client.ts @@ -54,6 +54,8 @@ const CU_DEFAULT_HEADERS = { 'Content-Type': 'application/json', Accept: 'application/json, text/javascript, */*; q=0.01', 'X-Requested-With': 'XMLHttpRequest', + 'User-Agent': + 'Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.0.0 Mobile Safari/537.36;BGFCU', } as const; const CU_WEB_DEFAULT_HEADERS = { diff --git a/tests/services/cu/client.test.ts b/tests/services/cu/client.test.ts index 097ee2f4..69330412 100644 --- a/tests/services/cu/client.test.ts +++ b/tests/services/cu/client.test.ts @@ -411,6 +411,16 @@ describe('primeCuStockDisplay', () => { expect.objectContaining({ method: 'POST' }), ); }); + + it('공식 앱 식별자가 포함된 User-Agent를 전송한다', async () => { + mockFetch.mockResolvedValue(new Response(JSON.stringify({ areaList: [] }))); + + await primeCuStockDisplay(); + + const options = mockFetch.mock.calls[0][1] as RequestInit; + const userAgent = new Headers(options.headers).get('user-agent') || ''; + expect(userAgent).toMatch(/;BGFCU$/); + }); }); describe('fetchCuStock', () => {