daiso 1.0.8 → 1.0.10
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 +96 -11
- package/dist/api/actionsProxy.js +5 -0
- package/dist/api/configStatus.js +8 -0
- package/dist/api/feedbackHandlers.js +60 -0
- package/dist/api/healthCheckDefinitions.js +188 -0
- package/dist/api/healthCheckShape.js +106 -0
- package/dist/api/healthCheckTypes.js +4 -0
- package/dist/api/healthChecks.js +13 -272
- package/dist/api/opinetHandlers.js +91 -0
- package/dist/api/routes/feedbackRoutes.js +5 -0
- package/dist/api/routes/healthRoutes.js +17 -1
- package/dist/api/routes/opinetRoutes.js +24 -0
- package/dist/bin.js +0 -0
- package/dist/cli/commandOptions.js +23 -2
- package/dist/cli/commands/apiCommand.js +24 -0
- package/dist/cli/commands/cinema.js +65 -0
- package/dist/cli/commands/convenience.js +57 -100
- package/dist/cli/http.js +1 -1
- package/dist/cli.js +10 -1
- package/dist/cliHelp.js +44 -8
- package/dist/core/outputSchema.js +141 -0
- package/dist/core/registry.js +1 -138
- package/dist/index.js +92 -29
- package/dist/pages/openapiFullSpec.js +5 -0
- package/dist/pages/openapiSpecActions.js +63 -0
- package/dist/pages/openapiSpecPathsFeedback.js +95 -0
- package/dist/pages/openapiSpecPathsOpinet.js +81 -0
- package/dist/pages/promptReferenceText.js +15 -0
- package/dist/services/cgv/client.js +7 -4
- package/dist/services/emart24/tools/searchProducts.js +37 -19
- package/dist/services/feedback/client.js +109 -0
- package/dist/services/feedback/index.js +22 -0
- package/dist/services/feedback/tools/submitDeveloperRequest.js +54 -0
- package/dist/services/feedback/types.js +3 -0
- package/dist/services/gs25/client.js +1 -84
- package/dist/services/gs25/productSearch.js +97 -0
- package/dist/services/oliveyoung/client.js +1 -55
- package/dist/services/oliveyoung/normalize.js +58 -0
- package/dist/services/opinet/client.js +231 -0
- package/dist/services/opinet/index.js +26 -0
- package/dist/services/opinet/location.js +119 -0
- package/dist/services/opinet/tools.js +80 -0
- package/dist/services/opinet/types.js +1 -0
- package/dist/services/places/client.js +29 -8
- package/dist/services/seveneleven/tools/searchProducts.js +36 -17
- package/openapi.json +82 -0
- package/openapi.yaml +55 -0
- package/package.json +24 -20
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { DEVELOPER_REQUEST_SEVERITIES, DEVELOPER_REQUEST_SOURCES, DEVELOPER_REQUEST_TYPES, } from './types.js';
|
|
2
|
+
function isOneOf(value, allowed) {
|
|
3
|
+
return Boolean(value && allowed.includes(value));
|
|
4
|
+
}
|
|
5
|
+
function trimText(value, maxLength) {
|
|
6
|
+
if (typeof value !== 'string') {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
const trimmed = value.trim();
|
|
10
|
+
if (!trimmed) {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed;
|
|
14
|
+
}
|
|
15
|
+
function requireText(value, fieldName, maxLength) {
|
|
16
|
+
const text = trimText(value, maxLength);
|
|
17
|
+
if (!text) {
|
|
18
|
+
throw new Error(`${fieldName}를 입력해주세요.`);
|
|
19
|
+
}
|
|
20
|
+
return text;
|
|
21
|
+
}
|
|
22
|
+
function normalizeType(value) {
|
|
23
|
+
return isOneOf(value, DEVELOPER_REQUEST_TYPES) ? value : 'other';
|
|
24
|
+
}
|
|
25
|
+
function normalizeSeverity(value) {
|
|
26
|
+
return isOneOf(value, DEVELOPER_REQUEST_SEVERITIES) ? value : 'medium';
|
|
27
|
+
}
|
|
28
|
+
function normalizeSource(value) {
|
|
29
|
+
return isOneOf(value, DEVELOPER_REQUEST_SOURCES) ? value : undefined;
|
|
30
|
+
}
|
|
31
|
+
function createInsertRow(input) {
|
|
32
|
+
const row = {
|
|
33
|
+
type: normalizeType(trimText(input.type, 40)),
|
|
34
|
+
severity: normalizeSeverity(trimText(input.severity, 40)),
|
|
35
|
+
title: requireText(input.title, 'title', 160),
|
|
36
|
+
description: requireText(input.description, 'description', 4000),
|
|
37
|
+
status: 'open',
|
|
38
|
+
};
|
|
39
|
+
const service = trimText(input.service, 80);
|
|
40
|
+
const toolName = trimText(input.toolName, 120);
|
|
41
|
+
const reproduction = trimText(input.reproduction, 4000);
|
|
42
|
+
const expected = trimText(input.expected, 2000);
|
|
43
|
+
const actual = trimText(input.actual, 2000);
|
|
44
|
+
const source = normalizeSource(trimText(input.source, 40));
|
|
45
|
+
if (service)
|
|
46
|
+
row.service = service;
|
|
47
|
+
if (toolName)
|
|
48
|
+
row.tool_name = toolName;
|
|
49
|
+
if (reproduction)
|
|
50
|
+
row.reproduction = reproduction;
|
|
51
|
+
if (expected)
|
|
52
|
+
row.expected = expected;
|
|
53
|
+
if (actual)
|
|
54
|
+
row.actual = actual;
|
|
55
|
+
if (source)
|
|
56
|
+
row.source = source;
|
|
57
|
+
if (input.userContext && typeof input.userContext === 'object') {
|
|
58
|
+
row.user_context = input.userContext;
|
|
59
|
+
}
|
|
60
|
+
return row;
|
|
61
|
+
}
|
|
62
|
+
function requireConfig(config) {
|
|
63
|
+
const supabaseUrl = trimText(config.supabaseUrl, 400);
|
|
64
|
+
const key = trimText(config.supabaseServiceRoleKey, 4000);
|
|
65
|
+
if (!supabaseUrl || !key) {
|
|
66
|
+
throw new Error('SUPABASE_URL과 SUPABASE_SERVICE_ROLE_KEY가 필요합니다.');
|
|
67
|
+
}
|
|
68
|
+
return { supabaseUrl: supabaseUrl.replace(/\/+$/, ''), key };
|
|
69
|
+
}
|
|
70
|
+
function stringOrUndefined(value) {
|
|
71
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
72
|
+
}
|
|
73
|
+
function resultFromRow(row, fallback) {
|
|
74
|
+
return {
|
|
75
|
+
id: stringOrUndefined(row.id) || '',
|
|
76
|
+
type: normalizeType(stringOrUndefined(row.type) || fallback.type),
|
|
77
|
+
severity: normalizeSeverity(stringOrUndefined(row.severity) || fallback.severity),
|
|
78
|
+
title: stringOrUndefined(row.title) || fallback.title,
|
|
79
|
+
service: stringOrUndefined(row.service) || fallback.service,
|
|
80
|
+
toolName: stringOrUndefined(row.tool_name) || fallback.tool_name,
|
|
81
|
+
source: normalizeSource(stringOrUndefined(row.source)) || fallback.source,
|
|
82
|
+
status: stringOrUndefined(row.status) || fallback.status,
|
|
83
|
+
createdAt: stringOrUndefined(row.created_at),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export async function submitDeveloperRequest(input, config) {
|
|
87
|
+
const { supabaseUrl, key } = requireConfig(config);
|
|
88
|
+
const row = createInsertRow(input);
|
|
89
|
+
const response = await fetch(`${supabaseUrl}/rest/v1/agent_requests`, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: {
|
|
92
|
+
apikey: key,
|
|
93
|
+
Authorization: `Bearer ${key}`,
|
|
94
|
+
'Content-Type': 'application/json',
|
|
95
|
+
Prefer: 'return=representation',
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify(row),
|
|
98
|
+
});
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
const detail = await response.text().catch(() => '');
|
|
101
|
+
throw new Error(`Supabase 저장 실패: ${response.status}${detail ? ` ${detail}` : ''}`);
|
|
102
|
+
}
|
|
103
|
+
const payload = (await response.json());
|
|
104
|
+
const returnedRow = Array.isArray(payload) ? payload[0] : payload;
|
|
105
|
+
if (!returnedRow || typeof returnedRow !== 'object') {
|
|
106
|
+
throw new Error('Supabase 저장 결과가 비어 있습니다.');
|
|
107
|
+
}
|
|
108
|
+
return resultFromRow(returnedRow, row);
|
|
109
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createSubmitDeveloperRequestTool } from './tools/submitDeveloperRequest.js';
|
|
2
|
+
const FEEDBACK_METADATA = {
|
|
3
|
+
id: 'feedback',
|
|
4
|
+
name: '개발자 요청',
|
|
5
|
+
version: '1.0.0',
|
|
6
|
+
description: 'AI 에이전트가 MCP 오류, 개선 요청, 신규 기능 요청을 개발자에게 전달하는 서비스',
|
|
7
|
+
};
|
|
8
|
+
class FeedbackService {
|
|
9
|
+
config;
|
|
10
|
+
metadata = FEEDBACK_METADATA;
|
|
11
|
+
constructor(config = {}) {
|
|
12
|
+
this.config = config;
|
|
13
|
+
}
|
|
14
|
+
getTools() {
|
|
15
|
+
return [createSubmitDeveloperRequestTool(this.config)];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function createFeedbackService(config = {}) {
|
|
19
|
+
return new FeedbackService(config);
|
|
20
|
+
}
|
|
21
|
+
export * from './client.js';
|
|
22
|
+
export * from './types.js';
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as z from 'zod';
|
|
2
|
+
import { submitDeveloperRequest } from '../client.js';
|
|
3
|
+
export function createSubmitDeveloperRequestTool(config) {
|
|
4
|
+
return {
|
|
5
|
+
name: 'submit_developer_request',
|
|
6
|
+
metadata: {
|
|
7
|
+
title: '개발자 요청 제출',
|
|
8
|
+
description: 'MCP 기능 오류, 개선 요청, 신규 기능 요청, 문서 문제를 개발자에게 전달하고 Supabase에 저장합니다.',
|
|
9
|
+
inputSchema: {
|
|
10
|
+
type: z
|
|
11
|
+
.enum(['bug', 'improvement', 'feature', 'docs', 'other'])
|
|
12
|
+
.optional()
|
|
13
|
+
.default('other')
|
|
14
|
+
.describe('요청 유형'),
|
|
15
|
+
severity: z
|
|
16
|
+
.enum(['low', 'medium', 'high', 'critical'])
|
|
17
|
+
.optional()
|
|
18
|
+
.default('medium')
|
|
19
|
+
.describe('영향도'),
|
|
20
|
+
title: z.string().describe('짧은 제목'),
|
|
21
|
+
description: z.string().describe('요청 상세 설명'),
|
|
22
|
+
service: z.string().optional().describe('관련 서비스 ID 또는 영역'),
|
|
23
|
+
toolName: z.string().optional().describe('관련 MCP 도구 이름'),
|
|
24
|
+
reproduction: z.string().optional().describe('재현 방법'),
|
|
25
|
+
expected: z.string().optional().describe('기대 결과'),
|
|
26
|
+
actual: z.string().optional().describe('실제 결과'),
|
|
27
|
+
source: z
|
|
28
|
+
.enum(['mcp', 'chatgpt', 'claude', 'cli', 'api', 'other'])
|
|
29
|
+
.optional()
|
|
30
|
+
.default('mcp')
|
|
31
|
+
.describe('요청 출처'),
|
|
32
|
+
userContext: z.object({}).loose().optional().describe('추가 컨텍스트 JSON'),
|
|
33
|
+
},
|
|
34
|
+
outputSchema: {
|
|
35
|
+
id: z.string().optional(),
|
|
36
|
+
type: z.string().optional(),
|
|
37
|
+
severity: z.string().optional(),
|
|
38
|
+
title: z.string().optional(),
|
|
39
|
+
service: z.string().optional(),
|
|
40
|
+
toolName: z.string().optional(),
|
|
41
|
+
source: z.string().optional(),
|
|
42
|
+
status: z.string().optional(),
|
|
43
|
+
createdAt: z.string().optional(),
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
handler: (async (args) => {
|
|
47
|
+
const result = await submitDeveloperRequest(args, config);
|
|
48
|
+
return {
|
|
49
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
50
|
+
structuredContent: { ...result },
|
|
51
|
+
};
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -6,6 +6,7 @@ import { fetchJson, fetchWithTimeout, HttpError } from '../../utils/http.js';
|
|
|
6
6
|
import { decodeZyteHttpBody, requestByZyte } from '../../utils/zyte.js';
|
|
7
7
|
import { GS25_API } from './api.js';
|
|
8
8
|
import { normalizeStore, toNumber } from './storeUtils.js';
|
|
9
|
+
export { fetchGs25NormalizedKeyword, fetchGs25SearchProducts, } from './productSearch.js';
|
|
9
10
|
export { attachDistanceToGs25Stores, calculateDistanceM, extractGs25ProductCandidates, filterGs25StoresByKeyword, selectGs25StoresForKeyword, sortGs25Stores, } from './storeUtils.js';
|
|
10
11
|
const GS25_DEFAULT_HEADERS = {
|
|
11
12
|
Accept: 'application/json, text/plain, */*',
|
|
@@ -14,10 +15,6 @@ const GS25_DEFAULT_HEADERS = {
|
|
|
14
15
|
Origin: 'https://woodongs.com',
|
|
15
16
|
Referer: 'https://woodongs.com/',
|
|
16
17
|
};
|
|
17
|
-
const GS25_TOTAL_SEARCH_HEADERS = {
|
|
18
|
-
...GS25_DEFAULT_HEADERS,
|
|
19
|
-
'Content-Type': 'application/json',
|
|
20
|
-
};
|
|
21
18
|
const GS25_DEFAULT_FETCH_OPTIONS = {
|
|
22
19
|
retries: 1,
|
|
23
20
|
retryDelayMs: 250,
|
|
@@ -91,40 +88,6 @@ async function fetchGs25StoreStock(url, options) {
|
|
|
91
88
|
return decodeZyteHttpBody(result);
|
|
92
89
|
}
|
|
93
90
|
}
|
|
94
|
-
async function fetchGs25TotalSearchResponse(query, options) {
|
|
95
|
-
const { timeout = 20000 } = options;
|
|
96
|
-
const endpoint = new URL(GS25_API.TOTAL_SEARCH_PATH, GS25_API.APIGW_BASE_URL);
|
|
97
|
-
const bodyText = JSON.stringify({ query });
|
|
98
|
-
try {
|
|
99
|
-
return await fetchJson(endpoint.toString(), {
|
|
100
|
-
...GS25_DEFAULT_FETCH_OPTIONS,
|
|
101
|
-
method: 'POST',
|
|
102
|
-
retryUnsafeMethods: true,
|
|
103
|
-
timeout,
|
|
104
|
-
headers: GS25_TOTAL_SEARCH_HEADERS,
|
|
105
|
-
body: bodyText,
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
catch (error) {
|
|
109
|
-
const zyteApiKey = options.zyteApiKey?.trim();
|
|
110
|
-
if (!(error instanceof HttpError) || error.status !== 403 || !zyteApiKey) {
|
|
111
|
-
throw error;
|
|
112
|
-
}
|
|
113
|
-
const result = await requestByZyte({
|
|
114
|
-
apiKey: zyteApiKey,
|
|
115
|
-
url: endpoint.toString(),
|
|
116
|
-
method: 'POST',
|
|
117
|
-
timeout,
|
|
118
|
-
retries: 1,
|
|
119
|
-
headers: Object.entries(GS25_TOTAL_SEARCH_HEADERS).map(([name, value]) => ({
|
|
120
|
-
name,
|
|
121
|
-
value,
|
|
122
|
-
})),
|
|
123
|
-
bodyText,
|
|
124
|
-
});
|
|
125
|
-
return decodeZyteHttpBody(result);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
91
|
function buildCacheKey(params) {
|
|
129
92
|
return [
|
|
130
93
|
params.serviceCode,
|
|
@@ -332,52 +295,6 @@ export async function fetchGs25Stores(params = {}, options = {}) {
|
|
|
332
295
|
cacheHit: false,
|
|
333
296
|
};
|
|
334
297
|
}
|
|
335
|
-
export async function fetchGs25NormalizedKeyword(keyword, options = {}) {
|
|
336
|
-
const query = keyword.trim();
|
|
337
|
-
if (query.length === 0) {
|
|
338
|
-
return null;
|
|
339
|
-
}
|
|
340
|
-
const body = await fetchGs25TotalSearchResponse(query, options);
|
|
341
|
-
const normalizedKeyword = body.SearchQueryResult?.keywordInfo?.keyword?.trim() || '';
|
|
342
|
-
const normalizedSearchKeyword = body.SearchQueryResult?.keywordInfo?.searchKeyword?.trim() || '';
|
|
343
|
-
if (normalizedKeyword.length === 0 && normalizedSearchKeyword.length === 0) {
|
|
344
|
-
return null;
|
|
345
|
-
}
|
|
346
|
-
return {
|
|
347
|
-
keyword: normalizedKeyword,
|
|
348
|
-
searchKeyword: normalizedSearchKeyword,
|
|
349
|
-
};
|
|
350
|
-
}
|
|
351
|
-
/**
|
|
352
|
-
* 키워드로 상품을 검색하여 itemCode 목록을 반환합니다.
|
|
353
|
-
* 재고 조회 API는 itemCode가 필요하므로 이 함수로 먼저 변환해야 합니다.
|
|
354
|
-
*/
|
|
355
|
-
export async function fetchGs25SearchProducts(keyword, options = {}) {
|
|
356
|
-
const query = keyword.trim();
|
|
357
|
-
if (query.length === 0) {
|
|
358
|
-
return [];
|
|
359
|
-
}
|
|
360
|
-
const body = await fetchGs25TotalSearchResponse(query, options);
|
|
361
|
-
const products = [];
|
|
362
|
-
const collections = body.SearchQueryResult?.Collection || [];
|
|
363
|
-
for (const collection of collections) {
|
|
364
|
-
const documents = collection.Documentset?.Document || [];
|
|
365
|
-
for (const doc of documents) {
|
|
366
|
-
const field = doc.field;
|
|
367
|
-
if (!field?.itemCode)
|
|
368
|
-
continue;
|
|
369
|
-
products.push({
|
|
370
|
-
itemCode: field.itemCode,
|
|
371
|
-
itemName: field.itemName || '',
|
|
372
|
-
shortItemName: field.shortItemName || '',
|
|
373
|
-
imageUrl: field.itemImageUrl || '',
|
|
374
|
-
rating: toNumber(field.starPoint),
|
|
375
|
-
stockCheckEnabled: field.stockCheckYn === 'Y',
|
|
376
|
-
});
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
return products;
|
|
380
|
-
}
|
|
381
298
|
export function clearGs25StoresCache() {
|
|
382
299
|
gs25StoresCache.clear();
|
|
383
300
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GS25 상품 검색 API
|
|
3
|
+
*/
|
|
4
|
+
/* c8 ignore start */
|
|
5
|
+
import { fetchJson, HttpError } from '../../utils/http.js';
|
|
6
|
+
import { decodeZyteHttpBody, requestByZyte } from '../../utils/zyte.js';
|
|
7
|
+
import { GS25_API } from './api.js';
|
|
8
|
+
import { toNumber } from './storeUtils.js';
|
|
9
|
+
const GS25_TOTAL_SEARCH_HEADERS = {
|
|
10
|
+
Accept: 'application/json, text/plain, */*',
|
|
11
|
+
'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7',
|
|
12
|
+
'User-Agent': 'Mozilla/5.0 (Linux; Android 15; SM-S928N) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/124.0 Mobile Safari/537.36',
|
|
13
|
+
Origin: 'https://woodongs.com',
|
|
14
|
+
Referer: 'https://woodongs.com/',
|
|
15
|
+
'Content-Type': 'application/json',
|
|
16
|
+
};
|
|
17
|
+
const GS25_DEFAULT_FETCH_OPTIONS = {
|
|
18
|
+
retries: 1,
|
|
19
|
+
retryDelayMs: 250,
|
|
20
|
+
};
|
|
21
|
+
async function fetchGs25TotalSearchResponse(query, options) {
|
|
22
|
+
const { timeout = 20000 } = options;
|
|
23
|
+
const endpoint = new URL(GS25_API.TOTAL_SEARCH_PATH, GS25_API.APIGW_BASE_URL);
|
|
24
|
+
const bodyText = JSON.stringify({ query });
|
|
25
|
+
try {
|
|
26
|
+
return await fetchJson(endpoint.toString(), {
|
|
27
|
+
...GS25_DEFAULT_FETCH_OPTIONS,
|
|
28
|
+
method: 'POST',
|
|
29
|
+
retryUnsafeMethods: true,
|
|
30
|
+
timeout,
|
|
31
|
+
headers: GS25_TOTAL_SEARCH_HEADERS,
|
|
32
|
+
body: bodyText,
|
|
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
|
+
const result = await requestByZyte({
|
|
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
|
+
});
|
|
52
|
+
return decodeZyteHttpBody(result);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export async function fetchGs25NormalizedKeyword(keyword, options = {}) {
|
|
56
|
+
const query = keyword.trim();
|
|
57
|
+
if (query.length === 0) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const body = await fetchGs25TotalSearchResponse(query, options);
|
|
61
|
+
const normalizedKeyword = body.SearchQueryResult?.keywordInfo?.keyword?.trim() || '';
|
|
62
|
+
const normalizedSearchKeyword = body.SearchQueryResult?.keywordInfo?.searchKeyword?.trim() || '';
|
|
63
|
+
if (normalizedKeyword.length === 0 && normalizedSearchKeyword.length === 0) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
keyword: normalizedKeyword,
|
|
68
|
+
searchKeyword: normalizedSearchKeyword,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export async function fetchGs25SearchProducts(keyword, options = {}) {
|
|
72
|
+
const query = keyword.trim();
|
|
73
|
+
if (query.length === 0) {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
const body = await fetchGs25TotalSearchResponse(query, options);
|
|
77
|
+
const products = [];
|
|
78
|
+
const collections = body.SearchQueryResult?.Collection || [];
|
|
79
|
+
for (const collection of collections) {
|
|
80
|
+
const documents = collection.Documentset?.Document || [];
|
|
81
|
+
for (const doc of documents) {
|
|
82
|
+
const field = doc.field;
|
|
83
|
+
if (!field?.itemCode)
|
|
84
|
+
continue;
|
|
85
|
+
products.push({
|
|
86
|
+
itemCode: field.itemCode,
|
|
87
|
+
itemName: field.itemName || '',
|
|
88
|
+
shortItemName: field.shortItemName || '',
|
|
89
|
+
imageUrl: field.itemImageUrl || '',
|
|
90
|
+
rating: toNumber(field.starPoint),
|
|
91
|
+
stockCheckEnabled: field.stockCheckYn === 'Y',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return products;
|
|
96
|
+
}
|
|
97
|
+
/* c8 ignore stop */
|
|
@@ -4,68 +4,14 @@
|
|
|
4
4
|
* Zyte extract API를 통해 올리브영 내부 API를 우회 호출합니다.
|
|
5
5
|
*/
|
|
6
6
|
import { OLIVEYOUNG_API } from './api.js';
|
|
7
|
+
import { resolveOliveyoungImageUrl, resolveOliveyoungInStock, resolveOliveyoungStoreStock, } from './normalize.js';
|
|
7
8
|
import { decodeBase64, requestByZyte } from '../../utils/zyte.js';
|
|
8
|
-
const OLIVEYOUNG_IMAGE_HOST = 'https://image.oliveyoung.co.kr';
|
|
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();
|
|
12
12
|
const oliveyoungProductSearchCache = new Map();
|
|
13
13
|
const oliveyoungStoreSearchCache = new Map();
|
|
14
14
|
const oliveyoungStockStoresCache = new Map();
|
|
15
|
-
const OLIVEYOUNG_IMAGE_PATH_PREFIX = '/uploads/images/goods';
|
|
16
|
-
function resolveOliveyoungInStock(o2oStockFlag, o2oRemainQuantity) {
|
|
17
|
-
return o2oStockFlag || o2oRemainQuantity > 0;
|
|
18
|
-
}
|
|
19
|
-
function resolveOliveyoungImageUrl(imagePath) {
|
|
20
|
-
if (!imagePath) {
|
|
21
|
-
return undefined;
|
|
22
|
-
}
|
|
23
|
-
if (/^https?:\/\//i.test(imagePath)) {
|
|
24
|
-
return imagePath;
|
|
25
|
-
}
|
|
26
|
-
if (imagePath.startsWith('//')) {
|
|
27
|
-
return `https:${imagePath}`;
|
|
28
|
-
}
|
|
29
|
-
const normalizedPath = imagePath.startsWith('/') ? imagePath : `/${imagePath}`;
|
|
30
|
-
if (normalizedPath.startsWith(`${OLIVEYOUNG_IMAGE_PATH_PREFIX}/`)) {
|
|
31
|
-
return `${OLIVEYOUNG_IMAGE_HOST}${normalizedPath}`;
|
|
32
|
-
}
|
|
33
|
-
return `${OLIVEYOUNG_IMAGE_HOST}${OLIVEYOUNG_IMAGE_PATH_PREFIX}${normalizedPath}`;
|
|
34
|
-
}
|
|
35
|
-
function resolveOliveyoungStoreQuantity(remainQuantity, o2oRemainQuantity) {
|
|
36
|
-
return Math.max(remainQuantity, o2oRemainQuantity);
|
|
37
|
-
}
|
|
38
|
-
function resolveOliveyoungStoreStock(rawStore) {
|
|
39
|
-
const remainQuantity = rawStore?.remainQuantity || 0;
|
|
40
|
-
const o2oRemainQuantity = rawStore?.o2oRemainQuantity || 0;
|
|
41
|
-
const quantity = resolveOliveyoungStoreQuantity(remainQuantity, o2oRemainQuantity);
|
|
42
|
-
const salesStoreYn = Boolean(rawStore?.salesStoreYn);
|
|
43
|
-
let stockStatus = 'out_of_stock';
|
|
44
|
-
let stockLabel = '품절';
|
|
45
|
-
if (!salesStoreYn) {
|
|
46
|
-
stockStatus = 'not_sold';
|
|
47
|
-
stockLabel = '미판매';
|
|
48
|
-
}
|
|
49
|
-
else if (quantity > 0) {
|
|
50
|
-
stockStatus = 'in_stock';
|
|
51
|
-
stockLabel = quantity >= 9 ? '재고 9개 이상' : `재고 ${quantity}개`;
|
|
52
|
-
}
|
|
53
|
-
return {
|
|
54
|
-
storeCode: rawStore?.storeCode || '',
|
|
55
|
-
storeName: rawStore?.storeName || '',
|
|
56
|
-
address: rawStore?.address || '',
|
|
57
|
-
latitude: rawStore?.latitude || 0,
|
|
58
|
-
longitude: rawStore?.longitude || 0,
|
|
59
|
-
distance: rawStore?.distance || 0,
|
|
60
|
-
pickupYn: Boolean(rawStore?.pickupYn),
|
|
61
|
-
salesStoreYn,
|
|
62
|
-
remainQuantity,
|
|
63
|
-
o2oRemainQuantity,
|
|
64
|
-
stockStatus,
|
|
65
|
-
stockLabel,
|
|
66
|
-
openYn: Boolean(rawStore?.openYn),
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
15
|
function createOliveyoungProductSearchCacheKey(params) {
|
|
70
16
|
return JSON.stringify([params.keyword, params.page, params.size, params.sort, params.includeSoldOut]);
|
|
71
17
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 올리브영 응답 정규화 유틸리티
|
|
3
|
+
*/
|
|
4
|
+
const OLIVEYOUNG_IMAGE_HOST = 'https://image.oliveyoung.co.kr';
|
|
5
|
+
const OLIVEYOUNG_IMAGE_PATH_PREFIX = '/uploads/images/goods';
|
|
6
|
+
export function resolveOliveyoungInStock(o2oStockFlag, o2oRemainQuantity) {
|
|
7
|
+
return o2oStockFlag || o2oRemainQuantity > 0;
|
|
8
|
+
}
|
|
9
|
+
export function resolveOliveyoungImageUrl(imagePath) {
|
|
10
|
+
if (!imagePath) {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
if (/^https?:\/\//i.test(imagePath)) {
|
|
14
|
+
return imagePath;
|
|
15
|
+
}
|
|
16
|
+
if (imagePath.startsWith('//')) {
|
|
17
|
+
return `https:${imagePath}`;
|
|
18
|
+
}
|
|
19
|
+
const normalizedPath = imagePath.startsWith('/') ? imagePath : `/${imagePath}`;
|
|
20
|
+
if (normalizedPath.startsWith(`${OLIVEYOUNG_IMAGE_PATH_PREFIX}/`)) {
|
|
21
|
+
return `${OLIVEYOUNG_IMAGE_HOST}${normalizedPath}`;
|
|
22
|
+
}
|
|
23
|
+
return `${OLIVEYOUNG_IMAGE_HOST}${OLIVEYOUNG_IMAGE_PATH_PREFIX}${normalizedPath}`;
|
|
24
|
+
}
|
|
25
|
+
function resolveOliveyoungStoreQuantity(remainQuantity, o2oRemainQuantity) {
|
|
26
|
+
return Math.max(remainQuantity, o2oRemainQuantity);
|
|
27
|
+
}
|
|
28
|
+
export function resolveOliveyoungStoreStock(rawStore) {
|
|
29
|
+
const remainQuantity = rawStore?.remainQuantity || 0;
|
|
30
|
+
const o2oRemainQuantity = rawStore?.o2oRemainQuantity || 0;
|
|
31
|
+
const quantity = resolveOliveyoungStoreQuantity(remainQuantity, o2oRemainQuantity);
|
|
32
|
+
const salesStoreYn = Boolean(rawStore?.salesStoreYn);
|
|
33
|
+
let stockStatus = 'out_of_stock';
|
|
34
|
+
let stockLabel = '품절';
|
|
35
|
+
if (!salesStoreYn) {
|
|
36
|
+
stockStatus = 'not_sold';
|
|
37
|
+
stockLabel = '미판매';
|
|
38
|
+
}
|
|
39
|
+
else if (quantity > 0) {
|
|
40
|
+
stockStatus = 'in_stock';
|
|
41
|
+
stockLabel = quantity >= 9 ? '재고 9개 이상' : `재고 ${quantity}개`;
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
storeCode: rawStore?.storeCode || '',
|
|
45
|
+
storeName: rawStore?.storeName || '',
|
|
46
|
+
address: rawStore?.address || '',
|
|
47
|
+
latitude: rawStore?.latitude || 0,
|
|
48
|
+
longitude: rawStore?.longitude || 0,
|
|
49
|
+
distance: rawStore?.distance || 0,
|
|
50
|
+
pickupYn: Boolean(rawStore?.pickupYn),
|
|
51
|
+
salesStoreYn,
|
|
52
|
+
remainQuantity,
|
|
53
|
+
o2oRemainQuantity,
|
|
54
|
+
stockStatus,
|
|
55
|
+
stockLabel,
|
|
56
|
+
openYn: Boolean(rawStore?.openYn),
|
|
57
|
+
};
|
|
58
|
+
}
|