8000 feat: 독립 웹 읽기 실험 워커 추가 by hmmhmmhm · Pull Request #329 · 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
27 changes: 27 additions & 0 deletions .github/workflows/deploy-web-read-lab.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Deploy Web Read Lab
on:
push:
branches: [main]
paths:
- 'experiments/web-read-lab/**'
- '.github/workflows/deploy-web-read-lab.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
deploy:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '24'
cache: npm
- run: npm ci
- run: npx tsc --ignoreConfig --noEmit --strict --skipLibCheck --target ES2022 --module ESNext --moduleResolution bundler --types @cloudflare/workers-types experiments/web-read-lab/worker.ts
- run: npx vitest run tests/scripts/web-read-lab.test.ts
- run: npx wrangler deploy --config experiments/web-read-lab/wrangler.toml
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
12 changes: 12 additions & 0 deletions docs/superpowers/plans/2026-10-26-web-read-lab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# 웹 읽기 독립 실험 Worker 계획

목표: 운영 원본을 호출하지 않고 웹 읽기 실패 조건과 요청 도달 여부를 비교한다.

구성: `experiments/web-read-lab/worker.ts`의 고정 테스트와 별도 SQLite Durable Object 로그, 전용 Wrangler 설정 및 GitHub 배포 workflow. 운영 바인딩·인증·도구 목록과 분리한다.

- [ ] `tests/scripts/web-read-lab.test.ts`로 HTML/JSON, 경로·쿼리, 지연, 리다이렉트, 생성 번호, 개인정보 미저장, 로그 개수/수명 제한을 검증한다. 먼저 실패를 확인한다.
- [ ] 고정 HTML/text/JSON, 한글/path/query, redirect, 1/5/15초 지연, 64/256KiB 본문, cache/no-store/noindex, JS 전용 본문을 구현한다. 임의 지연이나 외부 URL 전달 기능은 제공하지 않는다.
- [ ] GET/HEAD만 허용한다. 관측 로그는 실험명·단계·시각·서버 생성 번호·분류된 클라이언트만 기록한다. IP/쿠키/인증/전체 UA를 저장하지 않는다. 최대128개,24시간 보관한다.
- [ ] 별도 Worker `daiso-web-read-lab`을 기존 승인된 GitHub 배포 인증으로 배포한다. 운영 원본 요청은 하지 않는다.
- [ ] CI/리뷰 후 병합하고 실제 배포 링크를 확인한다. 새 실험 URL의 웹 읽기 open/click, 일반 HTTPS를 비교하고 도달 로그 및 응답 번호를 대조한다.
- [ ] 실패하면 동일 URL 반복보다 하나의 조건만 바꿔 비교한다. 외부 웹 도구 내부 원인을 관측 없이 확정하지 않는다. 결과와 재현 방법을 문서 및 PROJECT.md에 기록한다.
33 changes: 33 additions & 0 deletions experiments/web-read-lab/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Web Read Lab

독립 Worker에서 웹 읽기 도구의 실패 조건을 비교합니다. 모든 상품과 수량은 가상 데이터이며 운영 API를 호출하지 않습니다. 기존 MCP 도구와 인증·중계·캐시 바인딩을 공유하지 않습니다.

배포: `.github/workflows/deploy-web-read-lab.yml`. Worker 이름은 `daiso-web-read-lab`이며 배포 결과의 workers.dev URL을 사용합니다.

## 실행

1. 영문·숫자·하이픈으로 새로운 실험번호(최대48자)를 만들고 `/run/{번호}`를 엽니다.
2. 같은 번호 아래 링크를 한 번씩 열어 응답 여부, requestId, generatedAt, 웹 도구의 Crawled 정보를 기록합니다.
3. `/observations/{번호}`를 일반 HTTPS로 읽고 도착 기록과 비교합니다. 관측 조회 자체는 기록하지 않습니다.
4. 독립된 두 번째 번호로 순서를 바꿔 재현합니다. 직접 open과 링크 click은 별도로 기록합니다.

