8000 feat: 웹 읽기 세션별 새 응답 경로 실험 by hmmhmmhm · Pull Request #337 · hmmhmmhm/daiso-mcp · GitHub
[go: up one dir, main page]

Skip to content
8000
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
20 changes: 20 additions & 0 deletions docs/superpowers/plans/2026-10-27-browse-read-sessions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# 웹 읽기 세션 경로 실험 구현 계획

> 구현은 현재 세션에서 순서대로 수행합니다. 브라우저·테스트 병렬 실행을 하지 않습니다.

**목표:** 웹 도구가 기존 HTML 수집본을 재사용하는 상황에서 실제 링크 선택으로 새로운 응답 주소를 얻을 수 있는지 검증합니다.

**설계:** `/browse/read`는 임의 UUID를 포함한 `/browse/read/{id}/...` 링크를 제공합니다. 해당 경로는 같은 Worker의 기존 `/browse/...` GET으로만 전달하고 HTML의 내부 browse 링크를 세션 경로로 연결합니다. 새 응답 확인 링크는 새로운 UUID를 포함하면서 조회 조건을 유지합니다. API 캐시·권한·한도는 그대로 적용하며 원본 재고의 최신성을 보장한다는 표현은 사용하지 않습니다. JSON API와 공개 도구 10개는 바꾸지 않습니다.

**근거:** 2026-10-27 12:12 KST에 고정 상품 예제가 Crawled today이면서도 00:44 UTC 생성의 이전26.1 응답을 반환했습니다. /guide/latest는5일 전 redirect를 반환했습니다. 반면 새27.1 링크 입력 경로의 실제 click은 성공했습니다. UUID 경로가 이 현상을 피하는지는 미검증 가설입니다.

## 순서

- [ ] `tests/pages/browseRead.test.ts`: 기존 등록 함수만 사용하여 루트 세션 링크·조건 보존·새 ID·중첩 거절·GET 제한의 기대 동작을 작성합니다. 아직 없는 경로의404가 기대200과 달라 실패하는지 확인합니다. 로컬 보호기가 Chrome3개 상태이므로 GitHub cloud CI에서 확인하고 실패를 정상으로 기록하지 않습니다.
- [ ] `src/pages/browseRead.ts`: UUID 검증, 내부 browse만 전달, HTML 링크 및 Location 재작성, no-store/noindex, 새 응답 링크를 구현합니다. 외부 이미지·guide 링크는 그대로 유지합니다. 잘못된 ID·중첩 read 경로는 내부 호출 전400으로 거절합니다. 응답 실패 상태를 성공으로 바꾸지 않습니다.
- [ ] `src/pages/browse.ts`: 기존 catchall보다 먼저 등록합니다. 쓰기·관리 API는 전달하지 않습니다.
- [ ] Cloud CI에서 새 회귀와 전체 검사·커버리지를 통과시킵니다. 실제 배포 전에 독립 읽기 전용 리뷰를 수행합니다.
- [ ] 배포 후 웹 도구의 실제 링크만으로 서로 다른 ID·응답 생성시각을 관측합니다. 고정 URL 결과와 비교합니다. 상품·매장·재고·진열 위치와 수량 미확인 구분을 유지합니다. 실제 Chat UI 검증은 브라우저 연결이 복구될 때 별도 수행합니다.
- [ ] 효과가 확인된 범위만 안내에 반영합니다. 검색 캐시를 강제로 삭제했다거나 원본 fresh 재고를 보장한다는 주장은 하지 않습니다.

**제약:** 기존 root/GitHub 검색 수집본을 이 기능만으로 최신으로 갱신할 수 있다고 보지 않습니다. 사용자·운영 브라우저는 종료하지 않으며 새 본체를 반복 생성하지 않습니다. 누적 인증·쿼터 원장을 보존합니다.
2 changes: 2 additions & 0 deletions src/pages/brows 8000 e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,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';
import { registerBrowseReadRoutes } from './browseRead.js';

