Files
weidian/server/services/cart_service.py
Jeason 2ebdaec965 fix: cart_item_id不是itemID,修复商品链接错误导致抢购失败
- cart_service: 拦截购物车API提取真实itemID映射
- cart_service: 从Vue组件/data属性/window全局变量多路提取itemID
- tasks: 区分item_id和cart_item_id,只有真实itemID才拼URL
- snatcher: 增加商品不存在/已下架检测,增加空URL检测
2026-04-01 13:41:10 +08:00

211 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
购物车预售商品抓取服务
通过 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)