From 466eea0c94b89d8d478a719efa925329a3de9e80 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Thu, 22 Oct 2026 13:50:03 +0900 Subject: [PATCH 1/5] =?UTF-8?q?docs:=20=EA=B2=BD=EB=A1=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EB=AC=B4=EC=84=A4=EC=B9=98=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=EC=99=80=20=EA=B2=80=EC=A6=9D=20=EA=B3=84?= =?UTF-8?q?=ED=9A=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-10-22-path-only-browse.md | 32 +++++++++++++++++++ .../2026-10-22-path-only-browse-design.md | 27 ++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-22-path-only-browse.md create mode 100644 docs/superpowers/specs/2026-10-22-path-only-browse-design.md diff --git a/docs/superpowers/plans/2026-10-22-path-only-browse.md b/docs/superpowers/plans/2026-10-22-path-only-browse.md new file mode 100644 index 00000000..63b1ed49 --- /dev/null +++ b/docs/superpowers/plans/2026-10-22-path-only-browse.md @@ -0,0 +1,32 @@ +# 경로 기반 무설치 웹 조회 구현 계획 + +**Goal:** 웹 읽기용 모든 조회와 안내를 쿼리 없는 HTML 경로로 전환한다. +**Architecture:** 허용된 읽기 경로의 codec, 내부 API dispatch, 기존 HTML 렌더러와 후속 링크, 발견 안내를 분리한다. JSON API와 MCP 계약은 유지한다. +**Tech Stack:** TypeScript, Hono, Vitest, Cloudflare Workers. + +## 1. 경로 변환과 라우트 + +- [ ] `tests/pages/browse.test.ts`에 `/browse/daiso/products/핫식스/pageSize/3` → `/api/daiso/products?q=핫식스&pageSize=3&format=html` 왕복, 모든 지원 읽기 경로, 특수문자, 거절 대상 실패 회귀를 작성한다. +- [ ] `npx vitest run tests/pages/browse.test.ts`로 실패를 확인한다. +- [ ] `src/pages/browsePaths.ts`에 허용 경로 목록과 변환을 구현한다. `src/pages/browse.ts`에 목록 HTML과 GET 내부 dispatch를 구현하고 `src/index.ts`에 등록한다. +- [ ] 동일 테스트를 통과하고 실제 API 입력·오류·호출 제한의 동등성 회귀를 추가한다. +- [ ] `git commit -m 'feat: 쿼리 없는 웹 조회 경로 추가'`. + +## 2. 실제 결과와 후속 링크 + +- [ ] `tests/pages/apiResult.test.ts`, `productCards.test.ts`에 실제 상품 상세·재고·페이지 링크가 `/browse/`이고 쿼리가 없다는 실패 회귀를 추가한다. +- [ ] `src/pages/apiResult.ts`에서 요청·후속 링크를 경로 방식으로 표시하고 데이터·이미지·오류·보안 헤더는 유지한다. +- [ ] 테스트를 실행하고 `git commit -m 'feat: HTML 조회 후속 링크를 경로 방식으로 통일'`. + +## 3. 안내 전환 + +- [ ] pages 테스트에 첫 링크 및 모든 웹 조회 예시가 browse라는 실패 회귀를 추가한다. +- [ ] `src/pages/agentStart.ts`, `discovery.ts`, `prompt*.ts`, README, 웹 사용 docs를 갱신한다. 안내 버전은 `2026-10-22.1`로 올린다. 기존 ALIGNS 접근성 기준은 이번 승인 범위인 browse 우선과 구형 HTML 하위호환을 반영한다. +- [ ] `npm run build:discovery`로 정적 발견 파일을 재생성하고 `git commit -m 'docs: 무설치 웹 조회 안내를 browse 경로로 전환'`. + +## 4. 통합과 운영 + +- [ ] `npm run check`, `npm run test:coverage`, `npm run build`, `npm run build:discovery`, `git diff --check`를 실행한다. +- [ ] 독립 코드 리뷰 후 PR을 생성·첨부하고 모든 GitHub 필수 검사를 확인한다. +- [ ] 병합·배포 후 상품 검색·후속 재고·기존 JSON·MCP 10개·CLI를 검증한다. 웹 URL 직접 열기와 링크 이동의 결과를 구분한다. +- [ ] 개인 PROJECT.md에 근거·검증·외부 제한을 기록한다. diff --git a/docs/superpowers/specs/2026-10-22-path-only-browse-design.md b/docs/superpowers/specs/2026-10-22-path-only-browse-design.md new file mode 100644 index 00000000..073aff1f --- /dev/null +++ b/docs/superpowers/specs/2026-10-22-path-only-browse-design.md @@ -0,0 +1,27 @@ +# 경로 기반 무설치 웹 조회 + +사용자는 2026-10-22 제안한 `/browse/` 방식으로 무설치 웹 조회 전체 전환을 승인했습니다. 기존 MCP 프로토콜, JSON API, 구형 HTML URL은 유지합니다. 이 문서는 구현 선택을 구체화하며 추가 승인 대기가 아닙니다. + +## 계약 + +- 기본 주소는 `/browse/{service}/{operation}/{주요 검색 조건}`입니다. 예: `/browse/daiso/products/핫식스`, `/browse/daiso/stores/강남역`, `/browse/daiso/inventory/105603886/강남역`. +- 부가 조건은 경로의 이름/값 쌍입니다. 예: `/browse/daiso/products/수납박스/pageSize/3`, `/browse/daiso/products/펜/page/2/pageSize/10`. +- 주요 검색어를 생략하고 필터만 사용할 때는 `/browse/query/{service}/{operation}/{이름}/{값}`을 사용합니다. 예: `/browse/query/daiso/stores/sido/서울/gugun/마포구`. +- `/browse/daiso/product/{id}`와 `/browse/daiso-jp/product/{id}`는 상품 상세입니다. 상품 검색의 숫자 검색어와 구분합니다. +- 모든 현재 지원 공개 읽기 API를 명시적 목록으로 허용합니다. 관리·건강검사·요청 제출·지원 중단 롯데마트·임의 목적지 프록시는 노출하지 않습니다. +- 외부 요청은 경로에 조건을 담습니다. `format=html`은 필요 없고 Accept와 무관하게 HTML을 반환합니다. 추적 쿼리는 조회 조건으로 사용하지 않습니다. +- 경로 데이터는 UTF-8로 한 번만 인코딩/디코딩합니다. 슬래시·퍼센트·점 세그먼트·빈 값도 가역 변환하며 잘못된 인코딩과 중복 이름은 외부 호출 전에 400으로 거절합니다. + +## 처리와 보호 + +Browse 라우트는 동일 출처의 허용된 `/api/` 경로로 내부 dispatch합니다. 외부 HTTP 요청을 중복하지 않고 기존 입력 검증·캐시·일일 한도·중계·운영 인증을 그대로 통과합니다. 호출 제한은 한 번만 차감하며 인증·한도·원본 장애를 성공이나 품절로 바꾸지 않습니다. POST는 지원하지 않습니다. + +기존 HTML 렌더러가 모든 필드·정상 0·미확인 수량·오류 상태와 Retry-After를 보존합니다. 상세·재고·진열 위치·페이지 이동은 실제 식별자와 현재 필터를 보존하는 browse 링크입니다. 원본 상품 이미지 규칙과 HTML escape/CSP는 유지합니다. + +## 발견 안내 + +README 접힘 안내, 루트, 최신 버전 안내, llms.txt, prompt 전체 서비스 예시, service-reference 및 관련 웹 사용 안내를 경로 방식으로 통일합니다. 버전을 갱신하고 `/guide/latest`가 현재 안내를 가리키도록 유지합니다. JSON·OpenAPI·CLI를 사용하는 개발자 계약은 유지합니다. README 이미지와 배너는 변경하지 않습니다. + +## 검증과 한계 + +전체 공개 읽기 경로와 변환의 왕복·한글·특수문자·필터·페이지·거절 경로를 검사합니다. 실제 API 경계를 이용해 browse/JSON의 데이터·오류·quota 동등성 및 HTML 후속 링크를 검증합니다. 공개 MCP 도구는 10개 유지합니다. 전체 check, 기존 coverage gate 100%, build/discovery, 독립 리뷰, GitHub 검사 후 병합·배포합니다. 운영 WebFetch의 직접 URL 열기와 결과 링크 이동을 각각 검증하며, 외부 도구 접근 정책의 완전한 해결을 보장하지 않습니다. 일본 공식 매장 사이트 중단 등 기존 외부 장애를 새 기능으로 숨기지 않습니다. From 0c028ddb8081e723c8d0c913ddbead3b6a2acaf4 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Thu, 22 Oct 2026 13:55:03 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=EC=BF=BC=EB=A6=AC=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EC=9B=B9=20=EC=A1=B0=ED=9A=8C=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.ts | 2 + src/pages/browse.ts | 46 +++++++++ src/pages/browsePaths.ts | 131 +++++++++++++++++++++++++ tests/pages/browse-integration.test.ts | 35 +++++++ tests/pages/browse.test.ts | 68 +++++++++++++ 5 files changed, 282 insertions(+) create mode 100644 src/pages/browse.ts create mode 100644 src/pages/browsePaths.ts create mode 100644 tests/pages/browse-integration.test.ts create mode 100644 tests/pages/browse.test.ts diff --git a/src/index.ts b/src/index.ts index 9db5a26c..b52a0406 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import { registerBrowseRoutes } from './pages/browse.js'; import { applyCompactTools } from './core/compact/server.js'; import { COMPACT_CATALOG, PUBLIC_TOOL_NAMES } from './core/compact/catalog.js'; import { apiResultHtml } from './pages/apiResult.js'; @@ -362,6 +363,7 @@ registerHealthRoutes(app); registerRouteHealthRoutes(app); registerRateLimitStatsRoutes(app); registerServiceRoutes(app); +registerBrowseRoutes(app); // 설치 없는 첫 조회 안내 app.get('/guide/:version', (c) => createVersionedGuideResponse(new URL(c.req.url).origin, c.req.param('version'))); diff --git a/src/pages/browse.ts b/src/pages/browse.ts new file mode 100644 index 00000000..8eef80a2 --- /dev/null +++ b/src/pages/browse.ts @@ -0,0 +1,46 @@ +/** 기존 API의 보호·캐시·응답을 그대로 사용하는 웹 조회 진입점입니다. */ +import type { Hono } from 'hono'; +import type { AppBindings } from '../api/response.js'; +import { BROWSE_ROUTES, browseApiUrl } from './browsePaths.js'; + +function page(content: string, status = 200): Response { + return new Response( + `DAISO MCP 웹 조회
${content}

