diff --git a/ALIGNS.md b/ALIGNS.md index 6f717318..dbf5ba41 100644 --- a/ALIGNS.md +++ b/ALIGNS.md @@ -24,7 +24,7 @@ - **운영 대응:** 가능한 장애 조치는 선조치 후보고합니다. GS25 인증 만료도 복구 대상이며, 실제 사용자 로그인·2단계 인증이 필요하면 즉시 알리고 입력 후 복구를 이어갑니다. 구체적인 복구 방법과 실행 권한은 기존 운영 지침을 따릅니다. - **응답 정확성:** 정상 품절, 미확인 수량, 원본 차단, 자체 한도, 인증 실패를 구분합니다. 실패나 미확인 값을 재고 0으로 바꾸거나 캐시된 성공만으로 현재 정상을 판단하지 않습니다. - **완료 판단:** 변경에 필요한 검사를 통과하고 배포·중계 반영 후 실제 운영 응답을 확인합니다. 영향받는 CLI·MCP 경로도 검증하며 기존 검사 결과를 활용해 불필요한 원본 요청을 줄입니다. -- **접근성:** 사용자가 설치 없이 빠르게 첫 조회를 완료하도록 안내합니다. 웹 읽기 전용 에이전트의 REST 조회에는 모든 후속 요청까지 `format=html`을 명시하고 기존 JSON API는 유지합니다. +- **접근성:** 사용자가 설치 없이 빠르게 첫 조회를 완료하도록 안내합니다. 웹 읽기 전용 에이전트는 모든 후속 조회까지 쿼리 없는 `/browse/` HTML 경로를 사용합니다. 기존 JSON API와 구형 `format=html` 주소는 하위호환으로 유지합니다. 경로 방식 전환 사용자 승인: 2026-10-22. - **문서:** README 상단의 이미지와 구조는 유지하고 긴 설명은 별도 docs로 옮깁니다. 무설치 사용과 HTML 조회 안내의 원문은 최상단에 유지하되, 사람에게는 기본 접힘 상태의 AI 에이전트 안내로 표시합니다. 제목 바로 아래 설명은 짧게 요약합니다. 모든 문서에서 em dash를 사용하지 않습니다. 문서 문장부호 기준 확인: 2026-10-20. README 표시 기준 확인: 2026-10-19 14:49 (Asia/Seoul). - **인수인계:** 다음 에이전트가 실제 복구 과정을 재현할 수 있도록 원인·조치·검증과 남은 문제를 기록합니다. 인증 자료와 누적 사용량 원장은 보존합니다. diff --git a/README.md b/README.md index 3a43a896..66b0d7cc 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,19 @@ AI 에이전트 사용 안내 > [!IMPORTANT] -> **최신 안내:** [현재 버전](https://mcp.aka.page/guide/2026-10-21.3) · [항상 최신 버전으로 이동](https://mcp.aka.page/guide/latest) +> **최신 안내:** [현재 버전](https://mcp.aka.page/guide/2026-10-22.1) · [항상 최신 버전으로 이동](https://mcp.aka.page/guide/latest) > -> **안내 버전: 2026-10-21.3 · 갱신일: 2026-10-21** +> **안내 버전: 2026-10-22.1 · 갱신일: 2026-10-22** > -> **ChatGPT 웹 읽기:** 연결된 MCP 도구가 없으면 [HTML GET으로 바로 조회](https://mcp.aka.page/api/daiso/products?q=%EC%88%98%EB%82%A9%EB%B0%95%EC%8A%A4&pageSize=3&format=html&guide=2026-10-21.3)하세요. 설치·API 키·MCP 연결 설정은 필요 없습니다. +> **ChatGPT 웹 읽기:** 연결된 MCP 도구가 없으면 [HTML GET으로 바로 조회](https://mcp.aka.page/browse/daiso/products/%EC%88%98%EB%82%A9%EB%B0%95%EC%8A%A4/pageSize/3)하세요. 설치·API 키·MCP 연결 설정은 필요 없습니다. > > **로컬 설치·빌드·API 키 준비 없이 실제 조회부터 실행하세요.** 패키지 설치나 전체 스키마 탐색은 필요 없습니다. > > 1. 연결된 MCP 도구가 있으면 `search_products`에 `{"service":"daiso","query":"수납박스","pageSize":3}`를 전달합니다. -> 2. 웹 읽기(open/WebFetch)가 가능하면 [첫 HTML 조회](https://mcp.aka.page/api/daiso/products?q=%EC%88%98%EB%82%A9%EB%B0%95%EC%8A%A4&pageSize=3&format=html&guide=2026-10-21.3)를 바로 엽니다. **셸이 있어도 연결된 MCP 도구가 없으면 HTML GET이 우선입니다.** 검색어 `q`를 사용자 요청으로 바꾸세요. 웹 검색은 URL 열기를 대신하지 않습니다. +> 2. 웹 읽기(open/WebFetch)가 가능하면 [첫 HTML 조회](https://mcp.aka.page/browse/daiso/products/%EC%88%98%EB%82%A9%EB%B0%95%EC%8A%A4/pageSize/3)를 바로 엽니다. **셸이 있어도 연결된 MCP 도구가 없으면 HTML GET이 우선입니다.** 경로의 검색어를 사용자 요청으로 바꾸세요. 웹 검색은 URL 열기를 대신하지 않습니다. > 3. 위 두 수단이 없을 때만 사용 가능한 조회 도구가 없음을 알립니다. 터미널·셸 호출로 우회하지 마세요. 셸 DNS 실패를 웹 GET 실패로 확대하거나 실행하지 않은 요청의 실패를 만들지 마세요. > -> **검색 링크:** [핫식스 상품 검색](https://mcp.aka.page/api/daiso/products?format=html&guide=2026-10-21.3&pageSize=10&q=%ED%95%AB%EC%8B%9D%EC%8A%A4) · [강남 매장 검색](https://mcp.aka.page/api/daiso/stores?format=html&guide=2026-10-21.3&keyword=%EA%B0%95%EB%82%A8). 웹 읽기는 링크를 직접 열고, 접근 오류만으로 상품 없음이나 재고 0을 판단하지 마세요. +> **검색 링크:** [핫식스 상품 검색](https://mcp.aka.page/browse/daiso/products/%ED%95%AB%EC%8B%9D%EC%8A%A4/pageSize/10) · [강남 매장 검색](https://mcp.aka.page/browse/daiso/stores/%EA%B0%95%EB%82%A8). 웹 읽기는 링크를 직접 열고, 접근 오류만으로 상품 없음이나 재고 0을 판단하지 마세요. > > **실행 근거:** 실제 데이터·도구·오류만 보고하고 실행하지 않은 요청의 성공·실패를 만들지 마세요. REST GET 성공은 MCP 연결 성공과 구분합니다. > @@ -22,9 +22,9 @@ > > **MCP 도구는 10개 고정:** `service`와 `operation`으로 지원 서비스를 선택합니다. 새 연동은 [통합 도구 10개](docs/mcp-tools.md)만 사용하세요. 기존 서비스별 도구는 deprecated 호출 별칭으로 유지하지만 목록에는 표시하지 않습니다. > -> **웹 읽기 전용 에이전트의 `format=html` 사용 필수:** MCP 연결 없이 GET으로 조회할 때는 **모든 `/api/*` 조회 URL에 `format=html`을 반드시 붙이세요.** 상품 검색·상세·매장·재고·후속 조회 모두 적용하며, JSON을 읽을 수 있다고 가정해 생략하지 마세요. +> **웹 읽기 전용 에이전트는 `/browse/` 경로만 사용하세요.** 검색·상세·매장·재고·후속 조회가 모두 자동 HTML이며 쿼리 문자열이나 `format` 지정은 필요 없습니다. 검색어 뒤의 부가 조건은 `/page/2/pageSize/10`처럼 이름/값 쌍으로 붙입니다. > -> [첫 HTML 조회](https://mcp.aka.page/api/daiso/products?q=%EC%88%EB%82%A9%EB%B0%95%EC%8A%A4&pageSize=3&format=html&guide=2026-10-21.3) · [REST 사용법](https://mcp.aka.page/prompt?guide=2026-10-21.3) · [원격 MCP 연결](#원격-mcp로-바로-시작하기) +> [첫 HTML 조회](https://mcp.aka.page/browse/daiso/products/%EC%88%98%EB%82%A9%EB%B0%95%EC%8A%A4/pageSize/3) · [REST 사용법](https://mcp.aka.page/prompt) · [원격 MCP 연결](#원격-mcp로-바로-시작하기) @@ -127,7 +127,7 @@ 2. 서버 URL을 입력하고, 전송 방식을 묻는 경우 **HTTP / Streamable HTTP**를 선택합니다. 3. 도구를 활성화하고 `다이소 MCP로 수납박스 검색해줘`라고 요청합니다. -[앱별 연결 방법](docs/client-guide.md#원격-mcp-연결) · [WebFetch로 첫 조회](docs/service-reference.md?guide=2026-10-21.3#설치-없이-첫-조회) +[앱별 연결 방법](docs/client-guide.md#원격-mcp-연결) · [WebFetch로 첫 조회](docs/service-reference.md?guide=2026-10-22.1#설치-없이-첫-조회)

스마트폰과 노트북을 클라우드로 연결하는 두 햄스터 @@ -156,9 +156,9 @@ npx daiso products 수납박스 --json ## 웹으로 조회하기 MCP 연결 없이 웹 읽기와 GET 요청을 지원하는 에이전트는 [REST 사용법](https://mcp.aka.page/prompt)을 읽고 조회할 수 있습니다. -**모든 `/api/*` 조회와 후속 요청에 `format=html`을 반드시 붙이세요.** +**상품·매장·재고와 모든 후속 조회는 `/browse/` 경로를 사용하세요.** 자동 HTML이며 쿼리 문자열은 필요 없습니다. -[수납박스 검색해 보기](https://mcp.aka.page/api/daiso/products?q=%EC%88%EB%82%A9%EB%B0%95%EC%8A%A4&pageSize=3&format=html) +[수납박스 검색해 보기](https://mcp.aka.page/browse/daiso/products/%EC%88%98%EB%82%A9%EB%B0%95%EC%8A%A4/pageSize/3)

브라우저 검색 결과에서 매장을 찾아가는 햄스터 @@ -205,7 +205,7 @@ MCP 연결 없이 웹 읽기와 GET 요청을 지원하는 에이전트는 [REST | 문서 | 내용 | | :-------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------- | | [연결·사용 가이드](docs/client-guide.md) | 앱별 MCP 연결, CLI, Skill, OpenAPI | -| [서비스 레퍼런스](docs/service-reference.md?guide=2026-10-21.3) | 서비스별 도구·REST API·응답 모델·가격 비교 | +| [서비스 레퍼런스](docs/service-reference.md?guide=2026-10-22.1) | 서비스별 도구·REST API·응답 모델·가격 비교 | | [일본 다이소](docs/daiso-jp-network-analysis.md) | 일본 상품·매장 검색과 지원 범위 | | [독립·예술영화관](docs/dtryx.md) | 디트릭스 지원 극장과 조회 방법 | | [AI 조회 가이드](docs/ai-instruction.md) | 도구 선택과 상품·매장·재고 조회 순서 | diff --git a/docs/ai-instruction.md b/docs/ai-instruction.md index a5e7e5a5..a2b8a646 100644 --- a/docs/ai-instruction.md +++ b/docs/ai-instruction.md @@ -8,11 +8,11 @@ 2. 웹 읽기(open/WebFetch)가 가능하면 아래 HTML GET을 바로 엽니다. 셸이 있어도 연결된 MCP 도구가 없으면 HTML GET이 우선입니다. 웹 검색(search)은 URL 열기(open)를 대신하지 않습니다. 3. 위 두 수단이 없을 때만 사용 가능한 조회 도구가 없음을 알립니다. 셸 호출로 우회하지 말고 실제 실행하지 않은 요청을 실패라고 설명하지 않습니다. 셸 DNS 오류는 웹 GET 실패가 아닙니다. -https://mcp.aka.page/api/daiso/products?q=%EC%88%98%EB%82%A9%EB%B0%95%EC%8A%A4&pageSize=3&format=html&guide=2026-10-21.3 +https://mcp.aka.page/browse/daiso/products/%EC%88%98%EB%82%A9%EB%B0%95%EC%8A%A4/pageSize/3 -검색어 q를 사용자 요청으로 바꾸세요. 첫 조회 전에 npm·CLI·스킬 설치, health/help, 전체 도구 목록이나 OpenAPI 탐색을 하지 않습니다. 연결 설정을 요구하기 전에 현재 웹 도구로 실제 조회부터 실행합니다. +경로의 검색어를 사용자 요청으로 바꾸세요. 첫 조회 전에 npm·CLI·스킬 설치, health/help, 전체 도구 목록이나 OpenAPI 탐색을 하지 않습니다. 연결 설정을 요구하기 전에 현재 웹 도구로 실제 조회부터 실행합니다. -**모든 `/api/*` 웹 GET 조회 URL에 `format=html`을 반드시 붙입니다.** 상품 검색·상세·매장·재고·후속 조회 모두 적용합니다. 실제 검색 결과의 ID를 사용하고 문서 예제 ID나 수량을 조회 결과로 쓰지 않습니다. REST GET 성공은 MCP 연결 성공과 구분합니다. +**모든 웹 GET 조회는 `/browse/` 경로를 사용합니다. 자동 HTML이며 쿼리 문자열과 format 지정은 필요 없습니다.** 상품 검색·상세·매장·재고·후속 조회 모두 적용합니다. 실제 검색 결과의 ID를 사용하고 문서 예제 ID나 수량을 조회 결과로 쓰지 않습니다. REST GET 성공은 MCP 연결 성공과 구분합니다. **실행 근거:** 실제로 실행한 도구·전체 URL·반환 데이터·오류만 보고합니다. 실행하지 않은 웹 GET을 실패했다고 설명하지 않습니다. 웹 도구가 HTTP 상태를 제공하지 않으면 HTTP 200이라고 추정하지 않습니다. diff --git a/docs/client-guide.md b/docs/client-guide.md index 8d5f858d..d6dc004f 100644 --- a/docs/client-guide.md +++ b/docs/client-guide.md @@ -135,21 +135,21 @@ https://mcp.aka.page/prompt AI: (페이지를 읽고 API 사용법 이해) 사용자: 수납박스 검색해줘 -AI: (https://mcp.aka.page/api/daiso/products?q=수납박스&format=html 호출 후 결과 제공) +AI: (https://mcp.aka.page/browse/daiso/products/%EC%88%98%EB%82%A9%EB%B0%95%EC%8A%A4 호출 후 결과 제공) 사용자: 안산 중앙역 근처 메가박스 지점 찾아줘 -AI: (https://mcp.aka.page/api/megabox/theaters?keyword=안산%20중앙역&format=html 호출 후 결과 제공) +AI: (https://mcp.aka.page/browse/megabox/theaters/%EC%95%88%EC%82%B0%20%EC%A4%91%EC%95%99%EC%97%AD 호출 후 결과 제공) 사용자: 잠실 근처 롯데시네마 지점 찾아줘 -AI: (https://mcp.aka.page/api/lottecinema/theaters?keyword=%EC%9E%A0%EC%8B%A4&format=html 호출 후 결과 제공) +AI: (https://mcp.aka.page/browse/lottecinema/theaters/%EC%9E%A0%EC%8B%A4 호출 후 결과 제공) 사용자: 오늘 강남 CGV 시간표 알려줘 -AI: (https://mcp.aka.page/api/cgv/timetable?playDate=YYYYMMDD&theaterCode=0056&format=html 호출 후 결과 제공) +AI: (https://mcp.aka.page/browse/cgv/timetable/playDate/YYYYMMDD/theaterCode/0056 호출 후 결과 제공) 사용자: 안산 중앙역 근처 CGV 찾아서 오늘 영화랑 시간표 알려줘 -AI: (https://mcp.aka.page/api/cgv/theaters?playDate=YYYYMMDD&keyword=안산%20중앙역&format=html 호출 후 결과 제공) -AI: (https://mcp.aka.page/api/cgv/movies?playDate=YYYYMMDD&keyword=안산%20중앙역&format=html 호출 후 결과 제공) -AI: (https://mcp.aka.page/api/cgv/timetable?playDate=YYYYMMDD&keyword=안산%20중앙역&format=html 호출 후 결과 제공) +AI: (https://mcp.aka.page/browse/cgv/theaters/%EC%95%88%EC%82%B0%20%EC%A4%91%EC%95%99%EC%97%AD/playDate/YYYYMMDD 호출 후 결과 제공) +AI: (https://mcp.aka.page/browse/cgv/movies/playDate/YYYYMMDD/keyword/안산%20중앙역 호출 후 결과 제공) +AI: (https://mcp.aka.page/browse/cgv/timetable/playDate/YYYYMMDD/keyword/안산%20중앙역 호출 후 결과 제공) ```
diff --git a/docs/daiso-jp-network-analysis.md b/docs/daiso-jp-network-analysis.md index 420191bd..15c8cb4c 100644 --- a/docs/daiso-jp-network-analysis.md +++ b/docs/daiso-jp-network-analysis.md @@ -22,9 +22,9 @@ 웹 읽기 에이전트는 후속 요청을 포함해 반드시 `format=html`을 지정합니다. -- [상품 검색](https://mcp.aka.page/api/daiso-jp/products?q=%E3%83%9C%E3%82%A6%E3%83%AB&format=html) -- [상품 상세](https://mcp.aka.page/api/daiso-jp/products/4550746075917?format=html) -- [매장 검색](https://mcp.aka.page/api/daiso-jp/stores?keyword=%E6%9D%B1%E4%BA%AC%E9%83%BD%E6%B8%8B%E8%B0%B7%E5%8C%BA&format=html) +- [상품 검색](https://mcp.aka.page/browse/daiso-jp/products/%E3%83%9C%E3%82%A6%E3%83%AB) +- [상품 상세](https://mcp.aka.page/browse/daiso-jp/product/4550746075917) +- [매장 검색](https://mcp.aka.page/browse/daiso-jp/stores/%E6%9D%B1%E4%BA%AC%E9%83%BD%E6%B8%8B%E8%B0%B7%E5%8C%BA) 개발자용 JSON 응답은 기존처럼 format을 생략하면 됩니다. 페이지는 원본 페이지 단위이며 `hasNext`가 true인 경우에만 page를 증가시킵니다. 전체 검색을 한 번에 수집하지 않습니다. diff --git a/docs/gpts-maintenance.md b/docs/gpts-maintenance.md index 4d427be9..433112e0 100644 --- a/docs/gpts-maintenance.md +++ b/docs/gpts-maintenance.md @@ -6,7 +6,7 @@ 이 GPT는 원격 MCP 연결이 아니라 GPT Actions의 REST 연동입니다. 등록 스키마는 https://mcp.aka.page/openapi.json, 인증은 없음, 개인정보처리방침은 https://mcp.aka.page/privacy 입니다. 스키마를 다시 불러온 뒤 operationId `queryAction` 하나를 유지하고 action enum으로 기능을 선택합니다. MCP의 service/operation 인수나 deprecated 도구 이름을 Actions 호출에 그대로 넣지 않습니다. 공개 MCP의 도구10개 고정 규칙은 별도로 유지합니다. -Actions는 JSON 응답을 직접 소비합니다. 웹 검색/웹 읽기로 REST URL을 열 때에만 모든 후속 요청까지 `format=html`을 반드시 넣습니다. 웹으로 페이지를 읽은 것만으로 MCP나 Actions 연결이 생기지는 않습니다. +Actions는 JSON 응답을 직접 소비합니다. 웹 읽기를 보조 수단으로 사용할 때에는 모든 후속 조회까지 `/browse/` HTML 경로를 사용합니다. 쿼리 문자열은 필요 없습니다. 웹으로 페이지를 읽은 것만으로 MCP나 Actions 연결이 생기지는 않습니다. ## 갱신용 설명 @@ -33,7 +33,7 @@ Actions는 JSON 응답을 직접 소비합니다. 웹 검색/웹 읽기로 REST 사용자가 개발자 전달을 명시적으로 요청한 경우에만 submitDeveloperRequest를 사용합니다. 제목과 설명을 확인하고 비밀번호·인증 코드·토큰·쿠키·개인정보를 포함하지 않습니다. 테스트나 일반 조회 과정에서 요청을 임의로 제출하지 않습니다. -Actions 응답은 JSON으로 처리합니다. 웹 읽기를 보조 수단으로 사용할 때 모든 /api/* GET 링크에 format=html을 반드시 포함합니다. 웹 읽기 오류만으로 서버 장애나 로그인 필요를 단정하지 않습니다. +Actions 응답은 JSON으로 처리합니다. 웹 읽기를 보조 수단으로 사용할 때 모든 조회 링크에 쿼리 없는 /browse/ 경로를 사용합니다. HTML 형식은 자동입니다. 웹 읽기 오류만으로 서버 장애나 로그인 필요를 단정하지 않습니다. ``` ## 편집·검증 diff --git a/docs/service-reference.md b/docs/service-reference.md index fb7adb32..b8078425 100644 --- a/docs/service-reference.md +++ b/docs/service-reference.md @@ -6,15 +6,15 @@ README 하단에 있던 기능, REST API, 개발, 아키텍처 설명을 분리 **ChatGPT 및 웹 읽기 에이전트는 터미널을 실행하지 말고 WebFetch 또는 URL 열기(open) 도구로 아래 링크를 직접 여세요.** 설치·API 키·MCP 연결 설정 없이 상품 검색 결과를 읽을 수 있습니다. 검색 도구(search)는 URL 열기를 대신하지 않습니다. -[WebFetch로 다이소 수납박스 3개 조회](https://mcp.aka.page/api/daiso/products?q=%EC%88%98%EB%82%A9%EB%B0%95%EC%8A%A4&pageSize=3&format=html&guide=2026-10-21.3) +[WebFetch로 다이소 수납박스 3개 조회](https://mcp.aka.page/browse/daiso/products/%EC%88%98%EB%82%A9%EB%B0%95%EC%8A%A4/pageSize/3) -[핫식스 상품 검색](https://mcp.aka.page/api/daiso/products?format=html&guide=2026-10-21.3&pageSize=10&q=%ED%95%AB%EC%8B%9D%EC%8A%A4) · [강남 매장 검색](https://mcp.aka.page/api/daiso/stores?format=html&guide=2026-10-21.3&keyword=%EA%B0%95%EB%82%A8) +[핫식스 상품 검색](https://mcp.aka.page/browse/daiso/products/%ED%95%AB%EC%8B%9D%EC%8A%A4/pageSize/10) · [강남 매장 검색](https://mcp.aka.page/browse/daiso/stores/%EA%B0%95%EB%82%A8) 웹 읽기 도구에서는 위 링크를 직접 열어 조회하세요. URL 접근 오류가 나오면 실제 오류를 보고하고 상품 없음이나 재고 0으로 해석하지 마세요. -모든 후속 웹 GET에도 `format=html&guide=2026-10-21.3`을 유지하세요. 상품 검색 결과의 실제 ID로 재고를 조회하고 검색 성공만으로 매장 재고를 단정하지 않습니다. 이미지는 응답 imageUrl과 카드의 원본 Markdown만 사용하고 이미지 검색으로 대체하지 않습니다. +모든 후속 웹 GET도 `/browse/`를 사용하세요. 자동 HTML이며 쿼리 문자열이나 format 지정은 필요 없습니다. 상품 검색 결과의 실제 ID로 재고를 조회하고 검색 성공만으로 매장 재고를 단정하지 않습니다. 이미지는 응답 imageUrl과 카드의 원본 Markdown만 사용하고 이미지 검색으로 대체하지 않습니다. -현재 안내는 [버전 안내](https://mcp.aka.page/guide/2026-10-21.3), 다음 버전도 따라가려면 [항상 최신 안내](https://mcp.aka.page/guide/latest)를 여세요. 이 상세 레퍼런스를 끝까지 읽기 전에 첫 조회를 실행하세요. +현재 안내는 [버전 안내](https://mcp.aka.page/guide/2026-10-22.1), 다음 버전도 따라가려면 [항상 최신 안내](https://mcp.aka.page/guide/latest)를 여세요. 이 상세 레퍼런스를 끝까지 읽기 전에 첫 조회를 실행하세요. 이미 연결된 MCP 도구가 있는 환경만 `search_products`에 `service=daiso`, `query=수납박스`를 전달합니다. 서버는 https://mcp.aka.page 이며 Streamable HTTP, 인증 없음입니다. 실제로 실행한 도구·URL·오류만 보고하고 실행하지 않은 요청의 성공·실패를 만들지 않습니다. @@ -22,6 +22,12 @@ README 하단에 있던 기능, REST API, 개발, 아키텍처 설명을 분리 아래 서비스별 MCP 이름은 하위호환용 **deprecated 별칭** 레퍼런스입니다. 새 연동의 호출 예제로 사용하지 마세요. 현재 공개 MCP 도구는 정확히 10개이며 [통합 도구와 구형 매핑](mcp-tools.md)을 사용합니다. REST와 CLI 계약은 유지합니다. +### 웹 조회 경로 구성 + +`/browse/daiso/products/핫식스/pageSize/3`처럼 주요 검색어 뒤에 부가 조건을 이름/값 쌍으로 붙입니다. 상품 상세는 `/browse/daiso/product/{실제ID}`, 지역 재고는 `/browse/daiso/inventory/{실제ID}/{사용자지역}`입니다. 주요 검색어 없이 필터만 쓰면 `/browse/query/daiso/stores/sido/서울/gugun/마포구`처럼 구성합니다. 각 값은 한 번 URL 인코딩하며 응답의 후속 링크를 우선합니다. [전체 지원 웹 조회](https://mcp.aka.page/browse)에서 다른 서비스 경로를 확인합니다. + +아래 `/api/`와 JSON 모델은 기존 개발자 연동 계약입니다. 웹 읽기 전용 에이전트는 이 JSON 주소를 실행하지 않습니다. 기존 HTML 쿼리 주소도 하위호환으로 유지합니다. + ### MCP 표준 응답 모델 MCP 도구 응답은 각 서비스의 원본 필드를 유지하면서, 클라이언트가 공통 UI를 만들 수 있도록 `standard` 필드를 함께 제공합니다. 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..fdd70d0c --- /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. 경로 변환과 라우트 + +- [x] `tests/pages/browse.test.ts`에 `/browse/daiso/products/핫식스/pageSize/3` → `/api/daiso/products?q=핫식스&pageSize=3&format=html` 왕복, 모든 지원 읽기 경로, 특수문자, 거절 대상 실패 회귀를 작성한다. +- [x] `npx vitest run tests/pages/browse.test.ts`로 실패를 확인한다. +- [x] `src/pages/browsePaths.ts`에 허용 경로 목록과 변환을 구현한다. `src/pages/browse.ts`에 목록 HTML과 GET 내부 dispatch를 구현하고 `src/index.ts`에 등록한다. +- [x] 동일 테스트를 통과하고 실제 API 입력·오류·호출 제한의 동등성 회귀를 추가한다. +- [x] `git commit -m 'feat: 쿼리 없는 웹 조회 경로 추가'`. + +## 2. 실제 결과와 후속 링크 + +- [x] `tests/pages/apiResult.test.ts`, `productCards.test.ts`에 실제 상품 상세·재고·페이지 링크가 `/browse/`이고 쿼리가 없다는 실패 회귀를 추가한다. +- [x] `src/pages/apiResult.ts`에서 요청·후속 링크를 경로 방식으로 표시하고 데이터·이미지·오류·보안 헤더는 유지한다. +- [x] 테스트를 실행하고 `git commit -m 'feat: HTML 조회 후속 링크를 경로 방식으로 통일'`. + +## 3. 안내 전환 + +- [x] pages 테스트에 첫 링크 및 모든 웹 조회 예시가 browse라는 실패 회귀를 추가한다. +- [x] `src/pages/agentStart.ts`, `discovery.ts`, `prompt*.ts`, README, 웹 사용 docs를 갱신한다. 안내 버전은 `2026-10-22.1`로 올린다. 기존 ALIGNS 접근성 기준은 이번 승인 범위인 browse 우선과 구형 HTML 하위호환을 반영한다. +- [x] `npm run build:discovery`로 정적 발견 파일을 재생성하고 `git commit -m 'docs: 무설치 웹 조회 안내를 browse 경로로 전환'`. + +## 4. 통합과 운영 + +- [x] `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..0754e21d --- /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를 보존합니다. 상세·재고·진열 위치와 page 규약을 지원하는 API의 페이지 이동은 실제 식별자와 현재 필터를 보존하는 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 열기와 결과 링크 이동을 각각 검증하며, 외부 도구 접근 정책의 완전한 해결을 보장하지 않습니다. 일본 공식 매장 사이트 중단 등 기존 외부 장애를 새 기능으로 숨기지 않습니다. diff --git a/docs/web-discovery.md b/docs/web-discovery.md index 0d00ab4f..00759a2e 100644 --- a/docs/web-discovery.md +++ b/docs/web-discovery.md @@ -37,15 +37,15 @@ OpenAI 검색·사용자 요청·학습 봇의 역할과 공개 IP는 [공식 ## 웹 읽기만 가능한 채팅 에이전트 -샌드박스나 셸 없이 URL만 읽을 수 있다면 [수납박스 HTML 조회](https://mcp.aka.page/api/daiso/products?q=%EC%88%98%EB%82%A9%EB%B0%95%EC%8A%A4&pageSize=3&format=html)를 먼저 엽니다. 첫 화면과 llms.txt에도 이 링크를 안내합니다. 결과는 서버에서 생성하므로 JavaScript 실행이 필요 없습니다. +샌드박스나 셸 없이 URL만 읽을 수 있다면 [수납박스 HTML 조회](https://mcp.aka.page/browse/daiso/products/%EC%88%98%EB%82%A9%EB%B0%95%EC%8A%A4/pageSize/3)를 먼저 엽니다. 첫 화면과 llms.txt에도 이 링크를 안내합니다. 결과는 서버에서 생성하므로 JavaScript 실행이 필요 없습니다. -공개 `/api/*` GET 경로의 기존 파라미터에 `format=html`을 추가하면 같은 실제 조회 결과를 HTML로 제공합니다. `format`을 생략하면 기존 JSON 응답입니다. 예: `/api/daiso/stores?keyword=강남&format=html`, `/api/daiso/inventory?productId=실제상품ID&keyword=확인한매장명&lat=확인한위도&lng=확인한경도&format=html`. 상품 목록의 상세 링크도 HTML로 연결합니다. 다른 서비스의 필수 파라미터는 `/prompt`에서 확인합니다. +공개 `/browse/` GET 경로는 같은 실제 조회 결과를 항상 HTML로 제공합니다. 쿼리 문자열은 필요 없습니다. 기존 `/api/`의 JSON과 구형 HTML 주소는 하위호환으로 유지합니다. 예: `/browse/daiso/stores/%EA%B0%95%EB%82%A8`, `/browse/daiso/inventory/%EC%8B%A4%EC%A0%9C%EC%83%81%ED%92%88ID/%ED%99%95%EC%9D%B8%ED%95%9C%EB%A7%A4%EC%9E%A5%EB%AA%85/lat/%ED%99%95%EC%9D%B8%ED%95%9C%EC%9C%84%EB%8F%84/lng/%ED%99%95%EC%9D%B8%ED%95%9C%EA%B2%BD%EB%8F%84`. 상품 목록의 상세 링크도 HTML로 연결합니다. 다른 서비스의 필수 파라미터는 `/prompt`에서 확인합니다. HTML은 원래 응답의 성공·오류와 HTTP 상태를 보존합니다. 상품 검색 결과를 매장 재고로 단정하거나 오류를 재고 0으로 해석하면 안 됩니다. POST와 MCP 응답 형식은 변경하지 않습니다. 웹 읽기 도구가 HTML 접근도 거부하는 경우에는 이 옵션만으로 도구 내부 문제를 해결하지 못합니다. `llms.txt`와 `robots.txt`는 정적 Assets에서도 `text/plain; charset=utf-8`을 명시합니다. UTF-8 본문을 다른 인코딩으로 추측하는 클라이언트에서 한글이 깨지는 것을 방지합니다. -**웹 읽기 전용 에이전트는 모든 `/api/*` GET 조회 URL에 `format=html`을 반드시 붙이세요.** 검색·상세·매장·재고·후속 조회 모두 적용합니다. `/prompt`의 GET URL 예시도 모두 이 형식으로 제공합니다. JSON 스키마 예시는 필드 설명이고 웹 읽기용 URL 안내가 아닙니다. 일반 JSON API와 MCP 클라이언트의 계약은 유지합니다. +**웹 읽기 전용 에이전트는 검색·상세·매장·재고·후속 조회 모두 `/browse/` 경로를 사용하세요.** `/prompt`의 웹 GET 예시도 이 형식입니다. 쿼리 문자열이나 format 지정은 필요 없습니다. JSON 스키마 예시는 필드 설명이며 일반 JSON API와 MCP 클라이언트 계약은 유지합니다. ## 실행과 이미지 출처 @@ -53,13 +53,13 @@ HTML은 원래 응답의 성공·오류와 HTTP 상태를 보존합니다. 상 ## 안내 버전과 상품 이미지 카드 -현재 안내 버전은 `2026-10-21.3`, 갱신일은 `2026-10-21`입니다. README의 AI 안내, 첫 화면, llms.txt, prompt와 HTML 조회 결과에 표시합니다. 버전이 없거나 다르면 예전 검색 사본일 수 있으므로 실제 읽은 URL과 문구를 확인하고 최신 버전을 읽었다고 추정하지 않습니다. 검색 엔진의 재수집·캐시 반영 시각은 이 배포로 보장하지 않습니다. +현재 안내 버전은 `2026-10-22.1`, 갱신일은 `2026-10-22`입니다. README의 AI 안내, 첫 화면, llms.txt, prompt와 HTML 조회 결과에 표시합니다. 버전이 없거나 다르면 예전 검색 사본일 수 있으므로 실제 읽은 URL과 문구를 확인하고 최신 버전을 읽었다고 추정하지 않습니다. 검색 엔진의 재수집·캐시 반영 시각은 이 배포로 보장하지 않습니다. 상품 HTML은 이름·ID·원본 필드와 응답 imageUrl의 이미지를 같은 카드에 표시합니다. 이미지 링크는 원본 URL의 쿼리까지 유지하며 HTTPS이고 인증정보가 없는 주소만 사용합니다. imageUrl이 없거나 안전한 이미지 주소가 아니면 이미지 없음으로 표시합니다. 검색 이미지로 채우지 않습니다. JSON API 필드와 오류 상태는 유지합니다. ## 최신 안내의 연결 계약 -`/guide/latest` 및 이전 `/guide/버전`은 307과 Cache-Control: no-store로 현재 `/guide/2026-10-21.3`에 연결합니다. 새 버전 배포 시 공통 상수와 README·스킬 안내를 함께 갱신합니다. 첫 화면·robots·sitemap도 같은 상수의 현재 경로를 안내합니다. 외부 캐시가 과거 응답이나 리다이렉트를 재사용하는 것까지 강제할 수는 없습니다. +`/guide/latest` 및 이전 `/guide/버전`은 307과 Cache-Control: no-store로 현재 `/guide/2026-10-22.1`에 연결합니다. 새 버전 배포 시 공통 상수와 README·스킬 안내를 함께 갱신합니다. 첫 화면·robots·sitemap도 같은 상수의 현재 경로를 안내합니다. 외부 캐시가 과거 응답이나 리다이렉트를 재사용하는 것까지 강제할 수는 없습니다. -첫 HTML 조회와 상품 상세 링크는 format=html 및 guide 버전을 포함합니다. 프롬프트의 REST 예제도 같은 버전을 사용합니다. 안내와 HTML 결과는 no-store를 보내며 기존 JSON/API 원본 캐시 정책은 유지합니다. 이미지 카드에는 원본 imageUrl로 생성한 Markdown을 제공하고 이미지 검색/image_query/캐러셀 대체를 금지합니다. 표시가 불가능하면 원본 링크와 텍스트만 제공합니다. +첫 HTML 조회와 상세·재고·페이지 이동 링크는 쿼리 없는 `/browse/` 경로입니다. 프롬프트의 웹 GET 예제도 같은 경로를 사용하며 안내 버전은 `/guide/latest`에서 확인합니다. 안내와 HTML 결과는 no-store를 보내며 기존 JSON/API 원본 캐시 정책은 유지합니다. 이미지 카드에는 원본 imageUrl로 생성한 Markdown을 제공하고 이미지 검색/image_query/캐러셀 대체를 금지합니다. 표시가 불가능하면 원본 링크와 텍스트만 제공합니다. diff --git a/skills/daiso-cli/SKILL.md b/skills/daiso-cli/SKILL.md index 60b31437..4d0397d9 100644 --- a/skills/daiso-cli/SKILL.md +++ b/skills/daiso-cli/SKILL.md @@ -27,9 +27,9 @@ Use this skill to operate the Daiso MCP project through `npx daiso` and the publ 2. 웹 읽기(open/WebFetch)가 가능하면 아래 HTML GET을 바로 엽니다. 셸이 있어도 연결된 MCP 도구가 없으면 이 경로가 우선입니다. 검색(search)은 URL 열기(open)를 대신하지 않습니다. 3. 위 두 수단이 없을 때만 사용 가능한 조회 도구가 없음을 알립니다. 셸 호출로 우회하지 않으며, 셸 DNS 오류를 웹 GET 실패로 확대하지 않습니다. 실제로 실행하지 않은 요청의 성공·실패를 만들지 않습니다. -https://mcp.aka.page/api/daiso/products?q=%EC%88%98%EB%82%A9%EB%B0%95%EC%8A%A4&pageSize=3&format=html&guide=2026-10-21.3 +https://mcp.aka.page/browse/daiso/products/%EC%88%98%EB%82%A9%EB%B0%95%EC%8A%A4/pageSize/3 -검색어 q를 사용자 요청으로 바꾸고 상세·매장·재고를 포함한 모든 후속 웹 GET에도 `format=html`을 반드시 붙이세요. 첫 조회 전에 npx·패키지·스킬 설치, health/help, 전체 도구 목록이나 OpenAPI 탐색을 요구하지 않습니다. REST GET 성공과 MCP 도구 호출 성공은 구분합니다. +경로의 검색어를 사용자 요청으로 바꾸고 상세·매장·재고를 포함한 모든 후속 웹 GET에도 `/browse/`를 사용하세요. 자동 HTML이며 쿼리 문자열은 필요 없습니다. 첫 조회 전에 npx·패키지·스킬 설치, health/help, 전체 도구 목록이나 OpenAPI 탐색을 요구하지 않습니다. REST GET 성공과 MCP 도구 호출 성공은 구분합니다. 상품 이미지는 해당 조회 응답의 imageUrl만 사용합니다. 전체 URL과 query string을 유지하고, 없거나 표시되지 않으면 생략하며 image_query·이미지 검색·검색 캐러셀을 사용하거나 웹 검색 이미지·다른 상품·다른 국가 이미지로 대체하지 않습니다. 실제 도구·URL·반환 데이터·오류만 보고하고 실행하지 않은 요청의 성공·실패를 만들지 않습니다. @@ -113,7 +113,7 @@ Lotte Mart is discontinued; MINISO is unsupported. Japan DAISO supports products ## Fallbacks -- npx 또는 Node.js를 사용할 수 없으면 use connected MCP tools or open the equivalent HTML GET through web reading. Append format=html to every web GET. If neither is available, report the actual limitation without inventing results. +- npx 또는 Node.js를 사용할 수 없으면 use connected MCP tools or open the equivalent HTML GET through web reading. Use query-free /browse/ paths for every web GET. HTML is automatic. If neither is available, report the actual limitation without inventing results. - For network failures, retry once with a narrower query, then report the failing service and command. - For no results, preserve the command used and suggest a broader keyword or nearby area. - Switch from CLI to MCP only when the user is configuring an AI app, the shell cannot run `npx`, or the host environment already exposes the MCP server. diff --git a/src/index.ts b/src/index.ts index 9db5a26c..a48df9e8 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'; @@ -157,7 +158,7 @@ export function buildRootInfo() { description: 'Multi-Service MCP Server for Cloudflare Workers', endpoints: { mcp: '/ 또는 /mcp (POST) - MCP 프로토콜 엔드포인트', - quickStart: '/llms.txt (GET) - 설치 없는 첫 조회 안내', + quickStart: '/browse (GET) - 설치 없는 HTML 웹 조회와 /llms.txt 안내', health: '/health (GET) - 헬스 체크', healthChecks: '/api/health/checks (GET) - 서비스별 상세 헬스 체크', openapi: '/openapi.json (GET) - OpenAI Actions용 축약 OpenAPI', @@ -166,8 +167,8 @@ export function buildRootInfo() { compareProducts: '/api/compare/products (GET) - 키 없는 통합 상품 가격 후보 비교', opinetAverage: '/api/opinet/average (GET) - 오피넷 전국 평균 유가', opinetLowest: '/api/opinet/lowest (GET) - 오피넷 최저가 주유소', - japanProducts: '/api/daiso-jp/products?q=...&format=html (GET) - 일본 다이소 상품', - japanStores: '/api/daiso-jp/stores?keyword=...&format=html (GET) - 일본 다이소 매장 (재고 미지원)', + japanProducts: '/browse/daiso-jp/products/검색어 (GET) - 일본 다이소 상품', + japanStores: '/browse/daiso-jp/stores/지역 (GET) - 일본 다이소 매장 (재고 미지원)', developerRequests: '/api/feedback/requests (POST/GET) - 개발자 요청 제출', }, services, @@ -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/agentStart.ts b/src/pages/agentStart.ts index af98ca6a..db41fca4 100644 --- a/src/pages/agentStart.ts +++ b/src/pages/agentStart.ts @@ -1,24 +1,27 @@ /** 검색·읽기 사본의 안내 세대를 구분하는 고정 버전 */ -export const AGENT_GUIDE_VERSION = '2026-10-21.3'; -export const AGENT_GUIDE_UPDATED = '2026-10-21'; +export const AGENT_GUIDE_VERSION = '2026-10-22.1'; +export const AGENT_GUIDE_UPDATED = '2026-10-22'; export const AGENT_GUIDE_STAMP = `안내 버전: ${AGENT_GUIDE_VERSION} · 갱신일: ${AGENT_GUIDE_UPDATED}`; export const AGENT_GUIDE_PATH = `/guide/${AGENT_GUIDE_VERSION}`; -export const HTML_GUIDE_QUERY = `&guide=${AGENT_GUIDE_VERSION}`; -export const WEB_SEARCH_DESCRIPTION = 'ChatGPT 웹 읽기로 설치·API 키·MCP 연결 설정 없이 바로 상품을 조회하세요. URL 열기로 /api/daiso/products?q=검색어&format=html을 사용합니다. 다이소·편의점 재고와 영화·생활 정보도 지원합니다.'; +export const WEB_SEARCH_DESCRIPTION = + 'ChatGPT 웹 읽기로 설치·API 키·MCP 연결 설정 없이 바로 상품을 조회하세요. open/WebFetch로 /browse/daiso/products/검색어를 엽니다. 쿼리 문자열과 format 지정은 필요 없습니다. 다이소·편의점 재고와 영화·생활 정보도 지원합니다.'; -/** 웹 읽기 환경에서 조회 형식을 선택하는 공통 규칙 */ -export const WEB_READ_RULE = '웹 읽기 전용 에이전트: 모든 /api/* GET 조회 URL에 format=html을 반드시 포함하세요. 상품 검색뿐 아니라 상세·매장·재고·후속 조회에도 적용합니다. 아래 문서의 JSON 응답 예시는 필드 설명이며 웹 읽기로 JSON 주소를 열라는 뜻이 아닙니다. JSON URL이 읽히지 않으면 format=html로 다시 조회하고, 그 실패만으로 서비스 장애·로그인 필요·이 환경에서 실행할 수 없다고 단정하지 마세요.'; +/** 웹 읽기 환경에서 쿼리 없는 HTML 경로를 우선합니다. */ +export const WEB_READ_RULE = + '웹 읽기 전용 에이전트는 /browse/ 경로를 사용하세요. 상품 검색·상세·매장·재고·후속 조회가 모두 자동 HTML입니다. format이나 쿼리 문자열은 필요 없습니다. JSON API 주소를 웹 읽기로 열거나 셸로 우회하지 마세요. 접근 오류만으로 서비스 장애·로그인 필요·이 환경에서 실행할 수 없다고 단정하지 마세요.'; /** 셸 존재를 네트워크 접근 가능 여부로 오해하지 않는 실행 순서 */ export const ROUTE_SELECTION_TEXT = `1. 연결된 MCP 도구가 있으면 search_products {"service":"daiso","query":"수납박스","pageSize":3}를 호출합니다. -2. 웹 읽기(open/WebFetch)가 가능하면 HTML GET 주소를 바로 엽니다. 셸이 있어도 연결된 MCP 도구가 없으면 이 경로가 우선입니다. 웹 검색(search)은 URL 열기(open)를 대신하지 않습니다. +2. 웹 읽기(open/WebFetch)가 가능하면 /browse/ HTML GET 주소를 바로 엽니다. 셸이 있어도 연결된 MCP 도구가 없으면 이 경로가 우선입니다. 웹 검색(search)은 URL 열기(open)를 대신하지 않습니다. 3. 위 두 수단이 없을 때만 사용 가능한 조회 도구가 없음을 알립니다. 터미널·셸 호출로 우회하지 않습니다. 셸 DNS 오류를 웹 GET 실패나 서버 장애로 확대하지 말고 실제로 실행하지 않은 요청의 실패를 만들지 마세요.`; /** 유사 상품의 웹 검색 이미지를 실제 상품 사진으로 오인하지 않도록 제한합니다. */ -export const IMAGE_SOURCE_RULE = '상품 이미지는 해당 조회 응답의 imageUrl만 사용하세요. image_query·이미지 검색·검색 이미지 캐러셀은 사용하지 마세요. 카드의 원본 이미지 Markdown을 그대로 쓰고, 표시할 수 없으면 원본 링크와 텍스트만 제공하세요. 전체 URL과 query string을 유지합니다. imageUrl이 없거나 표시되지 않으면 이미지를 생략하고 웹 검색 이미지·다른 상품·다른 국가의 이미지로 대체하지 마세요.'; +export const IMAGE_SOURCE_RULE = + '상품 이미지는 해당 조회 응답의 imageUrl만 사용하세요. image_query·이미지 검색·검색 이미지 캐러셀은 사용하지 마세요. 카드의 원본 이미지 Markdown을 그대로 쓰고, 표시할 수 없으면 원본 링크와 텍스트만 제공하세요. 전체 URL과 query string을 유지합니다. imageUrl이 없거나 표시되지 않으면 이미지를 생략하고 웹 검색 이미지·다른 상품·다른 국가의 이미지로 대체하지 마세요.'; /** 실행 기록 없이 성공·실패나 HTTP 상태를 단정하지 않도록 안내합니다. */ -export const EXECUTION_EVIDENCE_RULE = '실제로 실행한 도구·요청 URL·반환 데이터와 확인된 오류만 보고하세요. 실행하지 않은 웹 GET을 실패했다고 말하거나 검색 결과·문서 예제를 실제 조회 결과로 제시하지 마세요. 도구가 HTTP 상태를 제공하지 않으면 HTTP 200이라고 추정하지 마세요. REST GET 성공과 MCP 도구 호출 성공은 구분합니다.'; +export const EXECUTION_EVIDENCE_RULE = + '실제로 실행한 도구·요청 URL·반환 데이터와 확인된 오류만 보고하세요. 실행하지 않은 웹 GET을 실패했다고 말하거나 검색 결과·문서 예제를 실제 조회 결과로 제시하지 마세요. 도구가 HTTP 상태를 제공하지 않으면 HTTP 200이라고 추정하지 마세요. REST GET 성공과 MCP 도구 호출 성공은 구분합니다.'; /** 설치 없이 첫 실제 조회를 실행하는 에이전트 안내 */ export function buildAgentStartText(baseUrl: string): string { @@ -34,16 +37,18 @@ ${AGENT_GUIDE_STAMP} ${ROUTE_SELECTION_TEXT} -웹 읽기용 첫 조회 (사용자 검색어로 q를 바꾸세요): -${baseUrl}/api/daiso/products?q=%EC%88%98%EB%82%A9%EB%B0%95%EC%8A%A4&pageSize=3&format=html${HTML_GUIDE_QUERY} -이것은 공개 REST GET이며 MCP 프로토콜 호출이 아닙니다. 상품 결과의 실제 id로 상세·재고를 조회하고 모든 후속 URL에도 format=html과 guide=${AGENT_GUIDE_VERSION}을 유지하세요. 상품 검색만으로 매장 재고를 단정하지 마세요. +웹 읽기용 첫 조회 (경로의 수납박스를 사용자 검색어로 바꾸세요): +${baseUrl}/browse/daiso/products/%EC%88%98%EB%82%A9%EB%B0%95%EC%8A%A4/pageSize/3 +핫식스: ${baseUrl}/browse/daiso/products/%ED%95%AB%EC%8B%9D%EC%8A%A4 +강남역 매장: ${baseUrl}/browse/daiso/stores/%EA%B0%95%EB%82%A8%EC%97%AD +이것은 공개 REST GET의 웹 조회이며 MCP 프로토콜 호출이 아닙니다. 상품 결과의 실제 id로 /browse/daiso/product/{id} 또는 /browse/daiso/inventory/{id}/{사용자 지역}을 여세요. 모든 후속 조회도 /browse/를 사용하세요. 검색어의 각 경로 값은 한 번 URL 인코딩합니다. 부가 조건은 /page/2/pageSize/10처럼 이름/값 쌍으로 붙입니다. 주요 검색어 없이 필터만 쓰면 /browse/query/서비스/기능/이름/값을 사용합니다. 상품 검색만으로 매장 재고를 단정하지 마세요. ${IMAGE_SOURCE_RULE} ${EXECUTION_EVIDENCE_RULE} 연결된 MCP 클라이언트는 initialize 절차를 처리합니다. MCP 결과의 isError=true/error를 확인하고 structuredContent 또는 content[].text에서 실제 상품을 읽습니다. 웹 읽기는 WebFetch·open 도구에서 위 HTML GET 링크를 그대로 여세요. 기존 서비스별 MCP 이름은 deprecated 호출 별칭입니다. 새 연동은 공개 도구 10개만 사용하며 도구를 늘리지 않습니다. -상세 API와 서비스 안내: ${baseUrl}/prompt?guide=${AGENT_GUIDE_VERSION} +상세 API와 서비스 안내: ${baseUrl}/prompt · 전체 웹 조회: ${baseUrl}/browse --- @@ -63,5 +68,8 @@ export function createAgentStartResponse(baseUrl: string): Response { /** 오래된 버전 주소도 현재 안내로 연결하며 영구 리다이렉트 캐시를 만들지 않습니다. */ export function createVersionedGuideResponse(baseUrl: string, version: string): Response { if (version === AGENT_GUIDE_VERSION) return createAgentStartResponse(baseUrl); - return new Response(null, { status: 307, headers: { Location: AGENT_GUIDE_PATH, 'Cache-Control': 'no-store' } }); + return new Response(null, { + status: 307, + headers: { Location: AGENT_GUIDE_PATH, 'Cache-Control': 'no-store' }, + }); } diff --git a/src/pages/apiResult.ts b/src/pages/apiResult.ts index 0a832605..19e90793 100644 --- a/src/pages/apiResult.ts +++ b/src/pages/apiResult.ts @@ -1,11 +1,23 @@ +import { toBrowsePath } from './browsePaths.js'; +import { browsePageLinks } from './browseLinks.js'; /** 웹 읽기 에이전트용 공개 GET 결과 표시 */ -import { WEB_READ_RULE, IMAGE_SOURCE_RULE, EXECUTION_EVIDENCE_RULE, AGENT_GUIDE_STAMP, AGENT_GUIDE_PATH, AGENT_GUIDE_VERSION, HTML_GUIDE_QUERY } from './agentStart.js'; +import { + WEB_READ_RULE, + IMAGE_SOURCE_RULE, + EXECUTION_EVIDENCE_RULE, + AGENT_GUIDE_STAMP, + AGENT_GUIDE_PATH, +} from './agentStart.js'; import type { MiddlewareHandler } from 'hono'; import type { AppBindings } from '../api/response.js'; function escapeHtml(value: unknown): string { - return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') - .replaceAll('"', '"').replaceAll("'", '''); + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); } /** 원본 HTTPS 이미지 URL만 렌더링하며 잘못된 주소는 대체하지 않습니다. */ @@ -19,23 +31,57 @@ function productImage(value: unknown, name: string): string { const markdown = `![${label}](<${value.replace(/[<>\r\n]/g, encodeURIComponent)}>)`; return `

${escapeHtml(name)}
원본 상품 이미지

복사할 원본 이미지 Markdown: ${escapeHtml(markdown)}

이 상품에는 이 원본 주소만 사용하세요. 이미지 검색·캐러셀로 대체하지 마세요.

`; } - } catch { /* 잘못된 원본 URL은 아래 이미지 없음으로 표시합니다. */ } + } catch { + /* 잘못된 원본 URL은 아래 이미지 없음으로 표시합니다. */ + } } return '

이미지 없음. 웹 검색 이미지로 대체하지 마세요.

'; } /** 모든 필드와 배열 순서를 보존하고 외부 문자열을 HTML로 해석하지 않습니다. */ -function renderValue(value: unknown, productLinks: string | null, product = false): string { +function renderValue( + value: unknown, + productLinks: string | null, + product: boolean, + source: URL, +): string { if (value === null || typeof value !== 'object') return escapeHtml(value); if (Array.isArray(value)) { - return value.length ? `
    ${value.map(item => `
  1. ${renderValue(item, productLinks, product)}
  2. `).join('')}
` : '

빈 목록

'; + return value.length + ? `
    ${value.map((item) => `
  1. ${renderValue(item, productLinks, product, source)}
  2. `).join('')}
` + : '

빈 목록

'; } - const fields = `
${Object.entries(value).map(([key, item]) => { - const rendered = renderValue(item, productLinks, key === 'products' || key === 'product' || (key === 'data' && productLinks !== null)); - const detail = productLinks && key === 'id' && (typeof item === 'string' || typeof item === 'number') - ? ` 상품 상세` : ''; - return `
${escapeHtml(key)}
${rendered}${detail}
`; - }).join('')}
`; + const fields = `
${Object.entries(value) + .map(([key, item]) => { + const rendered = renderValue( + item, + productLinks, + key === 'products' || key === 'product' || (key === 'data' && productLinks !== null), + source, + ); + let detail = ''; + if (productLinks && key === 'id' && (typeof item === 'string' || typeof item === 'number')) { + const path = toBrowsePath( + new URL(`${productLinks}/${encodeURIComponent(String(item))}`, 'https://browse.local'), + ); + detail = ` 상품 상세`; + if (productLinks === '/api/daiso/products') + detail += ` 매장 재고 (사용자 지역을 경로에 추가하세요)`; + } + if ( + source.pathname === '/api/daiso/inventory' && + key === 'storeCode' && + (typeof item === 'string' || typeof item === 'number') && + source.searchParams.has('productId') + ) { + const target = new URL('/api/daiso/display-location', source); + target.searchParams.set('productId', source.searchParams.get('productId')!); + target.searchParams.set('storeCode', String(item)); + detail += ` 진열 위치`; + } + return `
${escapeHtml(key)}
${rendered}${detail}
`; + }) + .join('')}
`; const record = value as Record; return product && typeof record.name === 'string' ? `

${escapeHtml(record.name)}

${productImage(record.imageUrl, record.name)}${fields}
` @@ -44,17 +90,34 @@ function renderValue(value: unknown, productLinks: string | null, product = fals export const apiResultHtml: MiddlewareHandler<{ Bindings: AppBindings }> = async (c, next) => { await next(); - if (c.req.method !== 'GET' || c.req.query('format') !== 'html' || !c.res.headers.get('Content-Type')?.includes('application/json')) return; + if ( + c.req.method !== 'GET' || + c.req.query('format') !== 'html' || + !c.res.headers.get('Content-Type')?.includes('application/json') + ) + return; let data: unknown; - try { data = await c.res.clone().json(); } catch { return; } + try { + data = await c.res.clone().json(); + } catch { + return; + } const url = new URL(c.req.url); url.searchParams.delete('format'); - const source = escapeHtml(url.pathname + url.search); + const source = escapeHtml(toBrowsePath(url) ?? url.pathname + url.search); + const jsonSource = escapeHtml(url.pathname + url.search); const failed = c.res.status >= 400 || (data as { success?: boolean } | null)?.success === false; const title = failed ? '조회 실패' : '조회 성공'; const japan = url.pathname.startsWith('/api/daiso-jp/'); - const productLinks = (url.pathname === '/api/daiso-jp/products' || url.pathname.startsWith('/api/daiso-jp/products/')) ? '/api/daiso-jp/products' : (url.pathname === '/api/daiso/products' || url.pathname.startsWith('/api/daiso/products/')) ? '/api/daiso/products' : null; - const inventoryGuide = japan ? '일본 매장 재고 미지원. 온라인 판매 가능 여부를 매장 재고로 해석하지 마세요.' : '매장 재고는 상품 ID와 매장명 또는 사용자 위치를 확인한 뒤 별도로 조회하세요.'; + const productLinks = + url.pathname === '/api/daiso-jp/products' || url.pathname.startsWith('/api/daiso-jp/products/') + ? '/api/daiso-jp/products' + : url.pathname === '/api/daiso/products' || url.pathname.startsWith('/api/daiso/products/') + ? '/api/daiso/products' + : null; + const inventoryGuide = japan + ? '일본 매장 재고 미지원. 온라인 판매 가능 여부를 매장 재고로 해석하지 마세요.' + : '매장 재고는 상품 ID와 매장명 또는 사용자 위치를 확인한 뒤 별도로 조회하세요.'; const html = `${title}: DAISO MCP

${title}

${escapeHtml(AGENT_GUIDE_STAMP)} · 현재 버전 안내 · 항상 최신 안내

@@ -62,16 +125,30 @@ export const apiResultHtml: MiddlewareHandler<{ Bindings: AppBindings }> = async

요청: ${source} · HTTP ${c.res.status}

실제 API 조회 결과입니다. 오류를 상품 없음이나 재고 0으로 해석하지 마세요. ${inventoryGuide}

${escapeHtml(IMAGE_SOURCE_RULE)}

${escapeHtml(EXECUTION_EVIDENCE_RULE)}

-${renderValue(data, productLinks)} -

개발자용 JSON 원본 (웹 읽기용 아님) · 설치 없는 조회 안내 · API 경로와 필수 파라미터 안내

-

다른 공개 GET 조회도 기존 파라미터에 format=html을 추가하면 HTML로 읽을 수 있습니다. 웹 읽기만으로 MCP 연결이 생기는 것은 아닙니다.

`; +${renderValue(data, productLinks, false, url)} + +

설치 없는 조회 안내 · API 경로와 필수 파라미터 안내

+

모든 서비스의 웹 조회 경로: 조회 조건은 /browse/ 경로에 넣으세요. format이나 쿼리 문자열은 필요 없습니다. 웹 읽기만으로 MCP 연결이 생기는 것은 아닙니다.

개발자용 JSON 원본 (웹 읽기용 아님)${jsonSource}
`; const headers = new Headers(c.res.headers); headers.set('Content-Type', 'text/html; charset=utf-8'); headers.set('Cache-Control', 'no-store'); headers.set('X-Content-Type-Options', 'nosniff'); - headers.set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src https:; base-uri 'none'; frame-ancestors 'none'"); + headers.set( + 'Content-Security-Policy', + "default-src 'none'; style-src 'unsafe-inline'; img-src https:; base-uri 'none'; frame-ancestors 'none'", + ); c.res = new Response(html, { status: c.res.status, headers }); c.res.headers.set('Cache-Control', 'no-store'); // Hono는 응답 교체 시 기존 헤더를 병합하므로 교체 후 원본 표현의 헤더를 제거합니다. - for (const name of ['Content-Length', 'Content-Encoding', 'ETag', 'Last-Modified', 'Content-MD5', 'Digest']) c.res.headers.delete(name); + for (const name of [ + 'Content-Length', + 'Content-Encoding', + 'ETag', + 'Last-Modified', + 'Content-MD5', + 'Digest', + ]) + c.res.headers.delete(name); }; diff --git a/src/pages/browse.ts b/src/pages/browse.ts new file mode 100644 index 00000000..cbdcae30 --- /dev/null +++ b/src/pages/browse.ts @@ -0,0 +1,47 @@ +/** 기존 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.get('/browse/', (c) => c.redirect('/browse', 307)); + 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/browseLinks.ts b/src/pages/browseLinks.ts new file mode 100644 index 00000000..e5e007f1 --- /dev/null +++ b/src/pages/browseLinks.ts @@ -0,0 +1,59 @@ +/** API가 명시한 페이지 정보만 사용해 검색 조건을 보존합니다. */ +import { toBrowsePath } from './browsePaths.js'; +const PAGE_ROUTES = new Set([ + '/api/daiso/products', + '/api/daiso/inventory', + '/api/daiso-jp/products', + '/api/daiso-jp/stores', + '/api/seveneleven/products', + '/api/emart24/products', + '/api/emart24/stores', + '/api/oliveyoung/products', + '/api/oliveyoung/inventory', +]); + +export function browsePageLinks(source: URL, body: unknown): { label: string; path: string }[] { + if (!body || typeof body !== 'object' || !PAGE_ROUTES.has(source.pathname)) return []; + const result = body as { + success?: boolean; + meta?: { page?: number; pageSize?: number; total?: number }; + data?: { hasNext?: boolean; nextPage?: number }; + }; + if (result.success === false) return []; + const page = Number(source.searchParams.get('page') ?? result.meta?.page ?? 1); + const size = Number( + source.searchParams.get('pageSize') ?? + source.searchParams.get('size') ?? + result.meta?.pageSize ?? + 0, + ); + const maxPage = source.pathname.startsWith('/api/daiso-jp/') ? 100 : Number.MAX_SAFE_INTEGER - 1; + if (!Number.isSafeInteger(page) || page < 1 || page > maxPage) return []; + const links: { label: string; path: string }[] = []; + const add = (label: string, next: number) => { + const target = new URL(source); + target.searchParams.set('page', String(next)); + links.push({ label, path: toBrowsePath(target)! }); + }; + if (page > 1) add('이전 페이지', page - 1); + const nextPage = result.data?.nextPage; + if ( + typeof nextPage === 'number' && + Number.isSafeInteger(nextPage) && + nextPage > page && + nextPage <= maxPage + ) { + add('다음 페이지', nextPage); + } else if ( + page < maxPage && + (result.data?.hasNext === true || + (Number.isSafeInteger(size) && + size > 0 && + typeof result.meta?.total === 'number' && + result.data?.hasNext !== false && + page * size < result.meta.total)) + ) { + add('다음 페이지', page + 1); + } + return links; +} diff --git a/src/pages/browsePaths.ts b/src/pages/browsePaths.ts new file mode 100644 index 00000000..d268efdd --- /dev/null +++ b/src/pages/browsePaths.ts @@ -0,0 +1,137 @@ +/** 공개 읽기 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) { + try { + params.set( + 'productId', + decodeURIComponent(source.pathname.slice(source.pathname.lastIndexOf('/') + 1)), + ); + } catch { + return null; + } + } + 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.positional.length === 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/src/pages/discovery.ts b/src/pages/discovery.ts index b1c57409..4590ea8c 100644 --- a/src/pages/discovery.ts +++ b/src/pages/discovery.ts @@ -1,17 +1,33 @@ -import { buildAgentStartText, WEB_READ_RULE, ROUTE_SELECTION_TEXT, IMAGE_SOURCE_RULE, EXECUTION_EVIDENCE_RULE, AGENT_GUIDE_STAMP, WEB_SEARCH_DESCRIPTION, AGENT_GUIDE_PATH, HTML_GUIDE_QUERY, AGENT_GUIDE_VERSION } from './agentStart.js'; +import { + buildAgentStartText, + WEB_READ_RULE, + ROUTE_SELECTION_TEXT, + IMAGE_SOURCE_RULE, + EXECUTION_EVIDENCE_RULE, + AGENT_GUIDE_STAMP, + WEB_SEARCH_DESCRIPTION, + AGENT_GUIDE_PATH, +} from './agentStart.js'; const BASE = 'https://mcp.aka.page'; /** 레지스트리 설명이 HTML로 해석되지 않도록 이스케이프합니다. */ function escapeHtml(value: string): string { - return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') - .replaceAll('"', '"').replaceAll("'", '''); + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); } /** 배포마다 현재 레지스트리에서 정적 발견 자산을 생성합니다. */ -export function buildDiscoveryAssets(info: { services: { name: string }[]; tools: string[] }): Record { +export function buildDiscoveryAssets(info: { + services: { name: string }[]; + tools: string[]; +}): Record { const services = info.services.map((service) => escapeHtml(service.name)).join(' · '); - const firstQuery = '/api/daiso/products?q=%EC%88%98%EB%82%A9%EB%B0%95%EC%8A%A4&pageSize=3&format=html' + HTML_GUIDE_QUERY; + const firstQuery = '/browse/daiso/products/%EC%88%98%EB%82%A9%EB%B0%95%EC%8A%A4/pageSize/3'; return { 'discovery.html': ` @@ -25,27 +41,57 @@ export function buildDiscoveryAssets(info: { services: { name: string }[]; tools

${escapeHtml(WEB_SEARCH_DESCRIPTION)}

${escapeHtml(WEB_READ_RULE)}

바로 조회하기

다이소 수납박스 상품 3개 조회

-

핫식스 상품 검색 · 강남 매장 검색

-

위 링크는 셸·설치 없이 웹 읽기로 호출하는 공개 REST GET의 HTML 결과입니다. 다른 공개 GET 조회도 기존 파라미터에 format=html을 추가하세요. 검색한 상품의 실제 ID로 재고를 조회하세요. 상품 검색만으로 매장 재고를 단정하지 마세요.

+

핫식스 상품 검색 · 강남 매장 검색

+

위 링크는 셸·설치 없이 웹 읽기로 호출하는 공개 REST GET의 HTML 결과입니다. 모든 서비스의 /browse/ 경로는 자동 HTML이며 쿼리 문자열은 필요 없습니다. 검색한 상품의 실제 ID로 재고를 조회하세요. 상품 검색만으로 매장 재고를 단정하지 마세요.

에이전트에서 설치 없이 사용하기

원격 MCP 주소: ${BASE}
전송: Streamable HTTP · 인증: 없음

${escapeHtml(ROUTE_SELECTION_TEXT).replaceAll('\n', '
')}

${escapeHtml(IMAGE_SOURCE_RULE)}

${escapeHtml(EXECUTION_EVIDENCE_RULE)}

웹 읽기 기능으로 이 페이지를 읽는 것만으로 MCP 연결이 생기지는 않습니다. 연결된 MCP 도구가 없고 웹 읽기가 가능하면 셸 유무와 무관하게 위 HTML REST 링크를 바로 여세요. 오류를 상품 없음으로 해석하지 마세요.

-

짧은 시작 안내 (현재 버전) · 전체 API 사용 안내 · 개발자용 서비스·도구 목록 JSON

+

짧은 시작 안내 (현재 버전) · 전체 API 사용 안내 · 개발자용 서비스·도구 목록 JSON

지원 서비스

${services}

\n`, 'root.json': JSON.stringify(info, null, 2) + '\n', 'llms.txt': buildAgentStartText(BASE), - 'robots.txt': '# 공개 검색 및 AI 에이전트의 읽기를 허용합니다.\n' + - ['*', 'ChatGPT-User', 'OAI-SearchBot', 'GPTBot', 'CCBot', 'anthropic-ai', 'Claude-Web', - 'Claude-User', 'Claude-SearchBot', 'Google-Extended', 'PerplexityBot', 'Perplexity-User', 'Applebot-Extended'] - .map((bot) => `User-agent: ${bot}\nAllow: /\n`).join('\n') + + 'robots.txt': + '# 공개 검색 및 AI 에이전트의 읽기를 허용합니다.\n' + + [ + '*', + 'ChatGPT-User', + 'OAI-SearchBot', + 'GPTBot', + 'CCBot', + 'anthropic-ai', + 'Claude-Web', + 'Claude-User', + 'Claude-SearchBot', + 'Google-Extended', + 'PerplexityBot', + 'Perplexity-User', + 'Applebot-Extended', + ] + .map((bot) => `User-agent: ${bot}\nAllow: /\n`) + .join('\n') + `\nSitemap: ${BASE}/sitemap.xml\n# 에이전트 안내: ${BASE}${AGENT_GUIDE_PATH}\n# 최신 안내 연결: ${BASE}/guide/latest\n`, - 'sitemap.xml': '\n\n' + - ['/', AGENT_GUIDE_PATH, '/guide/latest', '/llms.txt', '/prompt', '/root.json', '/privacy'] - .map((path) => `${BASE}${path}`).join('\n') + '\n\n', - '_headers': '/*\n Access-Control-Allow-Origin: *\n Cache-Control: public, max-age=300\n X-Content-Type-Options: nosniff\n/llms.txt\n Content-Type: text/plain; charset=utf-8\n Cache-Control: no-store\n/discovery.html\n Cache-Control: no-store\n/robots.txt\n Content-Type: text/plain; charset=utf-8\n', + 'sitemap.xml': + '\n\n' + + [ + '/', + '/browse', + '/browse/daiso/products/%ED%95%AB%EC%8B%9D%EC%8A%A4', + '/browse/daiso/stores/%EA%B0%95%EB%82%A8', + AGENT_GUIDE_PATH, + '/guide/latest', + '/llms.txt', + '/prompt', + '/root.json', + '/privacy', + ] + .map((path) => `${BASE}${path}`) + .join('\n') + + '\n\n', + _headers: + '/*\n Access-Control-Allow-Origin: *\n Cache-Control: public, max-age=300\n X-Content-Type-Options: nosniff\n/llms.txt\n Content-Type: text/plain; charset=utf-8\n Cache-Control: no-store\n/discovery.html\n Cache-Control: no-store\n/robots.txt\n Content-Type: text/plain; charset=utf-8\n', }; } diff --git a/src/pages/prompt.ts b/src/pages/prompt.ts index 5b2425bd..72912efa 100644 --- a/src/pages/prompt.ts +++ b/src/pages/prompt.ts @@ -5,7 +5,7 @@ * 에이전트가 이 페이지를 읽고 GET API를 사용할 수 있습니다. */ -import { buildAgentStartText, HTML_GUIDE_QUERY } from './agentStart.js'; +import { buildAgentStartText } from './agentStart.js'; import { buildPromptCoreText } from './promptCoreText.js'; import { buildPromptReferenceText } from './promptReferenceText.js'; import { buildPromptRetailCinemaText } from './promptRetailCinemaText.js'; @@ -22,15 +22,15 @@ export function generatePromptText(baseUrl: string): string { ` ## 일본 다이소 (daiso_jp) 일본어 키워드로 일본 다이소를 검색합니다. 한국 다이소와 상품·매장 ID가 다르며 가격은 JPY입니다. -- 상품: ${baseUrl}/api/daiso-jp/products?q=%E3%83%9C%E3%82%A6%E3%83%AB&format=html -- 상세: ${baseUrl}/api/daiso-jp/products/4550746075917?format=html -- 매장: ${baseUrl}/api/daiso-jp/stores?keyword=%E6%B8%8B%E8%B0%B7&format=html +- 상품: ${baseUrl}/browse/daiso-jp/products/ボウル +- 상세: ${baseUrl}/browse/daiso-jp/product/4550746075917 +- 매장: ${baseUrl}/browse/daiso-jp/stores/渋谷 매장명·주소를 검색하므로 지역을 확인하세요. 다음 페이지는 page를 증가시키되 hasNext=true일 때만 요청하세요. 온라인 판매 가능 여부는 매장 재고가 아닙니다. 일본 매장 재고 조회는 아직 지원하지 않습니다. MCP: search_products {"service":"daiso_jp","query":"ボウル"}, get_product_details {"service":"daiso_jp","operation":"product","id":"검색한 실제 코드"}, find_stores {"service":"daiso_jp","keyword":"渋谷"}. CLI: jp-products ボウル / jp-product 4550746075917 / jp-stores 渋谷. `, - ].join('').replace(/(https?:\/\/[^\s]+[?&]format=html)(?!&guide=)/g, '$1' + HTML_GUIDE_QUERY); + ].join(''); } /** diff --git a/src/pages/promptCoreText.ts b/src/pages/promptCoreText.ts index 36e7b387..396b7ba6 100644 --- a/src/pages/promptCoreText.ts +++ b/src/pages/promptCoreText.ts @@ -6,7 +6,7 @@ export function buildPromptCoreText(baseUrl: string): string { return `# 다이소 MCP API 다이소 제품 검색, 매장 찾기, 재고 확인을 위한 API입니다. -아래 상세 REST API 요청은 GET 방식입니다. 웹 읽기용 URL 예시는 format=html을 포함합니다. 응답 예시 JSON은 기본 JSON 응답의 구조를 설명하며 HTML에도 같은 실제 필드가 표시됩니다. JSON API 클라이언트는 format을 생략하세요. +아래 웹 조회는 /browse/ 경로를 open/WebFetch로 여는 GET 방식입니다. 모든 조회가 자동 HTML이며 쿼리 문자열은 필요 없습니다. 응답 예시 JSON은 기본 JSON 응답의 구조를 설명하며 HTML에도 같은 실제 필드가 표시됩니다. 개발자용 기존 /api/ JSON API는 그대로 유지합니다. Base URL: ${baseUrl} @@ -18,7 +18,7 @@ Base URL: ${baseUrl} **설명**: 키워드로 다이소 제품을 검색합니다. -**URL**: ${baseUrl}/api/daiso/products?q={검색어}&format=html +**URL**: ${baseUrl}/browse/daiso/products/{검색어} **필수 파라미터**: - q: 검색 키워드 (예: 수납박스, 펜, 정리함) @@ -28,8 +28,8 @@ Base URL: ${baseUrl} - pageSize: 페이지당 결과 수 (기본값: 30, 최대: 100) **예시**: -- ${baseUrl}/api/daiso/products?q=수납박스&format=html -- ${baseUrl}/api/daiso/products?q=펜&page=2&pageSize=10&format=html +- ${baseUrl}/browse/daiso/products/수납박스 +- ${baseUrl}/browse/daiso/products/펜/page/2/pageSize/10 **응답 예시**: \`\`\`json @@ -58,10 +58,10 @@ Base URL: ${baseUrl} **설명**: 제품 ID로 상세 정보를 조회합니다. -**URL**: ${baseUrl}/api/daiso/products/{제품ID}?format=html +**URL**: ${baseUrl}/browse/daiso/product/{제품ID} **예시**: -- ${baseUrl}/api/daiso/products/1234567890?format=html +- ${baseUrl}/browse/daiso/product/1234567890 **응답 예시**: \`\`\`json @@ -86,7 +86,7 @@ Base URL: ${baseUrl} **설명**: 키워드 또는 지역으로 다이소 매장을 검색합니다. -**URL**: ${baseUrl}/api/daiso/stores?keyword={키워드}&format=html +**URL**: ${baseUrl}/browse/daiso/stores/{키워드} **필수 파라미터** (둘 중 하나 필수): - keyword: 매장명 또는 주소 키워드 (예: 강남, 홍대, 안산) @@ -98,9 +98,9 @@ Base URL: ${baseUrl} - limit: 최대 결과 수 (기본값: 50) **예시**: -- ${baseUrl}/api/daiso/stores?keyword=강남&format=html -- ${baseUrl}/api/daiso/stores?sido=서울&gugun=마포구&format=html -- ${baseUrl}/api/daiso/stores?keyword=홍대&limit=10&format=html +- ${baseUrl}/browse/daiso/stores/강남 +- ${baseUrl}/browse/query/daiso/stores/sido/서울/gugun/마포구 +- ${baseUrl}/browse/daiso/stores/홍대/limit/10 **응답 예시**: \`\`\`json @@ -134,7 +134,7 @@ Base URL: ${baseUrl} **설명**: 특정 제품의 매장별 재고와 온라인 재고를 확인합니다. -**URL**: ${baseUrl}/api/daiso/inventory?productId={제품ID}&format=html +**URL**: ${baseUrl}/browse/daiso/inventory/{제품ID} **필수 파라미터**: - productId: 제품 ID (제품 검색 API에서 조회한 id 값) @@ -152,9 +152,9 @@ Base URL: ${baseUrl} - 안산 중앙역 같은 역명 키워드가 비면 안산중앙역, 안산중앙, 고잔처럼 붙여쓴 변형으로 재시도하세요. **예시**: -- ${baseUrl}/api/daiso/inventory?productId=1234567890&format=html -- ${baseUrl}/api/daiso/inventory?productId=1234567890&lat=37.3219&lng=126.8309&format=html -- ${baseUrl}/api/daiso/inventory?productId=1234567890&keyword=안산&format=html +- ${baseUrl}/browse/daiso/inventory/1234567890 +- ${baseUrl}/browse/daiso/inventory/1234567890/lat/37.3219/lng/126.8309 +- ${baseUrl}/browse/daiso/inventory/1234567890/안산 **응답 예시**: \`\`\`json @@ -197,14 +197,14 @@ Base URL: ${baseUrl} **설명**: 특정 상품이 특정 매장 내 어디에 진열되어 있는지(구역/층) 조회합니다. -**URL**: ${baseUrl}/api/daiso/display-location?productId={제품ID}&storeCode={매장코드}&format=html +**URL**: ${baseUrl}/browse/daiso/display-location/{제품ID}/{매장코드} **필수 파라미터**: - productId: 제품 ID (제품 검색 API에서 조회한 id 값) - storeCode: 매장 코드 (재고 확인 API 결과의 storeCode 값) **예시**: -- ${baseUrl}/api/daiso/display-location?productId=1234567890&storeCode=04515&format=html +- ${baseUrl}/browse/daiso/display-location/1234567890/04515 --- @@ -212,7 +212,7 @@ Base URL: ${baseUrl} **설명**: 위치 기반으로 주변 올리브영 매장을 검색합니다. -**URL**: ${baseUrl}/api/oliveyoung/stores?keyword={키워드}&format=html +**URL**: ${baseUrl}/browse/oliveyoung/stores/{키워드} **선택 파라미터**: - keyword: 매장명/지역 키워드 (예: 명동, 강남) @@ -222,8 +222,8 @@ Base URL: ${baseUrl} - limit: 최대 결과 수 (기본값: 20) **예시**: -- ${baseUrl}/api/oliveyoung/stores?keyword=명동&format=html -- ${baseUrl}/api/oliveyoung/stores?lat=37.498&lng=127.027&limit=5&format=html +- ${baseUrl}/browse/oliveyoung/stores/명동 +- ${baseUrl}/browse/query/oliveyoung/stores/lat/37.498/lng/127.027/limit/5 --- @@ -235,7 +235,7 @@ Base URL: ${baseUrl} - 응답의 \`products[].imageUrl\`이 있으면 각 상품 이미지를 반드시 마크다운 이미지로 함께 렌더링합니다. - 여러 상품이 나오면 첫 상품만 대표로 보여주지 말고, 이미지가 있는 상품은 전부 표시합니다. -**URL**: ${baseUrl}/api/oliveyoung/products?keyword={검색어}&format=html +**URL**: ${baseUrl}/browse/oliveyoung/products/{검색어} **필수 파라미터**: - keyword: 상품 검색어 (예: 마스크팩, 선크림, 립밤) @@ -247,8 +247,8 @@ Base URL: ${baseUrl} - includeSoldOut: 품절 포함 여부 (기본값: false) **예시**: -- ${baseUrl}/api/oliveyoung/products?keyword=마스크팩&format=html -- ${baseUrl}/api/oliveyoung/products?keyword=립밤&size=10&format=html +- ${baseUrl}/browse/oliveyoung/products/마스크팩 +- ${baseUrl}/browse/oliveyoung/products/립밤/size/10 --- @@ -256,7 +256,7 @@ Base URL: ${baseUrl} **설명**: 상품 키워드 기준 올리브영 재고를 조회하고 주변 매장 목록을 함께 반환합니다. 상위 상품에는 storeInventory가 포함되며 매장별 재고 3개, 재고 9개 이상, 품절, 미판매 같은 상태를 제공합니다. -**URL**: ${baseUrl}/api/oliveyoung/inventory?keyword={검색어}&format=html +**URL**: ${baseUrl}/browse/oliveyoung/inventory/{검색어} **필수 파라미터**: - keyword: 상품 검색어 (예: 선크림, 립밤) @@ -270,8 +270,8 @@ Base URL: ${baseUrl} - includeSoldOut: 품절 포함 여부 (기본값: false) **예시**: -- ${baseUrl}/api/oliveyoung/inventory?keyword=선크림&format=html -- ${baseUrl}/api/oliveyoung/inventory?keyword=립밤&storeKeyword=명동&format=html +- ${baseUrl}/browse/oliveyoung/inventory/선크림 +- ${baseUrl}/browse/oliveyoung/inventory/립밤/명동 --- diff --git a/src/pages/promptReferenceText.ts b/src/pages/promptReferenceText.ts index c3b524c1..25c9e67d 100644 --- a/src/pages/promptReferenceText.ts +++ b/src/pages/promptReferenceText.ts @@ -5,7 +5,7 @@ export function buildPromptReferenceText(baseUrl: string): string { return `## 응답 형식 -아래 JSON은 기본 JSON 응답의 구조입니다. 웹 읽기 전용 에이전트는 같은 API에 format=html을 반드시 포함해 HTML 결과를 읽으세요. +아래 JSON은 기본 JSON 응답의 구조입니다. 웹 읽기 전용 에이전트는 /browse/ 경로로 HTML 결과를 읽으세요. 쿼리 문자열이나 format은 필요 없습니다. ### 성공 응답 \`\`\`json @@ -65,12 +65,12 @@ export function buildPromptReferenceText(baseUrl: string): string { ## 에이전트 실행 레시피 -- "콜라 어디가 싸?", "컵라면 가격 비교해줘": 먼저 \`compare_products\` 또는 /api/compare/products를 호출합니다. 실제 재고와 매장 행사가가 필요하면 최저가 후보의 서비스별 재고/상품 도구로 한 번 더 확인합니다. -- "강남역 근처 카페", "성수동 브런치 음식점": 브랜드를 되묻지 말고 \`find_places\` (service=places) 또는 /api/places/search를 호출합니다. 이 검색은 키워드 기반이므로 좌표 반경 결과처럼 단정하지 않습니다. +- "콜라 어디가 싸?", "컵라면 가격 비교해줘": 먼저 \`compare_products\` 또는 /browse/compare/products를 호출합니다. 실제 재고와 매장 행사가가 필요하면 최저가 후보의 서비스별 재고/상품 도구로 한 번 더 확인합니다. +- "강남역 근처 카페", "성수동 브런치 음식점": 브랜드를 되묻지 말고 \`find_places\` (service=places) 또는 /browse/places/search를 호출합니다. 이 검색은 키워드 기반이므로 좌표 반경 결과처럼 단정하지 않습니다. - "다이소 핫식스 재고", "올리브영 두바이초콜릿 있어?": 상품이 브랜드와 어색해 보여도 브랜드를 바꾸지 말고 먼저 다이소로 검색하거나 명시된 서비스로 검색합니다. 결과가 없을 때만 다른 서비스 대안을 제안합니다. - "GS25 강남 오감자 재고": 상품명과 위치를 분리해 상품 후보를 먼저 찾고, 선택한 itemCode나 keyword와 storeKeyword로 재고를 확인합니다. - "오늘 강남 CGV 시간표": 오늘 날짜는 KST 기준 YYYYMMDD로 계산하고, theaterCode가 없으면 극장 검색 후 시간표를 조회합니다. -- MCP 기능이 제대로 동작하지 않거나 개선/신규 기능 요청이 있으면 \`submit_request\` (requestType=developer, request에 제목과 설명) 또는 /api/feedback/requests를 사용해 개발자에게 전달합니다. 제목과 설명은 필수입니다. +- MCP 기능이 제대로 동작하지 않거나 개선/신규 기능 요청이 있으면 \`submit_request\` (requestType=developer, request에 제목과 설명) 또는 연결된 MCP를 통해 개발자에게 전달합니다. 웹 GET으로 요청을 제출하지 않습니다. 제목과 설명은 필수입니다. - 도구 호출이 실패하면 같은 도구를 한 번 더 좁은 조건으로 재시도합니다. 두 번 실패하면 실패한 서비스와 요청 조건을 밝히고 추측으로 결과를 만들지 않습니다. - 결과에 imageUrl이 있으면 각 항목 이미지 표시를 우선합니다. 쿼리 문자열이 있는 이미지 URL도 그대로 사용합니다. @@ -93,10 +93,10 @@ export function buildPromptReferenceText(baseUrl: string): string { - **규칙 6**: \`imageUrl\`에 query string이 있으면 \`?l=ko\` 같은 쿼리를 절대 삭제하거나 정규화하지 말고, 마크다운 이미지 src에 전체 URL을 그대로 사용하세요. - **규칙 7**: 이미지 src를 비교하거나 캐시 키를 판단할 때도 hostname + pathname만 쓰지 말고 query string까지 포함한 전체 URL을 기준으로 취급하세요. 5. **재고 확인 워크플로우**: - - 먼저 /api/daiso/products로 제품 검색 + - 먼저 /browse/daiso/products로 제품 검색 - 결과에서 원하는 제품의 id 확인 - - /api/daiso/inventory에 해당 id와 확인한 매장 조건, format=html로 재고 조회 - - 진열 위치가 필요하면 재고 응답의 storeCode를 확인한 뒤 /api/daiso/display-location에 productId + storeCode로 조회 + - /browse/daiso/inventory/{실제id}/{확인한매장명}으로 재고 조회 + - 진열 위치가 필요하면 재고 응답의 storeCode를 확인한 뒤 /browse/daiso/display-location/{실제productId}/{실제storeCode}로 조회 - 다이소 재고 조회는 storeCode가 필요하지 않습니다. 6. **최소 정보 요청 처리**: - 사용자가 상품명만 주면 먼저 상품 검색 도구로 후보를 찾고, 응답의 productId/itemCode/pluCd를 다음 단계에 전달합니다. @@ -108,12 +108,12 @@ export function buildPromptReferenceText(baseUrl: string): string { - 너무 넓은 축약어로만 매칭된 경우에는 확정 상품처럼 단정하지 말고, 추정 매칭임을 짧게 밝힌 뒤 재고를 안내합니다. - 위치/브랜드/상품명은 한 인자에 섞지 말고 가능한 한 분리해서 도구에 전달합니다. 8. **위치 기반 재고**: lat, lng 파라미터로 가까운 매장 우선 조회 -10. **세븐일레븐 재고 조회**: /api/seveneleven/inventory에 keyword + storeKeyword를 함께 주면 매장별 수량을 바로 확인할 수 있습니다. -11. **이마트24 재고 조회**: /api/emart24/inventory는 pluCd + storeKeyword 조합도 지원하므로, 상품 선택 뒤 매장 코드를 다시 모으지 않아도 됩니다. +10. **세븐일레븐 재고 조회**: /browse/seveneleven/inventory에 keyword + storeKeyword를 함께 주면 매장별 수량을 바로 확인할 수 있습니다. +11. **이마트24 재고 조회**: /browse/emart24/inventory는 pluCd + storeKeyword 조합도 지원하므로, 상품 선택 뒤 매장 코드를 다시 모으지 않아도 됩니다. 12. **올리브영 재고 해석**: inventory.products[].storeInventory.stores[]가 있으면 그 매장별 stockLabel과 remainQuantity를 우선 사용하고, inStock는 그 주변 매장 기준 결과로 해석합니다. -13. **올리브영 상품 이미지 표시**: /api/oliveyoung/products 또는 search_products(service=oliveyoung) 결과에 imageUrl이 있으면 목록형 답변에서도 각 상품 이미지를 생략하지 말고 모두 렌더링합니다. 특히 \`?l=ko\` 같은 query string을 삭제하지 말고 전체 URL 그대로 마크다운 이미지 src에 넣습니다. -14. **통합 가격 후보 비교**: 사용자가 브랜드를 특정하지 않고 "어디가 싸?", "비교해줘"처럼 물으면 \`compare_products\` 또는 /api/compare/products를 먼저 사용합니다. 새 외부 키 없이 기존 상품 검색을 묶은 1차 가격 후보이므로 실제 재고/행사가는 각 서비스 상세 조회로 확인해야 합니다. -15. **주변 음식점/카페 검색**: 음식점이나 카페만 물으면 \`find_places\` (service=places) 또는 /api/places/search를 먼저 사용합니다. 이 기능은 네이버 지역 검색 기반 키워드 검색이므로 좌표 반경 검색처럼 단정하지 말고, \`searchMode: "keyword"\`임을 필요할 때 짧게 밝혀주세요. +13. **올리브영 상품 이미지 표시**: /browse/oliveyoung/products 또는 search_products(service=oliveyoung) 결과에 imageUrl이 있으면 목록형 답변에서도 각 상품 이미지를 생략하지 말고 모두 렌더링합니다. 특히 \`?l=ko\` 같은 query string을 삭제하지 말고 전체 URL 그대로 마크다운 이미지 src에 넣습니다. +14. **통합 가격 후보 비교**: 사용자가 브랜드를 특정하지 않고 "어디가 싸?", "비교해줘"처럼 물으면 \`compare_products\` 또는 /browse/compare/products를 먼저 사용합니다. 새 외부 키 없이 기존 상품 검색을 묶은 1차 가격 후보이므로 실제 재고/행사가는 각 서비스 상세 조회로 확인해야 합니다. +15. **주변 음식점/카페 검색**: 음식점이나 카페만 물으면 \`find_places\` (service=places) 또는 /browse/places/search를 먼저 사용합니다. 이 기능은 네이버 지역 검색 기반 키워드 검색이므로 좌표 반경 검색처럼 단정하지 말고, \`searchMode: "keyword"\`임을 필요할 때 짧게 밝혀주세요. --- diff --git a/src/pages/promptRetailCinemaText.ts b/src/pages/promptRetailCinemaText.ts index 2a4c7597..8002eb0e 100644 --- a/src/pages/promptRetailCinemaText.ts +++ b/src/pages/promptRetailCinemaText.ts @@ -5,9 +5,9 @@ export function buildPromptRetailCinemaText(baseUrl: string): string { return `### 디트릭스 독립·예술영화관 -- 극장 카탈로그: ${baseUrl}/api/dtryx/cinemas?region=서울&format=html -- 상영작: ${baseUrl}/api/dtryx/movies?keyword=모모&includePlayDates=true&format=html -- 회차와 잔여 좌석: ${baseUrl}/api/dtryx/seats?region=서울&format=html +- 극장 카탈로그: ${baseUrl}/browse/dtryx/cinemas/region/서울 +- 상영작: ${baseUrl}/browse/dtryx/movies/keyword/모모/includePlayDates/true +- 회차와 잔여 좌석: ${baseUrl}/browse/dtryx/seats/region/서울 상영작 조회는 cinemaCode 또는 keyword가 필요합니다. 날짜 기본값은 한국 날짜 기준 오늘이며 YYYYMMDD 또는 YYYY-MM-DD를 받습니다. 지역·극장명·브랜드 필터는 함께 적용합니다. 카탈로그는 확인된 22곳이며 전체 제휴 극장이 아닙니다. 일부 실패는 failedCinemas로 표시하고 전체 조회 실패는 오류입니다. @@ -16,15 +16,15 @@ export function buildPromptRetailCinemaText(baseUrl: string): string { **설명**: 새 외부 API 키 없이 기존 다이소, GS25, 세븐일레븐, 이마트24 상품 검색을 묶어 가격 후보를 비교합니다. 실제 매장 재고와 행사가까지 확정하는 기능이 아니라 "어디가 싸 보이는지"를 빠르게 고르는 1차 검색입니다. **URL**: -- ${baseUrl}/api/compare/products?keyword={검색어}&format=html +- ${baseUrl}/browse/compare/products/{검색어} **선택 파라미터**: - services: daiso,gs25,seveneleven,emart24 중 쉼표 구분 목록 - limit: 서비스별 최대 결과 수 (기본값: 5) **예시**: -- ${baseUrl}/api/compare/products?keyword=콜라&limit=3&format=html -- ${baseUrl}/api/compare/products?keyword=컵라면&services=seveneleven,emart24&format=html +- ${baseUrl}/browse/compare/products/콜라/limit/3 +- ${baseUrl}/browse/compare/products/컵라면/services/seveneleven%2Cemart24 --- @@ -33,8 +33,8 @@ export function buildPromptRetailCinemaText(baseUrl: string): string { **설명**: 네이버 지역 검색으로 특정 지역의 음식점, 카페, 디저트 가게 등 주변 장소를 조회합니다. 좌표 반경 검색이 아니라 \`강남역 카페\` 같은 키워드 기반 검색입니다. **URL**: -- ${baseUrl}/api/places/search?location={지역}&category={카테고리}&format=html -- ${baseUrl}/api/places/search?location={지역}&keyword={검색어}&format=html +- ${baseUrl}/browse/places/search/{지역}/category/{카테고리} +- ${baseUrl}/browse/places/search/{지역}/{검색어} **선택 파라미터**: - location: 지역/역/주소 키워드 (예: 강남역, 성수동) @@ -44,8 +44,8 @@ export function buildPromptRetailCinemaText(baseUrl: string): string { - sort: random 또는 comment **예시**: -- ${baseUrl}/api/places/search?location=강남역&category=cafe&limit=5&format=html -- ${baseUrl}/api/places/search?location=성수동&keyword=브런치&limit=5&format=html +- ${baseUrl}/browse/places/search/강남역/category/cafe/limit/5 +- ${baseUrl}/browse/places/search/성수동/브런치/limit/5 --- @@ -54,10 +54,10 @@ export function buildPromptRetailCinemaText(baseUrl: string): string { **설명**: GS25 매장 탐색, 상품 키워드 검색, 재고 조회를 제공합니다. **URL**: -- ${baseUrl}/api/gs25/stores?keyword={키워드}&format=html -- ${baseUrl}/api/gs25/products?keyword={검색어}&format=html -- ${baseUrl}/api/gs25/inventory?keyword={검색어}&format=html -- ${baseUrl}/api/gs25/inventory?itemCode={상품코드}&format=html +- ${baseUrl}/browse/gs25/stores/{키워드} +- ${baseUrl}/browse/gs25/products/{검색어} +- ${baseUrl}/browse/gs25/inventory/{검색어} +- ${baseUrl}/browse/query/gs25/inventory/itemCode/{상품코드} **선택 파라미터(공통)**: - lat: 위도 (선택) @@ -66,10 +66,10 @@ export function buildPromptRetailCinemaText(baseUrl: string): string { - serviceCode: 서비스 코드 (기본값: 01) **예시**: -- ${baseUrl}/api/gs25/stores?keyword=강남&limit=10&format=html -- ${baseUrl}/api/gs25/products?keyword=오감자&limit=20&format=html -- ${baseUrl}/api/gs25/inventory?keyword=오감자&storeKeyword=강남&storeLimit=10&format=html -- ${baseUrl}/api/gs25/inventory?itemCode=8801056038861&storeKeyword=안산%20중앙역&storeLimit=10&format=html +- ${baseUrl}/browse/gs25/stores/강남/limit/10 +- ${baseUrl}/browse/gs25/products/오감자/limit/20 +- ${baseUrl}/browse/gs25/inventory/오감자/강남/storeLimit/10 +- ${baseUrl}/browse/query/gs25/inventory/itemCode/8801056038861/storeKeyword/안산%20중앙역/storeLimit/10 --- @@ -78,11 +78,11 @@ export function buildPromptRetailCinemaText(baseUrl: string): string { **설명**: 세븐일레븐 상품 검색, 매장 검색, 재고 수량 조회, 인기 검색어, 카탈로그 스냅샷을 제공합니다. **URL**: -- ${baseUrl}/api/seveneleven/products?query={검색어}&format=html -- ${baseUrl}/api/seveneleven/stores?keyword={매장키워드}&format=html -- ${baseUrl}/api/seveneleven/inventory?keyword={검색어}&format=html -- ${baseUrl}/api/seveneleven/popwords?label={라벨}&format=html -- ${baseUrl}/api/seveneleven/catalog?includeIssues={true|false}&includeExhibition={true|false}&limit={개수}&format=html +- ${baseUrl}/browse/seveneleven/products/{검색어} +- ${baseUrl}/browse/seveneleven/stores/{매장키워드} +- ${baseUrl}/browse/seveneleven/inventory/{검색어} +- ${baseUrl}/browse/seveneleven/popwords/{라벨} +- ${baseUrl}/browse/seveneleven/catalog/includeIssues/{true|false}/includeExhibition/{true|false}/limit/{개수} **선택 파라미터**: - page: 페이지 번호 (기본값: 1) @@ -94,11 +94,11 @@ export function buildPromptRetailCinemaText(baseUrl: string): string { - label: 인기 검색어 라벨 (기본값: home) **예시**: -- ${baseUrl}/api/seveneleven/products?query=삼각김밥&size=20&format=html -- ${baseUrl}/api/seveneleven/stores?keyword=안산%20중앙역&limit=10&format=html -- ${baseUrl}/api/seveneleven/inventory?keyword=핫식스&storeKeyword=안산%20중앙역&storeLimit=10&format=html -- ${baseUrl}/api/seveneleven/popwords?label=home&format=html -- ${baseUrl}/api/seveneleven/catalog?includeIssues=true&includeExhibition=true&limit=10&format=html +- ${baseUrl}/browse/seveneleven/products/삼각김밥/size/20 +- ${baseUrl}/browse/seveneleven/stores/안산%20중앙역/limit/10 +- ${baseUrl}/browse/seveneleven/inventory/핫식스/안산%20중앙역/storeLimit/10 +- ${baseUrl}/browse/seveneleven/popwords/home +- ${baseUrl}/browse/seveneleven/catalog/includeIssues/true/includeExhibition/true/limit/10 --- @@ -106,7 +106,7 @@ export function buildPromptRetailCinemaText(baseUrl: string): string { **설명**: 사용자 좌표 또는 위치 키워드 기준으로 메가박스 지점을 거리순으로 조회합니다. -**URL**: ${baseUrl}/api/megabox/theaters?lat={위도}&lng={경도}&format=html +**URL**: ${baseUrl}/browse/query/megabox/theaters/lat/{위도}/lng/{경도} **선택 파라미터**: - keyword: 위치 키워드 (예: 안산 중앙역, 강남역) @@ -117,9 +117,9 @@ export function buildPromptRetailCinemaText(baseUrl: string): string { - limit: 최대 결과 수 (기본값: 10) **예시**: -- ${baseUrl}/api/megabox/theaters?keyword=안산%20중앙역&limit=5&format=html -- ${baseUrl}/api/megabox/theaters?lat=37.4982&lng=127.0264&format=html -- ${baseUrl}/api/megabox/theaters?areaCode=11&limit=5&format=html +- ${baseUrl}/browse/megabox/theaters/안산%20중앙역/limit/5 +- ${baseUrl}/browse/query/megabox/theaters/lat/37.4982/lng/127.0264 +- ${baseUrl}/browse/query/megabox/theaters/areaCode/11/limit/5 --- @@ -127,7 +127,7 @@ export function buildPromptRetailCinemaText(baseUrl: string): string { **설명**: 날짜/지점 조건으로 메가박스 영화와 상영 회차를 조회합니다. theaterId가 없고 위치 키워드/좌표가 있으면 가장 가까운 지점을 먼저 선택합니다. -**URL**: ${baseUrl}/api/megabox/movies?playDate={YYYYMMDD}&format=html +**URL**: ${baseUrl}/browse/megabox/movies/playDate/{YYYYMMDD} **선택 파라미터**: - playDate: 조회 날짜 (YYYYMMDD, 기본값: 오늘) @@ -139,9 +139,9 @@ export function buildPromptRetailCinemaText(baseUrl: string): string { - areaCode: 지역 코드 (기본값: 11) **예시**: -- ${baseUrl}/api/megabox/movies?playDate=20260315&keyword=안산%20중앙역&format=html -- ${baseUrl}/api/megabox/movies?playDate=20260304&theaterId=1372&format=html -- ${baseUrl}/api/megabox/movies?playDate=20260304&movieId=25104500&format=html +- ${baseUrl}/browse/megabox/movies/playDate/20260315/keyword/안산%20중앙역 +- ${baseUrl}/browse/megabox/movies/playDate/20260304/theaterId/1372 +- ${baseUrl}/browse/megabox/movies/playDate/20260304/movieId/25104500 --- @@ -149,7 +149,7 @@ export function buildPromptRetailCinemaText(baseUrl: string): string { **설명**: 영화/지점/날짜 기준으로 회차별 잔여 좌석 수를 조회합니다. theaterId가 없고 위치 키워드/좌표가 있으면 가장 가까운 지점을 먼저 선택합니다. -**URL**: ${baseUrl}/api/megabox/seats?playDate={YYYYMMDD}&format=html +**URL**: ${baseUrl}/browse/megabox/seats/playDate/{YYYYMMDD} **선택 파라미터**: - playDate: 조회 날짜 (YYYYMMDD, 기본값: 오늘) @@ -162,9 +162,9 @@ export function buildPromptRetailCinemaText(baseUrl: string): string { - limit: 최대 결과 수 (기본값: 50) **예시**: -- ${baseUrl}/api/megabox/seats?playDate=20260315&keyword=안산%20중앙역&format=html -- ${baseUrl}/api/megabox/seats?playDate=20260304&theaterId=1372&format=html -- ${baseUrl}/api/megabox/seats?playDate=20260304&movieId=25104500&limit=20&format=html +- ${baseUrl}/browse/megabox/seats/playDate/20260315/keyword/안산%20중앙역 +- ${baseUrl}/browse/megabox/seats/playDate/20260304/theaterId/1372 +- ${baseUrl}/browse/megabox/seats/playDate/20260304/movieId/25104500/limit/20 --- @@ -172,7 +172,7 @@ export function buildPromptRetailCinemaText(baseUrl: string): string { **설명**: 지역 코드 또는 위치 키워드 기준으로 CGV 극장 목록을 조회합니다. -**URL**: ${baseUrl}/api/cgv/theaters?playDate={YYYYMMDD}&format=html +**URL**: ${baseUrl}/browse/query/cgv/theaters/playDate/{YYYYMMDD} **선택 파라미터**: - playDate: 조회 날짜 (YYYYMMDD, 기본값: 오늘) @@ -183,9 +183,9 @@ export function buildPromptRetailCinemaText(baseUrl: string): string { - limit: 최대 결과 수 (기본값: 30) **예시**: -- ${baseUrl}/api/cgv/theaters?playDate=20260304®ionCode=01&format=html -- ${baseUrl}/api/cgv/theaters?playDate=20260315&keyword=안산%20중앙역&format=html -- ${baseUrl}/api/cgv/theaters?playDate=20260304&limit=10&format=html +- ${baseUrl}/browse/query/cgv/theaters/playDate/20260304/regionCode/01 +- ${baseUrl}/browse/cgv/theaters/안산%20중앙역/playDate/20260315 +- ${baseUrl}/browse/query/cgv/theaters/playDate/20260304/limit/10 --- @@ -193,7 +193,7 @@ export function buildPromptRetailCinemaText(baseUrl: string): string { **설명**: 날짜/극장 조건으로 CGV 영화 목록을 조회합니다. theaterCode가 없으면 keyword 또는 lat,lng 기준으로 가장 가까운 극장을 먼저 선택합니다. -**URL**: ${baseUrl}/api/cgv/movies?playDate={YYYYMMDD}&format=html +**URL**: ${baseUrl}/browse/cgv/movies/playDate/{YYYYMMDD} **선택 파라미터**: - playDate: 조회 날짜 (YYYYMMDD, 기본값: 오늘) @@ -203,8 +203,8 @@ export function buildPromptRetailCinemaText(baseUrl: string): string { - lng: 경도 **예시**: -- ${baseUrl}/api/cgv/movies?playDate=20260304&theaterCode=0056&format=html -- ${baseUrl}/api/cgv/movies?playDate=20260315&keyword=안산%20중앙역&format=html +- ${baseUrl}/browse/cgv/movies/playDate/20260304/theaterCode/0056 +- ${baseUrl}/browse/cgv/movies/playDate/20260315/keyword/안산%20중앙역 --- @@ -212,7 +212,7 @@ export function buildPromptRetailCinemaText(baseUrl: string): string { **설명**: 날짜/극장/영화 조건으로 CGV 상영 시간표를 조회합니다. theaterCode가 없으면 keyword 또는 lat,lng 기준으로 가장 가까운 극장을 먼저 선택합니다. 잔여 좌석은 각 회차의 \`remainingSeats\` 필드로 함께 내려옵니다. -**URL**: ${baseUrl}/api/cgv/timetable?playDate={YYYYMMDD}&format=html +**URL**: ${baseUrl}/browse/cgv/timetable/playDate/{YYYYMMDD} **선택 파라미터**: - playDate: 조회 날짜 (YYYYMMDD, 기본값: 오늘) @@ -224,9 +224,9 @@ export function buildPromptRetailCinemaText(baseUrl: string): string { - limit: 최대 결과 수 (기본값: 50) **예시**: -- ${baseUrl}/api/cgv/timetable?playDate=20260304&theaterCode=0056&format=html -- ${baseUrl}/api/cgv/timetable?playDate=20260315&keyword=안산%20중앙역&format=html -- ${baseUrl}/api/cgv/timetable?playDate=20260304&movieCode=200001&format=html +- ${baseUrl}/browse/cgv/timetable/playDate/20260304/theaterCode/0056 +- ${baseUrl}/browse/cgv/timetable/playDate/20260315/keyword/안산%20중앙역 +- ${baseUrl}/browse/cgv/timetable/playDate/20260304/movieCode/200001 --- @@ -234,7 +234,7 @@ export function buildPromptRetailCinemaText(baseUrl: string): string { **설명**: 위치 키워드 또는 사용자 좌표 기준으로 롯데시네마 지점을 거리순으로 조회합니다. -**URL**: ${baseUrl}/api/lottecinema/theaters?keyword={위치키워드}&format=html +**URL**: ${baseUrl}/browse/lottecinema/theaters/{위치키워드} **선택 파라미터**: - keyword: 위치 키워드 (예: 안산 중앙역, 잠실역) @@ -244,8 +244,8 @@ export function buildPromptRetailCinemaText(baseUrl: string): string { - limit: 최대 결과 수 (기본값: 10) **예시**: -- ${baseUrl}/api/lottecinema/theaters?keyword=안산%20중앙역&format=html -- ${baseUrl}/api/lottecinema/theaters?lat=37.5133&lng=127.1042&format=html +- ${baseUrl}/browse/lottecinema/theaters/안산%20중앙역 +- ${baseUrl}/browse/query/lottecinema/theaters/lat/37.5133/lng/127.1042 --- @@ -253,7 +253,7 @@ export function buildPromptRetailCinemaText(baseUrl: string): string { **설명**: 날짜/지점/영화 조건으로 롯데시네마 영화와 상영 회차를 조회합니다. theaterId가 없으면 위치 키워드 기준 최근접 지점을 선택할 수 있습니다. -**URL**: ${baseUrl}/api/lottecinema/movies?playDate={YYYYMMDD}&format=html +**URL**: ${baseUrl}/browse/lottecinema/movies/playDate/{YYYYMMDD} **선택 파라미터**: - playDate: 조회 날짜 (YYYYMMDD, 기본값: 오늘) @@ -264,9 +264,9 @@ export function buildPromptRetailCinemaText(baseUrl: string): string { - lng: 경도 (theaterId가 없을 때 사용) **예시**: -- ${baseUrl}/api/lottecinema/movies?playDate=20260315&keyword=안산%20중앙역&format=html -- ${baseUrl}/api/lottecinema/movies?playDate=20260310&theaterId=1016&format=html -- ${baseUrl}/api/lottecinema/movies?playDate=20260310&theaterId=1016&movieId=23816&format=html +- ${baseUrl}/browse/lottecinema/movies/playDate/20260315/keyword/안산%20중앙역 +- ${baseUrl}/browse/lottecinema/movies/playDate/20260310/theaterId/1016 +- ${baseUrl}/browse/lottecinema/movies/playDate/20260310/theaterId/1016/movieId/23816 --- @@ -274,7 +274,7 @@ export function buildPromptRetailCinemaText(baseUrl: string): string { **설명**: 영화/지점/날짜 기준으로 회차별 잔여 좌석 수를 조회합니다. theaterId가 없으면 위치 키워드 기준 최근접 지점을 선택할 수 있습니다. -**URL**: ${baseUrl}/api/lottecinema/seats?playDate={YYYYMMDD}&format=html +**URL**: ${baseUrl}/browse/lottecinema/seats/playDate/{YYYYMMDD} **선택 파라미터**: - playDate: 조회 날짜 (YYYYMMDD, 기본값: 오늘) @@ -286,8 +286,8 @@ export function buildPromptRetailCinemaText(baseUrl: string): string { - limit: 최대 결과 수 (기본값: 50) **예시**: -- ${baseUrl}/api/lottecinema/seats?playDate=20260315&keyword=안산%20중앙역&format=html -- ${baseUrl}/api/lottecinema/seats?playDate=20260310&theaterId=1016&movieId=23816&format=html +- ${baseUrl}/browse/lottecinema/seats/playDate/20260315/keyword/안산%20중앙역 +- ${baseUrl}/browse/lottecinema/seats/playDate/20260310/theaterId/1016/movieId/23816 --- diff --git a/tests/pages/agentStart.test.ts b/tests/pages/agentStart.test.ts index 2be5a8d0..40856556 100644 --- a/tests/pages/agentStart.test.ts +++ b/tests/pages/agentStart.test.ts @@ -22,7 +22,7 @@ describe('에이전트 시작 안내', () => { expect(text).toContain('initialize'); expect(text).toContain('REST GET'); expect(text).toContain('MCP 프로토콜 호출이 아닙니다'); - expect(text).toContain('/api/daiso/products?q=%EC%88%98%EB%82%A9%EB%B0%95%EC%8A%A4&pageSize=3'); + expect(text).toContain('/browse/daiso/products/%EC%88%98%EB%82%A9%EB%B0%95%EC%8A%A4/pageSize/3'); expect(text).toContain('https://example.com/prompt'); expect(text).toContain('전체 도구 목록이나 OpenAPI를 먼저 읽을 필요는 없습니다'); expect(text.split('\n').length).toBeLessThan(45); @@ -33,7 +33,7 @@ describe('에이전트 시작 안내', () => { const text = generatePromptText(baseUrl); expect(text.startsWith(buildAgentStartText(baseUrl))).toBe(true); expect(text.indexOf('# 다이소 MCP API')).toBeGreaterThan(text.indexOf('REST GET')); - expect(text).toContain('/api/daiso/inventory?productId={제품ID}'); + expect(text).toContain('/browse/daiso/inventory/{제품ID}'); expect(text).toContain('MCP 지원 서비스'); }); diff --git a/tests/pages/apiResult.test.ts b/tests/pages/apiResult.test.ts index c891c202..7f11ccf0 100644 --- a/tests/pages/apiResult.test.ts +++ b/tests/pages/apiResult.test.ts @@ -40,7 +40,7 @@ describe('GET HTML 결과', () => { expect(body).toContain('조회 성공'); expect(body).toContain('1000'); expect(body).toContain('<script>alert(1)</script>'); expect(body).not.toContain(' { }); it('셸 없는 에이전트에게 HTML GET을 안내하며 txt charset을 명시한다', () => { const assets = buildDiscoveryAssets({ services: [], tools: [] }); - expect(assets['discovery.html']).toContain('pageSize=3&format=html'); - expect(assets['llms.txt']).toContain('pageSize=3&format=html'); + expect(assets['discovery.html']).toContain('/pageSize/3'); + expect(assets['llms.txt']).toContain('/pageSize/3'); expect(assets['_headers']).toContain('/llms.txt\n Content-Type: text/plain; charset=utf-8'); }); }); + +it('후속 상세·재고·페이지 링크는 실제 식별자와 필터를 보존하는 browse 경로다', async () => { + const html = await (await fixture().request('/api/daiso/products?q=핫식스&page=1&pageSize=1&format=html')).text(); + expect(html).toContain('href="/browse/daiso/product/123%26%22"'); + expect(html).toContain('href="/browse/daiso/inventory/123%26%22"'); + expect(html).toContain('/browse/daiso/products/'); + expect(html).not.toMatch(/href="\/api\//); +}); +it('구형 HTML 상세 주소의 잘못된 인코딩이 원래 400 응답을 500으로 바꾸지 않는다', async () => { + const a = new Hono(); a.use('/api/*', apiResultHtml); + a.get('/api/daiso/products/:id', c => c.json({ success: false, error: { code: 'INVALID_PARAMS', message: '잘못된 상품 ID' } }, 400)); + const res = await a.request('/api/daiso/products/%ZZ?format=html'); + expect(res.status).toBe(400); expect(res.headers.get('Content-Type')).toContain('text/html'); + expect(await res.text()).toContain('INVALID_PARAMS'); +}); diff --git a/tests/pages/browse-guidance.test.ts b/tests/pages/browse-guidance.test.ts new file mode 100644 index 00000000..45331da9 --- /dev/null +++ b/tests/pages/browse-guidance.test.ts @@ -0,0 +1,46 @@ +import { readFileSync } from 'node:fs'; +import { expect, it } from 'vitest'; +import { buildAgentStartText } from '../../src/pages/agentStart.js'; +import { buildDiscoveryAssets } from '../../src/pages/discovery.js'; +import { generatePromptText } from '../../src/pages/prompt.js'; +it('모든 진입 문서의 웹 조회 링크는 browse이며 포맷 선택을 요구하지 않는다', () => { + const assets = buildDiscoveryAssets({ services: [], tools: [] }); + const docs = [ + 'README.md', + 'docs/service-reference.md', + 'docs/client-guide.md', + 'docs/web-discovery.md', + 'docs/ai-instruction.md', + 'skills/daiso-cli/SKILL.md', + ].map((p) => readFileSync(new URL('../../' + p, import.meta.url), 'utf8')); + for (const text of [ + ...docs, + assets['discovery.html'], + assets['llms.txt'], + generatePromptText('https://mcp.aka.page'), + ]) { + expect(text).toContain('/browse/'); + const queryLinks = + text.match( + /(?:https?:\/\/[^\s)"<>]+)?\/api\/[^\s)"<>]*[?&](?:format=html|[^\s)"<>]*&format=html)[^\s)"<>]*/g, + ) ?? []; + expect(queryLinks).toEqual([]); + } + expect(buildAgentStartText('https://mcp.aka.page')).toContain('쿼리 문자열은 필요 없습니다'); +}); +it('전체 서비스 상세 안내의 모든 웹 실행 URL이 쿼리 없는 경로다', () => { + const prompt = generatePromptText('https://guide.example'); + const urls = prompt.match(/https:\/\/guide\.example\/browse\/[^\s]+/g)!; + expect(urls.length).toBeGreaterThan(30); + for (const url of urls) expect(url).not.toContain('?'); +}); +it('완전한 웹 예시 URL은 공백 없이 열리고 등록된 조회로 해석된다', async () => { + const { browseApiUrl } = await import('../../src/pages/browsePaths.js'); + const lines = generatePromptText('https://guide.example').split('\n'); + for (const line of lines) { + const url = line.match(/https:\/\/guide\.example\/browse\/.*$/)?.[0]; + if (!url) continue; + expect(url).not.toMatch(/\s/); + expect(browseApiUrl(new URL(url))).not.toBeNull(); + } +}); diff --git a/tests/pages/browse-integration.test.ts b/tests/pages/browse-integration.test.ts new file mode 100644 index 00000000..e5e83f0f --- /dev/null +++ b/tests/pages/browse-integration.test.ts @@ -0,0 +1,117 @@ +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'); + } + }, +); + +it('재고 0과 미확인 값을 그대로 보존하고 실제 매장의 진열 위치를 연결한다', async () => { + const { Hono } = await import('hono'); + const { apiResultHtml } = await import('../../src/pages/apiResult.js'); + const { registerBrowseRoutes } = await import('../../src/pages/browse.js'); + const a = new Hono(); + a.use('/api/*', apiResultHtml); + a.get('/api/daiso/inventory', (c) => + c.json({ + success: true, + data: { + stores: [ + { storeCode: 'S1', quantity: 0 }, + { storeCode: 'S2', quantity: null }, + { storeCode: 7, quantity: 0 }, + { storeCode: true, quantity: 0 }, + ], + }, + }), + ); + registerBrowseRoutes(a); + const res = await a.request('/browse/daiso/inventory/105603886/강남역'); + const body = await res.text(); + expect(body).toContain('href="/browse/daiso/display-location/105603886/S1"'); + expect(body).toContain('/browse/daiso/display-location/105603886/7'); + expect(body).not.toContain('/browse/daiso/display-location/105603886/true'); + expect(body).toContain('
0
'); + expect(body).toContain('
null
'); + expect(body).not.toMatch(/href="\/api\//); +}); + +it.each([true, false])('운영 인증과 새 조회 헤더는 그대로 전달하며 비인증 우회는 거절한다: %s', async (authorized) => { + const fetcher = vi.fn(async () => Response.json({ SearchQueryResult: { Collection: [{ Documentset: { Document: [{ field: { itemCode: '123', itemName: '콜라', stockCheckYn: 'Y' } }] } }] } })); + vi.stubGlobal('fetch', fetcher); + const env = { HEALTH_CHECK_SECRET: 'fixture-ops-secret', CONVENIENCE_RELAY_URL: 'https://relay.example', CONVENIENCE_RELAY_TOKEN: 'fixture-relay-token' }; + const res = await app.request('/browse/gs25/products/콜라', { headers: { Authorization: authorized ? 'Bearer fixture-ops-secret' : 'Bearer wrong-fixture-token', 'x-health-check-force-fresh': 'true' } }, env); + expect(res.status).toBe(200); expect(await res.text()).toContain('콜라'); + expect(fetcher).toHaveBeenCalledTimes(1); + const [, init] = fetcher.mock.calls[0] as unknown as [string, RequestInit]; + expect(new Headers(init.headers).get('x-relay-cache-bypass')).toBe(authorized ? 'true' : null); +}); diff --git a/tests/pages/browse.test.ts b/tests/pages/browse.test.ts new file mode 100644 index 00000000..4ce49c95 --- /dev/null +++ b/tests/pages/browse.test.ts @@ -0,0 +1,120 @@ +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/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', '