daiso 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/api/configStatus.js +2 -1
- package/dist/api/handlers.js +10 -5
- package/dist/core/errors.js +20 -8
- package/dist/index.js +2 -1
- package/dist/services/cgv/errors.js +2 -1
- package/dist/services/cgv/transport.js +3 -37
- package/dist/services/cu/client.js +3 -19
- package/dist/services/cu/upstreamError.js +0 -4
- package/dist/services/gs25/productSearch.js +2 -15
- package/dist/services/gs25/storeStockTransport.js +1 -26
- package/dist/services/lottemart/session.js +12 -49
- package/dist/services/oliveyoung/client.js +7 -40
- package/dist/services/oliveyoung/index.js +4 -4
- package/dist/services/oliveyoung/tools/checkInventory.js +6 -3
- package/dist/services/oliveyoung/tools/findNearbyStores.js +4 -3
- package/dist/services/oliveyoung/tools/searchProducts.js +4 -3
- package/dist/services/oliveyoung/transport.js +46 -0
- package/dist/services/seveneleven/client.js +3 -2
- package/dist/services/seveneleven/readCache.js +49 -0
- package/dist/utils/http.js +2 -2
- package/dist/utils/zyte.js +4 -112
- package/dist/utils/zyteJsonFallback.js +3 -42
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -94,6 +94,8 @@
|
|
|
94
94
|
|
|
95
95
|
</div>
|
|
96
96
|
|
|
97
|
+
> Zyte 유료 호출을 중지했습니다. 무료 원본 조회를 우선하며 일부 서비스는 별도 실행 환경이 필요합니다. [비용 정책과 서비스별 상태](docs/zyte-free-access.md)를 확인하세요.
|
|
98
|
+
|
|
97
99
|
> [!IMPORTANT]
|
|
98
100
|
> 최근 공개 서버 사용량이 크게 증가하여 2026년 7월 18일부터 올리브영·CGV·CU·GS25·롯데마트의 검색을 포함한 공개 GET API에 IP당 하루 합산 3,000회(KST 기준)의 호출 제한을 적용합니다. 한도를 초과하는 사용이 필요하다면 Daiso MCP는 오픈 소스이므로 이 저장소를 직접 배포해 이용해 주세요.
|
|
99
101
|
|
package/dist/api/configStatus.js
CHANGED
|
@@ -9,7 +9,8 @@ export function buildConfigStatus(bindings) {
|
|
|
9
9
|
},
|
|
10
10
|
zyteApiKey: {
|
|
11
11
|
configured: isConfigured(bindings?.ZYTE_API_KEY),
|
|
12
|
-
|
|
12
|
+
enabled: false,
|
|
13
|
+
usedBy: [],
|
|
13
14
|
},
|
|
14
15
|
naverLocalSearch: {
|
|
15
16
|
configured: isConfigured(bindings?.NAVER_CLIENT_ID) && isConfigured(bindings?.NAVER_CLIENT_SECRET),
|
package/dist/api/handlers.js
CHANGED
|
@@ -44,7 +44,8 @@ export async function handleOliveyoungSearchProducts(c) {
|
|
|
44
44
|
sort,
|
|
45
45
|
includeSoldOut,
|
|
46
46
|
}, {
|
|
47
|
-
|
|
47
|
+
relayUrl: c.env?.OY_RELAY_URL,
|
|
48
|
+
relayToken: c.env?.OY_RELAY_TOKEN,
|
|
48
49
|
timeout: timeoutMs,
|
|
49
50
|
});
|
|
50
51
|
return successResponse(c, {
|
|
@@ -79,7 +80,8 @@ export async function handleOliveyoungFindStores(c) {
|
|
|
79
80
|
pageIdx,
|
|
80
81
|
searchWords: keyword,
|
|
81
82
|
}, {
|
|
82
|
-
|
|
83
|
+
relayUrl: c.env?.OY_RELAY_URL,
|
|
84
|
+
relayToken: c.env?.OY_RELAY_TOKEN,
|
|
83
85
|
timeout: timeoutMs,
|
|
84
86
|
});
|
|
85
87
|
return successResponse(c, {
|
|
@@ -120,7 +122,8 @@ export async function handleOliveyoungCheckInventory(c) {
|
|
|
120
122
|
pageIdx: 1,
|
|
121
123
|
searchWords: storeKeyword,
|
|
122
124
|
}, {
|
|
123
|
-
|
|
125
|
+
relayUrl: c.env?.OY_RELAY_URL,
|
|
126
|
+
relayToken: c.env?.OY_RELAY_TOKEN,
|
|
124
127
|
timeout: timeoutMs,
|
|
125
128
|
}),
|
|
126
129
|
fetchOliveyoungProducts({
|
|
@@ -130,7 +133,8 @@ export async function handleOliveyoungCheckInventory(c) {
|
|
|
130
133
|
sort,
|
|
131
134
|
includeSoldOut,
|
|
132
135
|
}, {
|
|
133
|
-
|
|
136
|
+
relayUrl: c.env?.OY_RELAY_URL,
|
|
137
|
+
relayToken: c.env?.OY_RELAY_TOKEN,
|
|
134
138
|
timeout: timeoutMs,
|
|
135
139
|
}),
|
|
136
140
|
]);
|
|
@@ -140,7 +144,8 @@ export async function handleOliveyoungCheckInventory(c) {
|
|
|
140
144
|
storeKeyword,
|
|
141
145
|
maxProducts: Math.min(productResult.products.length, stockCheckLimit),
|
|
142
146
|
}, {
|
|
143
|
-
|
|
147
|
+
relayUrl: c.env?.OY_RELAY_URL,
|
|
148
|
+
relayToken: c.env?.OY_RELAY_TOKEN,
|
|
144
149
|
timeout: timeoutMs,
|
|
145
150
|
});
|
|
146
151
|
const inStockCount = enrichedInventory.products.filter((product) => product.inStock).length;
|
package/dist/core/errors.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MCP/API 공통 에러 진단 구조
|
|
3
3
|
*/
|
|
4
|
+
export const ZYTE_COST_POLICY_MESSAGE = 'Zyte 유료 호출은 비용 정책에 따라 비활성화되어 있습니다.';
|
|
4
5
|
const SERVICE_PREFIXES = [
|
|
5
6
|
'daiso',
|
|
6
7
|
'gs25',
|
|
@@ -50,15 +51,22 @@ function buildHint(retryable) {
|
|
|
50
51
|
}
|
|
51
52
|
export function toStandardErrorDiagnostics(code, message, options = {}) {
|
|
52
53
|
const inferred = inferServiceAndOperation(code);
|
|
54
|
+
const costPolicyError = message.includes(ZYTE_COST_POLICY_MESSAGE);
|
|
53
55
|
// 실제 Zyte 진단 문구만 설정 오류로 취급해 일반적인 접근 차단과 구분합니다.
|
|
54
56
|
const configurationError = [
|
|
55
57
|
'Zyte API 호출 실패: 403 Your account has been suspended.',
|
|
56
58
|
'Zyte API 호출 실패: 403 account suspended',
|
|
57
59
|
'ZYTE_API_KEY가 설정되지 않았습니다.',
|
|
58
60
|
].some((diagnostic) => message.includes(diagnostic));
|
|
59
|
-
const gs25AuthenticationError =
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
const gs25AuthenticationError = message.includes('GS25 재고 서비스 인증을 사용할 수 없습니다.');
|
|
62
|
+
const oliveyoungRelayConfigurationError = [
|
|
63
|
+
'올리브영 릴레이 URL은 HTTPS 또는 로컬 HTTP 주소여야 합니다.',
|
|
64
|
+
'OY_RELAY_TOKEN이 필요합니다.',
|
|
65
|
+
'올리브영 직접 요청 실패. 운영자는 OY_RELAY_URL과 OY_RELAY_TOKEN으로 브라우저 릴레이를 설정해주세요.',
|
|
66
|
+
].includes(message);
|
|
67
|
+
const retryable = !oliveyoungRelayConfigurationError &&
|
|
68
|
+
!costPolicyError &&
|
|
69
|
+
!configurationError &&
|
|
62
70
|
!gs25AuthenticationError &&
|
|
63
71
|
isRetryable(code, options.status || options.upstreamStatus);
|
|
64
72
|
return {
|
|
@@ -69,11 +77,15 @@ export function toStandardErrorDiagnostics(code, message, options = {}) {
|
|
|
69
77
|
service: options.service || inferred.service,
|
|
70
78
|
operation: options.operation || inferred.operation,
|
|
71
79
|
upstreamStatus: options.upstreamStatus,
|
|
72
|
-
hint:
|
|
73
|
-
? '운영자는
|
|
74
|
-
:
|
|
75
|
-
?
|
|
76
|
-
:
|
|
80
|
+
hint: oliveyoungRelayConfigurationError
|
|
81
|
+
? '운영자는 OY_RELAY_URL과 OY_RELAY_TOKEN 및 브라우저 릴레이 실행 상태를 확인하세요.'
|
|
82
|
+
: costPolicyError
|
|
83
|
+
? ZYTE_COST_POLICY_MESSAGE
|
|
84
|
+
: gs25AuthenticationError
|
|
85
|
+
? '운영자는 GS25_API_KEY 설정을 확인하세요.'
|
|
86
|
+
: configurationError
|
|
87
|
+
? '운영자는 ZYTE_API_KEY 설정과 Zyte 계정 상태를 확인하세요.'
|
|
88
|
+
: buildHint(retryable),
|
|
77
89
|
};
|
|
78
90
|
}
|
|
79
91
|
export function getErrorMessage(error) {
|
package/dist/index.js
CHANGED
|
@@ -85,7 +85,8 @@ const createRegistry = (bindings) => {
|
|
|
85
85
|
googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY,
|
|
86
86
|
}),
|
|
87
87
|
() => createOliveyoungService({
|
|
88
|
-
|
|
88
|
+
relayUrl: bindings?.OY_RELAY_URL,
|
|
89
|
+
relayToken: bindings?.OY_RELAY_TOKEN,
|
|
89
90
|
}),
|
|
90
91
|
]);
|
|
91
92
|
return registry;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CGV 원본 및 대체 전송 경로를 사용할 수 없는 상태
|
|
3
3
|
*/
|
|
4
|
+
import { ZYTE_COST_POLICY_MESSAGE } from '../../core/errors.js';
|
|
4
5
|
export class CgvUpstreamUnavailableError extends Error {
|
|
5
6
|
constructor() {
|
|
6
|
-
super(
|
|
7
|
+
super(`CGV 원본 서비스에 연결할 수 없습니다. ${ZYTE_COST_POLICY_MESSAGE}`);
|
|
7
8
|
this.name = 'CgvUpstreamUnavailableError';
|
|
8
9
|
}
|
|
9
10
|
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CGV 전송 계층
|
|
3
3
|
*
|
|
4
|
-
* 서명
|
|
4
|
+
* 서명 헤더를 생성하고 직접 호출합니다. 유료 대체 경로는 사용하지 않습니다.
|
|
5
5
|
*/
|
|
6
6
|
import { createTimeoutController } from '../../utils/http.js';
|
|
7
|
-
import { decodeZyteHttpBody, requestByZyte } from '../../utils/zyte.js';
|
|
8
7
|
import { CGV_API } from './api.js';
|
|
9
8
|
import { CgvUpstreamUnavailableError } from './errors.js';
|
|
10
9
|
function toBase64(bytes) {
|
|
@@ -36,28 +35,7 @@ async function parseJsonResponse(response) {
|
|
|
36
35
|
throw new Error(text.slice(0, 120) || 'CGV API 응답 파싱 실패');
|
|
37
36
|
}
|
|
38
37
|
}
|
|
39
|
-
async function
|
|
40
|
-
const url = `${CGV_API.BASE_URL}${path}?${searchParams.toString()}`;
|
|
41
|
-
const timestamp = Math.floor(Date.now() / 1000).toString();
|
|
42
|
-
const signature = await createSignature(path, '', timestamp);
|
|
43
|
-
const result = await requestByZyte({
|
|
44
|
-
apiKey,
|
|
45
|
-
timeout,
|
|
46
|
-
url,
|
|
47
|
-
headers: [
|
|
48
|
-
{ name: 'Accept', value: 'application/json' },
|
|
49
|
-
{ name: 'Accept-Language', value: 'ko-KR' },
|
|
50
|
-
{ name: 'X-TIMESTAMP', value: timestamp },
|
|
51
|
-
{ name: 'X-SIGNATURE', value: signature },
|
|
52
|
-
],
|
|
53
|
-
tags: { service: 'cgv' },
|
|
54
|
-
});
|
|
55
|
-
if (result.statusCode === 401 || result.statusCode === 403) {
|
|
56
|
-
throw new CgvUpstreamUnavailableError();
|
|
57
|
-
}
|
|
58
|
-
return decodeZyteHttpBody(result);
|
|
59
|
-
}
|
|
60
|
-
export async function requestCgv(path, searchParams, timeout = 15000, zyteApiKey) {
|
|
38
|
+
export async function requestCgv(path, searchParams, timeout = 15000, _zyteApiKey) {
|
|
61
39
|
const url = `${CGV_API.BASE_URL}${path}?${searchParams.toString()}`;
|
|
62
40
|
const timestamp = Math.floor(Date.now() / 1000).toString();
|
|
63
41
|
const signature = await createSignature(path, '', timestamp);
|
|
@@ -77,19 +55,7 @@ export async function requestCgv(path, searchParams, timeout = 15000, zyteApiKey
|
|
|
77
55
|
return await parseJsonResponse(response);
|
|
78
56
|
}
|
|
79
57
|
if (response.status === 401 || response.status === 403) {
|
|
80
|
-
|
|
81
|
-
if (!normalizedZyteApiKey) {
|
|
82
|
-
throw new CgvUpstreamUnavailableError();
|
|
83
|
-
}
|
|
84
|
-
try {
|
|
85
|
-
return await requestByZyteCgv(path, searchParams, timeout, normalizedZyteApiKey);
|
|
86
|
-
}
|
|
87
|
-
catch (fallbackError) {
|
|
88
|
-
if (fallbackError instanceof CgvUpstreamUnavailableError) {
|
|
89
|
-
throw fallbackError;
|
|
90
|
-
}
|
|
91
|
-
throw new CgvUpstreamUnavailableError();
|
|
92
|
-
}
|
|
58
|
+
throw new CgvUpstreamUnavailableError();
|
|
93
59
|
}
|
|
94
60
|
throw new Error(`CGV API 호출 실패: ${response.status}`);
|
|
95
61
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* CU API 클라이언트
|
|
3
3
|
*/
|
|
4
4
|
import { fetchJson } from '../../utils/http.js';
|
|
5
|
-
import {
|
|
5
|
+
import { ZYTE_COST_POLICY_MESSAGE } from '../../core/errors.js';
|
|
6
6
|
import { fetchJsonWithZyteFallback } from '../../utils/zyteJsonFallback.js';
|
|
7
7
|
import { CU_API } from './api.js';
|
|
8
8
|
import { cuStockUnavailableReason } from './upstreamError.js';
|
|
@@ -57,7 +57,7 @@ async function requestCuJson(path, body, options = {}) {
|
|
|
57
57
|
zyteTags: { service: 'cu' },
|
|
58
58
|
});
|
|
59
59
|
}
|
|
60
|
-
async function requestCuWebHtml(path, body,
|
|
60
|
+
async function requestCuWebHtml(path, body, _apiKey, timeout = 15000) {
|
|
61
61
|
const form = new URLSearchParams(body);
|
|
62
62
|
const formText = form.toString();
|
|
63
63
|
const targetUrl = `${CU_API.WEB_BASE_URL}${path}`;
|
|
@@ -71,23 +71,7 @@ async function requestCuWebHtml(path, body, apiKey, timeout = 15000) {
|
|
|
71
71
|
return response.text();
|
|
72
72
|
}
|
|
73
73
|
if (response.status === 400 || response.status === 403 || response.status === 429) {
|
|
74
|
-
|
|
75
|
-
const result = await requestByZyte({
|
|
76
|
-
apiKey,
|
|
77
|
-
url: targetUrl,
|
|
78
|
-
timeout,
|
|
79
|
-
method: 'POST',
|
|
80
|
-
headers: Object.entries(CU_WEB_DEFAULT_HEADERS).map(([name, value]) => ({ name, value })),
|
|
81
|
-
bodyText: formText,
|
|
82
|
-
tags: { service: 'cu' },
|
|
83
|
-
});
|
|
84
|
-
if (result.statusCode === 200 && result.httpResponseBody) {
|
|
85
|
-
return decodeBase64(result.httpResponseBody);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
catch {
|
|
89
|
-
// 직접 호출 실패 시 Zyte 재시도도 실패하면 원본 에러를 반환합니다.
|
|
90
|
-
}
|
|
74
|
+
throw new Error(ZYTE_COST_POLICY_MESSAGE);
|
|
91
75
|
}
|
|
92
76
|
throw new Error(`API 요청 실패: ${response.status} ${response.statusText}`);
|
|
93
77
|
}
|
|
@@ -7,9 +7,5 @@ export function cuStockUnavailableReason(error) {
|
|
|
7
7
|
if (error instanceof HttpError && [400, 403, 429].includes(error.status)) {
|
|
8
8
|
return `CU 재고 API가 차단되었습니다 (${error.status} Request Blocked).`;
|
|
9
9
|
}
|
|
10
|
-
const message = error instanceof Error ? error.message : '';
|
|
11
|
-
if (message.includes('Zyte API 호출 실패: 520') || message.includes('Zyte 대상 응답 실패: 520')) {
|
|
12
|
-
return 'CU 재고 API가 차단되었습니다 (Zyte Website Ban 520).';
|
|
13
|
-
}
|
|
14
10
|
return null;
|
|
15
11
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
/* c8 ignore start */
|
|
5
5
|
import { fetchJson, HttpError } from '../../utils/http.js';
|
|
6
|
-
import {
|
|
6
|
+
import { requestByZyte } from '../../utils/zyte.js';
|
|
7
7
|
import { GS25_API } from './api.js';
|
|
8
8
|
import { toNumber } from './storeUtils.js';
|
|
9
9
|
const GS25_TOTAL_SEARCH_HEADERS = {
|
|
@@ -37,20 +37,7 @@ async function fetchGs25TotalSearchResponse(query, options) {
|
|
|
37
37
|
if (!(error instanceof HttpError) || error.status !== 403 || !zyteApiKey) {
|
|
38
38
|
throw error;
|
|
39
39
|
}
|
|
40
|
-
|
|
41
|
-
apiKey: zyteApiKey,
|
|
42
|
-
url: endpoint.toString(),
|
|
43
|
-
method: 'POST',
|
|
44
|
-
timeout,
|
|
45
|
-
retries: 1,
|
|
46
|
-
headers: Object.entries(GS25_TOTAL_SEARCH_HEADERS).map(([name, value]) => ({
|
|
47
|
-
name,
|
|
48
|
-
value,
|
|
49
|
-
})),
|
|
50
|
-
bodyText,
|
|
51
|
-
tags: { service: 'gs25' },
|
|
52
|
-
});
|
|
53
|
-
return decodeZyteHttpBody(result);
|
|
40
|
+
return requestByZyte({ apiKey: zyteApiKey, url: endpoint.toString() });
|
|
54
41
|
}
|
|
55
42
|
}
|
|
56
43
|
export async function fetchGs25NormalizedKeyword(keyword, options = {}) {
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
* GS25 재고 원본 요청과 Zyte 대체 경로
|
|
3
3
|
*/
|
|
4
4
|
import { fetchJson, HttpError } from '../../utils/http.js';
|
|
5
|
-
import { decodeZyteHttpBody, requestByZyte } from '../../utils/zyte.js';
|
|
6
5
|
import { Gs25UpstreamUnavailableError } from './errors.js';
|
|
7
6
|
function withApiKey(headers, apiKey) {
|
|
8
7
|
const normalizedApiKey = apiKey?.trim();
|
|
@@ -26,30 +25,6 @@ export async function fetchGs25StoreStockResponse(url, options, headers) {
|
|
|
26
25
|
if (!(error instanceof HttpError) || !isAuthenticationStatus(error.status)) {
|
|
27
26
|
throw error;
|
|
28
27
|
}
|
|
29
|
-
|
|
30
|
-
if (error.status === 401 || !zyteApiKey) {
|
|
31
|
-
throw new Gs25UpstreamUnavailableError();
|
|
32
|
-
}
|
|
33
|
-
try {
|
|
34
|
-
const result = await requestByZyte({
|
|
35
|
-
apiKey: zyteApiKey,
|
|
36
|
-
url,
|
|
37
|
-
method: 'GET',
|
|
38
|
-
timeout: options.timeout,
|
|
39
|
-
retries: 1,
|
|
40
|
-
headers: Object.entries(requestHeaders).map(([name, value]) => ({ name, value })),
|
|
41
|
-
tags: { service: 'gs25' },
|
|
42
|
-
});
|
|
43
|
-
if (isAuthenticationStatus(result.statusCode)) {
|
|
44
|
-
throw new Gs25UpstreamUnavailableError();
|
|
45
|
-
}
|
|
46
|
-
return decodeZyteHttpBody(result);
|
|
47
|
-
}
|
|
48
|
-
catch (fallbackError) {
|
|
49
|
-
if (fallbackError instanceof Gs25UpstreamUnavailableError) {
|
|
50
|
-
throw fallbackError;
|
|
51
|
-
}
|
|
52
|
-
throw new Gs25UpstreamUnavailableError();
|
|
53
|
-
}
|
|
28
|
+
throw new Gs25UpstreamUnavailableError();
|
|
54
29
|
}
|
|
55
30
|
}
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* 롯데마트 세션 쿠키 관리
|
|
3
3
|
*/
|
|
4
4
|
import { HttpError, fetchWithTimeout } from '../../utils/http.js';
|
|
5
|
-
import {
|
|
5
|
+
import { ZYTE_COST_POLICY_MESSAGE } from '../../core/errors.js';
|
|
6
6
|
import { LOTTEMART_API } from './api.js';
|
|
7
|
-
import { fetchLotteMartSocketResponse,
|
|
7
|
+
import { fetchLotteMartSocketResponse, withLotteMartSessionCookie, } from './socketTransport.js';
|
|
8
8
|
export { __testOnlyCreateLotteMartSocketResponse, __testOnlyFetchLotteMartSocketResponse, withLotteMartSessionCookie, } from './socketTransport.js';
|
|
9
9
|
const SESSION_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
10
10
|
const FALLBACK_TIMEOUT_MS = 5000;
|
|
@@ -67,28 +67,6 @@ async function fetchLotteMartResponse(url, init, timeout, sessionCookie) {
|
|
|
67
67
|
throw directError;
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
-
function toZyteHeaders(headers) {
|
|
71
|
-
return Array.from(headers.entries()).map(([name, value]) => ({ name, value }));
|
|
72
|
-
}
|
|
73
|
-
async function fetchLotteMartHtmlByZyte(url, init, timeout, sessionCookie, zyteApiKey) {
|
|
74
|
-
const headers = withLotteMartSessionCookie({
|
|
75
|
-
Accept: 'text/html, */*; q=0.01',
|
|
76
|
-
...init.headers,
|
|
77
|
-
}, sessionCookie);
|
|
78
|
-
const result = await requestByZyte({
|
|
79
|
-
apiKey: zyteApiKey,
|
|
80
|
-
url,
|
|
81
|
-
timeout,
|
|
82
|
-
method: init.method || 'GET',
|
|
83
|
-
headers: toZyteHeaders(headers),
|
|
84
|
-
bodyText: toLotteMartBodyText(init.body),
|
|
85
|
-
tags: { service: 'lottemart' },
|
|
86
|
-
});
|
|
87
|
-
if (!result.httpResponseBody) {
|
|
88
|
-
throw new Error('Zyte HTTP 응답 본문이 비어 있습니다.');
|
|
89
|
-
}
|
|
90
|
-
return decodeBase64(result.httpResponseBody);
|
|
91
|
-
}
|
|
92
70
|
function toBodyPreview(bodyText) {
|
|
93
71
|
const normalized = bodyText.trim().replace(/\s+/g, ' ');
|
|
94
72
|
return normalized.length > 0 ? normalized.slice(0, 300) : null;
|
|
@@ -122,29 +100,15 @@ export async function probeLotteMartRequest(url, init, timeout, sessionCookie, z
|
|
|
122
100
|
});
|
|
123
101
|
}
|
|
124
102
|
if (zyteApiKey) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
sessionCookie: sessionCookie || null,
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
catch (error) {
|
|
138
|
-
attempts.push({
|
|
139
|
-
used: 'zyte',
|
|
140
|
-
success: false,
|
|
141
|
-
status: null,
|
|
142
|
-
statusText: null,
|
|
143
|
-
error: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.',
|
|
144
|
-
bodyPreview: null,
|
|
145
|
-
sessionCookie: sessionCookie || null,
|
|
146
|
-
});
|
|
147
|
-
}
|
|
103
|
+
attempts.push({
|
|
104
|
+
used: 'zyte',
|
|
105
|
+
success: false,
|
|
106
|
+
status: null,
|
|
107
|
+
statusText: null,
|
|
108
|
+
error: ZYTE_COST_POLICY_MESSAGE,
|
|
109
|
+
bodyPreview: null,
|
|
110
|
+
sessionCookie: sessionCookie || null,
|
|
111
|
+
});
|
|
148
112
|
}
|
|
149
113
|
return attempts;
|
|
150
114
|
}
|
|
@@ -186,8 +150,7 @@ export async function fetchLotteMartHtml(url, init, timeout, sessionCookie, zyte
|
|
|
186
150
|
}
|
|
187
151
|
catch (error) {
|
|
188
152
|
if (zyteApiKey && error instanceof Error && !error.message.includes('Zyte')) {
|
|
189
|
-
|
|
190
|
-
return fetchLotteMartHtmlByZyte(url, init, Math.min(timeout, FALLBACK_TIMEOUT_MS), fallbackCookie || sessionCookie, zyteApiKey);
|
|
153
|
+
throw new Error(ZYTE_COST_POLICY_MESSAGE);
|
|
191
154
|
}
|
|
192
155
|
throw error;
|
|
193
156
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 올리브영
|
|
2
|
+
* 올리브영 무료 API 클라이언트
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 공식 API 또는 운영자가 설정한 브라우저 릴레이를 호출합니다.
|
|
5
5
|
*/
|
|
6
6
|
import { OLIVEYOUNG_API } from './api.js';
|
|
7
7
|
import { resolveOliveyoungImageUrl, resolveOliveyoungInStock, resolveOliveyoungStoreStock, } from './normalize.js';
|
|
8
|
-
import {
|
|
8
|
+
import { requestOliveyoung } from './transport.js';
|
|
9
9
|
const OLIVEYOUNG_PRODUCT_ID_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
10
10
|
const OLIVEYOUNG_PRODUCT_SEARCH_STALE_CACHE_TTL_MS = 30 * 60 * 1000;
|
|
11
11
|
const oliveyoungProductIdCache = new Map();
|
|
@@ -56,39 +56,6 @@ function writeStaleResult(cache, cacheKey, result, cloneResult) {
|
|
|
56
56
|
result: cloneResult(result),
|
|
57
57
|
});
|
|
58
58
|
}
|
|
59
|
-
async function zyteExtract(targetPath, requestBody, options = {}) {
|
|
60
|
-
const { timeout = 15000, apiKey } = options;
|
|
61
|
-
try {
|
|
62
|
-
const result = await requestByZyte({
|
|
63
|
-
apiKey,
|
|
64
|
-
timeout,
|
|
65
|
-
url: `${OLIVEYOUNG_API.BASE_URL}${targetPath}`,
|
|
66
|
-
method: 'POST',
|
|
67
|
-
headers: [
|
|
68
|
-
{ name: 'Content-Type', value: 'application/json' },
|
|
69
|
-
{ name: 'Accept', value: 'application/json' },
|
|
70
|
-
{ name: 'X-Requested-With', value: 'XMLHttpRequest' },
|
|
71
|
-
],
|
|
72
|
-
bodyText: JSON.stringify(requestBody),
|
|
73
|
-
tags: { service: 'oliveyoung' },
|
|
74
|
-
});
|
|
75
|
-
if (result.statusCode !== 200 || !result.httpResponseBody) {
|
|
76
|
-
throw new Error(`올리브영 API 응답 실패: ${result.statusCode || 'unknown'}`);
|
|
77
|
-
}
|
|
78
|
-
const decodedBody = decodeBase64(result.httpResponseBody);
|
|
79
|
-
const parsedBody = JSON.parse(decodedBody);
|
|
80
|
-
if (parsedBody.status !== 'SUCCESS') {
|
|
81
|
-
throw new Error(`올리브영 API 상태 오류: ${parsedBody.status || 'UNKNOWN'}`);
|
|
82
|
-
}
|
|
83
|
-
return parsedBody;
|
|
84
|
-
}
|
|
85
|
-
catch (error) {
|
|
86
|
-
if (error instanceof Error && error.name === 'AbortError') {
|
|
87
|
-
throw new Error('올리브영 API 요청 시간 초과');
|
|
88
|
-
}
|
|
89
|
-
throw error;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
59
|
export async function fetchOliveyoungStores(params, options = {}) {
|
|
93
60
|
const payload = {
|
|
94
61
|
lat: params.latitude,
|
|
@@ -102,7 +69,7 @@ export async function fetchOliveyoungStores(params, options = {}) {
|
|
|
102
69
|
};
|
|
103
70
|
const cacheKey = createOliveyoungStoreSearchCacheKey(params);
|
|
104
71
|
try {
|
|
105
|
-
const body = await
|
|
72
|
+
const body = await requestOliveyoung(OLIVEYOUNG_API.STORE_FINDER_PATH, payload, options);
|
|
106
73
|
const stores = (body.data?.storeList || []).map((store) => ({
|
|
107
74
|
storeCode: store.storeCode || '',
|
|
108
75
|
storeName: store.storeName || '',
|
|
@@ -137,7 +104,7 @@ export async function fetchOliveyoungProducts(params, options = {}) {
|
|
|
137
104
|
};
|
|
138
105
|
const cacheKey = createOliveyoungProductSearchCacheKey(params);
|
|
139
106
|
try {
|
|
140
|
-
const body = await
|
|
107
|
+
const body = await requestOliveyoung(OLIVEYOUNG_API.PRODUCT_SEARCH_PATH, payload, options);
|
|
141
108
|
const list = body.data?.serachList || body.data?.searchList || [];
|
|
142
109
|
const products = list.map((product) => {
|
|
143
110
|
const o2oStockFlag = Boolean(product.o2oStockFlag);
|
|
@@ -185,7 +152,7 @@ async function fetchOliveyoungProductId(goodsNumber, options = {}) {
|
|
|
185
152
|
if (cached && cached.expiresAt > now) {
|
|
186
153
|
return cached.productId;
|
|
187
154
|
}
|
|
188
|
-
const body = await
|
|
155
|
+
const body = await requestOliveyoung(OLIVEYOUNG_API.STOCK_GOODS_INFO_PATH, { goodsNo: normalizedGoodsNumber }, options);
|
|
189
156
|
const productId = body.data?.goodsInfo?.masterGoodsNumber || '';
|
|
190
157
|
if (productId) {
|
|
191
158
|
oliveyoungProductIdCache.set(normalizedGoodsNumber, {
|
|
@@ -198,7 +165,7 @@ async function fetchOliveyoungProductId(goodsNumber, options = {}) {
|
|
|
198
165
|
async function fetchOliveyoungStockStores(params, options = {}) {
|
|
199
166
|
const cacheKey = createOliveyoungStockStoresCacheKey(params);
|
|
200
167
|
try {
|
|
201
|
-
const body = await
|
|
168
|
+
const body = await requestOliveyoung(OLIVEYOUNG_API.STOCK_STORES_PATH, {
|
|
202
169
|
productId: params.productId,
|
|
203
170
|
lat: params.latitude,
|
|
204
171
|
lon: params.longitude,
|
|
@@ -8,7 +8,7 @@ const OLIVEYOUNG_METADATA = {
|
|
|
8
8
|
id: 'oliveyoung',
|
|
9
9
|
name: '올리브영',
|
|
10
10
|
version: '1.0.0',
|
|
11
|
-
description: '올리브영 상품 검색, 주변 매장 탐색 및 재고 파악 서비스 (
|
|
11
|
+
description: '올리브영 상품 검색, 주변 매장 탐색 및 재고 파악 서비스 (무료 직접 요청 및 브라우저 릴레이)',
|
|
12
12
|
};
|
|
13
13
|
class OliveyoungService {
|
|
14
14
|
options;
|
|
@@ -18,9 +18,9 @@ class OliveyoungService {
|
|
|
18
18
|
}
|
|
19
19
|
getTools() {
|
|
20
20
|
return [
|
|
21
|
-
createSearchProductsTool(this.options.zyteApiKey),
|
|
22
|
-
createFindNearbyStoresTool(this.options.zyteApiKey),
|
|
23
|
-
createCheckInventoryTool(this.options.zyteApiKey),
|
|
21
|
+
createSearchProductsTool(this.options.zyteApiKey, this.options),
|
|
22
|
+
createFindNearbyStoresTool(this.options.zyteApiKey, this.options),
|
|
23
|
+
createCheckInventoryTool(this.options.zyteApiKey, this.options),
|
|
24
24
|
];
|
|
25
25
|
}
|
|
26
26
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import * as z from 'zod';
|
|
5
5
|
import { enrichOliveyoungProductsWithNearbyStoreInventory, fetchOliveyoungProducts, fetchOliveyoungStores, } from '../client.js';
|
|
6
|
-
async function checkInventory(args) {
|
|
6
|
+
async function checkInventory(args, transport) {
|
|
7
7
|
const { keyword, latitude = 37.5665, longitude = 126.978, storeKeyword = '', page = 1, size = 20, sort = '01', includeSoldOut = false, storeLimit = 10, stockCheckLimit = 5, timeoutMs = 15000, zyteApiKey, } = args;
|
|
8
8
|
if (!keyword || keyword.trim().length === 0) {
|
|
9
9
|
throw new Error('상품 검색어(keyword)를 입력해주세요.');
|
|
@@ -17,6 +17,7 @@ async function checkInventory(args) {
|
|
|
17
17
|
}, {
|
|
18
18
|
timeout: timeoutMs,
|
|
19
19
|
apiKey: zyteApiKey,
|
|
20
|
+
...transport,
|
|
20
21
|
}),
|
|
21
22
|
fetchOliveyoungProducts({
|
|
22
23
|
keyword,
|
|
@@ -27,6 +28,7 @@ async function checkInventory(args) {
|
|
|
27
28
|
}, {
|
|
28
29
|
timeout: timeoutMs,
|
|
29
30
|
apiKey: zyteApiKey,
|
|
31
|
+
...transport,
|
|
30
32
|
}),
|
|
31
33
|
]);
|
|
32
34
|
const enrichedInventory = await enrichOliveyoungProductsWithNearbyStoreInventory(productResult.products, {
|
|
@@ -37,6 +39,7 @@ async function checkInventory(args) {
|
|
|
37
39
|
}, {
|
|
38
40
|
timeout: timeoutMs,
|
|
39
41
|
apiKey: zyteApiKey,
|
|
42
|
+
...transport,
|
|
40
43
|
});
|
|
41
44
|
const inStockProducts = enrichedInventory.products.filter((product) => product.inStock);
|
|
42
45
|
const result = {
|
|
@@ -71,7 +74,7 @@ async function checkInventory(args) {
|
|
|
71
74
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
72
75
|
};
|
|
73
76
|
}
|
|
74
|
-
export function createCheckInventoryTool(apiKey) {
|
|
77
|
+
export function createCheckInventoryTool(apiKey, transport = {}) {
|
|
75
78
|
return {
|
|
76
79
|
name: 'oliveyoung_check_inventory',
|
|
77
80
|
metadata: {
|
|
@@ -98,6 +101,6 @@ export function createCheckInventoryTool(apiKey) {
|
|
|
98
101
|
timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'),
|
|
99
102
|
},
|
|
100
103
|
},
|
|
101
|
-
handler: ((args) => checkInventory({ ...args, zyteApiKey: apiKey })),
|
|
104
|
+
handler: ((args) => checkInventory({ ...args, zyteApiKey: apiKey }, transport)),
|
|
102
105
|
};
|
|
103
106
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import * as z from 'zod';
|
|
5
5
|
import { fetchOliveyoungStores } from '../client.js';
|
|
6
|
-
async function findNearbyStores(args) {
|
|
6
|
+
async function findNearbyStores(args, transport) {
|
|
7
7
|
const { latitude = 37.5665, longitude = 126.978, keyword = '', pageIdx = 1, limit = 20, timeoutMs = 15000, zyteApiKey, } = args;
|
|
8
8
|
const { totalCount, stores } = await fetchOliveyoungStores({
|
|
9
9
|
latitude,
|
|
@@ -13,6 +13,7 @@ async function findNearbyStores(args) {
|
|
|
13
13
|
}, {
|
|
14
14
|
timeout: timeoutMs,
|
|
15
15
|
apiKey: zyteApiKey,
|
|
16
|
+
...transport,
|
|
16
17
|
});
|
|
17
18
|
const limitedStores = stores.slice(0, limit);
|
|
18
19
|
const result = {
|
|
@@ -30,7 +31,7 @@ async function findNearbyStores(args) {
|
|
|
30
31
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
31
32
|
};
|
|
32
33
|
}
|
|
33
|
-
export function createFindNearbyStoresTool(apiKey) {
|
|
34
|
+
export function createFindNearbyStoresTool(apiKey, transport = {}) {
|
|
34
35
|
return {
|
|
35
36
|
name: 'oliveyoung_find_nearby_stores',
|
|
36
37
|
metadata: {
|
|
@@ -48,6 +49,6 @@ export function createFindNearbyStoresTool(apiKey) {
|
|
|
48
49
|
timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'),
|
|
49
50
|
},
|
|
50
51
|
},
|
|
51
|
-
handler: ((args) => findNearbyStores({ ...args, zyteApiKey: apiKey })),
|
|
52
|
+
handler: ((args) => findNearbyStores({ ...args, zyteApiKey: apiKey }, transport)),
|
|
52
53
|
};
|
|
53
54
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import * as z from 'zod';
|
|
5
5
|
import { fetchOliveyoungProducts } from '../client.js';
|
|
6
|
-
async function searchProducts(args) {
|
|
6
|
+
async function searchProducts(args, transport) {
|
|
7
7
|
const { keyword, page = 1, size = 20, sort = '01', includeSoldOut = false, timeoutMs = 15000, zyteApiKey, } = args;
|
|
8
8
|
if (!keyword || keyword.trim().length === 0) {
|
|
9
9
|
throw new Error('상품 검색어(keyword)를 입력해주세요.');
|
|
@@ -17,6 +17,7 @@ async function searchProducts(args) {
|
|
|
17
17
|
}, {
|
|
18
18
|
timeout: timeoutMs,
|
|
19
19
|
apiKey: zyteApiKey,
|
|
20
|
+
...transport,
|
|
20
21
|
});
|
|
21
22
|
return {
|
|
22
23
|
content: [
|
|
@@ -37,7 +38,7 @@ async function searchProducts(args) {
|
|
|
37
38
|
],
|
|
38
39
|
};
|
|
39
40
|
}
|
|
40
|
-
export function createSearchProductsTool(apiKey) {
|
|
41
|
+
export function createSearchProductsTool(apiKey, transport = {}) {
|
|
41
42
|
return {
|
|
42
43
|
name: 'oliveyoung_search_products',
|
|
43
44
|
metadata: {
|
|
@@ -52,6 +53,6 @@ export function createSearchProductsTool(apiKey) {
|
|
|
52
53
|
timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'),
|
|
53
54
|
},
|
|
54
55
|
},
|
|
55
|
-
handler: ((args) => searchProducts({ ...args, zyteApiKey: apiKey })),
|
|
56
|
+
handler: ((args) => searchProducts({ ...args, zyteApiKey: apiKey }, transport)),
|
|
56
57
|
};
|
|
57
58
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** 올리브영 무료 직접 요청 및 운영자가 설정한 브라우저 릴레이 전송. */
|
|
2
|
+
import { fetchJson } from '../../utils/http.js';
|
|
3
|
+
import { OLIVEYOUNG_API } from './api.js';
|
|
4
|
+
export async function requestOliveyoung(path, body, options = {}) {
|
|
5
|
+
const { timeout = 15000, relayUrl, relayToken } = options;
|
|
6
|
+
const headers = {
|
|
7
|
+
'Content-Type': 'application/json', Accept: 'application/json',
|
|
8
|
+
'X-Requested-With': 'XMLHttpRequest',
|
|
9
|
+
};
|
|
10
|
+
let url = `${OLIVEYOUNG_API.BASE_URL}${path}`;
|
|
11
|
+
if (relayUrl) {
|
|
12
|
+
let relay;
|
|
13
|
+
try {
|
|
14
|
+
relay = new URL(relayUrl);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new Error('올리브영 릴레이 URL은 HTTPS 또는 로컬 HTTP 주소여야 합니다.');
|
|
18
|
+
}
|
|
19
|
+
if (relay.username || relay.password || relay.search || relay.hash ||
|
|
20
|
+
(relay.protocol !== 'https:' && !(relay.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(relay.hostname)))) {
|
|
21
|
+
throw new Error('올리브영 릴레이 URL은 HTTPS 또는 로컬 HTTP 주소여야 합니다.');
|
|
22
|
+
}
|
|
23
|
+
if (!relayToken?.trim())
|
|
24
|
+
throw new Error('OY_RELAY_TOKEN이 필요합니다.');
|
|
25
|
+
url = `${relayUrl.replace(/\/$/, '')}/v1/oliveyoung/${path.split('/').pop()}`;
|
|
26
|
+
headers.Authorization = `Bearer ${relayToken}`;
|
|
27
|
+
}
|
|
28
|
+
let result;
|
|
29
|
+
try {
|
|
30
|
+
result = await fetchJson(url, {
|
|
31
|
+
method: 'POST', headers, body: JSON.stringify(body), timeout, retries: 0, expectedStatus: 200,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
36
|
+
throw new Error('올리브영 API 요청 시간 초과');
|
|
37
|
+
}
|
|
38
|
+
if (relayUrl)
|
|
39
|
+
throw new Error('올리브영 브라우저 릴레이 요청 실패');
|
|
40
|
+
throw new Error('올리브영 직접 요청 실패. 운영자는 OY_RELAY_URL과 OY_RELAY_TOKEN으로 브라우저 릴레이를 설정해주세요.', { cause: error });
|
|
41
|
+
}
|
|
42
|
+
if (result?.status !== 'SUCCESS') {
|
|
43
|
+
throw new Error(`올리브영 API 상태 오류: ${result?.status || 'UNKNOWN'}`);
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* 세븐일레븐 API 클라이언트
|
|
3
3
|
*/
|
|
4
4
|
/* c8 ignore start */
|
|
5
|
+
import { withSevenElevenReadCache } from './readCache.js';
|
|
5
6
|
import { fetchJsonWithZyteFallback } from '../../utils/zyteJsonFallback.js';
|
|
6
7
|
import { SEVENELEVEN_API } from './api.js';
|
|
7
8
|
const SEVENELEVEN_DEFAULT_HEADERS = {
|
|
@@ -111,7 +112,7 @@ function normalizeStores(items) {
|
|
|
111
112
|
async function requestSevenElevenJson(path, method, body, options = {}) {
|
|
112
113
|
const { timeout = 15000, zyteApiKey } = options;
|
|
113
114
|
const url = `${SEVENELEVEN_API.BASE_URL}${path}`;
|
|
114
|
-
return fetchJsonWithZyteFallback(url, {
|
|
115
|
+
return withSevenElevenReadCache(path, body, timeout, () => fetchJsonWithZyteFallback(url, {
|
|
115
116
|
...SEVENELEVEN_DEFAULT_FETCH_OPTIONS,
|
|
116
117
|
method,
|
|
117
118
|
retryUnsafeMethods: method === 'POST',
|
|
@@ -120,7 +121,7 @@ async function requestSevenElevenJson(path, method, body, options = {}) {
|
|
|
120
121
|
body: method === 'POST' ? JSON.stringify(body || {}) : undefined,
|
|
121
122
|
zyteApiKey,
|
|
122
123
|
zyteTags: { service: 'seveneleven' },
|
|
123
|
-
});
|
|
124
|
+
}));
|
|
124
125
|
}
|
|
125
126
|
export async function searchSevenElevenProducts(params, options = {}) {
|
|
126
127
|
const { query, page = 1, size = 20 } = params;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** 공개 상품·매장 조회를 REST와 MCP에서 함께 재사용합니다. 재고는 저장하지 않습니다. */
|
|
2
|
+
import { SEVENELEVEN_API } from './api.js';
|
|
3
|
+
const MAX_ENTRIES = 128;
|
|
4
|
+
const TTL_BY_PATH = new Map([
|
|
5
|
+
[SEVENELEVEN_API.SEARCH_GOODS_PATH, 5 * 60_000],
|
|
6
|
+
[SEVENELEVEN_API.SEARCH_STORE_PATH, 30 * 60_000],
|
|
7
|
+
[SEVENELEVEN_API.SEARCH_POPWORD_PATH, 5 * 60_000],
|
|
8
|
+
[SEVENELEVEN_API.PRODUCT_PAGES_PATH, 5 * 60_000],
|
|
9
|
+
[SEVENELEVEN_API.PRODUCT_ISSUES_PATH, 5 * 60_000],
|
|
10
|
+
[SEVENELEVEN_API.EXHIBITION_MAIN_PATH, 5 * 60_000],
|
|
11
|
+
]);
|
|
12
|
+
const values = new Map();
|
|
13
|
+
const pending = new Map();
|
|
14
|
+
export function clearSevenElevenReadCache() {
|
|
15
|
+
values.clear();
|
|
16
|
+
pending.clear();
|
|
17
|
+
}
|
|
18
|
+
export async function withSevenElevenReadCache(path, body, timeout, request) {
|
|
19
|
+
const ttl = TTL_BY_PATH.get(path.split('?')[0]);
|
|
20
|
+
if (!ttl)
|
|
21
|
+
return request();
|
|
22
|
+
const key = JSON.stringify([path, body, timeout]);
|
|
23
|
+
const cached = values.get(key);
|
|
24
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
25
|
+
return structuredClone(cached.value);
|
|
26
|
+
}
|
|
27
|
+
values.delete(key);
|
|
28
|
+
const existing = pending.get(key);
|
|
29
|
+
if (existing)
|
|
30
|
+
return structuredClone(await existing);
|
|
31
|
+
// 부하가 높아져도 진행 중인 조회 키를 무제한으로 보관하지 않습니다.
|
|
32
|
+
if (pending.size >= MAX_ENTRIES)
|
|
33
|
+
return request();
|
|
34
|
+
const promise = request().then((value) => {
|
|
35
|
+
if (value.success === true && value.data !== undefined) {
|
|
36
|
+
if (values.size >= MAX_ENTRIES)
|
|
37
|
+
values.delete(values.keys().next().value);
|
|
38
|
+
values.set(key, { expiresAt: Date.now() + ttl, value: structuredClone(value) });
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
});
|
|
42
|
+
pending.set(key, promise);
|
|
43
|
+
try {
|
|
44
|
+
return structuredClone(await promise);
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
pending.delete(key);
|
|
48
|
+
}
|
|
49
|
+
}
|
package/dist/utils/http.js
CHANGED
|
@@ -66,7 +66,7 @@ function parseRetryAfterDelayMs(response) {
|
|
|
66
66
|
return null;
|
|
67
67
|
}
|
|
68
68
|
async function requestWithTimeout(url, options, readResponse) {
|
|
69
|
-
const { timeout = 10000, retries = 0, retryDelayMs = 250, retryStatusCodes = DEFAULT_RETRY_STATUS_CODES, retryUnsafeMethods = false, retryMethods = DEFAULT_RETRY_METHODS, onRetry, ...restOptions } = options;
|
|
69
|
+
const { timeout = 10000, retries = 0, retryDelayMs = 250, retryStatusCodes = DEFAULT_RETRY_STATUS_CODES, retryUnsafeMethods = false, retryMethods = DEFAULT_RETRY_METHODS, onRetry, expectedStatus: _expectedStatus, ...restOptions } = options;
|
|
70
70
|
const maxAttempts = Math.max(1, Math.trunc(retries) + 1);
|
|
71
71
|
const method = normalizeMethod(restOptions.method);
|
|
72
72
|
const retryAllowed = canRetryMethod(method, retryMethods, retryUnsafeMethods);
|
|
@@ -132,7 +132,7 @@ async function readTextResponse(url, options) {
|
|
|
132
132
|
}
|
|
133
133
|
export async function fetchJson(url, options = {}) {
|
|
134
134
|
const { response, body } = await readTextResponse(url, options);
|
|
135
|
-
if (!response.ok) {
|
|
135
|
+
if (!response.ok || (options.expectedStatus !== undefined && response.status !== options.expectedStatus)) {
|
|
136
136
|
throw new HttpError(response.status, response.statusText, body);
|
|
137
137
|
}
|
|
138
138
|
if (/^\s*(?:<!doctype\s+html\b|<html\b)/i.test(body)) {
|
package/dist/utils/zyte.js
CHANGED
|
@@ -1,29 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Zyte Extract API 공통 유틸리티
|
|
3
3
|
*/
|
|
4
|
-
import {
|
|
5
|
-
export function resolveZyteApiKey(apiKey) {
|
|
6
|
-
if (apiKey && apiKey.trim().length > 0) {
|
|
7
|
-
return apiKey;
|
|
8
|
-
}
|
|
9
|
-
/* c8 ignore start */
|
|
10
|
-
if (typeof process !== 'undefined' && process.env?.ZYTE_API_KEY) {
|
|
11
|
-
return process.env.ZYTE_API_KEY;
|
|
12
|
-
}
|
|
13
|
-
/* c8 ignore end */
|
|
14
|
-
throw new Error('ZYTE_API_KEY가 설정되지 않았습니다. .env 또는 Cloudflare Worker Secret을 확인해주세요.');
|
|
15
|
-
}
|
|
16
|
-
export function encodeBasicAuth(apiKey) {
|
|
17
|
-
if (typeof btoa === 'function') {
|
|
18
|
-
return btoa(`${apiKey}:`);
|
|
19
|
-
}
|
|
20
|
-
/* c8 ignore start */
|
|
21
|
-
if (typeof Buffer !== 'undefined') {
|
|
22
|
-
return Buffer.from(`${apiKey}:`).toString('base64');
|
|
23
|
-
}
|
|
24
|
-
/* c8 ignore end */
|
|
25
|
-
throw new Error('Basic 인증 인코딩을 지원하지 않는 런타임입니다.');
|
|
26
|
-
}
|
|
4
|
+
import { ZYTE_COST_POLICY_MESSAGE } from '../core/errors.js';
|
|
27
5
|
export function decodeBase64(value) {
|
|
28
6
|
if (typeof atob === 'function') {
|
|
29
7
|
const binary = atob(value);
|
|
@@ -37,95 +15,9 @@ export function decodeBase64(value) {
|
|
|
37
15
|
/* c8 ignore end */
|
|
38
16
|
throw new Error('Base64 디코딩을 지원하지 않는 런타임입니다.');
|
|
39
17
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
class RetryableZyteError extends Error {
|
|
44
|
-
}
|
|
45
|
-
function isRetryableZyteStatus(status) {
|
|
46
|
-
return status === 408 || status === 429 || status >= 500;
|
|
47
|
-
}
|
|
48
|
-
function isRetryableTargetStatus(statusCode) {
|
|
49
|
-
return typeof statusCode === 'number' && statusCode >= 500;
|
|
50
|
-
}
|
|
51
|
-
function isRetryableError(error) {
|
|
52
|
-
return isAbortError(error) || error instanceof RetryableZyteError;
|
|
53
|
-
}
|
|
54
|
-
function parseZyteResponseBody(text) {
|
|
55
|
-
try {
|
|
56
|
-
return JSON.parse(text);
|
|
57
|
-
}
|
|
58
|
-
catch {
|
|
59
|
-
return {
|
|
60
|
-
detail: text.trim().replace(/\s+/g, ' ').slice(0, 300),
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
function wait(ms) {
|
|
65
|
-
if (ms <= 0) {
|
|
66
|
-
return Promise.resolve();
|
|
67
|
-
}
|
|
68
|
-
return new Promise((resolve) => {
|
|
69
|
-
setTimeout(resolve, ms);
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
export async function requestByZyte(options) {
|
|
73
|
-
const { timeout = 15000, retries = 1, retryDelayMs = 250, url, method = 'GET', headers = [], bodyText, apiKey, tags, } = options;
|
|
74
|
-
const auth = encodeBasicAuth(resolveZyteApiKey(apiKey));
|
|
75
|
-
const maxAttempts = Math.max(1, Math.trunc(retries) + 1);
|
|
76
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
77
|
-
const { controller, timeoutId } = createTimeoutController(timeout);
|
|
78
|
-
try {
|
|
79
|
-
let response;
|
|
80
|
-
try {
|
|
81
|
-
response = await fetch('https://api.zyte.com/v1/extract', {
|
|
82
|
-
method: 'POST',
|
|
83
|
-
headers: {
|
|
84
|
-
Authorization: `Basic ${auth}`,
|
|
85
|
-
'Content-Type': 'application/json',
|
|
86
|
-
Accept: 'application/json',
|
|
87
|
-
},
|
|
88
|
-
body: JSON.stringify({
|
|
89
|
-
url,
|
|
90
|
-
httpRequestMethod: method,
|
|
91
|
-
customHttpRequestHeaders: headers,
|
|
92
|
-
httpRequestText: bodyText,
|
|
93
|
-
httpResponseBody: true,
|
|
94
|
-
...(tags ? { tags } : {}),
|
|
95
|
-
}),
|
|
96
|
-
signal: controller.signal,
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
catch (error) {
|
|
100
|
-
if (error instanceof TypeError) {
|
|
101
|
-
throw new RetryableZyteError(error.message);
|
|
102
|
-
}
|
|
103
|
-
throw error;
|
|
104
|
-
}
|
|
105
|
-
const result = parseZyteResponseBody(await response.text());
|
|
106
|
-
if (!response.ok) {
|
|
107
|
-
const message = `Zyte API 호출 실패: ${response.status} ${result.detail || result.title || ''}`.trim();
|
|
108
|
-
if (isRetryableZyteStatus(response.status)) {
|
|
109
|
-
throw new RetryableZyteError(message);
|
|
110
|
-
}
|
|
111
|
-
throw new Error(message);
|
|
112
|
-
}
|
|
113
|
-
if (isRetryableTargetStatus(result.statusCode) && attempt < maxAttempts) {
|
|
114
|
-
throw new RetryableZyteError(`Zyte target response failed: ${result.statusCode}`);
|
|
115
|
-
}
|
|
116
|
-
return result;
|
|
117
|
-
}
|
|
118
|
-
catch (error) {
|
|
119
|
-
if (!isRetryableError(error) || attempt >= maxAttempts) {
|
|
120
|
-
throw error;
|
|
121
|
-
}
|
|
122
|
-
await wait(retryDelayMs);
|
|
123
|
-
}
|
|
124
|
-
finally {
|
|
125
|
-
clearTimeout(timeoutId);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
throw new Error('Zyte API 호출 실패');
|
|
18
|
+
/** 키와 재시도 설정에 관계없이 유료 네트워크 경로를 차단합니다. */
|
|
19
|
+
export async function requestByZyte(_options) {
|
|
20
|
+
throw new Error(ZYTE_COST_POLICY_MESSAGE);
|
|
129
21
|
}
|
|
130
22
|
export function decodeZyteHttpBody(result) {
|
|
131
23
|
if (!result.httpResponseBody) {
|
|
@@ -1,37 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 직접 JSON 조회와 유료 대체 경로 중지 안내
|
|
3
3
|
*/
|
|
4
4
|
import { fetchJson, HttpError } from './http.js';
|
|
5
|
-
import {
|
|
5
|
+
import { requestByZyte } from './zyte.js';
|
|
6
6
|
const ZYTE_FALLBACK_STATUSES = new Set([400, 403, 429]);
|
|
7
|
-
const ZYTE_METHODS = new Set([
|
|
8
|
-
'GET',
|
|
9
|
-
'POST',
|
|
10
|
-
'PUT',
|
|
11
|
-
'PATCH',
|
|
12
|
-
'DELETE',
|
|
13
|
-
]);
|
|
14
|
-
function normalizeMethod(method) {
|
|
15
|
-
const normalized = (method || 'GET').toUpperCase();
|
|
16
|
-
if (!ZYTE_METHODS.has(normalized)) {
|
|
17
|
-
throw new Error(`Zyte에서 지원하지 않는 HTTP 메서드입니다: ${normalized}`);
|
|
18
|
-
}
|
|
19
|
-
return normalized;
|
|
20
|
-
}
|
|
21
|
-
function toZyteHeaders(headers) {
|
|
22
|
-
const normalized = new Headers(headers);
|
|
23
|
-
const result = [];
|
|
24
|
-
normalized.forEach((value, name) => {
|
|
25
|
-
result.push({ name, value });
|
|
26
|
-
});
|
|
27
|
-
return result;
|
|
28
|
-
}
|
|
29
|
-
function assertSuccessfulTarget(result) {
|
|
30
|
-
const status = result.statusCode;
|
|
31
|
-
if (typeof status !== 'number' || status < 200 || status >= 300) {
|
|
32
|
-
throw new Error(`Zyte 대상 응답 실패: ${status ?? '알 수 없음'}`);
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
7
|
function hasZyteApiKey(apiKey) {
|
|
36
8
|
if (apiKey?.trim()) {
|
|
37
9
|
return true;
|
|
@@ -52,16 +24,5 @@ export async function fetchJsonWithZyteFallback(url, options = {}) {
|
|
|
52
24
|
throw error;
|
|
53
25
|
}
|
|
54
26
|
}
|
|
55
|
-
|
|
56
|
-
apiKey: zyteApiKey,
|
|
57
|
-
url,
|
|
58
|
-
timeout: directOptions.timeout,
|
|
59
|
-
retries: 0,
|
|
60
|
-
method: normalizeMethod(directOptions.method),
|
|
61
|
-
headers: toZyteHeaders(directOptions.headers),
|
|
62
|
-
bodyText: typeof directOptions.body === 'string' ? directOptions.body : undefined,
|
|
63
|
-
tags: zyteTags,
|
|
64
|
-
});
|
|
65
|
-
assertSuccessfulTarget(result);
|
|
66
|
-
return decodeZyteHttpBody(result);
|
|
27
|
+
return requestByZyte({ apiKey: zyteApiKey, url, tags: zyteTags });
|
|
67
28
|
}
|
package/package.json
CHANGED