8000 fix: 외부 사이트 중단과 중계 원본 오류 진단 구분 by hmmhmmhm · Pull Request #304 · 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
8 changes: 8 additions & 0 deletions docs/daiso-jp-network-analysis.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,11 @@ npx daiso jp-stores 東京都渋谷区 --json
일본 상세는 DTRYX/CGV와 동일한 호출 예산 원장을 공유하지만 서비스별 접수·동시성 큐는 별도입니다. 상품정보 캐시는 기존 REST와 동일하게30분이며 인증된 force-fresh는 Worker와 중계 양쪽을 우회합니다. 자체 할당량429·중계인증·원본차단·미확인판매 여부를 성공이나 재고0으로 바꾸지 않습니다. 원본 HTTP상태는 비공개 중계로그에서 확인하고 Worker 공개진단의 upstreamStatus는 중계응답 상태입니다. 일본 오프라인 매장 재고 미지원 범위는 그대로입니다.

기존 운영 Tunnel ingress는 `^/v1/dtryx/.*$`만4320에 전달합니다. 새 작업도 이 경로 안의 `/v1/dtryx/daiso-jp-product`에 배치하여 기존 인증·터널 범위를 유지합니다. 별도 `/v1/daiso-jp/` 경로는 올리브영 서비스로 전달돼401을 반환하는 것을 확인해 사용하지 않습니다.

## 공식 매장 사이트 일시 중단

공식 매장 검색이 HTTP 200과 함께 `当サイト一時停止のお知らせ` 안내 화면을
반환할 수 있습니다. 공식 제목과 본문 제목이 모두 일치하면
`DAISO_JP_MAINTENANCE` (HTTP 503, 재시도 가능)로 구분합니다.
원본 형식 변경이나 검색 결과 없음, 재고 0으로 처리하지 않습니다.
이 분류는 공식 사이트 복구를 뜻하지 않으며 상품 검색과 별도입니다.
6 changes: 6 additions & 0 deletions docs/seveneleven-stock-recovery-20261008.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,9 @@ Incapsula HTML을 반환했으며, Node 25.9.0은 HTTP 200 JSON을 반환했습
있습니다. 종료 상태를 확인하고 재등록한 다음 건강 응답과 CU·GS25·세븐일레븐·CGV를
확인합니다. 건강 응답 200만으로 외부 재고 성공을 판단하지 않습니다. 실행 파일이
패키지 관리자 갱신으로 사라지지 않도록 경로와 버전을 운영 기록에 남깁니다.