| 경로 | 변경 조건 |
| --------------------------------------------- | -------------------------------------------------------- |
| html / text / json | 같은 가상 상품 데이터의 Content-Type 및 표현 |
| wallet / 지갑 / query?q=wallet / query?q=지갑 | ASCII·한글 경로와 쿼리 |
| redirect | 같은 실행의 html로302이동 |
| delay1 / delay5 / delay15 | 응답 생성 전 고정1/5/15초 대기 |
| large64 / large256 | 전체 UTF-8 본문64/256KiB, 앞 데이터와 뒤 END-MARKER 비교 |
| cache | public,max-age=300 헤더. CDN 캐시 저장을 강제하지 않음 |
| noindex | X-Robots-Tag:noindex,follow |
| javascript | 브라우저 JavaScript만 상품 데이터를 화면에 표시 |

기본은 no-store이며 모든 응답에 서버 생성 requestId가 있습니다. 표시된 문서가 과거인지 판정할 때 서버 기록과 이 번호를 비교합니다. 각 경로 이름도 데이터에 포함되므로 바이트 단위 동일 비교는 아닙니다.

## 관측의 한계

received는 Worker 도착, prepared는 응답 생성 완료입니다. prepared가 있어도 웹 읽기 클라이언트가 수신·파싱에 성공했다고 볼 수 없습니다. 기록이 없으면 Worker 도달을 확인하지 못한 것이며 Cloudflare 앞단 차단과 도구 내부 거절을 이것만으로 구별하지는 못합니다.

User-Agent는 chatgpt-user/oai-searchbot/browser/other로만 분류하며 인증된 신원이나 클라이언트 원본을 증명하지 않습니다. IP·쿠키·토큰·전체 UA·자유 쿼리는 저장하지 않습니다. 공개 관측 기록은 최대128개/24시간이며 많은 요청이 들어오면 앞 기록이 사라질 수 있습니다. 관측 URL에 개인정보를 넣지 마세요.

새 링크 하나가 실패했다는 사실만으로 JSON·한글·크기 등이 원인이라고 결론 내리지 않습니다. 기준 HTML도 실패하면 그 실행에서는 내용별 한계를 비교할 수 없습니다. 검색 사본 갱신은 보장하지 않습니다.
180 changes: 180 additions & 0 deletions experiments/web-read-lab/worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/** 운영 API와 분리된 고정 데이터 웹 읽기 실험. 개인정보는 기록하지 않습니다. */
type Event = {
run: string;
test: string;
phase: string;
at: string;
requestId: string;
client: string;
};
export type Storage = {
transaction<T>(callback: (tx: Pick<Storage, 'get' | 'put'>) => Promise<T>): Promise<T>;
get(key: string): Promise<unknown>;
put(key: string, value: unknown): Promise<unknown>;
setAlarm(at: number): Promise<unknown>;
deleteAll(): Promise<unknown>;
};
type Env = {
PROBE_LOG: {
idFromName(name: string): unknown;
get(id: unknown): { fetch(request: Request): Promise<Response> };
};
};
const DAY = 86400000;
const CASES = [
'html',
'text',
'json',
'wallet',
'지갑',
'query',
'redirect',
'delay1',
'delay5',
'delay15',
'large64',
'large256',
'cache',
'noindex',
'javascript',
];
const RUN = /^[a-zA-Z0-9-]{1,48}$/;

/** 모든 실험의 최근 128개 기록만 유지하며 24시간 뒤 삭제합니다. */
export class ProbeLog {
constructor(private state: { storage: Storage }) {}
async fetch(request: Request): Promise<Response> {
if (request.method === 'POST') {
const event = (await request.json()) as Event;
await this.state.storage.transaction(async (tx) => {
const stored = (await tx.get('events')) as Event[] | undefined;
const events = (stored ?? []).filter((e) => Date.parse(e.at) > Date.now() - DAY);
events.push(event);
await tx.put('events', events.slice(-128));
});
await this.state.storage.setAlarm(Date.now() + DAY);
return new Response('ok');
}
const stored = (await this.state.storage.get('events')) as Event[] | undefined;
const events = (stored ?? []).filter((e) => Date.parse(e.at) > Date.now() - DAY);
const run = new URL(request.url).searchParams.get('run');
return Response.json(
{ retention: 'last 128 events, max 24h', events: events.filter((e) => e.run === run) },
{ headers: { 'Cache-Control': 'no-store' } },
);
}
async alarm(): Promise<void> {
await this.state.storage.deleteAll();
}
}

