Files
weidian/server/services/cart_service.py

211 lines
7.9 KiB
Python
Raw Normal View History

"""
购物车预售商品抓取服务
通过 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)