daiso 1.0.7 → 1.0.9
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 +106 -7
- package/dist/api/actionsProxy.js +10 -0
- package/dist/api/compareHandlers.js +26 -0
- package/dist/api/configStatus.js +8 -0
- package/dist/api/feedbackHandlers.js +60 -0
- package/dist/api/healthCheckDefinitions.js +170 -0
- package/dist/api/healthCheckShape.js +106 -0
- package/dist/api/healthCheckTypes.js +4 -0
- package/dist/api/healthChecks.js +3 -271
- package/dist/api/opinetHandlers.js +91 -0
- package/dist/api/routes/compareRoutes.js +9 -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 +24 -2
- package/dist/cli/commands/apiCommand.js +24 -0
- package/dist/cli/commands/cinema.js +65 -0
- package/dist/cli/commands/convenience.js +75 -100
- package/dist/cli/http.js +1 -1
- package/dist/cli.js +12 -1
- package/dist/cliHelp.js +55 -8
- package/dist/cliRenderer.js +15 -0
- package/dist/core/outputSchema.js +141 -0
- package/dist/core/registry.js +1 -126
- package/dist/index.js +96 -28
- package/dist/pages/openapiFullSpec.js +8 -0
- package/dist/pages/openapiSpecActions.js +70 -0
- package/dist/pages/openapiSpecPathsCompare.js +59 -0
- package/dist/pages/openapiSpecPathsFeedback.js +95 -0
- package/dist/pages/openapiSpecPathsOpinet.js +81 -0
- package/dist/pages/promptReferenceText.js +18 -1
- package/dist/pages/promptRetailCinemaText.js +21 -4
- package/dist/services/compare/client.js +133 -0
- package/dist/services/compare/index.js +17 -0
- package/dist/services/compare/tools/compareProducts.js +45 -0
- package/dist/services/compare/types.js +1 -0
- 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/openapi.json +92 -0
- package/openapi.yaml +62 -0
- package/package.json +18 -17
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { resolveOpinetLocation } from './location.js';
|
|
2
|
+
const OPINET_API_BASE_URL = 'https://www.opinet.co.kr/api';
|
|
3
|
+
const OPINET_SOURCE = '한국석유공사 오피넷';
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 10000;
|
|
5
|
+
const FUEL_CODES = new Set(['B027', 'D047', 'B034', 'C004', 'K015']);
|
|
6
|
+
const BRAND_NAMES = {
|
|
7
|
+
SKE: 'SK에너지',
|
|
8
|
+
GSC: 'GS칼텍스',
|
|
9
|
+
HDO: '현대오일뱅크',
|
|
10
|
+
SOL: 'S-OIL',
|
|
11
|
+
RTE: '자영알뜰',
|
|
12
|
+
RTX: '고속도로알뜰',
|
|
13
|
+
NHO: '농협알뜰',
|
|
14
|
+
ETC: '자가상표',
|
|
15
|
+
E1G: 'E1',
|
|
16
|
+
SKG: 'SK가스',
|
|
17
|
+
};
|
|
18
|
+
function getProcessEnvValue(name) {
|
|
19
|
+
/* c8 ignore next -- Worker 런타임은 bindings로 키를 주입합니다. */
|
|
20
|
+
return typeof process !== 'undefined' ? process.env[name] : undefined;
|
|
21
|
+
}
|
|
22
|
+
function requireApiKey(apiKey) {
|
|
23
|
+
const resolved = apiKey?.trim() || getProcessEnvValue('OPINET_API_KEY')?.trim();
|
|
24
|
+
if (!resolved) {
|
|
25
|
+
throw new Error('OPINET_API_KEY 환경 변수가 필요합니다.');
|
|
26
|
+
}
|
|
27
|
+
return resolved;
|
|
28
|
+
}
|
|
29
|
+
export function normalizeFuelCode(value, fallback = 'B027') {
|
|
30
|
+
const normalized = (value || fallback).trim().toUpperCase();
|
|
31
|
+
if (!FUEL_CODES.has(normalized)) {
|
|
32
|
+
throw new Error('fuelCode는 B027, D047, B034, C004, K015 중 하나여야 합니다.');
|
|
33
|
+
}
|
|
34
|
+
return normalized;
|
|
35
|
+
}
|
|
36
|
+
export function normalizeOpinetSort(value) {
|
|
37
|
+
return value === 'distance' || value === '2' ? 'distance' : 'price';
|
|
38
|
+
}
|
|
39
|
+
function toOpinetSortCode(sort) {
|
|
40
|
+
return sort === 'distance' ? '2' : '1';
|
|
41
|
+
}
|
|
42
|
+
function toNumber(value) {
|
|
43
|
+
if (value === null || value === undefined || value === '') {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const parsed = Number(value);
|
|
47
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
48
|
+
}
|
|
49
|
+
function toStringValue(value) {
|
|
50
|
+
return typeof value === 'string' || typeof value === 'number' ? String(value).trim() : '';
|
|
51
|
+
}
|
|
52
|
+
function toBooleanYn(value) {
|
|
53
|
+
const normalized = toStringValue(value).toUpperCase();
|
|
54
|
+
if (normalized === 'Y') {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
if (normalized === 'N') {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
function asRecord(value) {
|
|
63
|
+
return typeof value === 'object' && value !== null ? value : {};
|
|
64
|
+
}
|
|
65
|
+
function asArray(value) {
|
|
66
|
+
if (Array.isArray(value)) {
|
|
67
|
+
return value.map(asRecord);
|
|
68
|
+
}
|
|
69
|
+
if (value && typeof value === 'object') {
|
|
70
|
+
return [asRecord(value)];
|
|
71
|
+
}
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
function unwrapOil(payload) {
|
|
75
|
+
return asArray(payload.RESULT?.OIL ?? payload.OIL);
|
|
76
|
+
}
|
|
77
|
+
async function fetchOpinetJson(path, params, options) {
|
|
78
|
+
const url = new URL(`${OPINET_API_BASE_URL}/${path}`);
|
|
79
|
+
for (const [key, value] of Object.entries(params)) {
|
|
80
|
+
url.searchParams.set(key, value);
|
|
81
|
+
}
|
|
82
|
+
url.searchParams.set('out', 'json');
|
|
83
|
+
url.searchParams.set('certkey', requireApiKey(options.apiKey));
|
|
84
|
+
const response = await (options.fetchImpl ?? fetch)(url.toString(), {
|
|
85
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
86
|
+
});
|
|
87
|
+
const bodyText = await response.text();
|
|
88
|
+
let parsed;
|
|
89
|
+
try {
|
|
90
|
+
parsed = JSON.parse(bodyText);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
parsed = {};
|
|
94
|
+
}
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
throw new Error(`오피넷 API 호출 실패: HTTP ${response.status} ${bodyText}`);
|
|
97
|
+
}
|
|
98
|
+
return parsed;
|
|
99
|
+
}
|
|
100
|
+
function normalizeStationSummary(row) {
|
|
101
|
+
const brandCode = toStringValue(row.POLL_DIV_CD || row.POLL_DIV_CO);
|
|
102
|
+
return {
|
|
103
|
+
stationId: toStringValue(row.UNI_ID),
|
|
104
|
+
brandCode,
|
|
105
|
+
brandName: BRAND_NAMES[brandCode] || brandCode,
|
|
106
|
+
name: toStringValue(row.OS_NM),
|
|
107
|
+
price: toNumber(row.PRICE),
|
|
108
|
+
distanceMeters: toNumber(row.DISTANCE),
|
|
109
|
+
address: toStringValue(row.VAN_ADR) || undefined,
|
|
110
|
+
roadAddress: toStringValue(row.NEW_ADR) || undefined,
|
|
111
|
+
katecX: toNumber(row.GIS_X_COOR),
|
|
112
|
+
katecY: toNumber(row.GIS_Y_COOR),
|
|
113
|
+
raw: row,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function normalizeAveragePrice(row) {
|
|
117
|
+
return {
|
|
118
|
+
tradeDate: toStringValue(row.TRADE_DT),
|
|
119
|
+
productCode: toStringValue(row.PRODCD),
|
|
120
|
+
productName: toStringValue(row.PRODNM),
|
|
121
|
+
price: toNumber(row.PRICE),
|
|
122
|
+
diff: toStringValue(row.DIFF),
|
|
123
|
+
raw: row,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function normalizeStationDetail(row) {
|
|
127
|
+
const summary = normalizeStationSummary(row);
|
|
128
|
+
const prices = asArray(row.OIL_PRICE).map((priceRow) => ({
|
|
129
|
+
productCode: toStringValue(priceRow.PRODCD),
|
|
130
|
+
price: toNumber(priceRow.PRICE),
|
|
131
|
+
tradeDate: toStringValue(priceRow.TRADE_DT),
|
|
132
|
+
tradeTime: toStringValue(priceRow.TRADE_TM),
|
|
133
|
+
raw: priceRow,
|
|
134
|
+
}));
|
|
135
|
+
return {
|
|
136
|
+
...summary,
|
|
137
|
+
phone: toStringValue(row.TEL) || undefined,
|
|
138
|
+
areaCode: toStringValue(row.SIGUNCD) || undefined,
|
|
139
|
+
lpgType: toStringValue(row.LPG_YN) || undefined,
|
|
140
|
+
hasMaintenance: toBooleanYn(row.MAINT_YN),
|
|
141
|
+
hasCarWash: toBooleanYn(row.CAR_WASH_YN),
|
|
142
|
+
hasConvenienceStore: toBooleanYn(row.CVS_YN),
|
|
143
|
+
isKpetroCertified: toBooleanYn(row.KPETRO_YN),
|
|
144
|
+
prices,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
export async function fetchOpinetAveragePrices(options = {}) {
|
|
148
|
+
const payload = await fetchOpinetJson('avgAllPrice.do', {}, options);
|
|
149
|
+
const prices = unwrapOil(payload).map(normalizeAveragePrice);
|
|
150
|
+
return {
|
|
151
|
+
provider: 'opinet',
|
|
152
|
+
source: OPINET_SOURCE,
|
|
153
|
+
fetchedAt: new Date().toISOString(),
|
|
154
|
+
count: prices.length,
|
|
155
|
+
prices,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
export async function fetchOpinetLowestStations(params, options = {}) {
|
|
159
|
+
const fuelCode = normalizeFuelCode(params.fuelCode);
|
|
160
|
+
const count = Math.min(Math.max(Math.trunc(params.count ?? 10), 1), 20);
|
|
161
|
+
const apiParams = {
|
|
162
|
+
prodcd: fuelCode,
|
|
163
|
+
cnt: String(count),
|
|
164
|
+
};
|
|
165
|
+
const areaCode = params.areaCode?.trim();
|
|
166
|
+
if (areaCode) {
|
|
167
|
+
apiParams.area = areaCode;
|
|
168
|
+
}
|
|
169
|
+
const payload = await fetchOpinetJson('lowTop10.do', apiParams, options);
|
|
170
|
+
const stations = unwrapOil(payload).map(normalizeStationSummary);
|
|
171
|
+
return {
|
|
172
|
+
provider: 'opinet',
|
|
173
|
+
source: OPINET_SOURCE,
|
|
174
|
+
fetchedAt: new Date().toISOString(),
|
|
175
|
+
fuelCode,
|
|
176
|
+
areaCode: areaCode || null,
|
|
177
|
+
count: stations.length,
|
|
178
|
+
stations,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
export async function fetchOpinetStationsAround(params, options = {}) {
|
|
182
|
+
const resolvedLocation = await resolveOpinetLocation(params, {
|
|
183
|
+
googleMapsApiKey: options.googleMapsApiKey,
|
|
184
|
+
timeoutMs: options.timeoutMs,
|
|
185
|
+
fetchImpl: options.fetchImpl,
|
|
186
|
+
});
|
|
187
|
+
const radiusMeters = Math.min(Math.max(Math.trunc(params.radiusMeters ?? 3000), 100), 5000);
|
|
188
|
+
const fuelCode = normalizeFuelCode(params.fuelCode);
|
|
189
|
+
const sort = normalizeOpinetSort(params.sort);
|
|
190
|
+
const payload = await fetchOpinetJson('aroundAll.do', {
|
|
191
|
+
x: String(resolvedLocation.katec.x),
|
|
192
|
+
y: String(resolvedLocation.katec.y),
|
|
193
|
+
radius: String(radiusMeters),
|
|
194
|
+
prodcd: fuelCode,
|
|
195
|
+
sort: toOpinetSortCode(sort),
|
|
196
|
+
}, options);
|
|
197
|
+
const stations = unwrapOil(payload).map(normalizeStationSummary);
|
|
198
|
+
return {
|
|
199
|
+
provider: 'opinet',
|
|
200
|
+
source: OPINET_SOURCE,
|
|
201
|
+
fetchedAt: new Date().toISOString(),
|
|
202
|
+
fuelCode,
|
|
203
|
+
radiusMeters,
|
|
204
|
+
sort,
|
|
205
|
+
katec: resolvedLocation.katec,
|
|
206
|
+
location: {
|
|
207
|
+
latitude: resolvedLocation.latitude,
|
|
208
|
+
longitude: resolvedLocation.longitude,
|
|
209
|
+
location: resolvedLocation.location,
|
|
210
|
+
formattedAddress: resolvedLocation.formattedAddress,
|
|
211
|
+
geocodeUsed: resolvedLocation.geocodeUsed,
|
|
212
|
+
inputType: resolvedLocation.inputType,
|
|
213
|
+
},
|
|
214
|
+
count: stations.length,
|
|
215
|
+
stations,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
export async function fetchOpinetStationDetail(stationId, options = {}) {
|
|
219
|
+
const id = stationId.trim();
|
|
220
|
+
if (!id) {
|
|
221
|
+
throw new Error('주유소 ID를 입력해주세요.');
|
|
222
|
+
}
|
|
223
|
+
const payload = await fetchOpinetJson('detailById.do', { id }, options);
|
|
224
|
+
const station = unwrapOil(payload).map(normalizeStationDetail)[0] ?? null;
|
|
225
|
+
return {
|
|
226
|
+
provider: 'opinet',
|
|
227
|
+
source: OPINET_SOURCE,
|
|
228
|
+
fetchedAt: new Date().toISOString(),
|
|
229
|
+
station,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createOpinetAveragePricesTool, createOpinetLowestStationsTool, createOpinetStationDetailTool, createOpinetStationsAroundTool, } from './tools.js';
|
|
2
|
+
const OPINET_METADATA = {
|
|
3
|
+
id: 'opinet',
|
|
4
|
+
name: '오피넷',
|
|
5
|
+
version: '1.0.0',
|
|
6
|
+
description: '한국석유공사 오피넷 기반 전국 평균 유가, 최저가 주유소, 반경 내 주유소 조회 서비스',
|
|
7
|
+
};
|
|
8
|
+
class OpinetService {
|
|
9
|
+
options;
|
|
10
|
+
metadata = OPINET_METADATA;
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
this.options = options;
|
|
13
|
+
}
|
|
14
|
+
getTools() {
|
|
15
|
+
return [
|
|
16
|
+
createOpinetAveragePricesTool(this.options.apiKey),
|
|
17
|
+
createOpinetLowestStationsTool(this.options.apiKey),
|
|
18
|
+
createOpinetStationsAroundTool(this.options.apiKey, this.options.googleMapsApiKey),
|
|
19
|
+
createOpinetStationDetailTool(this.options.apiKey),
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function createOpinetService(options = {}) {
|
|
24
|
+
return new OpinetService(options);
|
|
25
|
+
}
|
|
26
|
+
export * from './types.js';
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import proj4 from 'proj4';
|
|
2
|
+
const WGS84 = 'EPSG:4326';
|
|
3
|
+
const KATEC = 'KATEC';
|
|
4
|
+
const KATEC_PROJ4 = '+proj=tmerc +lat_0=38 +lon_0=128 +k=0.9999 +x_0=400000 +y_0=600000 +ellps=bessel +units=m +no_defs +towgs84=-115.80,474.99,674.11,1.16,-2.31,-1.63,6.43';
|
|
5
|
+
proj4.defs(KATEC, KATEC_PROJ4);
|
|
6
|
+
const GEOCODE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
7
|
+
const geocodeCache = new Map();
|
|
8
|
+
function getProcessEnvValue(name) {
|
|
9
|
+
/* c8 ignore next -- Worker 런타임은 bindings로 키를 주입합니다. */
|
|
10
|
+
return typeof process !== 'undefined' ? process.env[name] : undefined;
|
|
11
|
+
}
|
|
12
|
+
function isFiniteNumber(value) {
|
|
13
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
14
|
+
}
|
|
15
|
+
export function wgs84ToKatec(latitude, longitude) {
|
|
16
|
+
if (!isFiniteNumber(latitude) || !isFiniteNumber(longitude)) {
|
|
17
|
+
throw new Error('유효한 위도/경도를 입력해주세요.');
|
|
18
|
+
}
|
|
19
|
+
const [x, y] = proj4(WGS84, KATEC, [longitude, latitude]);
|
|
20
|
+
return {
|
|
21
|
+
x: Number(x.toFixed(3)),
|
|
22
|
+
y: Number(y.toFixed(3)),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
async function geocodeLocation(location, options) {
|
|
26
|
+
const keyword = location.trim();
|
|
27
|
+
const cached = geocodeCache.get(keyword);
|
|
28
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
29
|
+
return cached.value;
|
|
30
|
+
}
|
|
31
|
+
const apiKey = options.googleMapsApiKey?.trim() || getProcessEnvValue('GOOGLE_MAPS_API_KEY')?.trim();
|
|
32
|
+
if (!apiKey) {
|
|
33
|
+
throw new Error('location 검색에는 GOOGLE_MAPS_API_KEY가 필요합니다.');
|
|
34
|
+
}
|
|
35
|
+
const endpoint = new URL('https://maps.googleapis.com/maps/api/geocode/json');
|
|
36
|
+
endpoint.searchParams.set('address', keyword);
|
|
37
|
+
endpoint.searchParams.set('key', apiKey);
|
|
38
|
+
const response = await (options.fetchImpl ?? fetch)(endpoint.toString(), {
|
|
39
|
+
headers: { Accept: 'application/json' },
|
|
40
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 10000),
|
|
41
|
+
});
|
|
42
|
+
const bodyText = await response.text();
|
|
43
|
+
let parsed;
|
|
44
|
+
try {
|
|
45
|
+
parsed = JSON.parse(bodyText);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
parsed = { status: 'INVALID_JSON' };
|
|
49
|
+
}
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
throw new Error(`Google Geocoding 호출 실패: HTTP ${response.status} ${bodyText}`);
|
|
52
|
+
}
|
|
53
|
+
if (parsed.status !== 'OK') {
|
|
54
|
+
geocodeCache.set(keyword, {
|
|
55
|
+
expiresAt: Date.now() + GEOCODE_CACHE_TTL_MS,
|
|
56
|
+
value: null,
|
|
57
|
+
});
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const first = parsed.results?.[0];
|
|
61
|
+
const latitude = first?.geometry?.location?.lat;
|
|
62
|
+
const longitude = first?.geometry?.location?.lng;
|
|
63
|
+
const value = isFiniteNumber(latitude) && isFiniteNumber(longitude)
|
|
64
|
+
? {
|
|
65
|
+
latitude,
|
|
66
|
+
longitude,
|
|
67
|
+
formattedAddress: first?.formatted_address ?? null,
|
|
68
|
+
}
|
|
69
|
+
: null;
|
|
70
|
+
geocodeCache.set(keyword, {
|
|
71
|
+
expiresAt: Date.now() + GEOCODE_CACHE_TTL_MS,
|
|
72
|
+
value,
|
|
73
|
+
});
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
export async function resolveOpinetLocation(input, options = {}) {
|
|
77
|
+
if (isFiniteNumber(input.x) && isFiniteNumber(input.y)) {
|
|
78
|
+
return {
|
|
79
|
+
katec: { x: input.x, y: input.y },
|
|
80
|
+
latitude: null,
|
|
81
|
+
longitude: null,
|
|
82
|
+
location: input.location?.trim() || null,
|
|
83
|
+
formattedAddress: null,
|
|
84
|
+
geocodeUsed: false,
|
|
85
|
+
inputType: 'katec',
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (isFiniteNumber(input.latitude) && isFiniteNumber(input.longitude)) {
|
|
89
|
+
return {
|
|
90
|
+
katec: wgs84ToKatec(input.latitude, input.longitude),
|
|
91
|
+
latitude: input.latitude,
|
|
92
|
+
longitude: input.longitude,
|
|
93
|
+
location: input.location?.trim() || null,
|
|
94
|
+
formattedAddress: null,
|
|
95
|
+
geocodeUsed: false,
|
|
96
|
+
inputType: 'coordinates',
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const location = input.location?.trim() || '';
|
|
100
|
+
if (location.length > 0) {
|
|
101
|
+
const geocoded = await geocodeLocation(location, options);
|
|
102
|
+
if (!geocoded) {
|
|
103
|
+
throw new Error(`위치를 좌표로 변환하지 못했습니다: ${location}`);
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
katec: wgs84ToKatec(geocoded.latitude, geocoded.longitude),
|
|
107
|
+
latitude: geocoded.latitude,
|
|
108
|
+
longitude: geocoded.longitude,
|
|
109
|
+
location,
|
|
110
|
+
formattedAddress: geocoded.formattedAddress,
|
|
111
|
+
geocodeUsed: true,
|
|
112
|
+
inputType: 'location',
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
throw new Error('KATEC x/y, 위도/경도(latitude/longitude), 또는 location 중 하나를 입력해주세요.');
|
|
116
|
+
}
|
|
117
|
+
export function __testOnlyClearOpinetGeocodeCache() {
|
|
118
|
+
geocodeCache.clear();
|
|
119
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import * as z from 'zod';
|
|
2
|
+
import { fetchOpinetAveragePrices, fetchOpinetLowestStations, fetchOpinetStationDetail, fetchOpinetStationsAround, } from './client.js';
|
|
3
|
+
function buildTextResponse(payload) {
|
|
4
|
+
return {
|
|
5
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
6
|
+
structuredContent: payload,
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
export function createOpinetAveragePricesTool(apiKey) {
|
|
10
|
+
return {
|
|
11
|
+
name: 'opinet_get_average_prices',
|
|
12
|
+
metadata: {
|
|
13
|
+
title: '오피넷 전국 평균 유가',
|
|
14
|
+
description: '한국석유공사 오피넷의 전국 평균 유가를 조회합니다.',
|
|
15
|
+
inputSchema: {
|
|
16
|
+
timeoutMs: z.number().optional().default(10000).describe('요청 제한 시간(ms)'),
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
handler: (async (args) => buildTextResponse(await fetchOpinetAveragePrices({ apiKey, timeoutMs: args.timeoutMs }))),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function createOpinetLowestStationsTool(apiKey) {
|
|
23
|
+
return {
|
|
24
|
+
name: 'opinet_get_lowest_price_stations',
|
|
25
|
+
metadata: {
|
|
26
|
+
title: '오피넷 최저가 주유소',
|
|
27
|
+
description: '전국 또는 지역별 최저가 주유소를 유종별로 조회합니다.',
|
|
28
|
+
inputSchema: {
|
|
29
|
+
fuelCode: z
|
|
30
|
+
.enum(['B027', 'D047', 'B034', 'C004', 'K015'])
|
|
31
|
+
.optional()
|
|
32
|
+
.default('B027')
|
|
33
|
+
.describe('유종 코드: B027 휘발유, D047 경유, B034 고급휘발유, C004 실내등유, K015 자동차부탄'),
|
|
34
|
+
areaCode: z.string().optional().describe('오피넷 지역 코드. 미입력 시 전국 기준'),
|
|
35
|
+
count: z.number().optional().default(10).describe('최저가 결과 수(1~20)'),
|
|
36
|
+
timeoutMs: z.number().optional().default(10000).describe('요청 제한 시간(ms)'),
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
handler: (async (args) => buildTextResponse(await fetchOpinetLowestStations(args, { apiKey, timeoutMs: args.timeoutMs }))),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export function createOpinetStationsAroundTool(apiKey, googleMapsApiKey) {
|
|
43
|
+
return {
|
|
44
|
+
name: 'opinet_search_stations_around',
|
|
45
|
+
metadata: {
|
|
46
|
+
title: '오피넷 반경 내 주유소',
|
|
47
|
+
description: 'KATEC x/y, 위도/경도, 또는 장소 키워드 기준 반경 내 주유소를 가격순 또는 거리순으로 조회합니다.',
|
|
48
|
+
inputSchema: {
|
|
49
|
+
x: z.number().optional().describe('기준 위치 X좌표(KATEC)'),
|
|
50
|
+
y: z.number().optional().describe('기준 위치 Y좌표(KATEC)'),
|
|
51
|
+
latitude: z.number().optional().describe('위도(WGS84)'),
|
|
52
|
+
longitude: z.number().optional().describe('경도(WGS84)'),
|
|
53
|
+
location: z.string().optional().describe('장소/주소 키워드. 예: 강남역, 서울역'),
|
|
54
|
+
radiusMeters: z.number().optional().default(3000).describe('검색 반경 m(100~5000)'),
|
|
55
|
+
fuelCode: z
|
|
56
|
+
.enum(['B027', 'D047', 'B034', 'C004', 'K015'])
|
|
57
|
+
.optional()
|
|
58
|
+
.default('B027')
|
|
59
|
+
.describe('유종 코드'),
|
|
60
|
+
sort: z.enum(['price', 'distance']).optional().default('price').describe('정렬 기준'),
|
|
61
|
+
timeoutMs: z.number().optional().default(10000).describe('요청 제한 시간(ms)'),
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
handler: (async (args) => buildTextResponse(await fetchOpinetStationsAround(args, { apiKey, googleMapsApiKey, timeoutMs: args.timeoutMs }))),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export function createOpinetStationDetailTool(apiKey) {
|
|
68
|
+
return {
|
|
69
|
+
name: 'opinet_get_station_detail',
|
|
70
|
+
metadata: {
|
|
71
|
+
title: '오피넷 주유소 상세정보',
|
|
72
|
+
description: '오피넷 주유소 ID로 상세 정보, 부가시설, 유종별 가격을 조회합니다.',
|
|
73
|
+
inputSchema: {
|
|
74
|
+
stationId: z.string().describe('오피넷 주유소 ID'),
|
|
75
|
+
timeoutMs: z.number().optional().default(10000).describe('요청 제한 시간(ms)'),
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
handler: (async (args) => buildTextResponse(await fetchOpinetStationDetail(args.stationId, { apiKey, timeoutMs: args.timeoutMs }))),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|