function clientKind(request: Request): string {
const ua = request.headers.get('user-agent') ?? '';
if (ua.includes('ChatGPT-User')) return 'chatgpt-user';
if (ua.includes('OAI-SearchBot')) return 'oai-searchbot';
if (ua.includes('Mozilla/')) return 'browser';
return 'other';
}

function html(body: string): string {
return `<!doctype html><html lang="ko"><head><meta charset="utf-8"><title>Web Read Lab</title></head><body><h1>Web Read Lab</h1>${body}</body></html>`;
}

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (!['GET', 'HEAD'].includes(request.method))
return new Response('Method not allowed', { status: 405, headers: { Allow: 'GET, HEAD' } });
if (url.pathname === '/robots.txt')
return new Response('User-agent: *\nAllow: /\n', {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
const parts = url.pathname.split('/').filter(Boolean);
if (url.pathname === '/') {
return new Response(
html(
'<p>독립 웹 읽기 실험. 모든 상품과 수량은 가상 데이터입니다.</p><p>/run/실험번호 에서 테스트 링크를 확인하세요.</p><a href="/run/start">시작</a>',
),
{ headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' } },
);
}
if (!RUN.test(parts[1] ?? '')) return new Response('Invalid run', { status: 400 });
const run = parts[1];
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 });
let test: string;
try {
test = decodeURIComponent(parts[2] ?? 'index');
} catch {
return new Response('Invalid path', { status: 400 });
}
if (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/', {
method: 'POST',
body: JSON.stringify({
run,
test,
phase,
at: new Date().toISOString(),
requestId,
client: clientKind(request),
}),
}),
);
await record('received');
if (/^delay(1|5|15)$/.test(test))
await new Promise((resolve) => setTimeout(resolve, Number(test.slice(5)) * 1000));
const data = {
synthetic: true,
run,
test,
requestId,
generatedAt: new Date().toISOString(),
name: '실험용 지갑',
quantity: 7,
};
const headers = new Headers({
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': test === 'cache' ? 'public, max-age=300' : 'no-store',
'X-Lab-Request-Id': requestId,
});
let body = html(
`<p>가상 실험 데이터, 실제 상품 재고가 아닙니다.</p><pre>${JSON.stringify(data, null, 2)}</pre>`,
);
let status = 200;
if (test === 'index') {
body = html(
`<p>가상 상품: 실험용 지갑, 수량7. 실험번호: ${run}</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(
'Content-Type',
test === 'json' ? 'application/json; charset=utf-8' : 'text/plain; charset=utf-8',
);
body = JSON.stringify(data, null, 2);
} else if (test === 'redirect') {
headers.set('Location', `/run/${run}/html`);
status = 302;
} else if (test === 'javascript') {
body = html(
`<p>JavaScript 생성 데이터:</p><div id="result"></div><script>document.getElementById('result').textContent=${JSON.stringify(JSON.stringify(data))};</script>`,
);
} else if (test === 'noindex') {
headers.set('X-Robots-Tag', 'noindex, follow');
} else if (test === 'large64' || test === 'large256') {
const targetBytes = (test === 'large64' ? 64 : 256) * 1024;
const prefix = `<pre>${JSON.stringify(data)}</pre><p>`;
const suffix = `</p><p>END-MARKER-${requestId}</p>`;
const paddingBytes = targetBytes - new TextEncoder().encode(html(prefix + suffix)).byteLength;
body = html(prefix + 'x'.repeat(paddingBytes) + suffix);
}
await record('prepared');
return new Response(request.method === 'HEAD' ? null : body, { status, headers });
},
};
12 changes: 12 additions & 0 deletions experiments/web-read-lab/wrangler.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
name = "daiso-web-read-lab"
main = "worker.ts"
compatibility_date = "2025-01-23"
workers_dev = true

[[durable_objects.bindings]]
name = "PROBE_LOG"
class_name = "ProbeLog"

[[migrations]]
tag = "v1"
new_sqlite_classes = ["ProbeLog"]
104 changes: 104 additions & 0 deletions tests/scripts/web-read-lab.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, expect, it, vi } from 'vitest';
import worker, { ProbeLog, type Storage } from '../../experiments/web-read-lab/worker.js';

function fixture() {
const values = new Map<string, unknown>();
let queue = Promise.resolve();
const storage: Storage = {
transaction<T>(callback: (tx: Pick<Storage, 'get' | 'put'>) => Promise<T>): Promise<T> {
const result = queue.then(() => callback(storage));
queue = result.then(
() => {},
() => {},
);
return result;
},
get: vi.fn(async (key: string) => values.get(key)),
put: vi.fn(async (key: string, value: unknown) => {
values.set(key, value);
}),
setAlarm: vi.fn(async () => {}),
deleteAll: vi.fn(async () => {
values.clear();
}),
};
const log = new ProbeLog({ storage });
const env = { PROBE_LOG: { idFromName: () => 'global', get: () => log } };
const get = (path: string, init?: RequestInit) =>
worker.fetch(new Request(`https://lab.example${path}`, init), env);
return { get, log, storage };
}

describe('독립 웹 읽기 실험', () => {
it('동시 요청의 도착과 완료 기록을 덮어쓰지 않는다', async () => {
const { get } = fixture();
await Promise.all(Array.from({ length: 20 }, () => get('/run/concurrent/html')));
const { events } = await (await get('/observations/concurrent')).json();
expect(events).toHaveLength(40);
expect(new Set(events.map((event: { requestId: string }) => event.requestId)).size).toBe(20);
});
it('동일 가상 데이터를 HTML·JSON으로 제공하고 요청마다 생성 번호를 바꾼다', async () => {
const { get } = fixture();
const a = await get('/run/test/html');
expect(a.headers.get('content-type')).toContain('text/html');
expect(await a.text()).toContain('실험용 지갑');
const b = await get('/run/test/json');
const data = await b.json();
expect(data.quantity).toBe(7);
expect(data.requestId).not.toBe(a.headers.get('x-lab-request-id'));
expect(b.headers.get('cache-control')).toBe('no-store');
});
it('경로·쿼리·리다이렉트·크기·캐시 조건이 독립적으로 동작한다', async () => {
const { get } = fixture();
for (const path of [
'wallet',
'%EC%A7%80%EA%B0%91',
'query?q=wallet',
'query?q=%EC%A7%80%EA%B0%91',
'text',
'noindex',
'javascript',
]) {
expect((await get(`/run/test/${path}`)).status).toBe(200);
}
expect((await get('/run/test/redirect')).headers.get('location')).toBe('/run/test/html');
expect((await (await get('/run/test/large64')).arrayBuffer()).byteLength).toBe(65536);
expect((await (await get('/run/test/large256')).arrayBuffer()).byteLength).toBe(262144);
expect((await get('/run/test/cache')).headers.get('cache-control')).toBe('public, max-age=300');
expect((await get('/run/test/unknown')).status).toBe(404);
expect((await get('/run/test/html', { method: 'POST' })).status).toBe(405);
});
it('지연 중 도착을 기록하고 개인정보와 임의 쿼리를 저장하지 않는다', async () => {
vi.useFakeTimers();
try {
const { get } = fixture();
const pending = get('/run/test/delay5?private=secret', {
headers: { cookie: 'private=secret', 'user-agent': 'ChatGPT-User/private' },
});
await vi.advanceTimersByTimeAsync(10);
const before = await (await get('/observations/test')).json();
expect(before.events).toHaveLength(1);
expect(before.events[0].phase).toBe('received');
expect(JSON.stringify(before)).not.toContain('secret');
await vi.advanceTimersByTimeAsync(5000);
expect((await pending).status).toBe(200);
expect((await (await get('/observations/test')).json()).events).toHaveLength(2);
} finally {
vi.useRealTimers();
}
});
it('로그는 128개와 24시간으로 제한하고 알람으로 정리한다', async () => {
vi.useFakeTimers();
try {
const { get, log, storage } = fixture();
for (let i = 0; i < 70; i++) await get('/run/test/html');
expect((await (await get('/observations/test')).json()).events).toHaveLength(128);
await vi.advanceTimersByTimeAsync(86400001);
expect((await (await get('/observations/test')).json()).events).toHaveLength(0);
await log.alarm();
expect(storage.deleteAll).toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
});
Loading
0