function page(content: string, status = 200): Response {
return new Response(
Expand All @@ -20,6 +21,7 @@ function page(content: string, status = 200): Response {
}

export function registerBrowseRoutes(app: Hono<{ Bindings: AppBindings }>): void {
registerBrowseReadRoutes(app);
app.get('/browse/compose', () =>
page(
'<h1>링크를 선택하여 검색하기</h1><p>검색어를 URL에 직접 조립하지 않고 한 글자씩 선택합니다. 아직 실제 조회를 실행하지 않습니다.</p><ul><li><a href="/browse/compose/daiso/products/~">상품 검색어 선택</a></li><li><a href="/browse/compose/daiso/stores/~">매장 검색어 선택</a></li></ul>',
Expand Down
57 changes: 57 additions & 0 deletions src/pages/browseRead.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/** 웹 도구의 수집 주소를 구분하되 기존 API 캐시와 한도를 유지합니다. */
import type { Context, Hono } from 'hono';
import type { AppBindings } from '../api/response.js';

type Bindings = { Bindings: AppBindings };
const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;

export function registerBrowseReadRoutes(app: Hono<Bindings>): void {
const forward = async (c: Context<Bindings>, id: string, suffix: string): Promise<Response> => {
if (c.req.method !== 'GET') return c.text('GET 조회만 지원합니다.', 405);
if (!ID.test(id) || suffix.split('/')[1] === 'read')
return c.text('읽기 세션 경로가 올바르지 않습니다.', 400);
const prefix = `/browse/read/${id}`;
const scoped = (path: string) =>
path === '/browse/read' || path.startsWith('/browse/read/')
? path
: prefix + path.slice('/browse'.length);
const target = new URL(c.req.url);
target.pathname = '/browse' + suffix;
const requestHeaders = new Headers(c.req.raw.headers);
requestHeaders.delete('host');
const source = await app.fetch(
new Request(target, { method: 'GET', headers: requestHeaders, signal: c.req.raw.signal }),
c.env,
);
const headers = new Headers(source.headers);
headers.set('Cache-Control', 'no-store');
headers.set('X-Robots-Tag', 'noindex, nofollow');
headers.set('X-Daiso-Read-Id', id);
const location = headers.get('Location');
if (location === '/browse' || location?.startsWith('/browse/'))
headers.set('Location', scoped(location));
if (!headers.get('Content-Type')?.includes('text/html'))
return new Response(source.body, { status: source.status, headers });
let body = (await source.text()).replace(
/href="(\/browse(?:\/[^"]*)?)"/g,
(_, path: string) => `href="${scoped(path)}"`,
);
const refresh = `/browse/read/${crypto.randomUUID()}${suffix}`
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
const note = `<footer><p>읽기 세션: <code>${id}</code> · HTML 응답 생성: ${new Date().toISOString()}</p><p>새 주소도 기존 API 캐시·한도·권한을 사용합니다. HTML 응답 생성 시각은 원본 재고 갱신 시각이 아닙니다.</p><p><a href="${refresh}">같은 조건으로 새 응답 확인</a></p></footer>`;
body = body.includes('</body>') ? body.replace('</body>', note + '</body>') : body + note;
headers.delete('Content-Length');
headers.delete('ETag');
return new Response(body, { status: source.status, headers });
};
app.all('/browse/read', (c) => forward(c, crypto.randomUUID(), ''));
const existing = (c: Context<Bindings>) => {
const id = c.req.path.split('/')[3];
return forward(c, id, c.req.path.slice(`/browse/read/${id}`.length));
};
app.all('/browse/read/:id', existing);
app.all('/browse/read/:id/*', existing);
}
137 changes: 137 additions & 0 deletions tests/pages/browseRead.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { Hono } from 'hono';
import { describe, expect, it } from 'vitest';
import type { AppBindings } from '../../src/api/response.js';
import { registerBrowseRoutes } from '../../src/pages/browse.js';

const uuid = '[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}';
function fixture() {
const app = new Hono<{ Bindings: AppBindings }>();
const hits: URL[] = [];
app.get('/api/daiso/products', (c) => {
hits.push(new URL(c.req.url));
return c.html(
`<main>response ${hits.length}<a href="/browse/daiso/product/58450">상세</a><a href="/browse/compose/daiso/inventory/58450/~">지역 선택</a><a href="https://cdn.daisomall.co.kr/image.jpg">이미지</a><a href="/guide/latest">안내</a></main>`,
);
});
app.get('/api/daiso/products/:id', (c) =>
c.req.param('id') === 'redirect'
? c.redirect('/browse/daiso/product/58450')
: c.html(`<main>상품 ${c.req.param('id')}</main>`),
);
app.get('/api/daiso/stores', (c) =>
c.req.query('keyword') === 'json'
? c.json({ success: false }, 503)
: c.html('<main>실패</main>', 503),
);
app.get('/browse/existing-read', (c) => c.html('<a href="/browse/read">새 세션</a><a href="/browse/read/12345678-1234-4123-8123-123456789abc">기존 세션</a>'));
registerBrowseRoutes(app);
return { app, hits };
}
async function seed(app: ReturnType<typeof fixture>['app']) {
const response = await app.request('/browse/read');
expect(response.status).toBe(200);
const body = await response.text();
const prefix = body.match(new RegExp(`href="(/browse/read/${uuid})/compose"`))?.[1];
expect(prefix).toBeTruthy();
return { prefix: prefix!, response, body };
}

describe('웹 수집본과 구분할 수 있는 읽기 세션 링크', () => {
it('원본 API 호출 없이 실제 입력 링크와 새 응답 링크를 발급한다', async () => {
const { app, hits } = fixture();
const { prefix, response, body } = await seed(app);
expect(response.headers.get('Cache-Control')).toBe('no-store');
expect(response.headers.get('X-Robots-Tag')).toBe('noindex, nofollow');
expect(body).toContain('원본 재고 갱신');
const input = await app.request(prefix + '/compose/daiso/products/~');
expect(await input.text()).toContain(prefix + '/compose/daiso/products/~/ko-0');
expect(hits).toHaveLength(0);
});
it('API 조건은 그대로 전달하고 실제 상품·지역 링크에만 세션을 유지한다', async () => {
const { app, hits } = fixture();
const { prefix } = await seed(app);
const response = await app.request(prefix + '/daiso/products/연필/pageSize/3');
expect(response.status).toBe(200);
const body = await response.text();
expect(hits).toHaveLength(1);
expect(Object.fromEntries(hits[0].searchParams)).toEqual({ q: '연필', pageSize: '3', format: 'html' });
expect(body).toContain(`href="${prefix}/daiso/product/58450"`);
expect(body).toContain(`href="${prefix}/compose/daiso/inventory/58450/~"`);
expect(body).toContain('href="https://cdn.daisomall.co.kr/image.jpg"');
expect(body).toContain('href="/guide/latest"');
expect(body).not.toContain(prefix + '/read/');
});
it('새 응답 링크의 ID와 응답을 바꾸면서 같은 검색 조건을 보존한다', async () => {
const { app, hits } = fixture();
const { prefix } = await seed(app);
const response = await app.request(prefix + '/daiso/products/연필/pageSize/3');
const body = await response.text();
const refresh = body.match(/href="([^"]+)">같은 조건으로 새 응답 확인/)!;
expect(refresh).not.toBeNull();
expect(refresh[1]).not.toContain(prefix + '/');
expect(refresh[1]).toMatch(new RegExp(`^/browse/read/${uuid}/daiso/products/`));
expect(await (await app.request(refresh[1])).text()).toContain('response 2');
expect(hits[1].search).toBe(hits[0].search);
});
it('HTML entity처럼 보이는 검색어도 새 응답 링크에서 그대로 유지한다', async () => {
const { app, hits } = fixture();
const { prefix } = await seed(app);
const body = await (await app.request(prefix + '/daiso/products/&copy;/pageSize/3')).text();
expect(hits[0].searchParams.get('q')).toBe('&copy;');
const refresh = body.match(/href="([^"]+)">같은 조건으로 새 응답 확인/)!;
expect(refresh[1]).toContain('/daiso/products/&amp;copy;/pageSize/3');
await app.request(refresh[1].replaceAll('&amp;', '&'));
expect(hits[1].searchParams.get('q')).toBe('&copy;');
});
it('기존 실패 상태를 성공으로 바꾸지 않는다', async () => {
const { app } = fixture();
const { prefix } = await seed(app);
const response = await app.request(prefix + '/daiso/stores/안산');
expect(response.status).toBe(503);
expect(await response.text()).toContain('실패');
});
it('내부 리디렉션도 같은 세션 안에서만 이동한다', async () => {
const { app } = fixture();
const { prefix } = await seed(app);
const response = await app.request(prefix + '/');
expect(response.status).toBe(307);
expect(response.headers.get('Location')).toBe(prefix);
const detail = await app.request(prefix + '/daiso/product/redirect');
expect(detail.status).toBe(302);
expect(detail.headers.get('Location')).toBe(prefix + '/daiso/product/58450');
expect(await (await app.request(detail.headers.get('Location')!)).text()).toContain('상품 58450');
});
it('기존 읽기 링크는 이중으로 감싸지 않는다', async () => {
const { app } = fixture();
const { prefix } = await seed(app);
const body = await (await app.request(prefix + '/existing-read')).text();
expect(body).toContain('href="/browse/read"');
expect(body).toContain('href="/browse/read/12345678-1234-4123-8123-123456789abc"');
});
it('비 HTML 오류 본문은 변형하지 않는다', async () => {
const { app } = fixture();
const { prefix } = await seed(app);
const response = await app.request(prefix + '/daiso/stores/json');
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ success: false });
});
it.each(['invalid', '00000000-0000-0000-0000-000000000000'])('잘못된 ID를 거절한다: %s', async (id) => {
const { app, hits } = fixture();
expect((await app.request(`/browse/read/${id}/daiso/products/a`)).status).toBe(400);
expect(hits).toHaveLength(0);
});
it('중첩 read와 관리·외부 URL 경로는 전달하지 않는다', async () => {
const { app, hits } = fixture();
const { prefix } = await seed(app);
expect((await app.request(prefix + '/read/again/compose')).status).toBe(400);
expect((await app.request(prefix + '/api/health/checks')).status).toBe(404);
expect((await app.request(prefix + '/https://example.com')).status).toBe(404);
expect(hits).toHaveLength(0);
});
it('세션 쓰기 요청을 거절하고 API를 호출하지 않는다', async () => {
const { app, hits } = fixture();
const response = await app.request('/browse/read', { method: 'POST' });
expect(response.status).toBe(405);
expect(hits).toHaveLength(0);
});
});
Loading
0