8000 feat: 실제 상품 이미지 카드와 웹 안내 버전 표시 by hmmhmmhm · Pull Request #298 · hmmhmmhm/daiso-mcp · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
<summary>AI 에이전트 사용 안내</summary>

> [!IMPORTANT]
> **안내 버전: 2026-10-21.2 · 갱신일: 2026-10-21**
>
> **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)하세요. 설치·API 키·MCP 연결 설정은 필요 없습니다.
>
> **로컬 설치·빌드·API 키 준비 없이 실제 조회부터 실행하세요.** 패키지 설치나 전체 스키마 탐색은 필요 없습니다.
>
> 1. 연결된 MCP 도구가 있으면 `search_products`에 `{"service":"daiso","query":"수납박스","pageSize":3}`를 전달합니다.
Expand Down
8 changes: 8 additions & 0 deletions docs/superpowers/plans/2026-10-21-product-html-cards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# 웹 발견 설명과 상품 이미지 카드

사용자 승인: 검색 설명의 HTML GET 우선 안내, 버전·갱신일 표시, 실제 응답 이미지를 상품에 연결한 카드를 즉시 구현·배포한다.

1. 검색·버전·상품 카드의 회귀 검사를 먼저 추가하고 기존 응답에서 실패함을 확인한다.
2. 공통 안내 버전과 검색 설명을 첫 화면/llms/README에 표시한다. 상품 객체의 모든 원본 필드를 보존하면서 이름·이미지·식별값을 하나의 article로 묶는다. HTTPS의 인증정보 없는 imageUrl만 img에 사용하고 없거나 잘못된 URL은 이미지 없음으로 표시한다. JSON/MCP 계약은 변경하지 않는다.
3. 전체 check/coverage100/build/발견 자산 생성 및 독립 리뷰 후 PR 검사·병합배포한다. 공개 응답의 최신 버전/실제 이미지와 ID 연결/후속 링크 및 MCP10개를 검증한다.
4. GitHub 저장소 설명도 HTML GET 우선 문구로 갱신한다. 검색 엔진의 재수집·캐시 반영 시각이나 다른 Chat의 행동은 보장하지 않는다.
6 changes: 6 additions & 0 deletions docs/web-discovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,9 @@ HTML은 원래 응답의 성공·오류와 HTTP 상태를 보존합니다. 상
## 실행과 이미지 출처

셸 DNS 실패를 웹 GET 실패로 확대하지 않습니다. 실제 URL을 open/WebFetch로 연 결과와 웹 검색을 구분하고 실행하지 않은 요청의 성공·실패나 확인하지 않은 HTTP 상태를 만들지 않습니다. 상품 사진은 해당 응답의 imageUrl만 사용하며 없거나 표시되지 않으면 웹 검색 이미지·다른 상품·다른 국가 이미지로 대체하지 않고 생략합니다.

## 안내 버전과 상품 이미지 카드

현재 안내 버전은 `2026-10-21.2`, 갱신일은 `2026-10-21`입니다. README의 AI 안내, 첫 화면, llms.txt, prompt와 HTML 조회 결과에 표시합니다. 버전이 없거나 다르면 예전 검색 사본일 수 있으므로 실제 읽은 URL과 문구를 확인하고 최신 버전을 읽었다고 추정하지 않습니다. 검색 엔진의 재수집·캐시 반영 시각은 이 배포로 보장하지 않습니다.

상품 HTML은 이름·ID·원본 필드와 응답 imageUrl의 이미지를 같은 카드에 표시합니다. 이미지 링크는 원본 URL의 쿼리까지 유지하며 HTTPS이고 인증정보가 없는 주소만 사용합니다. imageUrl이 없거나 안전한 이미지 주소가 아니면 이미지 없음으로 표시합니다. 검색 이미지로 채우지 않습니다. JSON API 필드와 오류 상태는 유지합니다.
7 changes: 7 additions & 0 deletions src/pages/agentStart.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
/** 검색·읽기 사본의 안내 세대를 구분하는 고정 버전 */
export const AGENT_GUIDE_VERSION = '2026-10-21.2';
export const AGENT_GUIDE_UPDATED = '2026-10-21';
export const AGENT_GUIDE_STAMP = `안내 버전: ${AGENT_GUIDE_VERSION} · 갱신일: ${AGENT_GUIDE_UPDATED}`;
export const WEB_SEARCH_DESCRIPTION = 'ChatGPT 웹 읽기로 설치·API 키·MCP 연결 설정 없이 바로 상품을 조회하세요. URL 열기로 /api/daiso/products?q=검색어&format=html을 사용합니다. 다이소·편의점 재고와 영화·생활 정보도 지원합니다.';

