8000 feat: 링크 선택 기반 검색어 전달 실험 추가 by hmmhmmhm · Pull Request #333 · 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
16 changes: 16 additions & 0 deletions docs/superpowers/plans/2026-10-27-web-read-link-composer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# 링크 선택으로 검색어 전달 실험

**목표:** 임의로 만든 URL의 open이 실패하지만 서버가 제공한 링크의 click은 성공하는 조건에서, 고정 예제에 없는 한글·영문·숫자 검색어를 전송할 수 있는지 검증합니다.

**구성:** 독립 가상 데이터 Worker에 `/compose/{run}/{text}/{stage}`를 추가합니다. 빈 입력은 `~`로 표시합니다. 한글은 초성·중성·종성을 실제 링크로 선택하며 영문·숫자·공백·하이픈도 링크로 추가합니다. 완료 링크는 입력한 문자열과 서버 생성 번호를 표시합니다. 실제 상품 API는 호출하지 않습니다.

**범위:** 32자 이하 한글 완성형, 영문 대소문자, 숫자, 공백, 하이픈. 모든 언어 또는 실제 상품 검색이 해결됐다는 주장은 하지 않습니다. 브라우저 입력 폼, JavaScript 실행, 임의 URL 이동 기능은 필요하지 않습니다. 기존 10개 MCP 도구 및 운영 경로는 변경하지 않습니다.

- [ ] 문서에 실제 존재하는 링크만 따라 예제에 없는 `연필7`을 완성하는 회귀 검사를 먼저 작성하고 실패를 확인합니다.
- [ ] 링크 생성 모듈과 Worker의 가상 결과·bounded 관측을 구현합니다. 입력 원문은 관측 로그에 저장하지 않습니다.
- [ ] 잘못된 인코딩·단계·초성/중성 범위·과도한 길이를 거절합니다. 마크업 삽입은 허용하지 않습니다.
- [ ] 원래 실험 및 새 회귀, 타입 검사, 저장소 필수 검사와 독립 리뷰 후 배포합니다.
- [ ] 두 개의 새 실행에서 실제 웹 도구 click만으로 한글·숫자 검색어를 완성하고 요청 번호를 서버 관측과 대조합니다. 호출 횟수와 소요시간도 기록합니다.
- [ ] 일반 ChatGPT 웹에서도 확인 가능한 경우 같은 실험을 수행합니다. Codex 웹 도구 성공만으로 Chat 제품 전체 성공으로 확대하지 않습니다.

선택 근거: 2026-10-27 도메인 비교에서 aka.page와 workers.dev 모두 raw open은 실패하고 실제 링크 click은 새 요청으로 성공했습니다. 임의 검색어가 웹 읽기 요청에 실리는지를 먼저 검증하며, 실제 상품 및 매장 조회 통합은 그 결과를 보고 진행합니다.
6 changes: 6 additions & 0 deletions experiments/web-read-lab/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@

두 주소는 동일한 Worker와 관측 로그를 사용합니다. 호스트별로 다른 실행번호를 사용해 요청을 구분하세요. 별칭의 배포 완료와 웹 읽기 성공은 별도로 검증합니다.

## 링크 입력 실험

목록의 **링크로 검색어 만들기**에서 한글 초성·중성·완성 글자 또는 영문·숫자 링크를 선택하고 **입력 완료**로 결과를 확인합니다. 고정 검색어 예제 없이 임의의 한글·영문·숫자·공백·하이픈 조합을32자까지 만들 수 있는지 확인합니다. 외부 상품 조회는 하지 않으며 결과는 입력한 문자열을 되돌려주는 가상 데이터입니다.

직접 URL을 조립하지 말고 각 응답의 실제 링크 ID로 이동하세요. 새 실험번호에서 시작하고 최종 requestId를 관측 기록과 비교합니다. 입력은 URL과 응답에 포함되므로 개인정보를 넣지 마세요. 애플리케이션 관측 로그에는 입력 원문을 저장하지 않습니다. 문자 선택 페이지는 no-store 및 noindex를 사용하지만 웹 도구의 사본 재사용을 막는다고 보장하지 않습니다.

[2026-10-26 실험 결과](../../docs/web-reading-lab-results-2026-10-26.md)