최신 사용 안내 · 전체 웹 조회

`, + { + status, + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + 'Content-Security-Policy': "default-src 'none'; base-uri 'none'; frame-ancestors 'none'", + 'X-Content-Type-Options': 'nosniff', + }, + }, + ); +} + +export function registerBrowseRoutes(app: Hono<{ Bindings: AppBindings }>): void { + app.get('/browse', () => + page( + `

설치 없이 웹으로 조회하기

open/WebFetch로 아래 링크를 여세요. 모든 결과는 HTML입니다. format이나 쿼리 문자열은 필요 없습니다.

수납박스 상품 3개 조회 · 핫식스 검색 · 강남역 매장 검색

주요 검색어 뒤에는 /page/2/pageSize/10처럼 조건 이름과 값을 붙입니다. 주요 검색어 없이 조건만 사용하려면 /browse/query/서비스/기능/조건/값을 사용하세요. 상품 검색은 재고 확인이 아닙니다. 실제 상품 ID와 사용자 지역으로 재고를 별도 조회하세요.

`, + ), + ); + app.all('/browse/*', async (c) => { + if (c.req.method !== 'GET') return page('

GET 조회만 지원합니다.

', 405); + let target: URL | null; + try { + target = browseApiUrl(new URL(c.req.url)); + } catch { + return page( + '

조회 조건이 올바르지 않습니다.

경로의 이름·값 쌍, 중복 조건과 문자 인코딩을 확인해주세요.

', + 400, + ); + } + if (!target) return page('

지원하지 않는 웹 조회 경로입니다.

', 404); + const headers = new Headers(c.req.raw.headers); + headers.delete('host'); + return app.fetch( + new Request(target, { method: 'GET', headers, signal: c.req.raw.signal }), + c.env, + ); + }); +} diff --git a/src/pages/browsePaths.ts b/src/pages/browsePaths.ts new file mode 100644 index 00000000..2f065584 --- /dev/null +++ b/src/pages/browsePaths.ts @@ -0,0 +1,131 @@ +/** 공개 읽기 API와 쿼리 없는 웹 경로의 가역 변환입니다. */ +import { ACTION_QUERY_DEFINITIONS } from '../api/actionsProxy.js'; + +interface BrowseRoute { + api: string; + path: string; + title: string; + positional: string[]; + detail: boolean; +} + +function positionalKeys(path: string): string[] { + if (path.endsWith('/:productId')) return ['productId']; + if (path === '/api/daiso/inventory') return ['productId', 'keyword']; + if (path === '/api/daiso/display-location') return ['productId', 'storeCode']; + if (path.endsWith('/inventory')) return ['keyword', 'storeKeyword']; + if (path.endsWith('/products')) { + if (/\/daiso(?:-jp)?\//.test(path)) return ['q']; + return [path.includes('/seveneleven/') ? 'query' : 'keyword']; + } + if (path.endsWith('/stores') || path.endsWith('/theaters')) return ['keyword']; + if (path.endsWith('/popwords')) return ['label']; + if (path === '/api/places/search') return ['location', 'keyword']; + if (path === '/api/opinet/station') return ['id']; + return []; +} + +export const BROWSE_ROUTES: readonly BrowseRoute[] = ACTION_QUERY_DEFINITIONS.filter( + (def) => def.action !== 'submitDeveloperRequest', +).map((def) => ({ + api: def.targetPath, + path: def.targetPath.replace('/api/', '/browse/').replace('/products/:productId', '/product'), + title: def.summary, + positional: positionalKeys(def.targetPath), + detail: def.pathParam !== undefined, +})); + +/** URL 표준이 점 세그먼트와 빈 값을 삭제하지 않도록 구분합니다. */ +function encodeSegment(value: string): string { + return ['', '.', '..'].includes(value) + ? '~' + value + : encodeURIComponent(value).replaceAll('~', '%7E'); +} +function decodeSegment(value: string): string { + const decoded = ['~', '~.', '~..'].includes(value) ? value.slice(1) : decodeURIComponent(value); + if (decoded.length > 1024 || /[\p{Cc}]/u.test(decoded)) throw new Error('잘못된 경로 값입니다.'); + return decoded; +} + +/** JSON 및 구형 HTML URL을 검색 조건이 같은 browse 경로로 변환합니다. */ +export function toBrowsePath(source: URL): string | null { + const route = BROWSE_ROUTES.find((r) => + r.detail + ? source.pathname.startsWith(r.api.replace('/:productId', '/')) && + source.pathname.slice(r.api.indexOf('/:productId') + 1).length > 0 && + !source.pathname.slice(r.api.indexOf('/:productId') + 1).includes('/') + : r.api === source.pathname, + ); + if (!route) return null; + const params = new URLSearchParams(source.search); + for (const key of [...params.keys()]) { + if (key === 'format' || key === 'guide' || key.startsWith('utm_')) params.delete(key); + } + if (route.detail) { + params.set( + 'productId', + decodeURIComponent(source.pathname.slice(source.pathname.lastIndexOf('/') + 1)), + ); + } + const parts: string[] = []; + const primary = route.positional[0]; + const friendly = primary !== undefined && params.has(primary); + if (friendly) { + for (const key of route.positional) { + if (!params.has(key)) break; + parts.push(encodeSegment(params.get(key)!)); + params.delete(key); + } + } + for (const [key, value] of params) parts.push(encodeSegment(key), encodeSegment(value)); + const base = + friendly || params.size === 0 ? route.path : route.path.replace('/browse/', '/browse/query/'); + return base + (parts.length ? '/' + parts.join('/') : ''); +} + +/** 임의의 목적지를 받지 않고 등록된 공개 GET만 변환합니다. */ +export function browseApiUrl(source: URL): URL | null { + if (source.pathname.length > 8192) throw new Error('조회 경로가 너무 깁니다.'); + for (const key of source.searchParams.keys()) { + if (!key.startsWith('utm_') && !['guide', 'format', 'gclid', 'fbclid'].includes(key)) { + throw new Error('조회 조건은 쿼리 대신 경로에 넣어주세요.'); + } + } + const generic = source.pathname.startsWith('/browse/query/'); + const path = generic ? source.pathname.replace('/browse/query/', '/browse/') : source.pathname; + const route = [...BROWSE_ROUTES] + .sort((a, b) => b.path.length - a.path.length) + .find((r) => path === r.path || path.startsWith(r.path + '/')); + if (!route) return null; + const tail = path.slice(route.path.length); + const values = tail ? tail.slice(1).split('/').map(decodeSegment) : []; + const params = new URLSearchParams(); + if (!generic && values.length && route.positional.length) { + params.set(route.positional[0], values.shift()!); + if (route.positional.length === 2 && values.length % 2 === 1) + params.set(route.positional[1], values.shift()!); + } + if (values.length % 2 !== 0) throw new Error('조건 이름과 값을 한 쌍으로 입력해주세요.'); + for (let i = 0; i < values.length; i += 2) { + const key = values[i]; + if ( + !/^[A-Za-z][A-Za-z0-9]*$/.test(key) || + ['format', 'guide', 'action'].includes(key) || + params.has(key) + ) { + throw new Error('중복되거나 지원하지 않는 조건 이름입니다.'); + } + params.set(key, values[i + 1]); + } + let api = route.api; + if (route.detail) { + const id = params.get('productId'); + if (!id || ['.', '..'].includes(id)) throw new Error('상품 ID를 입력해주세요.'); + api = api.replace(':productId', encodeURIComponent(id)); + params.delete('productId'); + } + const target = new URL(api, source.origin); + params.set('format', 'html'); + target.search = params.toString(); + return target; +} diff --git a/tests/pages/browse-integration.test.ts b/tests/pages/browse-integration.test.ts new file mode 100644 index 00000000..5a451411 --- /dev/null +++ b/tests/pages/browse-integration.test.ts @@ -0,0 +1,35 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import app from '../../src/index.js'; +import { nextKstMidnightEpochSeconds, toKstDay } from '../../src/durableObjects/dailyRateLimiter.js'; + +afterEach(() => vi.unstubAllGlobals()); +it('실제 상품 API와 같은 상품·필터를 사용하고 외부 호출을 중복하지 않는다', async () => { + const fetcher = vi.fn(async () => Response.json({ resultSet: { result: [{ totalSize: 2, resultDocuments: [ + { PD_NO: '105603886', PDNM: '핫식스', PD_PRC: '1000', SOLD_OUT_YN: 'N' }, + ] }] } })); + vi.stubGlobal('fetch', fetcher); + const res = await app.request('/browse/daiso/products/핫식스/pageSize/1', { headers: { Accept: 'application/json' } }); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toContain('text/html'); + expect(await res.text()).toContain('105603886'); + expect(fetcher).toHaveBeenCalledTimes(1); + const raw = await app.request('/api/daiso/products?q=핫식스&pageSize=1'); + expect(raw.headers.get('Content-Type')).toContain('application/json'); + expect((await raw.json()).data.products[0].id).toBe('105603886'); +}); +it.each([true, false])('기존 일일 한도와 CF 신원을 한 번 적용하고 보호 응답을 보존한다: %s', async (allowed) => { + const now = Date.now(); + const quota = vi.fn(async (url: string) => url.endsWith('/blocked-events') ? new Response(null, { status: 204 }) : Response.json({ allowed, count: allowed ? 1 : 3000, remaining: allowed ? 2999 : 0, day: toKstDay(now), resetAt: nextKstMidnightEpochSeconds(now) })); + const names: string[] = []; + const env = { DAILY_RATE_LIMITER: { idFromName: (name: string) => { names.push(name); return { toString: () => 'a'.repeat(64) }; }, get: () => ({ fetch: quota }) } }; + const res = await app.request('/browse/gs25/products', { headers: { 'CF-Connecting-IP': '192.0.2.15' } }, env); + expect(res.status).toBe(allowed ? 400 : 429); + expect(res.headers.get('Content-Type')).toContain('text/html'); + expect(res.headers.get('X-RateLimit-Remaining')).toBe(allowed ? '2999' : '0'); + expect(quota.mock.calls.filter(([url]) => url.endsWith('/consume'))).toHaveLength(1); + expect(names[0]).not.toContain('192.0.2.15'); + if (!allowed) { + expect(res.headers.get('Retry-After')).not.toBeNull(); + expect(await res.text()).toContain('DAILY_RATE_LIMIT_EXCEEDED'); + } +}); diff --git a/tests/pages/browse.test.ts b/tests/pages/browse.test.ts new file mode 100644 index 00000000..c06013bb --- /dev/null +++ b/tests/pages/browse.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { ACTION_QUERY_DEFINITIONS } from '../../src/api/actionsProxy.js'; +import { toBrowsePath, browseApiUrl } from '../../src/pages/browsePaths.js'; +import app from '../../src/index.js'; + +const origin = 'https://mcp.aka.page'; +describe('쿼리 없는 웹 조회 경로', () => { + it.each([ + ['/api/daiso/products?q=핫식스&pageSize=3&format=html', '/browse/daiso/products/핫식스/pageSize/3'], + ['/api/daiso/stores?keyword=강남역', '/browse/daiso/stores/강남역'], + ['/api/daiso/inventory?productId=105603886&keyword=강남역&pageSize=2', '/browse/daiso/inventory/105603886/강남역/pageSize/2'], + ['/api/daiso/products/105603886', '/browse/daiso/product/105603886'], + ['/api/daiso-jp/products/4550746075917', '/browse/daiso-jp/product/4550746075917'], + ['/api/daiso/stores?sido=서울&gugun=마포구', '/browse/query/daiso/stores/sido/서울/gugun/마포구'], + ['/api/opinet/stations/around?lat=37&lng=127', '/browse/query/opinet/stations/around/lat/37/lng/127'], + ])('실제 조회 조건을 경로로 보존한다: %s', (api, expected) => { + const path = toBrowsePath(new URL(api, origin))!; + expect(decodeURIComponent(path)).toBe(expected); + expect(path).not.toContain('?'); + const target = browseApiUrl(new URL(path, origin))!; + const source = new URL(api, origin); + source.searchParams.set('format', 'html'); + expect(target.pathname).toBe(source.pathname); + expect(Object.fromEntries(target.searchParams)).toEqual(Object.fromEntries(source.searchParams)); + }); + it('모든 현재 지원 읽기 경로가 왕복 가능하고 쓰기·관리 경로를 제외한다', () => { + for (const def of ACTION_QUERY_DEFINITIONS.filter(d => d.action !== 'submitDeveloperRequest')) { + const url = new URL(def.targetPath.replace(':productId', '123'), origin); + url.searchParams.set('limit', '3'); + const path = toBrowsePath(url)!; + expect(path).toMatch(/^\/browse\//); + expect(browseApiUrl(new URL(path, origin))!.pathname).toBe(url.pathname); + } + for (const path of ['/api/health/checks', '/api/feedback/requests', '/api/lottemart/stores', '/api/actions/query', '/api/unknown']) { + expect(toBrowsePath(new URL(path, origin))).toBeNull(); + } + }); + it.each(['한글 & /? # % +', '.', '..', '', '~', '~.', '~..', 'pageSize', 'q', '