/ clis / taobao / add-cart.js
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  });