@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/README.md +23 -7
- package/README.zh-CN.md +24 -8
- package/SKILL.md +6 -2
- package/dist/cli-manifest.json +80 -0
- package/dist/clis/coupang/add-to-cart.d.ts +1 -0
- package/dist/clis/coupang/add-to-cart.js +141 -0
- package/dist/clis/coupang/search.d.ts +1 -0
- package/dist/clis/coupang/search.js +453 -0
- package/dist/coupang.d.ts +24 -0
- package/dist/coupang.js +262 -0
- package/dist/coupang.test.d.ts +1 -0
- package/dist/coupang.test.js +62 -0
- package/dist/doctor.d.ts +15 -0
- package/dist/doctor.js +226 -25
- package/dist/doctor.test.js +13 -6
- package/dist/main.js +7 -0
- package/dist/setup.d.ts +4 -0
- package/dist/setup.js +145 -0
- package/dist/tui.d.ts +22 -0
- package/dist/tui.js +139 -0
- package/package.json +1 -1
- package/src/clis/coupang/add-to-cart.ts +149 -0
- package/src/clis/coupang/search.ts +466 -0
- package/src/coupang.test.ts +78 -0
- package/src/coupang.ts +302 -0
- package/src/doctor.test.ts +15 -6
- package/src/doctor.ts +221 -25
- package/src/main.ts +8 -0
- package/src/setup.ts +169 -0
- package/src/tui.ts +171 -0
package/dist/tui.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tui.ts — Zero-dependency interactive TUI components
|
|
3
|
+
*
|
|
4
|
+
* Uses raw stdin mode + ANSI escape codes for interactive prompts.
|
|
5
|
+
*/
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
/**
|
|
8
|
+
* Interactive multi-select checkbox prompt.
|
|
9
|
+
*
|
|
10
|
+
* Controls:
|
|
11
|
+
* ↑/↓ or j/k — navigate
|
|
12
|
+
* Space — toggle selection
|
|
13
|
+
* a — toggle all
|
|
14
|
+
* Enter — confirm
|
|
15
|
+
* q/Esc — cancel (returns empty)
|
|
16
|
+
*/
|
|
17
|
+
export async function checkboxPrompt(items, opts = {}) {
|
|
18
|
+
if (items.length === 0)
|
|
19
|
+
return [];
|
|
20
|
+
const { stdin, stdout } = process;
|
|
21
|
+
if (!stdin.isTTY) {
|
|
22
|
+
// Non-interactive: return all checked items
|
|
23
|
+
return items.filter(i => i.checked).map(i => i.value);
|
|
24
|
+
}
|
|
25
|
+
let cursor = 0;
|
|
26
|
+
const state = items.map(i => ({ ...i }));
|
|
27
|
+
function colorStatus(status, color) {
|
|
28
|
+
if (!status)
|
|
29
|
+
return '';
|
|
30
|
+
switch (color) {
|
|
31
|
+
case 'green': return chalk.green(status);
|
|
32
|
+
case 'yellow': return chalk.yellow(status);
|
|
33
|
+
case 'red': return chalk.red(status);
|
|
34
|
+
case 'dim': return chalk.dim(status);
|
|
35
|
+
default: return chalk.dim(status);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function render() {
|
|
39
|
+
// Move cursor to start and clear
|
|
40
|
+
let out = '';
|
|
41
|
+
if (opts.title) {
|
|
42
|
+
out += `\n${chalk.bold(opts.title)}\n\n`;
|
|
43
|
+
}
|
|
44
|
+
for (let i = 0; i < state.length; i++) {
|
|
45
|
+
const item = state[i];
|
|
46
|
+
const pointer = i === cursor ? chalk.cyan('❯') : ' ';
|
|
47
|
+
const checkbox = item.checked ? chalk.green('◉') : chalk.dim('○');
|
|
48
|
+
const label = i === cursor ? chalk.bold(item.label) : item.label;
|
|
49
|
+
const status = colorStatus(item.status, item.statusColor);
|
|
50
|
+
out += ` ${pointer} ${checkbox} ${label}${status ? ` ${status}` : ''}\n`;
|
|
51
|
+
}
|
|
52
|
+
out += `\n ${chalk.dim('↑↓ navigate · Space toggle · a all · Enter confirm · q cancel')}\n`;
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
const wasRaw = stdin.isRaw;
|
|
57
|
+
stdin.setRawMode(true);
|
|
58
|
+
stdin.resume();
|
|
59
|
+
stdout.write('\x1b[?25l'); // Hide cursor
|
|
60
|
+
let firstDraw = true;
|
|
61
|
+
function draw() {
|
|
62
|
+
// Clear previous render (skip on first draw)
|
|
63
|
+
if (!firstDraw) {
|
|
64
|
+
const lines = render().split('\n').length;
|
|
65
|
+
stdout.write(`\x1b[${lines}A\x1b[J`);
|
|
66
|
+
}
|
|
67
|
+
firstDraw = false;
|
|
68
|
+
stdout.write(render());
|
|
69
|
+
}
|
|
70
|
+
function cleanup() {
|
|
71
|
+
stdin.setRawMode(wasRaw ?? false);
|
|
72
|
+
stdin.pause();
|
|
73
|
+
stdin.removeListener('data', onData);
|
|
74
|
+
// Clear the TUI and restore cursor
|
|
75
|
+
const lines = render().split('\n').length;
|
|
76
|
+
stdout.write(`\x1b[${lines}A\x1b[J`);
|
|
77
|
+
stdout.write('\x1b[?25h'); // Show cursor
|
|
78
|
+
}
|
|
79
|
+
function onData(data) {
|
|
80
|
+
const key = data.toString();
|
|
81
|
+
// Arrow up / k
|
|
82
|
+
if (key === '\x1b[A' || key === 'k') {
|
|
83
|
+
cursor = (cursor - 1 + state.length) % state.length;
|
|
84
|
+
draw();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// Arrow down / j
|
|
88
|
+
if (key === '\x1b[B' || key === 'j') {
|
|
89
|
+
cursor = (cursor + 1) % state.length;
|
|
90
|
+
draw();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// Space — toggle
|
|
94
|
+
if (key === ' ') {
|
|
95
|
+
state[cursor].checked = !state[cursor].checked;
|
|
96
|
+
draw();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// Tab — toggle and move down
|
|
100
|
+
if (key === '\t') {
|
|
101
|
+
state[cursor].checked = !state[cursor].checked;
|
|
102
|
+
cursor = (cursor + 1) % state.length;
|
|
103
|
+
draw();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// 'a' — toggle all
|
|
107
|
+
if (key === 'a') {
|
|
108
|
+
const allChecked = state.every(i => i.checked);
|
|
109
|
+
for (const item of state)
|
|
110
|
+
item.checked = !allChecked;
|
|
111
|
+
draw();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// Enter — confirm
|
|
115
|
+
if (key === '\r' || key === '\n') {
|
|
116
|
+
cleanup();
|
|
117
|
+
const selected = state.filter(i => i.checked).map(i => i.value);
|
|
118
|
+
// Show summary
|
|
119
|
+
stdout.write(` ${chalk.green('✓')} ${chalk.bold(`${selected.length} file(s) selected`)}\n\n`);
|
|
120
|
+
resolve(selected);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// q / Esc — cancel
|
|
124
|
+
if (key === 'q' || key === '\x1b') {
|
|
125
|
+
cleanup();
|
|
126
|
+
stdout.write(` ${chalk.yellow('✗')} ${chalk.dim('Cancelled')}\n\n`);
|
|
127
|
+
resolve([]);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// Ctrl+C — exit process
|
|
131
|
+
if (key === '\x03') {
|
|
132
|
+
cleanup();
|
|
133
|
+
process.exit(130);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
stdin.on('data', onData);
|
|
137
|
+
draw();
|
|
138
|
+
});
|
|
139
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import { canonicalizeProductUrl, normalizeProductId } from '../../coupang.js';
|
|
3
|
+
|
|
4
|
+
function escapeJsString(value: string): string {
|
|
5
|
+
return JSON.stringify(value);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function buildAddToCartEvaluate(expectedProductId: string): string {
|
|
9
|
+
return `
|
|
10
|
+
(async () => {
|
|
11
|
+
const expectedProductId = ${escapeJsString(expectedProductId)};
|
|
12
|
+
const text = document.body.innerText || '';
|
|
13
|
+
const loginHints = {
|
|
14
|
+
hasLoginLink: Boolean(document.querySelector('a[href*="login"], a[title*="로그인"]')),
|
|
15
|
+
hasMyCoupang: /마이쿠팡/.test(text),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const pathMatch = location.pathname.match(/\\/vp\\/products\\/(\\d+)/);
|
|
19
|
+
const currentProductId = pathMatch?.[1] || '';
|
|
20
|
+
if (expectedProductId && currentProductId && expectedProductId !== currentProductId) {
|
|
21
|
+
return { ok: false, reason: 'PRODUCT_MISMATCH', currentProductId, loginHints };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const optionSelectors = [
|
|
25
|
+
'select',
|
|
26
|
+
'[role="listbox"]',
|
|
27
|
+
'.prod-option, .product-option, .option-select, .option-dropdown',
|
|
28
|
+
];
|
|
29
|
+
const hasRequiredOption = optionSelectors.some((selector) => {
|
|
30
|
+
try {
|
|
31
|
+
const nodes = Array.from(document.querySelectorAll(selector));
|
|
32
|
+
return nodes.some((node) => {
|
|
33
|
+
const label = (node.textContent || '') + ' ' + (node.getAttribute?.('aria-label') || '');
|
|
34
|
+
return /옵션|색상|사이즈|용량|선택/i.test(label);
|
|
35
|
+
});
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
if (hasRequiredOption) {
|
|
41
|
+
return { ok: false, reason: 'OPTION_REQUIRED', currentProductId, loginHints };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const clickCandidate = (elements) => {
|
|
45
|
+
for (const element of elements) {
|
|
46
|
+
if (!(element instanceof HTMLElement)) continue;
|
|
47
|
+
const label = ((element.innerText || '') + ' ' + (element.getAttribute('aria-label') || '')).trim();
|
|
48
|
+
if (/장바구니|카트|cart/i.test(label) && !/sold out|품절/i.test(label)) {
|
|
49
|
+
element.click();
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const beforeCount = (() => {
|
|
57
|
+
const node = document.querySelector('[class*="cart"] .count, #headerCartCount, .cart-count');
|
|
58
|
+
const text = node?.textContent || '';
|
|
59
|
+
const num = Number(text.replace(/[^\\d]/g, ''));
|
|
60
|
+
return Number.isFinite(num) ? num : null;
|
|
61
|
+
})();
|
|
62
|
+
|
|
63
|
+
const buttons = Array.from(document.querySelectorAll('button, a[role="button"], input[type="button"]'));
|
|
64
|
+
const clicked = clickCandidate(buttons);
|
|
65
|
+
if (!clicked) {
|
|
66
|
+
return { ok: false, reason: 'ADD_TO_CART_BUTTON_NOT_FOUND', currentProductId, loginHints };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
await new Promise((resolve) => setTimeout(resolve, 2500));
|
|
70
|
+
|
|
71
|
+
const afterText = document.body.innerText || '';
|
|
72
|
+
const successMessage = /장바구니에 담|장바구니 담기 완료|added to cart/i.test(afterText);
|
|
73
|
+
const afterCount = (() => {
|
|
74
|
+
const node = document.querySelector('[class*="cart"] .count, #headerCartCount, .cart-count');
|
|
75
|
+
const text = node?.textContent || '';
|
|
76
|
+
const num = Number(text.replace(/[^\\d]/g, ''));
|
|
77
|
+
return Number.isFinite(num) ? num : null;
|
|
78
|
+
})();
|
|
79
|
+
const countIncreased =
|
|
80
|
+
beforeCount != null &&
|
|
81
|
+
afterCount != null &&
|
|
82
|
+
afterCount >= beforeCount &&
|
|
83
|
+
(afterCount > beforeCount || beforeCount === 0);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
ok: successMessage || countIncreased,
|
|
87
|
+
reason: successMessage || countIncreased ? 'SUCCESS' : 'UNKNOWN',
|
|
88
|
+
currentProductId,
|
|
89
|
+
beforeCount,
|
|
90
|
+
afterCount,
|
|
91
|
+
loginHints,
|
|
92
|
+
};
|
|
93
|
+
})()
|
|
94
|
+
`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
cli({
|
|
98
|
+
site: 'coupang',
|
|
99
|
+
name: 'add-to-cart',
|
|
100
|
+
description: 'Add a Coupang product to cart using logged-in browser session',
|
|
101
|
+
domain: 'www.coupang.com',
|
|
102
|
+
strategy: Strategy.COOKIE,
|
|
103
|
+
browser: true,
|
|
104
|
+
args: [
|
|
105
|
+
{ name: 'productId', required: false, help: 'Coupang product ID' },
|
|
106
|
+
{ name: 'url', required: false, help: 'Canonical product URL' },
|
|
107
|
+
],
|
|
108
|
+
columns: ['ok', 'product_id', 'url', 'message'],
|
|
109
|
+
func: async (page, kwargs) => {
|
|
110
|
+
const rawProductId = kwargs.productId ?? kwargs.product_id;
|
|
111
|
+
const productId = normalizeProductId(rawProductId);
|
|
112
|
+
const targetUrl = canonicalizeProductUrl(kwargs.url, productId);
|
|
113
|
+
|
|
114
|
+
if (!productId && !targetUrl) {
|
|
115
|
+
throw new Error('Either --product-id or --url is required');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const finalUrl = targetUrl || canonicalizeProductUrl('', productId);
|
|
119
|
+
await page.goto(finalUrl);
|
|
120
|
+
await page.wait(3);
|
|
121
|
+
|
|
122
|
+
const result = await page.evaluate(buildAddToCartEvaluate(productId));
|
|
123
|
+
const loginHints = result?.loginHints ?? {};
|
|
124
|
+
if (loginHints.hasLoginLink && !loginHints.hasMyCoupang) {
|
|
125
|
+
throw new Error('Coupang login required. Please log into Coupang in Chrome and retry.');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const actualProductId = normalizeProductId(result?.currentProductId || productId);
|
|
129
|
+
if (result?.reason === 'PRODUCT_MISMATCH') {
|
|
130
|
+
throw new Error(`Product mismatch: expected ${productId}, got ${actualProductId || 'unknown'}`);
|
|
131
|
+
}
|
|
132
|
+
if (result?.reason === 'OPTION_REQUIRED') {
|
|
133
|
+
throw new Error('This product requires option selection and is not supported in v1.');
|
|
134
|
+
}
|
|
135
|
+
if (result?.reason === 'ADD_TO_CART_BUTTON_NOT_FOUND') {
|
|
136
|
+
throw new Error('Could not find an add-to-cart button on the product page.');
|
|
137
|
+
}
|
|
138
|
+
if (!result?.ok) {
|
|
139
|
+
throw new Error('Failed to confirm add-to-cart success.');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return [{
|
|
143
|
+
ok: true,
|
|
144
|
+
product_id: actualProductId || productId,
|
|
145
|
+
url: finalUrl,
|
|
146
|
+
message: 'Added to cart',
|
|
147
|
+
}];
|
|
148
|
+
},
|
|
149
|
+
});
|