add-cart.js
1 import { AuthRequiredError } from '@jackwener/opencli/errors'; 2 import { cli, Strategy } from '@jackwener/opencli/registry'; 3 import { normalizeNumericId } from '../_shared/common.js'; 4 cli({ 5 site: 'taobao', 6 name: 'add-cart', 7 description: '淘宝加入购物车', 8 domain: 'item.taobao.com', 9 strategy: Strategy.COOKIE, 10 args: [ 11 { name: 'id', positional: true, required: true, help: '商品 ID' }, 12 { name: 'spec', help: '规格关键词(如 "180度" "红色 XL"),多个规格用空格分隔,模糊匹配' }, 13 { name: 'dry-run', type: 'bool', default: false, help: '仅预览,不实际加入购物车' }, 14 ], 15 columns: ['status', 'title', 'price', 'selected_spec', 'item_id'], 16 navigateBefore: false, 17 func: async (page, kwargs) => { 18 const itemId = normalizeNumericId(kwargs.id, 'id', '827563850178'); 19 const specKeywords = kwargs.spec ? String(kwargs.spec).split(/\s+/).filter(Boolean) : []; 20 const dryRun = !!kwargs['dry-run']; 21 await page.goto('https://www.taobao.com'); 22 await page.wait(2); 23 await page.evaluate(`location.href = ${JSON.stringify(`https://item.taobao.com/item.htm?id=${itemId}`)}`); 24 await page.wait(6); 25 const info = await page.evaluate(` 26 (() => { 27 const normalize = v => (v || '').replace(/\\s+/g, ' ').trim(); 28 const titleEl = document.querySelector('[class*="mainTitle--"]'); 29 const title = titleEl ? normalize(titleEl.textContent) : document.title.split('-')[0].trim(); 30 const text = document.body?.innerText || ''; 31 const priceMatch = text.match(/[¥¥]\\s*(\\d+(?:\\.\\d{1,2})?)/); 32 const price = priceMatch ? '¥' + priceMatch[1] : ''; 33 return { title: title.slice(0, 80), price }; 34 })() 35 `); 36 if (dryRun) { 37 return [{ 38 status: 'dry-run', 39 title: info?.title || '', 40 price: info?.price || '', 41 selected_spec: specKeywords.join(' ') || '(未选择)', 42 item_id: itemId, 43 }]; 44 } 45 const specArgs = JSON.stringify(specKeywords); 46 const selectResult = await page.evaluate(` 47 (() => { 48 const normalize = v => (v || '').replace(/\\s+/g, ' ').trim(); 49 const keywords = ${specArgs}; 50 const items = document.querySelectorAll('[class*="valueItem--"]'); 51 const selected = []; 52 53 if (keywords.length === 0 && items.length > 0) { 54 const groups = new Map(); 55 for (const item of items) { 56 const group = item.closest('[class*="skuItem--"], [class*="prop--"]') || item.parentElement; 57 const groupKey = group?.className?.substring(0, 30) || 'default'; 58 if (!groups.has(groupKey)) groups.set(groupKey, []); 59 groups.get(groupKey).push(item); 60 } 61 for (const [, groupItems] of groups) { 62 const hasSelected = groupItems.some(el => el.className.includes('selected') || el.className.includes('active')); 63 if (hasSelected) continue; 64 for (const item of groupItems) { 65 if (!item.className.includes('disabled') && !item.className.includes('gray')) { 66 item.click(); 67 selected.push(normalize(item.textContent).substring(0, 40)); 68 break; 69 } 70 } 71 } 72 } else { 73 const groups = new Map(); 74 for (const item of items) { 75 const group = item.closest('[class*="skuItem--"], [class*="prop--"]') || item.parentElement; 76 const groupKey = group?.className?.substring(0, 30) || 'default'; 77 if (!groups.has(groupKey)) groups.set(groupKey, []); 78 groups.get(groupKey).push(item); 79 } 80 81 for (const [, groupItems] of groups) { 82 let best = null; 83 let bestScore = 0; 84 for (const item of groupItems) { 85 if (item.className.includes('disabled')) continue; 86 const t = normalize(item.textContent); 87 const score = keywords.filter(kw => t.includes(kw)).length; 88 if (score > bestScore) { bestScore = score; best = item; } 89 } 90 if (best && bestScore > 0) { 91 best.click(); 92 selected.push(normalize(best.textContent).substring(0, 40)); 93 } 94 } 95 } 96 return selected; 97 })() 98 `); 99 await page.wait(1); 100 await page.evaluate(` 101 (() => { 102 const all = document.querySelectorAll('button, [role="button"], a, div, span'); 103 for (const el of all) { 104 const t = (el.textContent || '').trim(); 105 if ((t === '加入购物车' || t === '加入 购物车') && el.children.length < 5) { 106 el.click(); 107 return 'clicked'; 108 } 109 } 110 return 'btn_not_found'; 111 })() 112 `); 113 const result = await page.evaluate(` 114 (async () => { 115 for (let i = 0; i < 10; i++) { 116 await new Promise(r => setTimeout(r, 500)); 117 const text = document.body?.innerText || ''; 118 if (text.includes('已加入购物车') || text.includes('商品已成功') || text.includes('去购物车结算') || text.includes('去购物车')) { 119 return 'success'; 120 } 121 if (text.includes('请选择') || text.includes('请先选择')) { 122 return 'need_spec'; 123 } 124 if (text.includes('请登录')) { 125 return 'login_required'; 126 } 127 } 128 if (location.href.includes('cart')) return 'success'; 129 return 'unknown'; 130 })() 131 `); 132 if (result === 'login_required') { 133 throw new AuthRequiredError('taobao add-cart requires a logged-in Taobao session'); 134 } 135 let status = '? 未确认'; 136 if (result === 'success') 137 status = '✓ 已加入购物车'; 138 else if (result === 'need_spec') 139 status = '✗ 需要选择更多规格'; 140 const selectedSpec = Array.isArray(selectResult) ? selectResult.join(' | ') : ''; 141 return [{ 142 status, 143 title: info?.title || '', 144 price: info?.price || '', 145 selected_spec: selectedSpec || '(未选择)', 146 item_id: itemId, 147 }]; 148 }, 149 });