@jackwener/opencli 0.5.2 → 0.6.1

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/src/coupang.ts ADDED
@@ -0,0 +1,302 @@
1
+ export interface CoupangSearchItem {
2
+ rank: number;
3
+ product_id: string;
4
+ title: string;
5
+ price: number | null;
6
+ original_price: number | null;
7
+ unit_price: string;
8
+ discount_rate: number | null;
9
+ rating: number | null;
10
+ review_count: number | null;
11
+ rocket: string;
12
+ delivery_type: string;
13
+ delivery_promise: string;
14
+ seller: string;
15
+ badge: string;
16
+ category: string;
17
+ url: string;
18
+ }
19
+
20
+ function itemKey(item: CoupangSearchItem): string {
21
+ return item.url || item.product_id || `${item.title}:${item.price ?? ''}`;
22
+ }
23
+
24
+ const ROCKET_PATTERNS = ['판매자로켓', '로켓프레시', '로켓와우', '로켓배송', '로켓직구'] as const;
25
+ const DELIVERY_TYPE_PATTERNS = ['무료배송', '일반배송'] as const;
26
+ const DELIVERY_PROMISE_PATTERNS = ['오늘도착', '내일도착', '새벽도착', '오늘출발'] as const;
27
+
28
+ const BADGE_ID_TO_ROCKET: Record<string, string> = {
29
+ ROCKET: '로켓배송',
30
+ ROCKET_MERCHANT: '판매자로켓',
31
+ ROCKET_WOW: '로켓와우',
32
+ WOW: '로켓와우',
33
+ ROCKET_FRESH: '로켓프레시',
34
+ FRESH: '로켓프레시',
35
+ SELLER_ROCKET: '판매자로켓',
36
+ ROCKET_JIKGU: '로켓직구',
37
+ JIKGU: '로켓직구',
38
+ COUPANG_GLOBAL: '로켓직구',
39
+ };
40
+
41
+ const BADGE_ID_TO_PROMISE: Record<string, string> = {
42
+ DAWN: '새벽도착',
43
+ EARLY_DAWN: '새벽도착',
44
+ TOMORROW: '내일도착',
45
+ TODAY: '오늘도착',
46
+ SAME_DAY: '오늘도착',
47
+ TODAY_SHIP: '오늘출발',
48
+ TODAY_DISPATCH: '오늘출발',
49
+ };
50
+
51
+ function asString(value: unknown): string {
52
+ if (value == null) return '';
53
+ return String(value).trim();
54
+ }
55
+
56
+ function toNumber(value: unknown): number | null {
57
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
58
+ const text = asString(value).replace(/[^\d.]/g, '');
59
+ if (!text) return null;
60
+ const num = Number(text);
61
+ return Number.isFinite(num) ? num : null;
62
+ }
63
+
64
+ function pickFirst(obj: Record<string, unknown>, paths: string[]): unknown {
65
+ for (const path of paths) {
66
+ const parts = path.split('.');
67
+ let current: unknown = obj;
68
+ let ok = true;
69
+ for (const part of parts) {
70
+ if (!current || typeof current !== 'object' || !(part in (current as Record<string, unknown>))) {
71
+ ok = false;
72
+ break;
73
+ }
74
+ current = (current as Record<string, unknown>)[part];
75
+ }
76
+ if (ok && current != null && asString(current) !== '') return current;
77
+ }
78
+ return null;
79
+ }
80
+
81
+ export function normalizeProductId(raw: unknown): string {
82
+ const text = asString(raw);
83
+ if (!text) return '';
84
+ const match = text.match(/\/vp\/products\/(\d+)/) || text.match(/\b(\d{6,})\b/);
85
+ return match?.[1] ?? text;
86
+ }
87
+
88
+ export function canonicalizeProductUrl(rawUrl: unknown, productId?: unknown): string {
89
+ const raw = asString(rawUrl);
90
+ if (raw) {
91
+ try {
92
+ const url = new URL(raw.startsWith('http') ? raw : `https://www.coupang.com${raw}`);
93
+ if (!url.hostname.includes('coupang.com')) return '';
94
+ const id = normalizeProductId(url.pathname) || normalizeProductId(productId);
95
+ if (!id) return url.toString();
96
+ return `https://www.coupang.com/vp/products/${id}`;
97
+ } catch {
98
+ return '';
99
+ }
100
+ }
101
+
102
+ const id = normalizeProductId(productId);
103
+ return id ? `https://www.coupang.com/vp/products/${id}` : '';
104
+ }
105
+
106
+ function extractTokens(values: unknown[]): string[] {
107
+ return values
108
+ .flatMap((value) => {
109
+ const text = asString(value);
110
+ if (!text) return [];
111
+ return text.split(/[,\s|]+/);
112
+ })
113
+ .map((token) => token.trim().toUpperCase())
114
+ .filter(Boolean);
115
+ }
116
+
117
+ function normalizeJoinedText(...values: unknown[]): string {
118
+ return values
119
+ .map(asString)
120
+ .filter(Boolean)
121
+ .join(' ')
122
+ .replace(/schema\.org\/[A-Za-z]+/gi, ' ')
123
+ .replace(/\s+/g, ' ')
124
+ .trim();
125
+ }
126
+
127
+ function normalizeRocket(...values: unknown[]): string {
128
+ const tokens = extractTokens(values);
129
+ for (const token of tokens) {
130
+ if (BADGE_ID_TO_ROCKET[token]) return BADGE_ID_TO_ROCKET[token];
131
+ }
132
+ const text = normalizeJoinedText(...values);
133
+ if (!text) return '';
134
+ if (/판매자\s*로켓/.test(text)) return '판매자로켓';
135
+ if (/로켓\s*프레시|새벽\s*도착\s*보장/.test(text)) return '로켓프레시';
136
+ if (/로켓\s*와우/.test(text)) return '로켓와우';
137
+ if (/로켓\s*직구|직구/.test(text)) return '로켓직구';
138
+ if (/로켓\s*배송/.test(text)) return '로켓배송';
139
+ return ROCKET_PATTERNS.find(pattern => text.includes(pattern)) ?? '';
140
+ }
141
+
142
+ function normalizeDeliveryType(...values: unknown[]): string {
143
+ const text = normalizeJoinedText(...values);
144
+ if (!text) return '';
145
+ if (/무료\s*배송/.test(text)) return '무료배송';
146
+ if (/일반\s*배송/.test(text)) return '일반배송';
147
+ return DELIVERY_TYPE_PATTERNS.find(pattern => text.includes(pattern)) ?? '';
148
+ }
149
+
150
+ function normalizeDeliveryPromise(...values: unknown[]): string {
151
+ const tokens = extractTokens(values);
152
+ for (const token of tokens) {
153
+ if (BADGE_ID_TO_PROMISE[token]) return BADGE_ID_TO_PROMISE[token];
154
+ }
155
+ const text = normalizeJoinedText(...values);
156
+ if (!text) return '';
157
+ if (/오늘\s*출발/.test(text)) return '오늘출발';
158
+ if (/오늘.*도착/.test(text)) return '오늘도착';
159
+ if (/새벽.*도착/.test(text)) return '새벽도착';
160
+ if (/내일.*도착/.test(text)) return '내일도착';
161
+ return DELIVERY_PROMISE_PATTERNS.find(pattern => text.includes(pattern)) ?? '';
162
+ }
163
+
164
+ function normalizeBadge(value: unknown): string {
165
+ const normalizeOne = (entry: unknown): string => {
166
+ const text = asString(entry);
167
+ if (!text) return '';
168
+ if (/schema\.org\//i.test(text)) {
169
+ return text.split('/').pop() ?? '';
170
+ }
171
+ return text;
172
+ };
173
+ if (Array.isArray(value)) {
174
+ return value.map(normalizeOne).filter(Boolean).join(', ');
175
+ }
176
+ return normalizeOne(value);
177
+ }
178
+
179
+ export function normalizeSearchItem(raw: Record<string, unknown>, index: number): CoupangSearchItem {
180
+ const productId = normalizeProductId(
181
+ pickFirst(raw, ['productId', 'product_id', 'id', 'productNo', 'item.id', 'product.productId', 'url'])
182
+ );
183
+ const title = asString(
184
+ pickFirst(raw, ['title', 'name', 'productName', 'productTitle', 'itemName', 'item.title'])
185
+ );
186
+ const price = toNumber(
187
+ pickFirst(raw, ['price', 'salePrice', 'finalPrice', 'sellingPrice', 'discountPrice', 'item.price'])
188
+ );
189
+ const originalPrice = toNumber(
190
+ pickFirst(raw, ['originalPrice', 'basePrice', 'listPrice', 'originPrice', 'strikePrice'])
191
+ );
192
+ const unitPrice = asString(
193
+ pickFirst(raw, ['unitPrice', 'unit_price', 'unitPriceText'])
194
+ );
195
+ const rating = toNumber(
196
+ pickFirst(raw, ['rating', 'star', 'reviewRating', 'review.rating', 'item.rating'])
197
+ );
198
+ const reviewCount = toNumber(
199
+ pickFirst(raw, ['reviewCount', 'ratingCount', 'reviews', 'reviewCnt', 'item.reviewCount'])
200
+ );
201
+ const deliveryHintValues = [
202
+ pickFirst(raw, ['deliveryType', 'deliveryBadge', 'badgeLabel', 'shippingType', 'shippingBadge']),
203
+ pickFirst(raw, ['badge', 'badges', 'labels', 'benefitBadge', 'promotionBadge']),
204
+ pickFirst(raw, ['text', 'summary']),
205
+ pickFirst(raw, ['deliveryPromise', 'promise', 'arrivalText', 'arrivalBadge']),
206
+ pickFirst(raw, ['rocket', 'rocketType']),
207
+ ];
208
+ const deliveryType = normalizeDeliveryType(...deliveryHintValues);
209
+ const deliveryPromise = normalizeDeliveryPromise(...deliveryHintValues);
210
+ const rocket = normalizeRocket(...deliveryHintValues);
211
+ const badge = normalizeBadge(
212
+ pickFirst(raw, ['badge', 'badges', 'labels', 'benefitBadge', 'promotionBadge'])
213
+ );
214
+ const category = asString(
215
+ pickFirst(raw, ['category', 'categoryName', 'categoryPath', 'item.category'])
216
+ );
217
+ const seller = asString(
218
+ pickFirst(raw, ['seller', 'sellerName', 'vendorName', 'merchantName', 'item.seller'])
219
+ );
220
+ const url = canonicalizeProductUrl(
221
+ pickFirst(raw, ['url', 'productUrl', 'link', 'item.url']),
222
+ productId
223
+ );
224
+ const discountRate = toNumber(
225
+ pickFirst(raw, ['discountRate', 'discount', 'discountPercent', 'discount_rate'])
226
+ );
227
+
228
+ return {
229
+ rank: index + 1,
230
+ product_id: productId,
231
+ title,
232
+ price,
233
+ original_price: originalPrice,
234
+ unit_price: unitPrice,
235
+ discount_rate: discountRate,
236
+ rating,
237
+ review_count: reviewCount,
238
+ rocket,
239
+ delivery_type: deliveryType,
240
+ delivery_promise: deliveryPromise,
241
+ seller,
242
+ badge,
243
+ category,
244
+ url,
245
+ };
246
+ }
247
+
248
+ export function dedupeSearchItems(items: CoupangSearchItem[]): CoupangSearchItem[] {
249
+ const seen = new Set<string>();
250
+ const out: CoupangSearchItem[] = [];
251
+ for (const item of items) {
252
+ const key = itemKey(item);
253
+ if (!key || seen.has(key)) continue;
254
+ seen.add(key);
255
+ out.push({ ...item, rank: out.length + 1 });
256
+ }
257
+ return out;
258
+ }
259
+
260
+ export function sanitizeSearchItems(items: CoupangSearchItem[], limit: number): CoupangSearchItem[] {
261
+ return dedupeSearchItems(
262
+ items.filter(item => Boolean(item.title && (item.product_id || item.url)))
263
+ ).slice(0, limit);
264
+ }
265
+
266
+ export function mergeSearchItems(base: CoupangSearchItem[], extra: CoupangSearchItem[], limit: number): CoupangSearchItem[] {
267
+ const extraMap = new Map<string, CoupangSearchItem>();
268
+ for (const item of extra) {
269
+ const key = itemKey(item);
270
+ if (key) extraMap.set(key, item);
271
+ }
272
+
273
+ const merged = base.map((item) => {
274
+ const key = itemKey(item);
275
+ const patch = key ? extraMap.get(key) : null;
276
+ if (!patch) return item;
277
+ return {
278
+ ...item,
279
+ price: patch.price ?? item.price,
280
+ original_price: patch.original_price ?? item.original_price,
281
+ unit_price: patch.unit_price || item.unit_price,
282
+ discount_rate: patch.discount_rate ?? item.discount_rate,
283
+ rating: patch.rating ?? item.rating,
284
+ review_count: patch.review_count ?? item.review_count,
285
+ rocket: patch.rocket || item.rocket,
286
+ delivery_type: patch.delivery_type || item.delivery_type,
287
+ delivery_promise: patch.delivery_promise || item.delivery_promise,
288
+ seller: patch.seller || item.seller,
289
+ badge: patch.badge || item.badge,
290
+ category: patch.category || item.category,
291
+ url: patch.url || item.url,
292
+ };
293
+ });
294
+
295
+ const mergedKeys = new Set(merged.map(item => itemKey(item)).filter(Boolean));
296
+ const appended = extra.filter(item => {
297
+ const key = itemKey(item);
298
+ return key && !mergedKeys.has(key);
299
+ });
300
+
301
+ return sanitizeSearchItems([...merged, ...appended], limit);
302
+ }
@@ -86,36 +86,45 @@ describe('json token helpers', () => {
86
86
  });
