diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e19a9569..1aedec31 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -64,6 +64,8 @@ jobs:
uses: ./.github/workflows/select-local-runner.yml
with:
cloud-runner: macos-latest
+ # 브라우저 실험·운영 중계와 분리하되 실제 macOS에서 검증합니다.
+ force-cloud: true
secrets:
status-token: ${{ secrets.LOCAL_RUNNER_STATUS_TOKEN }}
diff --git a/README.md b/README.md
index a36b17d2..0c62fd70 100644
--- a/README.md
+++ b/README.md
@@ -2,9 +2,11 @@
@@ -206,7 +208,7 @@ MCP 연결 없이 웹 읽기와 GET 요청을 지원하는 에이전트는 [REST
| 문서 | 내용 |
| :-------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------- |
| [연결·사용 가이드](docs/client-guide.md) | 앱별 MCP 연결, CLI, Skill, OpenAPI |
-| [서비스 레퍼런스](docs/service-reference.md?guide=2026-10-26.1) | 서비스별 도구·REST API·응답 모델·가격 비교 |
+| [서비스 레퍼런스](docs/service-reference.md?guide=2026-10-27.1) | 서비스별 도구·REST API·응답 모델·가격 비교 |
| [일본 다이소](docs/daiso-jp-network-analysis.md) | 일본 상품·매장 검색과 지원 범위 |
| [독립·예술영화관](docs/dtryx.md) | 디트릭스 지원 극장과 조회 방법 |
| [AI 조회 가이드](docs/ai-instruction.md) | 도구 선택과 상품·매장·재고 조회 순서 |
diff --git a/docs/service-reference.md b/docs/service-reference.md
index decd176a..398eb069 100644
--- a/docs/service-reference.md
+++ b/docs/service-reference.md
@@ -1,6 +1,6 @@
# DAISO MCP 서비스 레퍼런스
-실제 브라우저 도구가 있으면 /browse/ HTML을 그 도구로 여세요. 웹 읽기(open/WebFetch)의 접근 오류 시 사용 가능한 브라우저로 전환합니다. Crawled가 과거인 수집본은 현재 재고 확인이 아닙니다. 응답 생성 시각도 함께 확인하세요.
+실제 브라우저 도구가 있으면 /browse/ HTML을 그 도구로 여세요. 웹 읽기(open/WebFetch)는 [글자 선택 입력](https://mcp.aka.page/browse/compose/daiso/products/~)을 응답의 실제 링크 ID로 click합니다. 새 검색 URL을 조립하지 않고 상품·매장 지역·실제 재고 링크를 선택합니다. 접근 오류 시 사용 가능한 브라우저로 전환할 수 있으나 새 브라우저를 계속 실행하지 마세요. Crawled가 과거인 수집본은 현재 재고 확인이 아닙니다. 응답 생성 시각도 함께 확인하세요.
README 하단에 있던 기능, REST API, 개발, 아키텍처 설명을 분리한 문서입니다.
@@ -16,7 +16,7 @@ README 하단에 있던 기능, REST API, 개발, 아키텍처 설명을 분리
모든 후속 웹 GET도 `/browse/`를 사용하세요. 자동 HTML이며 쿼리 문자열이나 format 지정은 필요 없습니다. 상품 검색 결과의 실제 ID로 재고를 조회하고 검색 성공만으로 매장 재고를 단정하지 않습니다. 이미지는 응답 imageUrl과 카드의 원본 Markdown만 사용하고 이미지 검색으로 대체하지 않습니다.
-현재 안내는 [버전 안내](https://mcp.aka.page/guide/2026-10-26.1), 다음 버전도 따라가려면 [항상 최신 안내](https://mcp.aka.page/guide/latest)를 여세요. 이 상세 레퍼런스를 끝까지 읽기 전에 첫 조회를 실행하세요.
+현재 안내는 [버전 안내](https://mcp.aka.page/guide/2026-10-27.1), 다음 버전도 따라가려면 [항상 최신 안내](https://mcp.aka.page/guide/latest)를 여세요. 이 상세 레퍼런스를 끝까지 읽기 전에 첫 조회를 실행하세요.
이미 연결된 MCP 도구가 있는 환경만 `search_products`에 `service=daiso`, `query=수납박스`를 전달합니다. 서버는 https://mcp.aka.page 이며 Streamable HTTP, 인증 없음입니다. 실제로 실행한 도구·URL·오류만 보고하고 실행하지 않은 요청의 성공·실패를 만들지 않습니다.
diff --git a/docs/superpowers/plans/2026-10-27-browse-link-composer.md b/docs/superpowers/plans/2026-10-27-browse-link-composer.md
new file mode 100644
index 00000000..d2e3f760
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-27-browse-link-composer.md
@@ -0,0 +1,26 @@
+# 웹 읽기의 링크 선택으로 상품과 지역 입력
+
+## 근거와 범위
+
+PR335 실험에서 직접 open에 실패한 연필 검색 주소가 문자 링크 선택 뒤 실제 href click으로 성공했다. 상품58450 상세도 성공했다. 다음 병목은 재고 링크가 사용자 지역을 URL에 직접 추가하도록 안내하는 점이다. 고정 예제만 늘리지 않고 임의 검색어와 지역을 실제 링크 선택으로 전달한다.
+
+연구 목표는 오후2시까지 계속 유지한다. 이 기능 배포만으로 모든 ChatGPT 계정이나 새 Chat 대화의 성공을 주장하지 않는다.
+
+## 설계
+
+- `/browse/compose/daiso/products/{입력}/{단계}`와 stores 경로에서 한글 초성·중성·완성형 및 영문·숫자·공백·하이픈을 링크로 선택한다. 최대32자. 빈 입력은 `~`.
+- `/browse/compose/daiso/inventory/{상품ID}/{입력}/{단계}`는 선택한 실제 상품 ID를 유지하고 매장 지역을 입력한다. ID는 양의 정수 문자열만 허용한다.
+- 각 단계에 기존 `/browse/` 실제 조회 링크를 제공한다. 상품·매장은 최대3개, 재고는 최대3개 매장. 입력 페이지는 API·주소검색·재고 원본을 호출하지 않는다. 사용자가 선택한 실제 조회에서만 기존 보호·캐시·오류 처리를 사용한다.
+- 공개 MCP 도구10개, 기존 JSON API와 기존 browse 경로는 유지한다. 서버가 임의 외부 URL을 받아 프록시하지 않는다.
+- 상품 HTML의 재고 링크를 지역 입력 페이지로 연결한다. /browse, 루트 발견 자산, 최신 버전 안내 및 README에서 링크 방식이 자연스럽게 발견되도록 연결한다. 사람이 보는 README 이미지와 구조는 유지한다.
+- 웹 읽기는 응답에 있는 실제 링크 ID로 click하고 새 URL 문자열을 조립하지 않는다. 직접 open과 click의 차이를 명시한다. 원본 이미지 규칙·오류/품절/미확인 수량 구분을 유지한다.
+
+## 검증 순서
+
+1. 한글·영문 임의 입력과 상품 ID 보존, 부적절한 입력, 주입·경로 과다·최대 길이, 빈 검색 금지, 지우기/초기화, 상위 browse 처리와 원본 무호출 회귀를 먼저 실패시킨다.
+2. 새 렌더러와 Hono 경로를 구현하고 상품 응답 링크를 갱신한다. 1워커 대상 검사와 타입·린트·커버리지 필수 검사, 직렬 리뷰를 수행한다. 운영 검증과 겹치지 않는다.
+3. PR·CI를 통과한 뒤 배포한다. 생성 자산의 안내 버전과 실제 배포를 확인한다.
+4. 웹 도구의 원시 open 실패 대조를 남기고 실제 링크 ID만 사용해 새 검색어→상품→임의 지역→실제 재고·진열 위치를 검증한다. 가상 데이터와 실제 결과, 응답 생성 시각과 원본 갱신 시각을 구분한다.
+5. 독립된 두 번째 실행과 웹 커넥터 비교를 수행한다. 새 ChatGPT 대화의 UI 검증은 별도이며 브라우저 예산·세션 회수를 지킨다. 필요한 사용자 개입만 요청한다.
+
+실험 Worker 코드는 기존 조건 비교를 위해 독립 유지한다. 운영 구현이 실험의 진단 데이터·공개 관측 저장소를 가져오지 않는다.
diff --git a/docs/web-reading-lab-results-2026-10-27.md b/docs/web-reading-lab-results-2026-10-27.md
new file mode 100644
index 00000000..13278c05
--- /dev/null
+++ b/docs/web-reading-lab-results-2026-10-27.md
@@ -0,0 +1,35 @@
+# 웹 읽기 실험 진행 기록 (2026-10-27)
+
+## 실제 상품 조회로 확장
+
+PR335의 실험 Worker 배포40437090435가 성공했다. 별도 브라우저를 실행하지 않고 웹 도구의 실제 링크 ID만 선택했다.
+
+1. PR335 본문의 실험 링크에서 새 실행 `d770a877-3a4c-4941-bad9-e22a0566f231`을 시작했다.
+2. 초성·중성·완성 글자를 선택해 사전에 고정하지 않은 `연필`을 입력했다.
+3. 입력 완료 요청 번호 `d9f88fd9-8eed-4aa7-b459-cf31dbcc4f3d`와 공개 관측 기록이 일치했다. 해당 실행은18개 이벤트가 있으며 첫 도착02:15:12.921 UTC, 완료 생성02:16:21.592 UTC였다. 경과에는 도구 호출·에이전트 작업 시간이 포함된다.
+4. 완료 화면의 실제 상품 검색 링크를 click하자 `/browse/daiso/products/%EC%97%B0%ED%95%84/pageSize/3`이 실제 HTML을 반환했다. 응답 생성02:16:28.729 UTC, 투명 연필캡1034263·동물 모양 연필깎이1053585·전문가용2B연필58450을 확인했다.
+5. 실제 상품58450 상세 링크도 click 성공, 응답 생성02:16:52.578 UTC였다. 상품 검색 성공이며 매장 재고 성공으로 확대하지 않는다.
+
+같은 상품 검색 URL은 앞선 두 웹 도구의 직접 open 비교에서 모두 `URL ... is not accessible via this tool`이었다. 두 도구에서 example.com 대조군은 성공했다. 이름이 다른 커넥터를 고르는 것만으로 직접 open이 해결되지 않았다. 같은 백엔드를 사용하는지는 미확정이다.
+
+실제 상품·상세 click 성공 후 같은 문자열 URL을 두 도구에서 다시 직접 open했으나 모두 동일한 접근 오류였다. 따라서 이번 실행에서 링크 click의 성공이 문자열 open이나 다른 도구의 접근 자격까지 바꾸지는 않았다. URL을 한 번 읽히게 해 캐시를 데우는 것만으로 해결됐다고 보지 않는다.
+
+관측 JSON, 도구 비교와 UA 비교 원시 기록은 운영 기록의 `web-read-lab/2026-10-27/`에 보존했다. 생산 상품 응답에는 실험 Worker의 requestId를 넣지 않았으므로 생산 원본 fetch 시도와 직접 상관관계를 증명한 것은 아니다. HTML 응답 생성 시각은 원본 재고 갱신 시각이 아니다.
+
+## 별도로 확인한 Cloudflare1010
+
+동일한 synthetic URL에서 Python 기본 `Python-urllib/3.9`는 custom domain과 workers.dev 모두403/본문 `error code: 1010`이었다. curl UA와 ChatGPT 형태의 UA 문자열은200, 애플리케이션을 식별하는 `DAISO-MCP-WebReadLab/1.0`도200이었다. UA 문자열을 지정한 요청은 실제 OpenAI 클라이언트라는 증거가 아니다.
+
+[Cloudflare 공식1010 문서](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-1xxx-errors/error-1010/)는 기본 Python UA가 Browser Integrity Check에 걸릴 수 있고, workers.dev의 관리 영역 차단은 자체 영역의 Security Events나 Worker 로그에 남지 않을 수 있다고 설명한다. 식별 가능한 앱 UA를 사용한 일반 HTTP 대조군이 필요하다. 이것만으로 실제 hosted open 실패의 원인을1010으로 확정하지 않는다. 이번 실행에서는 Cloudflare 보안 설정을 변경하지 않았다.
+
+## 공식 문서와 공개 소스에서 확인한 범위
+
+- [OpenAI 웹 검색 문서](https://developers.openai.com/api/docs/guides/tools-web-search)는 open_page와 live/cache 제어를 설명한다. 특정 URL의 접근 거절 결정 단계나 서버 도착을 보장하지 않는다.
+- [Codex extension.rs](https://github.com/openai/codex/blob/main/codex-rs/ext/web-search/src/extension.rs)는 live를 external_web_access=true, cached/disabled를false로 변환한다.
+- [Codex tool.rs](https://github.com/openai/codex/blob/main/codex-rs/ext/web-search/src/tool.rs)와 [search endpoint](https://github.com/openai/codex/blob/main/codex-rs/codex-api/src/endpoint/search.rs)는 명령을 hosted alpha/search로 전송한다. 검사한 코드에서 해당 접근 오류 문자열은 발견하지 못했다. 공개 클라이언트 코드만으로 서버 내부 결정을 복원하지 않는다.
+- [history.rs](https://github.com/openai/codex/blob/main/codex-rs/ext/web-search/src/history.rs)는 최근 사용자 텍스트2건과 제한된 assistant 텍스트를 검색 요청에 포함한다. 이것이 이번 URL 실패의 원인이라는 증거는 없다.
+- [독립 재현 질문53849](https://github.com/openai/codex/discussions/53849)도 URL별 성공 차이와 원본 로그 부재를 보고하지만 미답변 상태다. 이를 공식 원인 설명으로 사용하지 않는다.
+
+## 남은 작업
+
+실제 상품 HTML의 재고 링크는 사용자 지역을 URL에 직접 추가하도록 요구한다. 따라서 지역도 링크로 입력하고 실제 상품 ID를 유지하는 운영 경로가 필요하다. 이 흐름의 실제 재고·진열 위치, 독립 실행 및 새 ChatGPT Chat 대화의 성공은 아직 검증하지 않았다. 오후2시 연구 목표는 계속 active이며 이번 결과만으로 완료 처리하지 않는다.
diff --git a/src/pages/agentStart.ts b/src/pages/agentStart.ts
index c21866fd..922588fa 100644
--- a/src/pages/agentStart.ts
+++ b/src/pages/agentStart.ts
@@ -1,19 +1,19 @@
/** 검색·읽기 사본의 안내 세대를 구분하는 고정 버전 */
-export const AGENT_GUIDE_VERSION = '2026-10-26.1';
-export const AGENT_GUIDE_UPDATED = '2026-10-26';
+export const AGENT_GUIDE_VERSION = '2026-10-27.1';
+export const AGENT_GUIDE_UPDATED = '2026-10-27';
export const AGENT_GUIDE_STAMP = `안내 버전: ${AGENT_GUIDE_VERSION} · 갱신일: ${AGENT_GUIDE_UPDATED}`;
export const AGENT_GUIDE_PATH = `/guide/${AGENT_GUIDE_VERSION}`;
export const WEB_SEARCH_DESCRIPTION =
- 'ChatGPT 웹 읽기로 설치·API 키·MCP 연결 설정 없이 바로 상품을 조회하세요. 사용 가능한 실제 브라우저 도구가 있으면 /browse/daiso/products/검색어를 열고, 없으면 open/WebFetch를 사용합니다. 쿼리 문자열과 format 지정은 필요 없습니다. 다이소·편의점 재고와 영화·생활 정보도 지원합니다.';
+ 'ChatGPT 웹 읽기로 설치·API 키·MCP 연결 설정 없이 상품을 조회하세요. /browse/compose의 실제 글자 링크를 선택하면 임의 검색어와 지역을 전달할 수 있습니다. 새 URL을 조립하지 말고 문서의 링크 ID로 click하세요. 다이소·편의점 재고와 영화·생활 정보도 지원합니다.';
/** 웹 읽기 환경에서 쿼리 없는 HTML 경로를 우선합니다. */
export const WEB_READ_RULE =
- '웹 조회는 /browse/ 경로를 사용하세요. Work 등에서 실제 브라우저 도구가 있으면 그 도구로 열고, 없으면 open/WebFetch를 사용하세요. 웹 읽기의 접근 오류가 나면 같은 주소를 반복하기 전에 사용 가능한 브라우저로 전환하세요. Crawled가 과거인 수집본을 현재 재고로 단정하지 마세요. 상품 검색·상세·매장·재고·후속 조회가 모두 자동 HTML입니다. format이나 쿼리 문자열은 필요 없습니다. JSON API 주소를 웹 읽기로 열거나 셸로 우회하지 마세요. 접근 오류만으로 서비스 장애·로그인 필요·이 환경에서 실행할 수 없다고 단정하지 마세요.';
+ '웹 조회는 /browse/ 경로를 사용하세요. Work 등에서 실제 브라우저 도구가 있으면 그 도구로 열고, 없으면 open/WebFetch를 사용하세요. 웹 읽기는 문서의 실제 링크 ID로 click하여 이동하세요. 검색어를 바꾼 새 URL을 조립해 open하지 말고 /browse/compose에서 글자를 선택하세요. 상품 HTML의 매장 지역 선택 링크는 실제 상품 ID를 유지합니다. 접근 오류가 나면 같은 주소를 반복하기 전에 실제 링크 선택을 확인하고 사용 가능한 브라우저로 전환할 수 있습니다. 브라우저를 추가로 계속 실행하지 마세요. Crawled가 과거인 수집본을 현재 재고로 단정하지 마세요. 상품 검색·상세·매장·재고·후속 조회가 모두 자동 HTML입니다. format이나 쿼리 문자열은 필요 없습니다. JSON API 주소를 웹 읽기로 열거나 셸로 우회하지 마세요. 접근 오류만으로 서비스 장애·로그인 필요·이 환경에서 실행할 수 없다고 단정하지 마세요.';
/** 셸 존재를 네트워크 접근 가능 여부로 오해하지 않는 실행 순서 */
export const ROUTE_SELECTION_TEXT = `1. 연결된 MCP 도구가 있으면 search_products {"service":"daiso","query":"수납박스","pageSize":3}를 호출합니다.
2. 실제 브라우저 도구가 있으면 /browse/ HTML GET 주소를 열고 페이지의 실제 상품·매장·재고를 읽습니다. Work의 내장 브라우저도 이 경로를 사용합니다.
-3. 웹 읽기(open/WebFetch)만 가능하면 같은 HTML GET을 실행합니다. 접근 오류가 나면 문서의 링크를 따라갈 수 있으나 성공을 보장하지 않습니다. Crawled가 과거인 수집본은 현재 재고 확인으로 쓰지 않습니다.
+3. 웹 읽기(open/WebFetch)만 가능하면 문서의 실제 링크 ID로 click합니다. /browse/compose에서 임의 검색어를 글자로 선택하고 상품 HTML의 매장 지역 선택으로 재고를 조회합니다. 새 URL 조립이나 직접 open 실패를 반복하지 마세요. 도구에 click이 없으면 결과의 실제 링크를 열되 같은 동작을 보장하지 않습니다. Crawled가 과거인 수집본은 현재 재고 확인으로 쓰지 않습니다.
4. 위 세 수단이 없거나 모두 실패하면 실제 조회 제약과 오류를 알립니다. 셸이 있어도 설치 없는 웹 조회가 우선입니다. 터미널·셸 호출로 우회하지 않습니다. 셸 DNS 오류를 웹 GET 실패나 서버 장애로 확대하지 말고 실제로 실행하지 않은 요청의 실패를 만들지 마세요.`;
/** 유사 상품의 웹 검색 이미지를 실제 상품 사진으로 오인하지 않도록 제한합니다. */
@@ -38,11 +38,14 @@ ${AGENT_GUIDE_STAMP}
${ROUTE_SELECTION_TEXT}
-웹 읽기용 첫 조회 (경로의 수납박스를 사용자 검색어로 바꾸세요):
+웹 읽기용 임의 검색어 입력 (응답의 실제 글자 링크 ID를 선택):
+${baseUrl}/browse/compose/daiso/products/~
+매장 검색어 입력: ${baseUrl}/browse/compose/daiso/stores/~
+아래 고정 예제도 문서의 실제 링크를 click하여 조회할 수 있습니다:
${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/서비스/기능/이름/값을 사용합니다. 상품 검색만으로 매장 재고를 단정하지 마세요.
+이것은 공개 REST GET의 웹 조회이며 MCP 프로토콜 호출이 아닙니다. 상품 결과의 실제 상품 상세·매장 지역 선택 링크를 따르세요. 지역 입력 후 표시된 실제 재고 조회 링크를 선택합니다. 후속 URL을 새로 조립하지 마세요. 상품 검색만으로 매장 재고를 단정하지 마세요. 일반 API 클라이언트의 경로·필터 문법은 상세 사용 안내를 참고하세요.
${IMAGE_SOURCE_RULE}
${EXECUTION_EVIDENCE_RULE}
diff --git a/src/pages/apiResult.ts b/src/pages/apiResult.ts
index 2acb0fac..8ccfaa78 100644
--- a/src/pages/apiResult.ts
+++ b/src/pages/apiResult.ts
@@ -65,8 +65,8 @@ function renderValue(
new URL(`${productLinks}/${encodeURIComponent(String(item))}`, 'https://browse.local'),
);
detail = ` 상품 상세`;
- if (productLinks === '/api/daiso/products')
- detail += ` 매장 재고 (사용자 지역을 경로에 추가하세요)`;
+ if (productLinks === '/api/daiso/products' && /^[1-9][0-9]{0,19}$/.test(String(item)))
+ detail += ` 매장 지역 선택`;
}
if (
source.pathname === '/api/daiso/inventory' &&
diff --git a/src/pages/browse.ts b/src/pages/browse.ts
index cbdcae30..2addd62b 100644
--- a/src/pages/browse.ts
+++ b/src/pages/browse.ts
@@ -2,6 +2,7 @@
import type { Hono } from 'hono';
import type { AppBindings } from '../api/response.js';
import { BROWSE_ROUTES, browseApiUrl } from './browsePaths.js';
+import { renderBrowseComposer } from './browseComposer.js';
function page(content: string, status = 200): Response {
return new Response(
@@ -19,9 +20,26 @@ function page(content: string, status = 200): Response {
}
export function registerBrowseRoutes(app: Hono<{ Bindings: AppBindings }>): void {
+ app.get('/browse/compose', () =>
+ page(
+ '
검색어를 URL에 직접 조립하지 않고 한 글자씩 선택합니다. 아직 실제 조회를 실행하지 않습니다.
', + ), + ); + app.get('/browse/compose/*', (c) => { + const content = renderBrowseComposer(new URL(c.req.url)); + const response = + content === null + ? page( + 'open/WebFetch로 아래 링크를 여세요. 모든 결과는 HTML입니다. format이나 쿼리 문자열은 필요 없습니다.
수납박스 상품 3개 조회 · 핫식스 검색 · 강남역 매장 검색
주요 검색어 뒤에는 /page/2/pageSize/10처럼 조건 이름과 값을 붙입니다. 주요 검색어 없이 조건만 사용하려면 /browse/query/서비스/기능/조건/값을 사용하세요. 상품 검색은 재고 확인이 아닙니다. 실제 상품 ID와 사용자 지역으로 재고를 별도 조회하세요.
${r.path}${r.positional.length ? ' 뒤에 ' + r.positional.join(', ') + ' 값을 순서대로 추가' : ''}open/WebFetch로 아래 링크를 여세요. 모든 결과는 HTML입니다. format이나 쿼리 문자열은 필요 없습니다.
수납박스 상품 3개 조회 · 핫식스 검색 · 강남역 매장 검색
주요 검색어 뒤에는 /page/2/pageSize/10처럼 조건 이름과 값을 붙입니다. 주요 검색어 없이 조건만 사용하려면 /browse/query/서비스/기능/조건/값을 사용하세요. 상품 검색은 재고 확인이 아닙니다. 실제 상품 ID와 사용자 지역으로 재고를 별도 조회하세요.
${r.path}${r.positional.length ? ' 뒤에 ' + r.positional.join(', ') + ' 값을 순서대로 추가' : ''}선택한 상품 ID: ${productId}
현재 입력: ${text || '(비어 있음)'}
+아직 상품·매장·재고를 조회하지 않았습니다. 아래 실제 링크 ID로 이동하세요. URL을 직접 조립하지 마세요. 한글은 초성, 중성, 완성 글자를 차례로 선택하고 영문·숫자는 바로 선택합니다. 최대32자이며 개인정보는 입력하지 마세요.
+${text.trim() ? `` : ''} +${link('현재 입력으로 돌아가기', text)} · ${link('한 글자 지우기', text.slice(0, -1))} · ${link('처음부터', '')}
`; +} diff --git a/src/pages/discovery.ts b/src/pages/discovery.ts index 4590ea8c..95da35ad 100644 --- a/src/pages/discovery.ts +++ b/src/pages/discovery.ts @@ -40,7 +40,7 @@ export function buildDiscoveryAssets(info: {${escapeHtml(AGENT_GUIDE_STAMP)} · 현재 버전 안내 · 항상 최신 안내로 이동
설치 없이 상품·매장·재고와 영화관 정보를 조회하세요. 사용자 API 키나 로그인 없이 쓸 수 있는 공개 서비스입니다.
${escapeHtml(WEB_SEARCH_DESCRIPTION)}
${escapeHtml(WEB_READ_RULE)}
-원하는 상품 검색어를 링크로 선택하기 · 매장 검색어를 링크로 선택하기
위 링크는 셸·설치 없이 웹 읽기로 호출하는 공개 REST GET의 HTML 결과입니다. 모든 서비스의 /browse/ 경로는 자동 HTML이며 쿼리 문자열은 필요 없습니다. 검색한 상품의 실제 ID로 재고를 조회하세요. 상품 검색만으로 매장 재고를 단정하지 마세요.
원격 MCP 주소: ${BASE}
전송: Streamable HTTP · 인증: 없음
58450');
+ const path = '/browse/daiso/inventory/58450/%EA%B0%95%EB%82%A8/pageSize/3';
+ expect(body).toContain(path);
+ const target = browseApiUrl(new URL(path, 'https://mcp.aka.page'))!;
+ expect(Object.fromEntries(target.searchParams)).toEqual({
+ productId: '58450',
+ keyword: '강남',
+ pageSize: '3',
+ format: 'html',
+ });
+ body = await walk('/browse/compose/daiso/inventory/58450/강남', ['한 글자 지우기']);
+ expect(body).toContain('/browse/daiso/inventory/58450/%EA%B0%95/pageSize/3');
+ body = await walk('/browse/compose/daiso/inventory/58450/강남', ['처음부터']);
+ expect(body).toContain('선택한 상품 ID: 58450');
+ expect(body).not.toContain('입력한 검색어로 실제 조회');
+ });
+ it('매장 검색은 영문·숫자·공백·하이픈을 보존하고 빈 검색을 금지한다', async () => {
+ const body = await walk('/browse/compose/daiso/stores/~', ['A', '공백', '1', '-']);
+ expect(body).toContain('/browse/daiso/stores/A%201-/limit/3');
+ const empty = renderBrowseComposer(
+ new URL('https://mcp.aka.page/browse/compose/daiso/stores/%20'),
+ )!;
+ expect(empty).not.toContain('입력한 검색어로 실제 조회');
+ const index = await app.request('/browse/compose');
+ expect(index.status).toBe(200);
+ expect(await index.text()).toContain('/browse/compose/daiso/products/~');
+ });
+ it.each([
+ '/other',
+ '/browse/compose/daiso/unknown/~',
+ '/browse/compose/daiso/products',
+ '/browse/compose/daiso/inventory/~',
+ '/browse/compose/daiso/inventory/0/~',
+ '/browse/compose/daiso/inventory',
+ '/browse/compose/daiso/inventory/123456789012345678901/~',
+ '/browse/compose/daiso/products/%ZZ',
+ '/browse/compose/daiso/products/%3Cscript%3E',
+ '/browse/compose/daiso/products/~/unknown',
+ '/browse/compose/daiso/products/~/ko-19',
+ '/browse/compose/daiso/products/~/ko-0-21',
+ '/browse/compose/daiso/products/~/ko-0/extra',
+ '/browse/compose/daiso/products/~?q=ignored',
+ `/browse/compose/daiso/products/${'a'.repeat(33)}`,
+ `/browse/compose/daiso/products/${'a'.repeat(32)}/ko-0`,
+ `/browse/compose/daiso/products/${'a'.repeat(513)}`,
+ ])('잘못된 입력은 원본 조회 전에 거절한다: %s', (path) => {
+ expect(renderBrowseComposer(new URL(path, 'https://mcp.aka.page'))).toBeNull();
+ });
+ it('최대 길이에서는 문자 선택만 멈추고 올바른 조회 링크는 유지한다', () => {
+ const text = '가'.repeat(32);
+ const body = renderBrowseComposer(
+ new URL(
+ `/browse/compose/daiso/products/${text}?utm_source=chatgpt&guide=current`,
+ 'https://mcp.aka.page',
+ ),
+ )!;
+ expect(body).not.toContain('초성 ㄱ');
+ expect(body).toContain('입력한 검색어로 실제 조회');
+ });
+ it('HTTP 오류와 잘못된 메서드도 HTML로 반환하며 원본을 호출하지 않는다', async () => {
+ const fetch = vi.fn();
+ vi.stubGlobal('fetch', fetch);
+ const response = await app.request('/browse/compose/daiso/products/~/invalid');
+ expect(response.status).toBe(400);
+ expect(response.headers.get('x-robots-tag')).toContain('noindex');
+ expect(response.headers.get('content-type')).toContain('text/html');
+ expect((await app.request('/browse/compose/daiso/products/~', { method: 'POST' })).status).toBe(
+ 405,
+ );
+ expect(fetch).not.toHaveBeenCalled();
+ });
+ it('실제 상품 HTML에서 지역 선택으로 연결하고 지역 없는 재고를 바로 조회하지 않는다', async () => {
+ const fixture = new Hono();
+ fixture.use('/api/*', apiResultHtml);
+ fixture.get('/api/daiso/products', (c) =>
+ c.json({ success: true, data: { products: [{ id: '58450', name: '전문가용 2B 연필' }] } }),
+ );
+ const body = await (await fixture.request('/api/daiso/products?format=html')).text();
+ expect(body).toContain('href="/browse/compose/daiso/inventory/58450/~"');
+ expect(body).not.toContain('href="/browse/daiso/inventory/58450"');
+ });
+});
diff --git a/tests/pages/latestGuide.test.ts b/tests/pages/latestGuide.test.ts
index fdb93d25..a787e098 100644
--- a/tests/pages/latestGuide.test.ts
+++ b/tests/pages/latestGuide.test.ts
@@ -4,7 +4,7 @@ import app from '../../src/index.js';
import { buildDiscoveryAssets } from '../../src/pages/discovery.js';
import { apiResultHtml } from '../../src/pages/apiResult.js';
-const version = '2026-10-26.1';
+const version = '2026-10-27.1';
describe('최신 안내 경로와 원본 이미지', () => {
it.each(['/guide/latest', '/guide/2026-10-21.2', '/guide/unknown'])('%s는 캐시 없이 현재 버전으로 연결한다', async path => {
const r = await app.request(path);
diff --git a/tests/pages/productCards.test.ts b/tests/pages/productCards.test.ts
index ac4550f9..1d1a4e13 100644
--- a/tests/pages/productCards.test.ts
+++ b/tests/pages/productCards.test.ts
@@ -51,8 +51,8 @@ describe('실제 상품 이미지 카드', () => {
const assets = buildDiscoveryAssets({ services: [], tools: [] });
expect(assets['discovery.html']).toMatch(/name="description" content="ChatGPT.*browse\//);
for (const text of [assets['discovery.html'], buildAgentStartText('https://example.com')]) {
- expect(text).toContain('안내 버전: 2026-10-26.1');
- expect(text).toContain('갱신일: 2026-10-26');
+ expect(text).toContain('안내 버전: 2026-10-27.1');
+ expect(text).toContain('갱신일: 2026-10-27');
}
});
});
diff --git a/tests/scripts/hide-readiness.test.ts b/tests/scripts/hide-readiness.test.ts
index 789a9a47..21accdfb 100644
--- a/tests/scripts/hide-readiness.test.ts
+++ b/tests/scripts/hide-readiness.test.ts
@@ -35,7 +35,11 @@ it.skipIf(process.platform !== 'darwin')(
implementation +
'\nlet cases=try JSONSerialization.jsonObject(with:Data(contentsOf:URL(fileURLWithPath:CommandLine.arguments[1]))) as! [[String:Any]]\nfor row in cases { print(readinessAllowsHiding(row, launchedAt:1000, now:1200)) }\n',
);
- const output = execFileSync('/usr/bin/swift', [script, data], { encoding: 'utf8' });
+ // 클라우드 Mac의 첫 Swift 컴파일도 제한 시간 안에서 끝나도록 별도 상한을 둡니다.
+ const output = execFileSync('/usr/bin/swift', [script, data], {
+ encoding: 'utf8',
+ timeout: 20_000,
+ });
expect(output.trim().split('\n')).toEqual(['true', ...Array(9).fill('false')]);
writeFileSync(
script,
@@ -43,11 +47,12 @@ it.skipIf(process.platform !== 'darwin')(
implementation +
'\nprint(processLaunchTime("Mon Oct 26 00:53:22 2026", timeZone:TimeZone(secondsFromGMT:0)!) == 1792976002)\nprint(processLaunchTime("invalid") == nil)\n',
);
- expect(execFileSync('/usr/bin/swift', [script], { encoding: 'utf8' }).trim()).toBe(
+ expect(execFileSync('/usr/bin/swift', [script], { encoding: 'utf8', timeout: 20_000 }).trim()).toBe(
'true\ntrue',
);
} finally {
rmSync(directory, { recursive: true, force: true });
}
},
+ 45_000,
);
diff --git a/tests/scripts/repository-config.test.ts b/tests/scripts/repository-config.test.ts
index e55a1e44..8ce63153 100644
--- a/tests/scripts/repository-config.test.ts
+++ b/tests/scripts/repository-config.test.ts
@@ -315,7 +315,7 @@ describe('repository maintenance configuration', () => {
expect(fastStart).toContain('설치·API 키 없이');
expect(fastStart).toMatch(/\| 인증\s*\| 없음\s*\|/);
expect(readme).toContain('로컬 설치·빌드·API 키 준비 없이');
- expect(fastStart).toContain('docs/service-reference.md?guide=2026-10-26.1#설치-없이-첫-조회');
+ expect(fastStart).toContain('docs/service-reference.md?guide=2026-10-27.1#설치-없이-첫-조회');
const directCall = readText('docs/service-reference.md').split('### MCP 표준 응답 모델')[0];
expect(directCall).toContain('WebFetch');
expect(directCall).toContain('/browse/daiso/products/');