8000 fix: 상품 상세에서 지역 재고 조회 링크 복구 by hmmhmmhm · Pull Request #340 · 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
19 changes: 18 additions & 1 deletion docs/web-reading-lab-results-2026-10-27.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ PR335의 실험 Worker 배포40437090435가 성공했다. 별도 브라우저를

실제 상품 HTML의 재고 링크는 사용자 지역을 URL에 직접 추가하도록 요구한다. 따라서 지역도 링크로 입력하고 실제 상품 ID를 유지하는 운영 경로가 필요하다. 이 흐름의 실제 재고·진열 위치, 독립 실행 및 새 ChatGPT Chat 대화의 성공은 아직 검증하지 않았다. 오후2시 연구 목표는 계속 active이며 이번 결과만으로 완료 처리하지 않는다.


## 운영 링크 입력·읽기 세션 실험 (12:35 KST)

PR336과337을 검사·리뷰 후 병합하고 운영에 배포했다. 공개 도구10개와 기존 JSON API, 원본 API 캐시·한도·권한은 유지했다.
Expand All @@ -47,3 +46,21 @@ PR336과337을 검사·리뷰 후 병합하고 운영에 배포했다. 공개
- 새 ID04e687c3-410c-451c-869a-b8df53e38729의 같은 재고 링크는 첫 click에 성공하고03:34:21.898UTC 응답을 반환했다. 각 경로의 캐시·네트워크·원본 지연을 분리해 인과관계를 확정한 것은 아니다. TimeoutError와 URL 접근 거절은 같은 오류로 취급하지 않는다.

새 주소도 웹 도구가 수집한 뒤에는 재사용될 수 있다. 새 응답 링크는 검증된 범위의 대안이며 모든 계정·도구에서의 최신성 보장은 아니다. 새 ChatGPT Chat UI의 독립 검증은 BrowserSkill 연결0개와 Computer Use 서버 시간 초과로 아직 미완료다. 브라우저를 반복 생성하지 않았다. 원시 관측은 운영 기록의 read-sessions-observations.json에 보존했다.

## 실제 ChatGPT와 익명 대화 검증 (13:46 KST)

앞선 미완료 기록 이후 Computer Use 연결이 복구되어 기존 Chrome의 소유 탭으로 검증했다. 로그인된 임시 대화와 로그아웃 상태의 익명 대화를 구분했다. UI의 응답·출처·활동 표시는 확인했지만, 이를 내보낸 원시 도구 로그로 취급하지 않는다.

- 로그인된 임시 대화에서 실제 문서 링크와 글자 선택으로 수납박스1049516의 강남 재고를 받았다. HTML 생성04:14:56.869 UTC, 강남역0·강남세곡null·강남구청역3이었다. 웹 조회 성공이며 MCP 프로토콜 연결 성공은 아니다.
- 첫 익명 대화의 자연어 요청은11초에 상품3개와 실제 ID·가격을 반환했다. 생성 시각은 없어 최신 원본 조회라고 주장하지 않는다. 이후 지갑 요청은 일반 쇼핑으로 이탈하거나 실패했다. 실제 링크·글자 입력을 명시한 요청은 성공했지만03:06:07 UTC의 이전 HTML이었다.
- 사용자가 승인한 첫 익명 테스트 대화만 삭제하고 새 대화를 시작했다. 공식 주소를 생략한 요청은 제삼자 GitHub 미러를 선택하고 실패했다.
- 새 대화의 후속 요청에 공식 GitHub 주소와 실제 '첫 HTML 조회' 링크 선택, 글자 입력, URL 재조립 금지, 증거·이미지 규칙을 함께 전달하자25초에 사전 고정하지 않은 `철사` 조회가 성공했다. 58951·1041764·1074544, 각각1000원, HTML 생성04:36:32.698 UTC였다. 일반 HTTP API 대조군에서도 같은 ID·이름·가격을 확인했다. 후속 요청 성공이므로 독립 첫 요청의 보편적 성공으로 확대하지 않는다.
- 철사58951 상세에서 강남 재고로 진행하려 하자 지역 선택 링크가 없었다. 실제 상세 DTO는 `productId`, 검색 DTO는 `id`여서 HTML 렌더러의 후속 링크 조건이 상세를 놓치는 버그를 확인했다. 문자열·숫자 상세 ID 회귀2건의 실패를 먼저 확인한 뒤 두 필드를 지원하도록 수정했다. JSON 응답은 유지한다.

