daiso 1.2.2 → 1.2.4
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 +3 -3
- 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/transport.js +2 -0
- package/dist/services/cu/client.js +2 -0
- package/dist/services/dtryx/api.js +5 -1
- 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 +15 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -85,11 +85,11 @@
|
|
|
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>
|
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 }],
|
|
@@ -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
|
},
|
|
@@ -12,6 +12,8 @@ const CU_DEFAULT_HEADERS = {
|
|
|
12
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',
|
|
13
13
|
};
|
|
14
14
|
const CU_WEB_DEFAULT_HEADERS = {
|
|
15
|
+
// Workers의 기본 요청에는 User-Agent가 없어 공식 웹이 400을 반환합니다.
|
|
16
|
+
'User-Agent': 'Mozilla/5.0',
|
|
15
17
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
|
16
18
|
'X-Requested-With': 'XMLHttpRequest',
|
|
17
19
|
Accept: 'text/html, */*; q=0.01',
|
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
* 공개 조회 엔드포인트이며 별도 인증 키가 필요하지 않습니다.
|
|
6
6
|
*/
|
|
7
7
|
export const DTRYX_API = {
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* 두 포트의 공개 조회 응답이 동일함을 확인해 표준 HTTPS 포트를 사용합니다.
|
|
10
|
+
* Worker의 간헐 연결 실패는 양쪽 포트에서 관측되어 별도로 추적합니다(#187).
|
|
11
|
+
*/
|
|
12
|
+
BASE_URL: 'https://api.dtryx.com',
|
|
9
13
|
THIRDPARTY_PATH: '/dtryx/cms/thirdparty/movie',
|
|
10
14
|
CHANNEL_CODE: 'homepage',
|
|
11
15
|
ENDPOINTS: {
|
|
@@ -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,5 +1,6 @@
|
|
|
1
1
|
/** 올리브영 무료 직접 요청 및 운영자가 설정한 브라우저 릴레이 전송. */
|
|
2
2
|
import { fetchJson } from '../../utils/http.js';
|
|
3
|
+
import { toOliveyoungRelayError } from './errors.js';
|
|
3
4
|
import { OLIVEYOUNG_API } from './api.js';
|
|
4
5
|
/** 설정 진단과 실제 요청에 같은 릴레이 URL 규칙을 적용합니다. */
|
|
5
6
|
export function isValidOliveyoungRelayUrl(value) {
|
|
@@ -44,11 +45,20 @@ export async function requestOliveyoung(path, body, options = {}) {
|
|
|
44
45
|
headers['CF-Access-Client-Secret'] = accessClientSecret;
|
|
45
46
|
}
|
|
46
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
|
+
}
|
|
47
56
|
let result;
|
|
48
57
|
try {
|
|
49
58
|
result = await fetchJson(url, {
|
|
50
59
|
method: 'POST',
|
|
51
|
-
|
|
60
|
+
// Workers는 error 모드를 지원하지 않으므로 따라가지 않고 아래 200 검사로 거절합니다.
|
|
61
|
+
redirect: 'manual',
|
|
52
62
|
headers,
|
|
53
63
|
body: JSON.stringify(body),
|
|
54
64
|
timeout,
|
|
@@ -57,14 +67,16 @@ export async function requestOliveyoung(path, body, options = {}) {
|
|
|
57
67
|
});
|
|
58
68
|
}
|
|
59
69
|
catch (error) {
|
|
70
|
+
if (relayUrl)
|
|
71
|
+
throw toOliveyoungRelayError(error);
|
|
60
72
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
61
73
|
throw new Error('올리브영 API 요청 시간 초과');
|
|
62
74
|
}
|
|
63
|
-
if (relayUrl)
|
|
64
|
-
throw new Error('올리브영 브라우저 릴레이 요청 실패');
|
|
65
75
|
throw new Error('올리브영 직접 요청 실패. 운영자는 OY_RELAY_URL과 OY_RELAY_TOKEN으로 브라우저 릴레이를 설정해주세요.', { cause: error });
|
|
66
76
|
}
|
|
67
77
|
if (result?.status !== 'SUCCESS') {
|
|
78
|
+
if (relayUrl)
|
|
79
|
+
throw toOliveyoungRelayError(new SyntaxError());
|
|
68
80
|
throw new Error(`올리브영 API 상태 오류: ${result?.status || 'UNKNOWN'}`);
|
|
69
81
|
}
|
|
70
82
|
return result;
|
package/package.json
CHANGED