From 4688ba7b4a7fffd86c734224e66cdcadc17a7d11 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Mon, 26 Oct 2026 19:08:57 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8F=85=EB=A6=BD=20=EC=9B=B9=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0=20=EC=8B=A4=ED=97=98=20=EC=9B=8C=EC=BB=A4?= =?UTF-8?q?=EC=99=80=20=EA=B4=80=EC=B8=A1=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-web-read-lab.yml | 27 +++ .../plans/2026-10-26-web-read-lab.md | 12 ++ experiments/web-read-lab/README.md | 33 ++++ experiments/web-read-lab/worker.ts | 180 ++++++++++++++++++ experiments/web-read-lab/wrangler.toml | 12 ++ tests/scripts/web-read-lab.test.ts | 104 ++++++++++ 6 files changed, 368 insertions(+) create mode 100644 .github/workflows/deploy-web-read-lab.yml create mode 100644 docs/superpowers/plans/2026-10-26-web-read-lab.md create mode 100644 experiments/web-read-lab/README.md create mode 100644 experiments/web-read-lab/worker.ts create mode 100644 experiments/web-read-lab/wrangler.toml create mode 100644 tests/scripts/web-read-lab.test.ts diff --git a/.github/workflows/deploy-web-read-lab.yml b/.github/workflows/deploy-web-read-lab.yml new file mode 100644 index 00000000..1cf3d029 --- /dev/null +++ b/.github/workflows/deploy-web-read-lab.yml @@ -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 }} diff --git a/docs/superpowers/plans/2026-10-26-web-read-lab.md b/docs/superpowers/plans/2026-10-26-web-read-lab.md new file mode 100644 index 00000000..9bd03381 --- /dev/null +++ b/docs/superpowers/plans/2026-10-26-web-read-lab.md @@ -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에 기록한다. diff --git a/experiments/web-read-lab/README.md b/experiments/web-read-lab/README.md new file mode 100644 index 00000000..550f3d91 --- /dev/null +++ b/experiments/web-read-lab/README.md @@ -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도 실패하면 그 실행에서는 내용별 한계를 비교할 수 없습니다. 검색 사본 갱신은 보장하지 않습니다. diff --git a/experiments/web-read-lab/worker.ts b/experiments/web-read-lab/worker.ts new file mode 100644 index 00000000..8d441df7 --- /dev/null +++ b/experiments/web-read-lab/worker.ts @@ -0,0 +1,180 @@ +/** 운영 API와 분리된 고정 데이터 웹 읽기 실험. 개인정보는 기록하지 않습니다. */ +type Event = { + run: string; + test: string; + phase: string; + at: string; + requestId: string; + client: string; +}; +export type Storage = { + transaction(callback: (tx: Pick) => Promise): Promise; + get(key: string): Promise; + put(key: string, value: unknown): Promise; + setAlarm(at: number): Promise; + deleteAll(): Promise; +}; +type Env = { + PROBE_LOG: { + idFromName(name: string): unknown; + get(id: unknown): { fetch(request: Request): Promise }; + }; +}; +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 { + 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 { + 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 `Web Read Lab

Web Read Lab

${body}`; +} + +export default { + async fetch(request: Request, env: Env): Promise { + 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( + '

독립 웹 읽기 실험. 모든 상품과 수량은 가상 데이터입니다.

/run/실험번호 에서 테스트 링크를 확인하세요.

시작', + ), + { 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( + `

가상 실험 데이터, 실제 상품 재고가 아닙니다.

${JSON.stringify(data, null, 2)}
`, + ); + let status = 200; + if (test === 'index') { + body = html( + `

가상 상품: 실험용 지갑, 수량7. 실험번호: ${run}

    ${CASES.map((c) => `
  • ${c}
  • `).join('')}
한글 쿼리

응답의 requestId와 generatedAt을 관측 기록과 비교하세요. received는 서버 도착, prepared는 응답 생성 완료이며 클라이언트 수신 성공을 증명하지 않습니다.

관측 기록 JSON

현재 요청 번호 ${requestId}

`, + ); + } 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( + `

JavaScript 생성 데이터:

`, + ); + } 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 = `
${JSON.stringify(data)}

`; + const suffix = `

END-MARKER-${requestId}

`; + 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 }); + }, +}; diff --git a/experiments/web-read-lab/wrangler.toml b/experiments/web-read-lab/wrangler.toml new file mode 100644 index 00000000..66bf2160 --- /dev/null +++ b/experiments/web-read-lab/wrangler.toml @@ -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"] diff --git a/tests/scripts/web-read-lab.test.ts b/tests/scripts/web-read-lab.test.ts new file mode 100644 index 00000000..88aaa97f --- /dev/null +++ b/tests/scripts/web-read-lab.test.ts @@ -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(); + let queue = Promise.resolve(); + const storage: Storage = { + transaction(callback: (tx: Pick) => Promise): Promise { + 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(); + } + }); +});