daiso 1.2.1 → 1.2.3
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 +7 -5
- package/dist/api/configStatus.js +20 -0
- package/dist/api/handlers.js +8 -1
- package/dist/api/response.js +9 -1
- package/dist/core/errors.js +27 -0
- package/dist/core/registry.js +7 -2
- package/dist/services/cgv/errors.js +5 -2
- package/dist/services/cgv/transport.js +3 -1
- package/dist/services/cu/client.js +2 -4
- package/dist/services/gs25/productSearch.js +9 -19
- package/dist/services/oliveyoung/client.js +44 -39
- package/dist/services/oliveyoung/errors.js +15 -0
- package/dist/services/oliveyoung/tools/checkInventory.js +1 -1
- package/dist/services/oliveyoung/transport.js +34 -16
- package/dist/utils/zyteJsonFallback.js +4 -24
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -85,17 +85,15 @@
|
|
|
85
85
|
**[실시간 서비스 상태 보기](https://aka-page.betteruptime.com/)**
|
|
86
86
|
|
|
87
87
|
<!-- WORKERS_INVOCATIONS_CHART:START -->
|
|
88
|
-
<h3>Cloudflare 요청 수 (2026-08-
|
|
88
|
+
<h3>Cloudflare 요청 수 (2026-08-29 ~ 2026-09-27, 30일)</h3>
|
|
89
89
|
|
|
90
|
-
<img src="./assets/analytics/workers-invocations.png?v=2026-09-
|
|
90
|
+
<img src="./assets/analytics/workers-invocations.png?v=2026-09-27T19:00:02.076Z" alt="Cloudflare 요청 수 그래프 (2026-08-29 ~ 2026-09-27)" width="100%">
|
|
91
91
|
|
|
92
|
-
<sub>기준 워커: <code>daiso-mcp</code> · 마지막 갱신: 2026-09-
|
|
92
|
+
<sub>기준 워커: <code>daiso-mcp</code> · 마지막 갱신: 2026-09-28 04:00 KST</sub>
|
|
93
93
|
<br><sub>집계: Worker 실행 + 루트 GET 리디렉션 요청 · 사용자 수와 다릅니다.</sub>
|
|
94
94
|
|
|
95
95
|
</div>
|
|
96
96
|
|
|
97
|
-
> Zyte 유료 호출을 중지했습니다. 무료 원본 조회를 우선하며 일부 서비스는 별도 실행 환경이 필요합니다. [비용 정책과 서비스별 상태](docs/zyte-free-access.md)를 확인하세요.
|
|
98
|
-
|
|
99
97
|
> [!IMPORTANT]
|
|
100
98
|
> 최근 공개 서버 사용량이 크게 증가하여 2026년 7월 18일부터 올리브영·CGV·CU·GS25·롯데마트의 검색을 포함한 공개 GET API에 IP당 하루 합산 3,000회(KST 기준)의 호출 제한을 적용합니다. 한도를 초과하는 사용이 필요하다면 Daiso MCP는 오픈 소스이므로 이 저장소를 직접 배포해 이용해 주세요.
|
|
101
99
|
|
|
@@ -462,6 +460,10 @@ curl -H "Authorization: Bearer $HEALTH_CHECK_SECRET" \
|
|
|
462
460
|
- `timeoutMs=20000`: 체크별 요청 제한 시간
|
|
463
461
|
- `slowThresholdMs=9000`: 지정 시간보다 느린 성공 응답을 degraded로 표시
|
|
464
462
|
|
|
463
|
+
Health Checks 알림은 모든 실패·저하 서비스 ID를 상세 오류보다 먼저 표시합니다. 푸시 알림은 3,500자로 제한되며 전체 오류와 샘플은 해당 실행의 GitHub Actions 요약에 남습니다. 올리브영 릴레이의 설정 완전성은 `config.oliveyoungRelay`로 확인할 수 있습니다. 이 값은 연결 성공을 뜻하지 않으며, [설정 진단 문서](./docs/oliveyoung-free-relay.md#설정-진단)를 참고하세요.
|
|
464
|
+
|
|
465
|
+
Zyte 유료 요청은 키 설정 여부와 관계없이 비활성화되어 있습니다. 직접 JSON 요청이 실패하면 원래 HTTP 상태와 오류를 유지합니다.
|
|
466
|
+
|
|
465
467
|
상태 기준:
|
|
466
468
|
|
|
467
469
|
- `ok`: 필수 응답 구조와 최소 결과가 정상입니다.
|
package/dist/api/configStatus.js
CHANGED
|
@@ -1,8 +1,28 @@
|
|
|
1
|
+
import { isValidOliveyoungRelayUrl } from '../services/oliveyoung/transport.js';
|
|
1
2
|
function isConfigured(value) {
|
|
2
3
|
return typeof value === 'string' && value.trim().length > 0;
|
|
3
4
|
}
|
|
4
5
|
export function buildConfigStatus(bindings) {
|
|
6
|
+
const urlValid = isValidOliveyoungRelayUrl(bindings?.OY_RELAY_URL);
|
|
7
|
+
const tokenConfigured = isConfigured(bindings?.OY_RELAY_TOKEN);
|
|
8
|
+
const accessClientIdConfigured = isConfigured(bindings?.OY_ACCESS_CLIENT_ID);
|
|
9
|
+
const accessClientSecretConfigured = isConfigured(bindings?.OY_ACCESS_CLIENT_SECRET);
|
|
10
|
+
const accessConfigured = accessClientIdConfigured && accessClientSecretConfigured;
|
|
11
|
+
const accessPairValid = (bindings?.OY_ACCESS_CLIENT_ID === undefined &&
|
|
12
|
+
bindings?.OY_ACCESS_CLIENT_SECRET === undefined) ||
|
|
13
|
+
accessConfigured;
|
|
5
14
|
return {
|
|
15
|
+
oliveyoungRelay: {
|
|
16
|
+
configured: urlValid && tokenConfigured && accessPairValid,
|
|
17
|
+
urlConfigured: isConfigured(bindings?.OY_RELAY_URL),
|
|
18
|
+
urlValid,
|
|
19
|
+
tokenConfigured,
|
|
20
|
+
accessClientIdConfigured,
|
|
21
|
+
accessClientSecretConfigured,
|
|
22
|
+
accessConfigured,
|
|
23
|
+
accessPairValid,
|
|
24
|
+
usedBy: ['oliveyoung'],
|
|
25
|
+
},
|
|
6
26
|
googleMapsApiKey: {
|
|
7
27
|
configured: isConfigured(bindings?.GOOGLE_MAPS_API_KEY),
|
|
8
28
|
usedBy: ['gs25', 'cu', 'lottemart', 'megabox', 'lottecinema', 'cgv'],
|
package/dist/api/handlers.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { enrichOliveyoungProductsWithNearbyStoreInventory, fetchOliveyoungProducts, fetchOliveyoungStores, } from '../services/oliveyoung/client.js';
|
|
8
8
|
import { fetchCuStock, fetchCuStores, geocodeCuAddress } from '../services/cu/client.js';
|
|
9
|
-
import {
|
|
9
|
+
import { ServiceError } from '../core/errors.js';
|
|
10
|
+
import { errorResponse, serviceErrorResponse, successResponse } from './response.js';
|
|
10
11
|
export { handleCheckInventory, handleFindStores, handleGetDisplayLocation, handleGetProduct, handleSearchProducts, } from './daisoHandlers.js';
|
|
11
12
|
function parseBooleanQuery(value, defaultValue = false) {
|
|
12
13
|
if (!value) {
|
|
@@ -59,6 +60,8 @@ export async function handleOliveyoungSearchProducts(c) {
|
|
|
59
60
|
}, { total: result.totalCount, page, pageSize: size });
|
|
60
61
|
}
|
|
61
62
|
catch (error) {
|
|
63
|
+
if (error instanceof ServiceError)
|
|
64
|
+
return serviceErrorResponse(c, error, 'product_search');
|
|
62
65
|
const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
|
|
63
66
|
return errorResponse(c, 'OLIVEYOUNG_PRODUCT_SEARCH_FAILED', message, 500);
|
|
64
67
|
}
|
|
@@ -93,6 +96,8 @@ export async function handleOliveyoungFindStores(c) {
|
|
|
93
96
|
}, { total: result.totalCount, page: pageIdx, pageSize: limit });
|
|
94
97
|
}
|
|
95
98
|
catch (error) {
|
|
99
|
+
if (error instanceof ServiceError)
|
|
100
|
+
return serviceErrorResponse(c, error, 'store_search');
|
|
96
101
|
const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
|
|
97
102
|
return errorResponse(c, 'OLIVEYOUNG_STORE_SEARCH_FAILED', message, 500);
|
|
98
103
|
}
|
|
@@ -178,6 +183,8 @@ export async function handleOliveyoungCheckInventory(c) {
|
|
|
178
183
|
}, { total: productResult.totalCount, page, pageSize: size });
|
|
179
184
|
}
|
|
180
185
|
catch (error) {
|
|
186
|
+
if (error instanceof ServiceError)
|
|
187
|
+
return serviceErrorResponse(c, error, 'inventory_check');
|
|
181
188
|
const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
|
|
182
189
|
return errorResponse(c, 'OLIVEYOUNG_INVENTORY_CHECK_FAILED', message, 500);
|
|
183
190
|
}
|
package/dist/api/response.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* API 공통 응답 유틸리티
|
|
3
3
|
*/
|
|
4
|
-
import { toStandardErrorDiagnostics } from '../core/errors.js';
|
|
4
|
+
import { toServiceErrorDiagnostics, toStandardErrorDiagnostics } from '../core/errors.js';
|
|
5
5
|
/**
|
|
6
6
|
* 성공 응답 생성
|
|
7
7
|
*/
|
|
@@ -22,3 +22,11 @@ export function errorResponse(c, code, message, status = 400) {
|
|
|
22
22
|
diagnostics: toStandardErrorDiagnostics(code, message, { status }),
|
|
23
23
|
}, status);
|
|
24
24
|
}
|
|
25
|
+
/** 원문이 제거된 외부 서비스 오류를 실제 상태와 함께 반환합니다. */
|
|
26
|
+
export function serviceErrorResponse(c, error, operation) {
|
|
27
|
+
return c.json({
|
|
28
|
+
success: false,
|
|
29
|
+
error: { code: error.code, message: error.message },
|
|
30
|
+
diagnostics: toServiceErrorDiagnostics(error, operation),
|
|
31
|
+
}, error.status);
|
|
32
|
+
}
|
package/dist/core/errors.js
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
* MCP/API 공통 에러 진단 구조
|
|
3
3
|
*/
|
|
4
4
|
export const ZYTE_COST_POLICY_MESSAGE = 'Zyte 유료 호출은 비용 정책에 따라 비활성화되어 있습니다.';
|
|
5
|
+
/** 외부 응답 원문 없이 API와 MCP에 전달할 오류 진단입니다. */
|
|
6
|
+
export class ServiceError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
status;
|
|
9
|
+
retryable;
|
|
10
|
+
upstreamStatus;
|
|
11
|
+
constructor(code, message, status, retryable, upstreamStatus) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.retryable = retryable;
|
|
16
|
+
this.upstreamStatus = upstreamStatus;
|
|
17
|
+
this.name = 'ServiceError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
5
20
|
const SERVICE_PREFIXES = [
|
|
6
21
|
'daiso',
|
|
7
22
|
'gs25',
|
|
@@ -92,3 +107,15 @@ export function toStandardErrorDiagnostics(code, message, options = {}) {
|
|
|
92
107
|
export function getErrorMessage(error) {
|
|
93
108
|
return error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
|
|
94
109
|
}
|
|
110
|
+
/** 전송 계층에서 확인한 재시도 가능 여부를 공통 진단에도 유지합니다. */
|
|
111
|
+
export function toServiceErrorDiagnostics(error, operation) {
|
|
112
|
+
return {
|
|
113
|
+
...toStandardErrorDiagnostics(error.code, error.message, {
|
|
114
|
+
status: error.status,
|
|
115
|
+
upstreamStatus: error.upstreamStatus,
|
|
116
|
+
operation,
|
|
117
|
+
}),
|
|
118
|
+
retryable: error.retryable,
|
|
119
|
+
hint: buildHint(error.retryable),
|
|
120
|
+
};
|
|
121
|
+
}
|
package/dist/core/registry.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 서비스 프로바이더를 등록하고 MCP 서버에 도구를 연결합니다.
|
|
5
5
|
*/
|
|
6
|
-
import { getErrorMessage } from './errors.js';
|
|
6
|
+
import { getErrorMessage, ServiceError, toServiceErrorDiagnostics } from './errors.js';
|
|
7
7
|
import { createToolOutputSchema } from './outputSchema.js';
|
|
8
8
|
function parseJsonText(text) {
|
|
9
9
|
try {
|
|
@@ -177,7 +177,12 @@ export class ServiceRegistry {
|
|
|
177
177
|
result = await tool.handler(args);
|
|
178
178
|
}
|
|
179
179
|
catch (error) {
|
|
180
|
-
const message =
|
|
180
|
+
const message = error instanceof ServiceError
|
|
181
|
+
? JSON.stringify({
|
|
182
|
+
error: { code: error.code, message: error.message },
|
|
183
|
+
diagnostics: toServiceErrorDiagnostics(error, tool.name.replace(/^[^_]+_/, '')),
|
|
184
|
+
})
|
|
185
|
+
: getErrorMessage(error);
|
|
181
186
|
return {
|
|
182
187
|
isError: true,
|
|
183
188
|
content: [{ type: 'text', text: message }],
|
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { ZYTE_COST_POLICY_MESSAGE } from '../../core/errors.js';
|
|
5
5
|
export class CgvUpstreamUnavailableError extends Error {
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
upstreamStatus;
|
|
7
|
+
constructor(upstreamStatus) {
|
|
8
|
+
const statusSuffix = upstreamStatus === undefined ? '' : ` (HTTP ${upstreamStatus})`;
|
|
9
|
+
super(`CGV 원본 서비스에 연결할 수 없습니다. ${ZYTE_COST_POLICY_MESSAGE}${statusSuffix}`);
|
|
10
|
+
this.upstreamStatus = upstreamStatus;
|
|
8
11
|
this.name = 'CgvUpstreamUnavailableError';
|
|
9
12
|
}
|
|
10
13
|
}
|
|
@@ -46,6 +46,8 @@ export async function requestCgv(path, searchParams, timeout = 15000, _zyteApiKe
|
|
|
46
46
|
headers: {
|
|
47
47
|
Accept: 'application/json',
|
|
48
48
|
'Accept-Language': 'ko-KR',
|
|
49
|
+
// 기본 fetch User-Agent는 CGV 앞단에서 403으로 차단된다.
|
|
50
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
|
|
49
51
|
'X-TIMESTAMP': timestamp,
|
|
50
52
|
'X-SIGNATURE': signature,
|
|
51
53
|
},
|
|
@@ -55,7 +57,7 @@ export async function requestCgv(path, searchParams, timeout = 15000, _zyteApiKe
|
|
|
55
57
|
return await parseJsonResponse(response);
|
|
56
58
|
}
|
|
57
59
|
if (response.status === 401 || response.status === 403) {
|
|
58
|
-
throw new CgvUpstreamUnavailableError();
|
|
60
|
+
throw new CgvUpstreamUnavailableError(response.status);
|
|
59
61
|
}
|
|
60
62
|
throw new Error(`CGV API 호출 실패: ${response.status}`);
|
|
61
63
|
}
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
* CU API 클라이언트
|
|
3
3
|
*/
|
|
4
4
|
import { fetchJson } from '../../utils/http.js';
|
|
5
|
-
import { ZYTE_COST_POLICY_MESSAGE } from '../../core/errors.js';
|
|
6
5
|
import { fetchJsonWithZyteFallback } from '../../utils/zyteJsonFallback.js';
|
|
7
6
|
import { CU_API } from './api.js';
|
|
8
7
|
import { cuStockUnavailableReason } from './upstreamError.js';
|
|
@@ -13,6 +12,8 @@ const CU_DEFAULT_HEADERS = {
|
|
|
13
12
|
'User-Agent': 'Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.0.0 Mobile Safari/537.36;BGFCU',
|
|
14
13
|
};
|
|
15
14
|
const CU_WEB_DEFAULT_HEADERS = {
|
|
15
|
+
// Workers의 기본 요청에는 User-Agent가 없어 공식 웹이 400을 반환합니다.
|
|
16
|
+
'User-Agent': 'Mozilla/5.0',
|
|
16
17
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
|
17
18
|
'X-Requested-With': 'XMLHttpRequest',
|
|
18
19
|
Accept: 'text/html, */*; q=0.01',
|
|
@@ -70,9 +71,6 @@ async function requestCuWebHtml(path, body, _apiKey, timeout = 15000) {
|
|
|
70
71
|
if (response.ok) {
|
|
71
72
|
return response.text();
|
|
72
73
|
}
|
|
73
|
-
if (response.status === 400 || response.status === 403 || response.status === 429) {
|
|
74
|
-
throw new Error(ZYTE_COST_POLICY_MESSAGE);
|
|
75
|
-
}
|
|
76
74
|
throw new Error(`API 요청 실패: ${response.status} ${response.statusText}`);
|
|
77
75
|
}
|
|
78
76
|
export async function geocodeCuAddress(address, options = {}) {
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
* GS25 상품 검색 API
|
|
3
3
|
*/
|
|
4
4
|
/* c8 ignore start */
|
|
5
|
-
import { fetchJson
|
|
6
|
-
import { requestByZyte } from '../../utils/zyte.js';
|
|
5
|
+
import { fetchJson } from '../../utils/http.js';
|
|
7
6
|
import { GS25_API } from './api.js';
|
|
8
7
|
import { toNumber } from './storeUtils.js';
|
|
9
8
|
const GS25_TOTAL_SEARCH_HEADERS = {
|
|
@@ -22,23 +21,14 @@ async function fetchGs25TotalSearchResponse(query, options) {
|
|
|
22
21
|
const { timeout = 20000 } = options;
|
|
23
22
|
const endpoint = new URL(GS25_API.TOTAL_SEARCH_PATH, GS25_API.APIGW_BASE_URL);
|
|
24
23
|
const bodyText = JSON.stringify({ query });
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
catch (error) {
|
|
36
|
-
const zyteApiKey = options.zyteApiKey?.trim();
|
|
37
|
-
if (!(error instanceof HttpError) || error.status !== 403 || !zyteApiKey) {
|
|
38
|
-
throw error;
|
|
39
|
-
}
|
|
40
|
-
return requestByZyte({ apiKey: zyteApiKey, url: endpoint.toString() });
|
|
41
|
-
}
|
|
24
|
+
return fetchJson(endpoint.toString(), {
|
|
25
|
+
...GS25_DEFAULT_FETCH_OPTIONS,
|
|
26
|
+
method: 'POST',
|
|
27
|
+
retryUnsafeMethods: true,
|
|
28
|
+
timeout,
|
|
29
|
+
headers: GS25_TOTAL_SEARCH_HEADERS,
|
|
30
|
+
body: bodyText,
|
|
31
|
+
});
|
|
42
32
|
}
|
|
43
33
|
export async function fetchGs25NormalizedKeyword(keyword, options = {}) {
|
|
44
34
|
const query = keyword.trim();
|
|
@@ -205,48 +205,53 @@ function sortOliveyoungProducts(products) {
|
|
|
205
205
|
return [...products].sort((left, right) => score(right) - score(left));
|
|
206
206
|
}
|
|
207
207
|
export async function enrichOliveyoungProductsWithNearbyStoreInventory(products, params, options = {}) {
|
|
208
|
-
const
|
|
208
|
+
const requestedLimit = Number.isFinite(params.maxProducts) ? Math.floor(params.maxProducts) : 5;
|
|
209
|
+
const maxProducts = Math.max(0, Math.min(products.length, requestedLimit, 5));
|
|
209
210
|
if (maxProducts === 0) {
|
|
210
211
|
return { checkedCount: 0, products };
|
|
211
212
|
}
|
|
212
|
-
const checkedProducts =
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
213
|
+
const checkedProducts = [];
|
|
214
|
+
// 릴레이 슬롯을 한 요청이 독점하지 않도록 상품 보강을 2개씩 실행합니다.
|
|
215
|
+
for (let start = 0; start < maxProducts; start += 2) {
|
|
216
|
+
checkedProducts.push(...await Promise.all(products.slice(start, Math.min(start + 2, maxProducts)).map(async (product) => {
|
|
217
|
+
let productId = '';
|
|
218
|
+
try {
|
|
219
|
+
productId = await fetchOliveyoungProductId(product.goodsNumber, options);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return { checked: false, product };
|
|
223
|
+
}
|
|
224
|
+
if (!productId) {
|
|
225
|
+
return { checked: false, product };
|
|
226
|
+
}
|
|
227
|
+
let storeInventory;
|
|
228
|
+
try {
|
|
229
|
+
storeInventory = await fetchOliveyoungStockStores({
|
|
230
|
+
productId,
|
|
231
|
+
latitude: params.latitude,
|
|
232
|
+
longitude: params.longitude,
|
|
233
|
+
pageIdx: 1,
|
|
234
|
+
searchWords: params.storeKeyword,
|
|
235
|
+
}, options);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return { checked: false, product };
|
|
239
|
+
}
|
|
240
|
+
const inStock = storeInventory.inStockCount > 0;
|
|
241
|
+
const stockStatus = inStock ? 'in_stock' : 'out_of_stock';
|
|
242
|
+
const stockSource = 'nearby_store';
|
|
243
|
+
return {
|
|
244
|
+
checked: true,
|
|
245
|
+
product: {
|
|
246
|
+
...product,
|
|
247
|
+
inStock,
|
|
248
|
+
stockStatus,
|
|
249
|
+
stockSource,
|
|
250
|
+
storeInventory,
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
})));
|
|
254
|
+
}
|
|
250
255
|
const enrichedProducts = [
|
|
251
256
|
...checkedProducts.map((result) => result.product),
|
|
252
257
|
...products.slice(maxProducts),
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** 릴레이 원문과 자격증명을 보존하지 않는 안전한 오류 분류. */
|
|
2
|
+
import { ServiceError } from '../../core/errors.js';
|
|
3
|
+
import { HttpError, UnexpectedHtmlResponseError } from '../../utils/http.js';
|
|
4
|
+
export function toOliveyoungRelayError(error) {
|
|
5
|
+
if (error instanceof HttpError) {
|
|
6
|
+
return new ServiceError('OLIVEYOUNG_RELAY_HTTP_ERROR', `올리브영 브라우저 릴레이 요청 실패 (HTTP ${error.status})`, [429, 502, 503].includes(error.status) ? error.status : 502, error.status >= 500 || error.status === 408 || error.status === 429, error.status);
|
|
7
|
+
}
|
|
8
|
+
if (error instanceof Error && ['AbortError', 'TimeoutError'].includes(error.name)) {
|
|
9
|
+
return new ServiceError('OLIVEYOUNG_RELAY_TIMEOUT', '올리브영 브라우저 릴레이 요청 시간 초과', 504, true);
|
|
10
|
+
}
|
|
11
|
+
if (error instanceof SyntaxError || error instanceof UnexpectedHtmlResponseError) {
|
|
12
|
+
return new ServiceError('OLIVEYOUNG_RELAY_INVALID_RESPONSE', '올리브영 브라우저 릴레이 응답 형식 오류', 502, false);
|
|
13
|
+
}
|
|
14
|
+
return new ServiceError('OLIVEYOUNG_RELAY_NETWORK_ERROR', '올리브영 브라우저 릴레이 네트워크 요청 실패', 502, true);
|
|
15
|
+
}
|
|
@@ -97,7 +97,7 @@ export function createCheckInventoryTool(apiKey, transport = {}) {
|
|
|
97
97
|
.number()
|
|
98
98
|
.optional()
|
|
99
99
|
.default(5)
|
|
100
|
-
.describe('주변 매장별 재고를 보강할 상품 수 (기본값: 5, 낮출수록 빠름)'),
|
|
100
|
+
.describe('주변 매장별 재고를 보강할 상품 수 (기본값: 5, 최대: 5, 낮출수록 빠름)'),
|
|
101
101
|
timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'),
|
|
102
102
|
},
|
|
103
103
|
},
|
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
/** 올리브영 무료 직접 요청 및 운영자가 설정한 브라우저 릴레이 전송. */
|
|
2
2
|
import { fetchJson } from '../../utils/http.js';
|
|
3
|
+
import { toOliveyoungRelayError } from './errors.js';
|
|
3
4
|
import { OLIVEYOUNG_API } from './api.js';
|
|
5
|
+
/** 설정 진단과 실제 요청에 같은 릴레이 URL 규칙을 적용합니다. */
|
|
6
|
+
export function isValidOliveyoungRelayUrl(value) {
|
|
7
|
+
if (!value?.trim())
|
|
8
|
+
return false;
|
|
9
|
+
let relay;
|
|
10
|
+
try {
|
|
11
|
+
relay = new URL(value);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
return !(relay.username ||
|
|
17
|
+
relay.password ||
|
|
18
|
+
relay.search ||
|
|
19
|
+
relay.hash ||
|
|
20
|
+
(relay.protocol !== 'https:' &&
|
|
21
|
+
!(relay.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(relay.hostname))));
|
|
22
|
+
}
|
|
4
23
|
export async function requestOliveyoung(path, body, options = {}) {
|
|
5
24
|
const { timeout = 15000, relayUrl, relayToken } = options;
|
|
6
25
|
const headers = {
|
|
@@ -10,19 +29,7 @@ export async function requestOliveyoung(path, body, options = {}) {
|
|
|
10
29
|
};
|
|
11
30
|
let url = `${OLIVEYOUNG_API.BASE_URL}${path}`;
|
|
12
31
|
if (relayUrl) {
|
|
13
|
-
|
|
14
|
-
try {
|
|
15
|
-
relay = new URL(relayUrl);
|
|
16
|
-
}
|
|
17
|
-
catch {
|
|
18
|
-
throw new Error('올리브영 릴레이 URL은 HTTPS 또는 로컬 HTTP 주소여야 합니다.');
|
|
19
|
-
}
|
|
20
|
-
if (relay.username ||
|
|
21
|
-
relay.password ||
|
|
22
|
-
relay.search ||
|
|
23
|
-
relay.hash ||
|
|
24
|
-
(relay.protocol !== 'https:' &&
|
|
25
|
-
!(relay.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(relay.hostname)))) {
|
|
32
|
+
if (!isValidOliveyoungRelayUrl(relayUrl)) {
|
|
26
33
|
throw new Error('올리브영 릴레이 URL은 HTTPS 또는 로컬 HTTP 주소여야 합니다.');
|
|
27
34
|
}
|
|
28
35
|
if (!relayToken?.trim())
|
|
@@ -38,11 +45,20 @@ export async function requestOliveyoung(path, body, options = {}) {
|
|
|
38
45
|
headers['CF-Access-Client-Secret'] = accessClientSecret;
|
|
39
46
|
}
|
|
40
47
|
}
|
|
48
|
+
else {
|
|
49
|
+
// 공식 사이트 직접 조회용 헤더는 운영자 릴레이에 전달하지 않습니다.
|
|
50
|
+
headers['User-Agent'] =
|
|
51
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36';
|
|
52
|
+
headers.Origin = OLIVEYOUNG_API.BASE_URL;
|
|
53
|
+
headers.Referer = `${OLIVEYOUNG_API.BASE_URL}/`;
|
|
54
|
+
headers['Accept-Language'] = 'ko-KR,ko;q=0.9';
|
|
55
|
+
}
|
|
41
56
|
let result;
|
|
42
57
|
try {
|
|
43
58
|
result = await fetchJson(url, {
|
|
44
59
|
method: 'POST',
|
|
45
|
-
|
|
60
|
+
// Workers는 error 모드를 지원하지 않으므로 따라가지 않고 아래 200 검사로 거절합니다.
|
|
61
|
+
redirect: 'manual',
|
|
46
62
|
headers,
|
|
47
63
|
body: JSON.stringify(body),
|
|
48
64
|
timeout,
|
|
@@ -51,14 +67,16 @@ export async function requestOliveyoung(path, body, options = {}) {
|
|
|
51
67
|
});
|
|
52
68
|
}
|
|
53
69
|
catch (error) {
|
|
70
|
+
if (relayUrl)
|
|
71
|
+
throw toOliveyoungRelayError(error);
|
|
54
72
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
55
73
|
throw new Error('올리브영 API 요청 시간 초과');
|
|
56
74
|
}
|
|
57
|
-
if (relayUrl)
|
|
58
|
-
throw new Error('올리브영 브라우저 릴레이 요청 실패');
|
|
59
75
|
throw new Error('올리브영 직접 요청 실패. 운영자는 OY_RELAY_URL과 OY_RELAY_TOKEN으로 브라우저 릴레이를 설정해주세요.', { cause: error });
|
|
60
76
|
}
|
|
61
77
|
if (result?.status !== 'SUCCESS') {
|
|
78
|
+
if (relayUrl)
|
|
79
|
+
throw toOliveyoungRelayError(new SyntaxError());
|
|
62
80
|
throw new Error(`올리브영 API 상태 오류: ${result?.status || 'UNKNOWN'}`);
|
|
63
81
|
}
|
|
64
82
|
return result;
|
|
@@ -1,28 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 레거시 Zyte 옵션을 제거하고 무료 직접 JSON 조회만 수행합니다.
|
|
3
3
|
*/
|
|
4
|
-
import { fetchJson
|
|
5
|
-
import { requestByZyte } from './zyte.js';
|
|
6
|
-
const ZYTE_FALLBACK_STATUSES = new Set([400, 403, 429]);
|
|
7
|
-
function hasZyteApiKey(apiKey) {
|
|
8
|
-
if (apiKey?.trim()) {
|
|
9
|
-
return true;
|
|
10
|
-
}
|
|
11
|
-
/* c8 ignore next */
|
|
12
|
-
return typeof process !== 'undefined' && Boolean(process.env?.ZYTE_API_KEY?.trim());
|
|
13
|
-
}
|
|
4
|
+
import { fetchJson } from './http.js';
|
|
14
5
|
export async function fetchJsonWithZyteFallback(url, options = {}) {
|
|
15
|
-
const { zyteApiKey, zyteTags, ...directOptions } = options;
|
|
16
|
-
|
|
17
|
-
return await fetchJson(url, directOptions);
|
|
18
|
-
}
|
|
19
|
-
catch (error) {
|
|
20
|
-
if (!(error instanceof HttpError) || !ZYTE_FALLBACK_STATUSES.has(error.status)) {
|
|
21
|
-
throw error;
|
|
22
|
-
}
|
|
23
|
-
if (!hasZyteApiKey(zyteApiKey)) {
|
|
24
|
-
throw error;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
return requestByZyte({ apiKey: zyteApiKey, url, tags: zyteTags });
|
|
6
|
+
const { zyteApiKey: _zyteApiKey, zyteTags: _zyteTags, ...directOptions } = options;
|
|
7
|
+
return fetchJson(url, directOptions);
|
|
28
8
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "daiso",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.3",
|
|
4
4
|
"description": "Korean retail and cinema MCP server, CLI, and Codex Skill for Daiso, convenience stores, marts, Olive Young, and theaters",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"@cloudflare/workers-types": "^5.20260914.1",
|
|
85
85
|
"@eslint/js": "^10.0.1",
|
|
86
86
|
"@types/node": "^26.5.1",
|
|
87
|
-
"@vitest/coverage-v8": "^
|
|
87
|
+
"@vitest/coverage-v8": "^5.0.1",
|
|
88
88
|
"eslint": "^10.8.0",
|
|
89
89
|
"eslint-config-prettier": "^10.1.8",
|
|
90
90
|
"globals": "^17.12.0",
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
"tsx": "^4.23.1",
|
|
96
96
|
"typescript": "6.0.3",
|
|
97
97
|
"typescript-eslint": "^8.65.0",
|
|
98
|
-
"vitest": "^
|
|
98
|
+
"vitest": "^5.0.1",
|
|
99
99
|
"wrangler": "^4.131.2"
|
|
100
100
|
},
|
|
101
101
|
"overrides": {
|