""" 购物车预售商品抓取服务 通过 Playwright 打开购物车页面,拦截 API + DOM 提取商品信息 """ import asyncio import json from playwright.async_api import async_playwright from utils.stealth import stealth_async from server.services.auth_service import get_browser_context, has_auth CART_URL = "https://weidian.com/new-cart/index.php" # 从 DOM 提取购物车商品(含尝试从 Vue 组件获取 itemID) EXTRACT_JS = """() => { const R = []; const sws = document.querySelectorAll( 'div.shop_info.cart_content div.shop_warp' ); for (const sw of sws) { const sn = (sw.querySelector('.shop_name') || {}).textContent || ''; const iws = sw.querySelectorAll('.item_warp'); for (const iw of iws) { const o = { shop_name: sn.trim(), cart_item_id: iw.id, item_id: '', title: '', sku_name: '', price: '', is_presale: false, countdown_text: '', sale_time: '', presale_type: '' }; // 尝试从 Vue 组件数据中提取 itemID try { const vue = iw.__vue__ || (iw.__vue_app__ && iw.__vue_app__._instance); if (vue) { const d = vue.$data || vue.data || vue; o.item_id = String(d.itemID || d.itemId || d.item_id || ''); } } catch(e) {} // 尝试从 data-* 属性提取 if (!o.item_id) { o.item_id = iw.dataset.itemId || iw.dataset.itemid || ''; } // 尝试从内部链接提取 if (!o.item_id) { const a = iw.querySelector('a[href*="itemID"]'); if (a) { const m = a.href.match(/itemID=(\\d+)/); if (m) o.item_id = m[1]; } } const te = iw.querySelector('.item_title'); if (te) o.title = te.textContent.trim(); const sk = iw.querySelector('.item_sku'); if (sk) o.sku_name = sk.textContent.trim(); const pr = iw.querySelector('.item_prices'); if (pr) o.price = pr.textContent.replace(/[^\\d.]/g, ''); const de = iw.querySelector('.item_desc'); if (de) { const dt = de.querySelector('.title'); const dd = de.querySelector('.desc'); const wm = de.querySelector('.warn_msg'); if (dt && /\\u5b9a\\u65f6\\s*\\u5f00\\u552e/.test(dt.textContent)) { o.is_presale = true; const d = dd ? dd.textContent.trim() : ''; const w = wm ? wm.textContent.trim() : ''; if (d.includes('\\u8ddd\\u79bb\\u5f00\\u552e\\u8fd8\\u5269')) { o.presale_type = 'countdown'; o.countdown_text = w; } else if (d.includes('\\u5f00\\u552e\\u65f6\\u95f4')) { o.presale_type = 'scheduled'; o.sale_time = w; } else { o.presale_type = 'unknown'; o.countdown_text = w; } } } R.push(o); } } return R; }""" async def fetch_cart_presale_items(account_id): """ 获取指定账号购物车中的预售商品列表。 双重提取:拦截购物车 API 获取 itemID 映射 + DOM 提取预售信息。 返回: (success, items_or_msg) """ if not has_auth(account_id): return False, "账号未登录" # 用于存储 API 返回的 cart_item_id -> itemID 映射 api_item_map = {} async with async_playwright() as p: browser, context = await get_browser_context( p, account_id, headless=True ) page = await context.new_page() await stealth_async(page) # 拦截购物车相关 API,提取 itemID async def on_response(response): url = response.url # 购物车 API 通常包含 cart 相关路径 if any(k in url for k in ['cart/list', 'cart/query', 'cartList', 'getCart', 'cart-server', 'newcart']): try: data = await response.json() _extract_item_ids(data, api_item_map) except Exception: pass page.on("response", on_response) try: await page.goto( CART_URL, wait_until="networkidle", timeout=20000 ) await asyncio.sleep(3) if "login" in page.url.lower(): await browser.close() return False, "登录态已过期,请重新登录" if "error" in page.url.lower(): await browser.close() return False, "购物车页面加载失败" except Exception as e: await browser.close() return False, f"打开购物车失败: {e}" # 也尝试从页面内嵌的 JS 变量/window 对象提取 try: extra_map = await page.evaluate("""() => { const m = {}; // 尝试从 window.__INITIAL_STATE__ 或类似全局变量提取 const sources = [ window.__INITIAL_STATE__, window.__NUXT__, window.__APP_DATA__, window.cartData, window.__data__, ]; function walk(obj, depth) { if (!obj || depth > 5) return; if (Array.isArray(obj)) { for (const item of obj) walk(item, depth + 1); } else if (typeof obj === 'object') { const cid = String(obj.cartItemId || obj.cart_item_id || obj.cartId || ''); const iid = String(obj.itemID || obj.itemId || obj.item_id || obj.goodsId || ''); if (cid && iid && iid !== cid) m[cid] = iid; // 也存 itemUrl if (cid && obj.itemUrl) m[cid + '_url'] = obj.itemUrl; for (const v of Object.values(obj)) walk(v, depth + 1); } } for (const s of sources) { if (s) walk(s, 0); } return m; }""") if extra_map: api_item_map.update(extra_map) except Exception: pass raw_items = await page.evaluate(EXTRACT_JS) await browser.close() # 合并 API 数据到 DOM 提取结果 for item in raw_items: cid = item.get('cart_item_id', '') if not item.get('item_id') and cid in api_item_map: item['item_id'] = api_item_map[cid] # 检查是否有 URL url_key = cid + '_url' if url_key in api_item_map: item['url'] = api_item_map[url_key] # 只返回预售商品 presale = [it for it in raw_items if it.get("is_presale")] return True, presale def _extract_item_ids(data, result_map): """递归遍历 API 响应 JSON,提取 cart_item_id -> itemID 映射""" if isinstance(data, list): for item in data: _extract_item_ids(item, result_map) elif isinstance(data, dict): # 常见字段名 cid = str(data.get('cartItemId', data.get('cart_item_id', data.get('cartId', '')))) iid = str(data.get('itemID', data.get('itemId', data.get('item_id', data.get('goodsId', ''))))) if cid and iid and cid != iid and iid != 'None': result_map[cid] = iid # 也提取 URL item_url = data.get('itemUrl', data.get('item_url', '')) if cid and item_url: result_map[cid + '_url'] = item_url for v in data.values(): if isinstance(v, (dict, list)): _extract_item_ids(v, result_map)