배포: `.github/workflows/deploy-web-read-lab.yml`. Worker 이름은 `daiso-web-read-lab`이며 배포 결과의 workers.dev URL을 사용합니다.
Expand Down
63 changes: 63 additions & 0 deletions experiments/web-read-lab/composer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/** 외부 조회 없이 실제 링크 선택만으로 한글·영문 검색어를 전달하는 실험입니다. */
const INITIALS = [...'ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ'];
const VOWELS = [...'ㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣ'];
const ASCII = [...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 -'];
const TEXT = /^[가-힣a-zA-Z0-9 -]{0,32}$/;

export function renderComposer(parts: string[], requestId: string): string | null {
if (parts.length < 3 || parts.length > 4) return null;
let text: string;
try {
text = parts[2] === '~' ? '' : decodeURIComponent(parts[2]);
} catch {
return null;
}
if (!TEXT.test(text)) return null;
const stage = parts[3] ?? '';
const base = `/compose/${parts[1]}/`;
const href = (value: string, next = '') =>
`${base}${value ? encodeURIComponent(value) : '~'}${next ? `/${next}` : ''}`;
const link = (label: string, value: string, next = '') =>
`<a href="${href(value, next)}">${label}</a>`;
const intro = `<h2>링크로 검색어 만들기</h2><p>가상 실험입니다. 실제 상품 조회가 아닙니다. 개인정보를 입력하지 마세요.</p><p>현재 입력: <strong>${text || '(비어 있음)'}</strong></p>`;
if (stage === 'done') {
if (!text.trim()) return null;
return (
intro +
`<pre>${JSON.stringify({ synthetic: true, query: text, requestId, generatedAt: new Date().toISOString() }, null, 2)}</pre>`
);
}
const choice = /^ko-(\d{1,2})(?:-(\d{1,2}))?$/.exec(stage);
if (stage && !choice) return null;
let options: string[];
if (choice) {
const initial = Number(choice[1]);
const vowel = choice[2] === undefined ? undefined : Number(choice[2]);
if (
initial >= INITIALS.length ||
(vowel !== undefined && vowel >= VOWELS.length) ||
text.length >= 32
)
return null;
options =
vowel === undefined
? VOWELS.map((v, i) => link(`중성 ${v}`, text, `ko-${initial}-${i}`))
: Array.from({ length: 28 }, (_, final) => {
const character = String.fromCharCode(0xac00 + initial * 588 + vowel * 28 + final);
return link(character, text + character);
});
} else {
options =
text.length >= 32
? []
: [
...INITIALS.map((c, i) => link(`초성 ${c}`, text, `ko-${i}`)),
...ASCII.map((c) => link(c === ' ' ? '공백' : c, text + c)),
];
}
return (
intro +
'<p>한글은 초성, 중성, 완성 글자를 차례로 선택하세요. 영문과 숫자는 바로 선택합니다. URL을 조립하지 않고 실제 링크를 따라갈 수 있는지 확인하는 실험입니다. 최대32자.</p>' +
`<ul>${options.map((option) => `<li>${option}</li>`).join('')}</ul><p>${link('현재 입력으로 돌아가기', text)} · ${link('한 글자 지우기', text.slice(0, -1))} · ${link('처음부터', '')}${text.trim() ? ` · ${link('입력 완료', text, 'done')}` : ''}</p>`
);
}
20 changes: 14 additions & 6 deletions experiments/web-read-lab/worker.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/** 운영 API와 분리된 고정 데이터 웹 읽기 실험. 개인정보는 기록하지 않습니다. */
import { renderComposer } from './composer.js';
type Event = {
run: string;
test: string;
Expand Down Expand Up @@ -103,16 +104,20 @@ export default {
const log = env.PROBE_LOG.get(env.PROBE_LOG.idFromName('bounded-log'));
if (parts[0] === 'observations' && parts.length === 2)
return log.fetch(new Request(`https://log/?run=${run}`));
if (parts[0] !== 'run' || parts.length > 3) return new Response('Not found', { status: 404 });
const isComposer = parts[0] === 'compose';
if (!isComposer && (parts[0] !== 'run' || parts.length > 3))
return new Response('Not found', { status: 404 });
const requestId = crypto.randomUUID();
const composer = isComposer ? renderComposer(parts, requestId) : null;
if (isComposer && composer === null) return new Response('Invalid input', { status: 400 });
let test: string;
try {
test = decodeURIComponent(parts[2] ?? 'index');
test = isComposer ? 'compose' : decodeURIComponent(parts[2] ?? 'index');
} catch {
return new Response('Invalid path', { status: 400 });
}
if (test !== 'index' && !CASES.includes(test))
if (!isComposer && test !== 'index' && !CASES.includes(test))
return new Response('Not found', { status: 404 });
const requestId = crypto.randomUUID();
const record = (phase: string) =>
log.fetch(
new Request('https://log/', {
Expand Down Expand Up @@ -148,9 +153,12 @@ export default {
`<p>가상 실험 데이터, 실제 상품 재고가 아닙니다.</p><pre>${JSON.stringify(data, null, 2)}</pre>`,
);
let status = 200;
if (test === 'index') {
if (isComposer) {
headers.set('X-Robots-Tag', 'noindex');
body = html(composer!);
} else if (test === 'index') {
body = html(
`<p>가상 상품: 실험용 지갑, 수량7. 실험번호: ${run}</p><p><a href="/run/${requestId}">새 실험 시작</a></p><ul>${CASES.map((c) => `<li><a href="/run/${run}/${encodeURIComponent(c)}${c === 'query' ? '?q=wallet' : ''}">${c}</a></li>`).join('')}</ul><a href="/run/${run}/query?q=%EC%A7%80%EA%B0%91">한글 쿼리</a><p>응답의 requestId와 generatedAt을 관측 기록과 비교하세요. received는 서버 도착, prepared는 응답 생성 완료이며 클라이언트 수신 성공을 증명하지 않습니다.</p><a href="/observations/${run}">관측 기록 JSON</a><p>현재 요청 번호 ${requestId}</p>`,
`<p>가상 상품: 실험용 지갑, 수량7. 실험번호: ${run}</p><p><a href="/run/${requestId}">새 실험 시작</a> · <a href="/compose/${run}/~">링크로 검색어 만들기</a></p><ul>${CASES.map((c) => `<li><a href="/run/${run}/${encodeURIComponent(c)}${c === 'query' ? '?q=wallet' : ''}">${c}</a></li>`).join('')}</ul><a href="/run/${run}/query?q=%EC%A7%80%EA%B0%91">한글 쿼리</a><p>응답의 requestId와 generatedAt을 관측 기록과 비교하세요. received는 서버 도착, prepared는 응답 생성 완료이며 클라이언트 수신 성공을 증명하지 않습니다.</p><a href="/observations/${run}">관측 기록 JSON</a><p>현재 요청 번호 ${requestId}</p>`,
);
} else if (test === 'json' || test === 'text') {
headers.set(
Expand Down
35 changes: 35 additions & 0 deletions tests/scripts/web-read-lab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,41 @@ function fixture() {
}

describe('독립 웹 읽기 실험', () => {
it('실제 링크만 따라 고정 예제에 없는 검색어를 완성한다', async () => {
const { get } = fixture();
let body = await (await get('/run/compose-test')).text();
const follow = async (label: string) => {
const links = [...body.matchAll(/href="([^"]+)">([^<]+)<\/a>/g)];
const link = links.find((entry) => entry[2] === label);
expect(link, `링크 없음: ${label}`).toBeDefined();
const response = await get(link![1].replaceAll('&amp;', '&'));
expect(response.status).toBe(200);
expect(response.headers.get('x-robots-tag')).toBe('noindex');
body = await response.text();
};
await follow('링크로 검색어 만들기');
for (const label of ['초성 ㅇ', '중성 ㅕ', '연', '초성 ㅍ', '중성 ㅣ', '필', '7', '입력 완료'])
await follow(label);
expect(body).toContain('연필7');
expect(body).toContain('synthetic');
expect(body).toContain('requestId');
const log = JSON.stringify(await (await get('/observations/compose-test')).json());
expect(log).not.toContain('연필');
});
it('문자 선택기의 잘못된 입력과 단계는 거절한다', async () => {
const { get } = fixture();
for (const path of [
'/compose/check/%ZZ',
'/compose/check/%3Cscript%3E',
'/compose/check/~/ko-19',
'/compose/check/~/ko-0-21',
'/compose/check/~/unknown',
'/compose/check/~/done/extra',
`/compose/check/${'a'.repeat(33)}`,
])
expect((await get(path)).status).toBe(400);
expect((await get('/compose/check/~/done')).status).toBe(400);
});
it('실험 목록에서 새 링크를 따라 독립 실행을 시작할 수 있다', async () => {
const { get } = fixture();
const links: string[] = [];
Expand Down
Loading
0