87
87
 
88
88
  describe('doctor report rendering', () => {
89
+ const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, '');
90
+
89
91
  it('renders OK-style report when tokens match', () => {
90
- const text = renderBrowserDoctorReport({
92
+ const text = strip(renderBrowserDoctorReport({
91
93
  envToken: 'abc123',
92
94
  envFingerprint: 'fp1',
95
+ extensionToken: 'abc123',
96
+ extensionFingerprint: 'fp1',
93
97
  shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'abc123', fingerprint: 'fp1' }],
94
98
  configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
95
99
  recommendedToken: 'abc123',
96
100
  recommendedFingerprint: 'fp1',
97
101
  warnings: [],
98
102
  issues: [],
99
- });
103
+ }));
100
104
 
101
105
  expect(text).toContain('[OK] Environment token: configured (fp1)');
102
- expect(text).toContain('[OK] MCP config /tmp/mcp.json: configured (fp1)');
106
+ expect(text).toContain('[OK] /tmp/mcp.json');
107
+ expect(text).toContain('configured (fp1)');
103
108
  });
104
109
 
105
110
  it('renders MISMATCH-style report when fingerprints differ', () => {
106
- const text = renderBrowserDoctorReport({
111
+ const text = strip(renderBrowserDoctorReport({
107
112
  envToken: 'abc123',
108
113
  envFingerprint: 'fp1',
114
+ extensionToken: null,
115
+ extensionFingerprint: null,
109
116
  shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'def456', fingerprint: 'fp2' }],
110
117
  configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
111
118
  recommendedToken: 'abc123',
112
119
  recommendedFingerprint: 'fp1',
113
120
  warnings: [],
114
121
  issues: ['Detected inconsistent Playwright MCP tokens across env/config files.'],
115
- });
122
+ }));
116
123
 
117
124
  expect(text).toContain('[MISMATCH] Environment token: configured (fp1)');
118
- expect(text).toContain('[MISMATCH] Shell file /tmp/.zshrc: configured (fp2)');
125
+ expect(text).toContain('[MISMATCH] /tmp/.zshrc');
126
+ expect(text).toContain('configured (fp2)');
119
127
  expect(text).toContain('[MISMATCH] Recommended token fingerprint: fp1');
120
128
  });
121
129
  });
130
+