버전 안내가 text/plain일 때 URL 문자열은 읽혀도 링크 ID가 없었다. PR339는 버전 안내를 실제 anchor가 있는 HTML로 바꾸고27.3으로 배포했다. 네이티브 웹 도구에서 안내의 시작 링크와 새 응답 링크를 실제 click해04:07:14.518 UTC의 새 세션을 확인했다. llms.txt와 prompt는 UTF-8 텍스트로 유지한다.

랜딩 페이지의 질문 버튼도 원시 동적 조회 URL 전달 대신 공식 GitHub·실제 링크·글자 입력을 전달하도록 v11을 배포했다. 한국어·영어와 정적 대체 링크3개를 함께 검증했다. 원본 확인이 안 된 이미지는 생략하도록 안내하지만, 실제 Chat은 앞선 대화에서 외부 쇼핑 이미지를 임의로 붙이기도 했다. 문서만으로 에이전트 준수를 보장할 수 없다.

### 확인된 한계와 운영 기준

고정 URL의 오래된 HTML·리디렉션, 직접 문자열 open 거절, 링크 click 성공, 시간 초과, 에이전트의 URL 재조립·출처 선택 실수는 서로 다른 문제다. `no-store`나 수집일 표시만으로 최신성을 보장하지 않으며 HTML 생성 시각과 원본 재고 시각도 다르다. 실제 링크를 따라가는 읽기 세션은 성공한 대안이지만 모든 계정과 웹 도구의 접근·최신성을 보장하는 우회는 아니다. 원본 요청량을 늘리는 무한 재시도는 하지 않는다.
6 changes: 5 additions & 1 deletion src/pages/apiResult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,11 @@ function renderValue(
source,
);
let detail = '';
if (productLinks && key === 'id' && (typeof item === 'string' || typeof item === 'number')) {
if (
productLinks &&
(key === 'id' || key === 'productId') &&
(typeof item === 'string' || typeof item === 'number')
) {
const path = toBrowsePath(
new URL(`${productLinks}/${encodeURIComponent(String(item))}`, 'https://browse.local'),
);
Expand Down
58 changes: 53 additions & 5 deletions tests/pages/productCards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,24 @@ 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 } }));
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 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);
Expand All @@ -30,14 +41,33 @@ describe('실제 상품 이미지 카드', () => {
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 => {
c 8000 onst html = await (await fixture([{ id: 'p', name: '상품', imageUrl }]).request('/api/daiso/products?format=html')).text();
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 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; >
Expand All @@ -47,6 +77,24 @@ describe('실제 상품 이미지 카드', () => {
expect(json.data.products).toEqual(products);
});

it.each(['58951', 58951])(
'실제 상세 응답의 productId %s에서 지역 선택으로 이어지고 JSON은 유지한다',
async (productId) => {
const a = new Hono();
a.use('/api/*', apiResultHtml);
const payload = {
success: true,
data: { productId, productName: '다용도 철사 2P SET', currentPrice: 1000 },
};
a.get('/api/daiso/products/:id', (c) => c.json(payload));
const response = await a.request('/api/daiso/products/58951?format=html');
expect(await response.text()).toContain(
'href="/browse/compose/daiso/inventory/58951/~">매장 지역 선택</a>',
);
expect(await (await a.request('/api/daiso/products/58951')).json()).toEqual(payload);
},
);

it('검색 설명과 공통 안내에 HTML GET 우선과 버전을 표시한다', () => {
const assets = buildDiscoveryAssets({ services: [], tools: [] });
expect(assets['discovery.html']).toMatch(/name="description" content="ChatGPT.*browse\//);
Expand Down
Loading
0