/** 웹 읽기 환경에서 조회 형식을 선택하는 공통 규칙 */
export const WEB_READ_RULE = '웹 읽기 전용 에이전트: 모든 /api/* GET 조회 URL에 format=html을 반드시 포함하세요. 상품 검색뿐 아니라 상세·매장·재고·후속 조회에도 적용합니다. 아래 문서의 JSON 응답 예시는 필드 설명이며 웹 읽기로 JSON 주소를 열라는 뜻이 아닙니다. JSON URL이 읽히지 않으면 format=html로 다시 조회하고, 그 실패만으로 서비스 ì 8000 ž¥애·로그인 필요·이 환경에서 실행할 수 없다고 단정하지 마세요.';

Expand All @@ -15,6 +21,7 @@ export const EXECUTION_EVIDENCE_RULE = '실제로 실행한 도구·요청 URL·
/** 설치 없이 첫 실제 조회를 실행하는 에이전트 안내 */
export function buildAgentStartText(baseUrl: string): string {
return `# DAISO MCP: 설치 없이 시작하기
${AGENT_GUIDE_STAMP}

**${WEB_READ_RULE}**

Expand Down
35 changes: 27 additions & 8 deletions src/pages/apiResult.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** 웹 읽기 에이전트용 공개 GET 결과 표시 */
import { WEB_READ_RULE, IMAGE_SOURCE_RULE, EXECUTION_EVIDENCE_RULE } from './agentStart.js';
import { WEB_READ_RULE, IMAGE_SOURCE_RULE, EXECUTION_EVIDENCE_RULE, AGENT_GUIDE_STAMP } from './agentStart.js';
import type { MiddlewareHandler } from 'hono';
import type { AppBindings } from '../api/response.js';

Expand All @@ -8,18 +8,36 @@ function escapeHtml(value: unknown): string {
.replaceAll('"', '&quot;').replaceAll("'", '&#39;');
}

/** 원본 HTTPS 이미지 URL만 렌더링하며 잘못된 주소는 대체하지 않습니다. */
function productImage(value: unknown, name: string): string {
if (typeof value === 'string') {
try {
const url = new URL(value);
if (url.protocol === 'https:' && !url.username && !url.password) {
const source = escapeHtml(value);
return `<figure><img src="${source}" alt="${escapeHtml(name)}" loading="lazy" referrerpolicy="no-referrer"><figcaption><a href="${source}" rel="noreferrer">원본 상품 이미지</a></figcaption></figure>`;
}
} catch { /* 잘못된 원본 URL은 아래 이미지 없음으로 표시합니다. */ }
}
return '<p class="image-missing">이미지 없음. 웹 검색 이미지로 대체하지 마세요.</p>';
}

/** 모든 필드와 배열 순서를 보존하고 외부 문자열을 HTML로 해석하지 않습니다. */
function renderValue(value: unknown, productLinks: string | null): string {
function renderValue(value: unknown, productLinks: string | null, product = false): string {
if (value === null || typeof value !== 'object') return escapeHtml(value);
if (Array.isArray(value)) {
return value.length ? `<ol>${value.map(item => `<li>${renderValue(item, productLinks)}</li>`).join('')}</ol>` : '<p>빈 목록</p>';
return value.length ? `<ol>${value.map(item => `<li>${renderValue(item, productLinks, product)}</li>`).join('')}</ol>` : '<p>빈 목록</p>';
}
return `<dl>${Object.entries(value).map(([key, item]) => {
const rendered = renderValue(item, productLinks);
const fields = `<dl>${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')
? ` <a href="${productLinks}/${encodeURIComponent(String(item))}?format=html">상품 상세</a>` : '';
return `<dt>${escapeHtml(key)}</dt><dd>${rendered}${detail}</dd>`;
}).join('')}</dl>`;
const record = value as Record<string, unknown>;
return product && typeof record.name === 'string'
? `<article class="product-card"><h2>${escapeHtml(record.name)}</h2>${productImage(record.imageUrl, record.name)}${fields}</article>`
: fields;
}

export const apiResultHtml: MiddlewareHandler<{ Bindings: AppBindings }> = async (c, next) => {
Expand All @@ -33,10 +51,11 @@ export const apiResultHtml: MiddlewareHandler<{ Bindings: AppBindings }> = async
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' ? '/api/daiso-jp/products' : url.pathname === '/api/daiso/products' ? '/api/daiso/products' : null;
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 = `<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,follow"><title>${title}: DAISO MCP</title>
<style>body{font-family:system-ui,sans-serif;max-width:900px;margin:32px auto;padding:0 20px;line-height:1.6;overflow-wrap:anywhere}dt{font-weight:600}dd{margin-bottom:12px}li{border-bottom:1px solid #ddd;padding:12px 0}a{color:#075bb5}</style></head><body><main><h1>${title}</h1>
<style>body{font-family:system-ui,sans-serif;max-width:900px;margin:32px auto;padding:0 20px;line-height:1.6;overflow-wrap:anywhere}dt{font-weight:600}dd{margin-bottom 8000 :12px}li{border-bottom:1px solid #ddd;padding:12px 0}a{color:#075bb5}.product-card{border:1px solid #d8dee5;border-radius:12px;padding:20px;margin:16px 0}.product-card img{max-width:100%;width:240px;height:240px;object-fit:contain}.product-card figure{margin:0}figcaption,.image-missing{color:#485666;font-size:.9em}</style></head><body><main><h1>${title}</h1>
<p>${escapeHtml(AGENT_GUIDE_STAMP)}</p>
<p><strong>${escapeHtml(WEB_READ_RULE)}</strong></p>
<p>요청: <code>${source}</code> · HTTP ${c.res.status}</p><p>실제 API 조회 결과입니다. 오류를 상품 없음이나 재고 0으로 해석하지 마세요. ${inventoryGuide}</p>
<p>${escapeHtml(IMAGE_SOURCE_RULE)}</p>
Expand All @@ -47,7 +66,7 @@ ${renderValue(data, productLinks)}
const headers = new Headers(c.res.headers);
headers.set('Content-Type', 'text/html; charset=utf-8');
headers.set('X-Content-Type-Options', 'nosniff');
headers.set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; 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 });
// Hono는 응답 교체 시 기존 헤더를 병합하므로 교체 후 원본 표현의 헤더를 제거합니다.
for (const name of ['Content-Length', 'Content-Encoding', 'ETag', 'Last-Modified', 'Content-MD5', 'Digest']) c.res.headers.delete(name);
Expand Down
7 changes: 4 additions & 3 deletions src/pages/discovery.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { buildAgentStartText, WEB_READ_RULE, ROUTE_SELECTION_TEXT, IMAGE_SOURCE_RULE, EXECUTION_EVIDENCE_RULE } from './agentStart.js';
import { buildAgentStartText, WEB_READ_RULE, ROUTE_SELECTION_TEXT, IMAGE_SOURCE_RULE, EXECUTION_EVIDENCE_RULE, AGENT_GUIDE_STAMP, WEB_SEARCH_DESCRIPTION } from './agentStart.js';

const BASE = 'https://mcp.aka.page';

Expand All @@ -16,12 +16,13 @@ export function buildDiscoveryAssets(info: { services: { name: string }[]; tools
'discovery.html': `<!doctype html>
<html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>DAISO MCP: 설치 없이 상품·매장·재고 조회</title>
<meta name="description" content="다이소, 편의점, 마트, 올리브영과 영화관을 조회하는 무료 공개 MCP. 설치나 사용자 API 키 없이 시작하세요.">
<meta name="description" content="${escapeHtml(WEB_SEARCH_DESCRIPTION)}">
<meta name="robots" content="index,follow"><link rel="canonical" href="${BASE}/">
<link rel="alternate" type="text/plain" href="${BASE}/llms.txt" title="에이전트 시작 안내">
<style>body{font-family:system-ui,sans-serif;max-width:760px;margin:48px auto;padding:0 24px;line-height:1.7;color:#18222c;background:#fafafa}h1{line-height:1.3}a{color:#075bb5}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#edf1f5;padding:16px;border-radius:8px}code{font-size:.9em}section{margin:32px 0}footer{border-top:1px solid #d8dee5;padding-top:20px}</style>
</head><body><main>
<h1>다이소 MCP</h1><p>설치 없이 상품·매장·재고와 영화관 정보를 조회하세요. 사용자 API 키나 로그인 없이 쓸 수 있는 공개 서비스입니다.</p>
<h1>DAISO MCP</h1><p>${escapeHtml(AGENT_GUIDE_STAMP)}</p><p>설치 없이 상품·매장·재고와 영화관 정보를 조회하세요. 사용자 API 키나 로그인 없이 쓸 수 있는 공개 서비스입니다.</p>
<p>${escapeHtml(WEB_SEARCH_DESCRIPTION)}</p>
<p><strong>${escapeHtml(WEB_READ_RULE)}</strong></p>
<section><h2>바로 조회하기</h2><p><a href="${escapeHtml(firstQuery)}">다이소 수납박스 상품 3개 조회</a></p>
<p>위 링크는 셸·설치 없이 웹 읽기로 호출하는 공개 REST GET의 HTML 결과입니다. 다른 공개 GET 조회도 기존 파라미터에 <code>format=html</code>을 추가하세요. 검색한 상품의 실제 ID로 재고를 조회하세요. 상품 검색만으로 매장 재고를 단정하지 마세요.</p></section>
Expand Down
58 changes: 58 additions & 0 deletions tests/pages/productCards.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Hono } from 'hono';
import { describe, expect, it } from 'vitest';
import { apiResultHtml } from '../../src/pages/apiResult.js';
import { buildDiscoveryAssets } from '../../src/pages/discovery.js';
import { buildAgentStartText } from '../../src/pages/agentStart.js';

function fixture(products: unknown[]) {
const a = new Hono();
a.use('/api/*', apiResultHtml);
a.get('/api/daiso/products', c => c.json({ success: true, data: { products }, meta: { total: 9 } }));
return a;
}

describe('실제 상품 이미지 카드', () => {
it('상품 이름·이미지·ID를 같은 카드에 연결하고 전체 원본 필드를 보존한다', async () => {
const r = await fixture([{ id: 'p1', name: '<상품>', imageUrl: 'https://cdn.example/p.png?l=ko&size=2', price: 5000, extra: { unknown: true } }, { id: 'p2', name: '사진 없음' }]).request('/api/daiso/products?format=html');
const html = await r.text();
const cards = html.match(/<article class="product-card">[\s\S]*?<\/article>/g)!;
expect(cards).toHaveLength(2);
expect(cards[0]).toContain('<h2>&lt;상품&gt;</h2>');
expect(cards[0]).toContain('src="https://cdn.example/p.png?l=ko&amp;size=2"');
expect(cards[0]).toContain('alt="&lt;상품&gt;"');
expect(cards[0]).toContain('원본 상품 이미지');
expect(cards[0]).toContain('p1');
expect(cards[0]).toContain('5000');
expect(cards[0]).toContain('unknown');
expect(cards[1]).toContain('이미지 없음');
expect(cards[1]).not.toContain('<img');
expect(html).toContain('total');
expect(r.headers.get('Content-Security-Policy')).toContain('img-src https:');
});

it.each([null, 42, '', 'broken', 'javascript:alert(1)', 'data:image/png;base64,xxx', 'http://cdn.example/p.png', 'https://user:pass@cdn.example/p.png', 'https://:pass@cdn.example/p.png'])('안전하지 않거나 없는 이미지 %s는 외부 대체 없이 생략한다', async imageUrl => {
const html = await (await fixture([{ id: 'p', name: '상품', imageUrl }]).request('/api/daiso/products?format=html')).text();
expect(html).not.toContain('<img');
expect(html).toContain('이미지 없음');
});

it('속성 탈출 문자열을 이스케이프하고 이름 없는 원본과 비객체도 보존한다', async () => {
const products = [{ id: 'x', name: '" imageUrl: 'https://cdn.example/p" }, { id: 'no-name' }, null, 7];
const html = await (await fixture(products).request('/api/daiso/products?format=html')).text();
expect(html).not.toContain(' >
expect(html).toContain('&quot; >
expect(html).toContain('no-name');
expect(html).toContain('null');
const json = await (await fixture(products).request('/api/daiso/products')).json();
expect(json.data.products).toEqual(products);
});

it('검색 설명과 공통 안내에 HTML GET 우선과 버전을 표시한다', () => {
const assets = buildDiscoveryAssets({ services: [], tools: [] });
expect(assets['discovery.html']).toMatch(/name="description" content="ChatGPT.*format=html/);
for (const text of [assets['discovery.html'], buildAgentStartText('https://example.com')]) {
expect(text).toContain('안내 버전: 2026-10-21.2');
expect(text).toContain('갱신일: 2026-10-21');
}
});
});
Loading
0