인증된 중계는 검증된 원본 HTTP 상태를 `x-relay-upstream-status`로도 전달합니다.
Worker는 이 헤더의 100~599 정수만 `ServiceError.upstreamStatus`에 보존합니다.
따라서 원본 403과 중계 502를 구분할 수 있으며, 기존 중계 오류 상태와 한도 헤더는
유지합니다. 헤더가 없는 이전 중계에서는 중계 응답 상태를 그대로 사용합니다.
원본 본문·쿠키·인증 헤더를 진단 응답에 넣지 않습니다.
20 changes: 14 additions & 6 deletions scripts/relay/http-relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,12 +291,20 @@ export function createHttpRelay(token: string, options: HttpRelayOptions) {
error: 'Relay request failed',
upstreamError: error instanceof RelayError ? error.upstreamError : undefined,
},
status === 429 && work?.stage === 'quota' && error instanceof RelayError && error.quota
? {
'x-relay-quota-reason': error.quota.quotaReason,
'Retry-After': String(error.quota.retryAfter),
}
: {},
{
...(error instanceof RelayError && error.upstreamStatus !== undefined
? { 'x-relay-upstream-status': String(error.upstreamStatus) }
: {}),
...(status === 429 &&
work?.stage === 'quota' &&
error instanceof RelayError &&
error.quota
? {
'x-relay-quota-reason': error.quota.quotaReason,
'Retry-After': String(error.quota.retryAfter),
}
: {}),
},
);
} finally {
try {
Expand Down
11 changes: 11 additions & 0 deletions src/services/daisoJp/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,17 @@ function coordinate(value: string | null, limit: number): number | null {
}

export function parseStoreSearch(html: string): { stores: JapanStore[]; hasNext: boolean } {
if (
/<title>当サイト一時停止のお知らせ \| DAISO(ダイソー)公式企業サイト<\/title>/.test(html) &&
/<h1\b[^>]*>当サイト一時停止のお知らせ<\/h1>/.test(html)
) {
throw new ServiceError(
'DAISO_JP_MAINTENANCE',
'일본 다이소 공식 매장 사이트가 일시 중단되었습니다. 잠시 후 다시 조회해주세요.',
503,
true,
);
}
const blocks = html.split(/<li\b[^>]*class=["']resultList-item["'][^>]*>/i).slice(1);
if (blocks.length === 0) {
if (/class=["']resultList["']/.test(html) && /該当する店舗がありません/.test(html)) {
Expand Down
4 changes: 3 additions & 1 deletion src/utils/convenienceTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,9 @@ async function requestConvenienceRelayRaw<T>(
'편의점 릴레이 요청에 실패했습니다.',
status === 429 || status === 503 || status === 504 ? status : 502,
status === 429 || status >= 500,
status,
/^[1-5]\d{2}$/.test(response.headers.get('x-relay-upstream-status') ?? '')
? Number(response.headers.get('x-relay-upstream-status'))
: status,
parseRelayQuota(status, response.headers),
);
}
Expand Down
85 changes: 75 additions & 10 deletions tests/relay/http-relay-quota-headers.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,36 @@
/** 로컬 정책 한도만 검증된 재시도 헤더를 전달합니다. */
import { expect, it, vi } from 'vitest';
import { createHttpRelay, RelayError, type HttpRelayOptions } from '../../scripts/relay/http-relay.js';
import {
createHttpRelay,
RelayError,
type HttpRelayOptions,
} from '../../scripts/relay/http-relay.js';
function request() {
return new Request('http://localhost/v1/test/read', { method: 'POST', headers: { Authorization: 'Bearer token' }, body: '{}' });
return new Request('http://localhost/v1/test/read', {
method: 'POST',
headers: { Authorization: 'Bearer token' },
body: '{}',
});
}
function setup(takeQuota: HttpRelayOptions['takeQuota'], upstream = vi.fn(async () => ({}))) {
return { upstream, relay: createHttpRelay('token', { prefix: '/v1/test/', operations: ['read'], validate: value => value, takeQuota, upstream }) };
return {
upstream,
relay: createHttpRelay('token', {
prefix: '/v1/test/',
operations: ['read'],
validate: (value) => value,
takeQuota,
upstream,
}),
};
}
it.each([['minute', 1], ['daily', 86400]])('정책 %s 제한에 재시도 헤더를 반환한다', async (blockedBy, retryAfter) => {
const takeQuota = Object.assign(async () => false, { status: () => ({ blockedBy, retryAfter }) }) as HttpRelayOptions['takeQuota'];
it.each([
['minute', 1],
['daily', 86400],
])('정책 %s 제한에 재시도 헤더를 반환한다', async (blockedBy, retryAfter) => {
const takeQuota = Object.assign(async () => false, {
status: () => ({ blockedBy, retryAfter }),
}) as HttpRelayOptions['takeQuota'];
const { relay, upstream } = setup(takeQuota);
const response = await relay(request());
expect(response.status).toBe(429);
Expand All @@ -18,27 +40,70 @@ it.each([['minute', 1], ['daily', 86400]])('정책 %s 제한에 재시도 헤더
expect(upstream).not.toHaveBeenCalled();
});
it.each([
[null, 30], ['foo', 30], ['consumer', 30], ['minute', undefined], ['minute', '30'],
['minute', NaN], ['minute', Infinity], ['minute', 0], ['minute', -1], ['minute', 1.5], ['daily', 86401],
[null, 30],
['foo', 30],
['consumer', 30],
['minute', undefined],
['minute', '30'],
['minute', NaN],
['minute', Infinity],
['minute', 0],
['minute', -1],
['minute', 1.5],
['daily', 86401],
])('유효하지 않은 정책 상태는 헤더 없이 429를 유지한다: %s/%s', async (blockedBy, retryAfter) => {
const takeQuota = Object.assign(async () => false, { status: () => ({ blockedBy, retryAfter }) }) as HttpRelayOptions['takeQuota'];
const takeQuota = Object.assign(async () => false, {
status: () => ({ blockedBy, retryAfter }),
}) as HttpRelayOptions['takeQuota'];
const response = await setup(takeQuota).relay(request());
expect(response.status).toBe(429);
expect(response.headers.get('Retry-After')).toBeNull();
expect(response.headers.get('x-relay-quota-reason')).toBeNull();
});
it('상태 함수가 없거나 실패해도 기존 429 계약을 유지한다', async () => {
for (const takeQuota of [async () => false, Object.assign(async () => false, { status: () => { throw new Error('private'); } })]) {
for (const takeQuota of [
async () => false,
Object.assign(async () => false, {
status: () => {
throw new Error('private');
},
}),
]) {
const response = await setup(takeQuota).relay(request());
expect(response.status).toBe(429);
expect(response.headers.get('Retry-After')).toBeNull();
expect(await response.text()).not.toContain('private');
}
});
it('원본 오류는 로컬 정책 재시도 헤더를 주입하지 못한다', async () => {
const upstream = vi.fn(async () => { throw Object.assign(new RelayError(429), { quota: { quotaReason: 'minute', retryAfter: 30 } }); });
const upstream = vi.fn(async () => {
throw Object.assign(new RelayError(429), { quota: { quotaReason: 'minute', retryAfter: 30 } });
});
const response = await setup(async () => true, upstream).relay(request());
expect(response.status).toBe(429);
expect(response.headers.get('Retry-After')).toBeNull();
expect(response.headers.get('x-relay-quota-reason')).toBeNull();
});
it.each([403, 200])('검증된 원본 HTTP %i만 진단 헤더로 전달한다', async (upstreamStatus) => {
const upstream = vi.fn(async () => {
throw new RelayError(502, undefined, undefined, upstreamStatus);
});
const response = await setup(async () => true, upstream).relay(request());
expect(response.status).toBe(502);
expect(response.headers.get('x-relay-upstream-status')).toBe(String(upstreamStatus));
expect(response.headers.get('x-relay-quota-reason')).toBeNull();
expect(await response.json()).toEqual({ error: 'Relay request failed' });
});
it.each([undefined, 99, 600, 403.5, NaN])(
'원본 상태가 없거나 유효하지 않으면 진단 헤더를 생략한다: %s',
async (upstreamStatus) => {
const upstream = vi.fn(async () => {
throw new RelayError(502, undefined, undefined, upstreamStatus);
});
expect(
(await setup(async () => true, upstream).relay(request())).headers.get(
'x-relay-upstream-status',
),
).toBeNull();
},
);
19 changes: 19 additions & 0 deletions tests/services/daisoJp/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,3 +244,22 @@ it('누락·차단·오염된 상품 HTML은 빈 결과 대신 안전한 형식
expect(() => parser.parseProductPage(html, '4550746075917')).not.toThrow('secret');
}
});

it('공식 사이트 일시중단 공지를 파서 변경이나 매장 없음으로 오인하지 않는다', () => {
const notice =
'<title>当サイト一時停止のお知らせ | DAISO(ダイソー)公式企業サイト</title><h1 class="page-title">当サイト一時停止のお知らせ</h1><p>システムの安全性を確保するため、当サイトを一時的に停止しております。</p>';
expect(() => parseStoreSearch(notice)).toThrow(
expect.objectContaining({ code: 'DAISO_JP_MAINTENANCE', status: 503, retryable: true }),
);
});

it('일시중단 문구 하나만 있는 다른 HTML은 공식 점검으로 단정하지 않는다', () => {
for (const html of [
'<title>当サイト一時停止のお知らせ | DAISO(ダイソー)公式企業サイト</title>',
'<h1>当サイト一時停止のお知らせ</h1>',
]) {
expect(() => parseStoreSearch(html)).toThrow(
expect.objectContaining({ code: 'DAISO_JP_PARSE_FAILED', status: 502 }),
);
}
});
29 changes: 29 additions & 0 deletions tests/utils/convenienceTransport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,3 +214,32 @@ it('캐시 우회 옵션은 인증된 고정 릴레이 요청 헤더로만 전
vi.unstubAllGlobals();
}
});
it.each([
['403', 403],
['200', 200],
['599', 599],
['', 502],
['099', 502],
['600', 502],
['403.5', 502],
['secret', 502],
])('원본 상태 진단을 안전하게 전달한다: %s', async (header, expected) => {
vi.stubGlobal(
'fetch',
async () =>
new Response('private upstream body', {
status: 502,
headers: { 'x-relay-upstream-status': header },
}),
);
try {
await expect(requestConvenienceRelay('seven-goods', {}, options)).rejects.toMatchObject({
code: 'CONVENIENCE_RELAY_FAILED',
status: 502,
upstreamStatus: expected,
message: '편의점 릴레이 요청에 실패했습니다.',
});
} finally {
vi.unstubAllGlobals();
}
});
Loading
0