// ==UserScript==
// @name 全网比价助手
// @name:zh 全网比价助手
// @namespace https://github.com/qinmuran0316/bilian-price-comparison
// @version 1.1.4
// @author qinmuran
// @description 在淘宝、天猫、京东、拼多多、唯品会商品页识别当前商品,一键跳转各平台搜同款;连接本机服务后可查看同款价格、隐藏券和历史价。需自行运行配套本机服务,数据不出本机。
// @description:zh 在淘宝、天猫、京东、拼多多、唯品会商品页识别当前商品,一键跳转各平台搜同款;连接本机服务后可查看同款价格、隐藏券和历史价。需自行运行配套本机服务,数据不出本机。
// @license MIT
// @homepageURL https://github.com/qinmuran0316/bilian-price-comparison
// @supportURL https://github.com/qinmuran0316/bilian-price-comparison/issues
// @tag 比价
// @tag 购物
// @tag 优惠券
// @tag 历史价格
// @match *://*.taobao.com/*
// @match *://*.tmall.com/*
// @match *://*.tmall.hk/*
// @match *://*.ju.taobao.com/*
// @match *://*.jd.com/*
// @match *://*.jd.hk/*
// @match *://*.yiyaojd.com/*
// @match *://*.jkcsjd.com/*
// @match *://*.jingdonghealth.cn/*
// @match *://*.vip.com/*
// @match *://*.vipglobal.hk/*
// @match *://*.pinduoduo.com/*
// @match *://*.yangkeduo.com/*
// @exclude *://login.taobao.com/*
// @exclude *://uland.taobao.com/*
// @exclude *://login.tmall.com/*
// @exclude *://pages.tmall.com/*
// @exclude *://cart.taobao.com/*
// @exclude *://cart.jd.com/*
// @exclude *://order.jd.com/*
// @exclude *://trade.jd.com/*
// @exclude *://wq.jd.com/*
// @exclude *://union.jd.com/*
// @exclude *://pay.jd.com/*
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_openInTab
// @grant GM_setClipboard
// @grant unsafeWindow
// @connect 127.0.0.1
// @connect localhost
// @run-at document-idle
// @noframes
// ==/UserScript==
/* ==================== core.js ==================== */
/**
* 公共核心:纯函数,不碰 DOM、不碰网络。
* 浏览器里挂到 window.BL.core,Node 里走 module.exports(服务端和测试共用同一份)。
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
root.BL = root.BL || {};
root.BL.core = factory();
}
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
var VERSION = '1.1.4';
var DAY_MS = 86400000;
function round2(n) {
return Math.round((Number(n) + Number.EPSILON) * 100) / 100;
}
function clamp(n, min, max) {
return Math.min(max, Math.max(min, n));
}
/** 价格解析:兼容 "¥1,299.00" / "1299,00" / "¥ 89" / 数字 / 分(由调用方标注) */
function parsePrice(input) {
if (typeof input === 'number') return isFinite(input) && input > 0 ? round2(input) : 0;
if (input === null || input === undefined) return 0;
var s = String(input).replace(/[^\d.,]/g, '');
if (!s) return 0;
// 欧洲写法 1.299,00:最后一个是小数点
if (/,\d{1,2}$/.test(s) && /\./.test(s)) {
s = s.replace(/\./g, '').replace(',', '.');
} else if (/,\d{1,2}$/.test(s) && !/\./.test(s)) {
s = s.replace(',', '.');
} else {
s = s.replace(/,/g, '');
}
var n = parseFloat(s);
return isFinite(n) && n > 0 ? round2(n) : 0;
}
/** 销量 "2.3万+" / "1.2亿" / "5000" -> 数字 */
function parseSales(input) {
if (typeof input === 'number') return Math.max(0, Math.round(input));
var s = String(input || '').replace(/,/g, '');
var m = s.match(/(\d+(?:\.\d+)?)\s*(万|亿|w|k)?/i);
if (!m) return 0;
var n = parseFloat(m[1]);
if (!isFinite(n)) return 0;
var unit = (m[2] || '').toLowerCase();
if (unit === '万' || unit === 'w') n *= 10000;
else if (unit === '亿') n *= 100000000;
else if (unit === 'k') n *= 1000;
return Math.round(n);
}
function toHalfWidth(str) {
return String(str || '')
.replace(/[\uFF01-\uFF5E]/g, function (ch) {
return String.fromCharCode(ch.charCodeAt(0) - 0xfee0);
})
.replace(/\u3000/g, ' ');
}
// 促销词直接抹掉:跨平台标题差异主要来自这些装饰词
var PROMO_WORDS = [
'官方正品', '官方旗舰店', '旗舰店', '专卖店', '自营', '官方', '正品', '包邮',
'百亿补贴', '补贴', '限时', '秒杀', '特价', '清仓', '促销', '优惠', '券后',
'拍下', '立减', '满减', '赠品', '买一送一', '假一赔十', '顺丰', '现货',
'预售', '新品', '新款', '爆款', '热卖', '同款', '专柜', '直营', '进口',
'国行', '七天无理由', '24期免息', '分期免息', '支持花呗', '以旧换新',
'直播间', '直播专享', '会员价', 'plus会员', 'plus', '京东自营', '天猫超市',
'天猫国际', '海外直邮', '保税仓发货', '次日达', '当天发货', '官方直营',
'正品保障', '品质保障', '厂直', '工厂直供', '一件代发', '批发', '团购'
];
function normalizeTitle(title) {
var t = toHalfWidth(String(title || '').toLowerCase());
for (var i = 0; i < PROMO_WORDS.length; i++) {
t = t.split(PROMO_WORDS[i].toLowerCase()).join(' ');
}
// 标点统一换成空格,保留数字里的点(型号 3.5 / 容量 1.5l)
// 用十六进制写方括号,避免 ESLint no-useless-escape 把 \[ 判成多余转义;
// 连字符放在字符类开头,行为仍是把这些标点替换为空格。
t = t.replace(/[-\x5B\x5D【】()()<>《》「」『』{}"'“”‘’·•…—_+*/\\|,,。!!??::;;~~@#$%^&^=]/g, ' ');
t = t.replace(/\s+/g, ' ').trim();
return t;
}
function tokenize(title) {
var t = normalizeTitle(title);
var out = new Set();
var latin = t.match(/[a-z0-9]+(?:\.[0-9]+)?/g) || [];
for (var i = 0; i < latin.length; i++) {
var w = latin[i];
if (w.length >= 2) out.add(w);
}
var cjkSegs = t.replace(/[a-z0-9.]+/g, ' ').split(/[^\u4e00-\u9fa5]+/);
for (var j = 0; j < cjkSegs.length; j++) {
var seg = cjkSegs[j].trim();
if (!seg) continue;
if (seg.length === 1) {
out.add(seg);
continue;
}
// 中文用 bigram:比单字稳,比分词器轻
for (var k = 0; k < seg.length - 1; k++) out.add(seg.substr(k, 2));
}
return out;
}
function similarity(a, b) {
var A = tokenize(a);
var B = tokenize(b);
if (!A.size || !B.size) return 0;
var inter = 0;
A.forEach(function (x) {
if (B.has(x)) inter++;
});
var union = A.size + B.size - inter;
var jaccard = union ? inter / union : 0;
var contain = inter / Math.min(A.size, B.size);
return round2(jaccard * 0.65 + contain * 0.35);
}
var SPEC_RE = /(\d+(?:\.\d+)?)\s*(ml|l|毫升|升|g|kg|克|千克|片|粒|袋|包|瓶|支|盒|件|双|只|套|张|抽|枚|条|个|寸|英寸)/gi;
function extractSpec(title) {
var out = new Set();
var m;
SPEC_RE.lastIndex = 0;
while ((m = SPEC_RE.exec(String(title || ''))) !== null) {
var n = parseFloat(m[1]);
if (!isFinite(n)) continue;
var unit = m[2].toLowerCase();
if (unit === 'l' || unit === '升') {
n = n * 1000;
unit = 'ml';
} else if (unit === '毫升') {
unit = 'ml';
} else if (unit === 'kg' || unit === '千克') {
n = n * 1000;
unit = 'g';
} else if (unit === '克') {
unit = 'g';
} else if (unit === '英寸') {
unit = '寸';
}
out.add(n + unit);
}
return out;
}
var GENERIC_TOKENS = {
plus: 1, pro: 1, max: 1, mini: 1, new: 1, gen: 1, series: 1, model: 1,
inch: 1, gb: 1, tb: 1, ml: 1, kg: 1, cm: 1, mm: 1, mah: 1, 'w': 1, 'v': 1,
the: 1, and: 1, for: 1, with: 1, pack: 1, set: 1
};
function brandToken(title) {
var words = String(title || '').toLowerCase().match(/[a-z][a-z0-9&'-]{2,}/g) || [];
var best = '';
for (var i = 0; i < words.length; i++) {
var w = words[i].replace(/[^a-z0-9]/g, '');
if (!w || GENERIC_TOKENS[w]) continue;
if (w.length > best.length) best = w;
}
return best;
}
/**
* 同款打分 0-100。
* 标题相似度为主,规格(容量/重量/片数)冲突直接扣分,品牌相同加分。
*/
function scoreMatch(queryTitle, candidateTitle) {
var sim = similarity(queryTitle, candidateTitle);
var score = sim * 100;
var qs = extractSpec(queryTitle);
var cs = extractSpec(candidateTitle);
if (qs.size && cs.size) {
var overlap = 0;
qs.forEach(function (x) {
if (cs.has(x)) overlap++;
});
if (!overlap) score -= 18;
else score += 4;
}
var qb = brandToken(queryTitle);
var cb = brandToken(candidateTitle);
if (qb && cb) score += qb === cb ? 8 : -6;
return Math.round(clamp(score, 0, 100));
}
function computeFinalPrice(price, coupon, threshold) {
var p = parsePrice(price);
var c = parsePrice(coupon);
var th = parsePrice(threshold);
if (!p) return 0;
if (!c) return round2(p);
if (th && p < th) return round2(p);
return round2(Math.max(0, p - c));
}
function hotScore(item) {
var sales = parseSales(item && item.sales);
var reviews = parseSales(item && item.reviews);
return sales * 0.75 + reviews * 0.25;
}
var SORTS = ['renqi', 'xiaoliang', 'jiage'];
function sortValue(item, sort) {
if (sort === 'jiage') {
var f = parsePrice(item.finalPrice || item.price);
return f > 0 ? f : Number.MAX_SAFE_INTEGER;
}
if (sort === 'xiaoliang') return parseSales(item.sales);
return hotScore(item);
}
/** 稳定排序:价格升序,其余降序;销量相同按到手价升序 */
function sortItems(items, sort) {
var arr = (items || []).slice();
var dir = sort === 'jiage' ? 1 : -1;
arr.sort(function (a, b) {
var va = sortValue(a, sort);
var vb = sortValue(b, sort);
if (va !== vb) return (va - vb) * dir;
var pa = parsePrice(a.finalPrice || a.price) || Number.MAX_SAFE_INTEGER;
var pb = parsePrice(b.finalPrice || b.price) || Number.MAX_SAFE_INTEGER;
return pa - pb;
});
return arr;
}
function formatPrice(n) {
var v = Number(n);
if (!isFinite(v)) return '¥0.00';
var fixed = v.toFixed(2);
var parts = fixed.split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return '¥' + parts.join('.');
}
function escapeHtml(str) {
return String(str === null || str === undefined ? '' : str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function timeAgo(ts) {
var diff = Date.now() - Number(ts || 0);
if (!isFinite(diff) || diff < 0) return '';
var m = Math.floor(diff / 60000);
if (m < 1) return '刚刚';
if (m < 60) return m + ' 分钟前';
var h = Math.floor(m / 60);
if (h < 24) return h + ' 小时前';
return Math.floor(h / 24) + ' 天前';
}
/** 站点识别:只看 host,不看 path,避免被查询参数里的域名骗到 */
function detectPlatform(host) {
var h = String(host || '').toLowerCase().split(':')[0];
if (!h) return '';
if (/(^|\.)tmall\.hk$/.test(h)) return 'tmall';
if (/(^|\.)pinduoduo\.com$/.test(h) || /(^|\.)yangkeduo\.com$/.test(h)) return 'pdd';
if (/(^|\.)vip\.com$/.test(h) || /(^|\.)vipglobal\.hk$/.test(h)) return 'vip';
if (/(^|\.)jd\.com$/.test(h) || /(^|\.)jd\.hk$/.test(h) ||
/(^|\.)yiyaojd\.com$/.test(h) || /(^|\.)jkcsjd\.com$/.test(h) ||
/(^|\.)jingdonghealth\.cn$/.test(h)) return 'jd';
if (/(^|\.)tmall\.com$/.test(h)) return 'tmall';
if (/(^|\.)taobao\.com$/.test(h)) return 'taobao';
return '';
}
function detectChannel(host, pathname) {
var h = String(host || '').toLowerCase().split(':')[0];
var p = String(pathname || '');
if (h.indexOf('ju.taobao.com') >= 0) return '聚划算';
if (h.indexOf('chaoshi.') === 0) return '天猫超市';
if (/(^|\.)tmall\.hk$/.test(h)) return '天猫国际';
if (/(^|\.)jd\.hk$/.test(h)) return '京东国际';
if (h === 'book.jd.com' || /^\/book\//.test(p)) return '京东图书';
if (h === 'e.jd.com' || h === 'ebook.jd.com') return '京东电子书';
if (h === 'gy.jd.com' || h.indexOf('gongyepin') >= 0) return '京东工业品';
if (/(^|\.)yiyaojd\.com$/.test(h) || /(^|\.)jingdonghealth\.cn$/.test(h) ||
/(^|\.)jkcsjd\.com$/.test(h)) return '京东大药房';
if (/(^|\.)vip\.com$/.test(h) || /(^|\.)vipglobal\.hk$/.test(h)) return '唯品会';
return '商品详情';
}
var PLATFORM_NAMES = {
taobao: '淘宝', tmall: '天猫', jd: '京东', pdd: '拼多多', vip: '唯品会'
};
function platformName(p) {
return PLATFORM_NAMES[p] || String(p || '');
}
function extractItemId(url, platform) {
var u;
try {
u = new URL(String(url || ''));
} catch (e) {
return '';
}
var q = u.searchParams;
var m;
if (platform === 'taobao' || platform === 'tmall') {
var id = q.get('id') || q.get('itemId') || q.get('item_id');
if (id && /^\d{5,}$/.test(id)) return id;
m = u.pathname.match(/\/i(\d+)\.htm/);
if (m) return m[1];
}
if (platform === 'jd') {
m = u.pathname.match(/\/(\d{5,})\.html/);
if (m) return m[1];
var sku = q.get('sku') || q.get('skuId');
if (sku && /^\d{5,}$/.test(sku)) return sku;
}
if (platform === 'pdd') {
var gid = q.get('goods_id') || q.get('goodsId');
if (gid && /^\d{5,}$/.test(gid)) return gid;
}
if (platform === 'vip') {
m = u.pathname.match(/detail-(\d+)-\d+\.html/);
if (m) return m[1];
m = u.pathname.match(/\/(\d{6,})\.html/);
if (m) return m[1];
var pid = q.get('pid') || q.get('productId');
if (pid && /^\d{5,}$/.test(pid)) return pid;
}
var any = q.get('id') || q.get('goods_id') || q.get('sku') || q.get('pid');
return any && /^\d{5,}$/.test(any) ? any : '';
}
function buildSearchUrl(platform, keyword) {
var q = encodeURIComponent(String(keyword || ''));
switch (platform) {
case 'taobao': return 'https://s.taobao.com/search?q=' + q;
case 'tmall': return 'https://list.tmall.com/search_product.htm?q=' + q;
case 'jd': return 'https://search.jd.com/Search?keyword=' + q + '&enc=utf-8';
case 'pdd': return 'https://mobile.yangkeduo.com/search_result.html?search_key=' + q;
case 'vip': return 'https://category.vip.com/suggest.php?keyword=' + q;
default: return '';
}
}
/** FNV-1a,用于 mock 数据的确定性种子、缓存 key */
function hashString(str) {
var h = 2166136261;
var s = String(str || '');
for (var i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
function debounce(fn, wait) {
var timer = null;
return function () {
var args = arguments;
var ctx = this;
if (timer) clearTimeout(timer);
timer = setTimeout(function () {
timer = null;
fn.apply(ctx, args);
}, wait);
};
}
function pad2(n) {
return n < 10 ? '0' + n : String(n);
}
function formatDate(ts, withTime) {
var d = new Date(Number(ts) || 0);
if (isNaN(d.getTime())) return '';
var base = d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
return withTime ? base + ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes()) : base;
}
/** 轴标签用的紧凑价格:1234.5 -> 1235,12345 -> 1.2万 */
function shortPrice(v) {
var n = Number(v) || 0;
if (n >= 10000) return (n / 10000).toFixed(n >= 100000 ? 0 : 1) + '万';
if (n >= 1000) return String(Math.round(n));
return n % 1 ? n.toFixed(2) : String(Math.round(n));
}
/**
* 按本地时区取「天」编号。
* 用 UTC 毫秒除以 86400000 会在东八区把凌晨算到前一天,
* 导致同一天的本地采样和上游数据被拆成两行。
*/
function dayKey(ts) {
var d = new Date(Number(ts) || 0);
return d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate();
}
/**
* 按天聚合成交价,每天只留最低价。
* 一天内反复刷新页面会产生大量同值采样,不聚合会把均值和分位数带偏。
*/
function dailyMin(points) {
var map = Object.create(null);
(points || []).forEach(function (x) {
var t = Number(x.t || x.ts || 0);
var p = parsePrice(x.p !== undefined ? x.p : x.price);
if (!t || !p) return;
var day = dayKey(t);
var cur = map[day];
if (!cur || p < cur.p) {
map[day] = { t: t, p: p, day: day };
} else if (p === cur.p && t > cur.t) {
cur.t = t;
}
});
var out = [];
for (var k in map) out.push(map[k]);
out.sort(function (a, b) { return a.day - b.day; });
return out;
}
/**
* 历史价统计。输入是顺序无关的采样点,输出全部为可序列化数值,前后端共用。
* position = 历史上比当前价更低的采样占比,用来判断「现在贵不贵」。
*/
function buildHistoryStats(points, currentPrice, days) {
var pts = dailyMin(points);
var cur = parsePrice(currentPrice);
if (!pts.length) {
return {
count: 0, current: cur, min: null, max: null, avg: 0, median: 0,
vsMin: 0, vsMax: 0, vsAvg: 0, position: 0, trend: 0,
range: [0, 0], days: days || 0, firstTs: 0, lastTs: 0
};
}
if (!cur) cur = pts[pts.length - 1].p;
var prices = pts.map(function (x) { return x.p; });
var sum = 0;
for (var i = 0; i < prices.length; i++) sum += prices[i];
var minP = Math.min.apply(null, prices);
var maxP = Math.max.apply(null, prices);
var sorted = prices.slice().sort(function (a, b) { return a - b; });
var mid = Math.floor(sorted.length / 2);
var median = sorted.length % 2 ? sorted[mid] : round2((sorted[mid - 1] + sorted[mid]) / 2);
var minPt = pts[0];
var maxPt = pts[0];
for (var j = 1; j < pts.length; j++) {
if (pts[j].p <= minPt.p) minPt = pts[j];
if (pts[j].p >= maxPt.p) maxPt = pts[j];
}
var below = 0;
for (var k = 0; k < prices.length; k++) if (prices[k] < cur) below++;
var position = Math.round((below / prices.length) * 100);
// 近 7 天均价 vs 前 7 天均价,判断短期是涨是跌
var lastTs = pts[pts.length - 1].t;
var recent = [];
var prev = [];
for (var m = 0; m < pts.length; m++) {
if (pts[m].t > lastTs - 7 * DAY_MS) recent.push(pts[m]);
else if (pts[m].t > lastTs - 14 * DAY_MS) prev.push(pts[m]);
}
function mean(arr) {
if (!arr.length) return 0;
var s = 0;
for (var q = 0; q < arr.length; q++) s += arr[q].p;
return s / arr.length;
}
var rAvg = mean(recent);
var pAvg = mean(prev);
var trend = pAvg && rAvg ? round2(((rAvg - pAvg) / pAvg) * 100) : 0;
return {
count: pts.length,
current: round2(cur),
min: { price: round2(minP), ts: minPt.t, date: formatDate(minPt.t) },
max: { price: round2(maxP), ts: maxPt.t, date: formatDate(maxPt.t) },
avg: round2(sum / prices.length),
median: median,
vsMin: minP ? round2(((cur - minP) / minP) * 100) : 0,
vsMax: maxP ? round2(((cur - maxP) / maxP) * 100) : 0,
vsAvg: sum ? round2(((cur - sum / prices.length) / (sum / prices.length)) * 100) : 0,
position: position,
trend: trend,
range: [round2(minP), round2(maxP)],
days: days || Math.max(1, Math.round((pts[pts.length - 1].t - pts[0].t) / DAY_MS)),
firstTs: pts[0].t,
lastTs: lastTs
};
}
/** 把统计结果翻成人话,code 用于配色 */
function historyAdvice(stats) {
if (!stats || !stats.count) {
return { code: 'unknown', label: '暂无历史数据', hint: '多打开几次商品页,价格会自动累积成曲线' };
}
if (stats.count < 3) {
return { code: 'sampling', label: '采样中', hint: '目前只有 ' + stats.count + ' 个采样点,至少 3 天才有参考价值' };
}
if (stats.vsMin <= 1) {
return { code: 'best', label: '接近历史最低', hint: '比历史最低仅高 ' + stats.vsMin.toFixed(1) + '%,可以入手' };
}
if (stats.position <= 25) {
return { code: 'good', label: '低于多数历史价位', hint: '比 ' + (100 - stats.position) + '% 的历史时间更便宜' };
}
if (stats.position >= 80) {
return { code: 'high', label: '高于多数历史价位', hint: '建议再等等,历史最低 ' + formatPrice(stats.min.price) };
}
return { code: 'normal', label: '价格处于中间区间', hint: '距历史最低还有 ' + stats.vsMin.toFixed(1) + '% 空间' };
}
/**
* 生成走势图 SVG,返回 { svg, geom, stats }。
* geom 带每个点的 viewBox 坐标,交互层(hover 竖线、tooltip)复用同一套映射,
* 避免图形和命中区域算两遍。
*/
function buildChartSvg(rawPoints, opts) {
var o = opts || {};
var W = o.width || 340;
var H = o.height || 150;
var compact = Boolean(o.compact);
var pad = o.padding || (compact
? { l: 6, r: 6, t: 8, b: 6 }
: { l: 46, r: 16, t: 18, b: 22 });
var pts = dailyMin(rawPoints);
if (!pts.length) return { svg: '', geom: null, stats: null };
var plotW = W - pad.l - pad.r;
var plotH = H - pad.t - pad.b;
// 深色模式由调用方传色,core 不猜站点主题
var C = Object.assign({
line: '#e11d48',
grid: '#eef0f3',
axis: '#98a2b3',
low: '#12b76a',
lowText: '#067647',
high: '#98a2b3',
current: '#f79009',
dot: '#fff',
bg: '#fff'
}, o.colors || {});
var prices = pts.map(function (x) { return x.p; });
var minP = o.min !== undefined && o.min !== null ? o.min : Math.min.apply(null, prices);
var maxP = o.max !== undefined && o.max !== null ? o.max : Math.max.apply(null, prices);
var span = maxP - minP;
if (!span) span = Math.max(1, maxP * 0.08);
// 上下留 12% 余量,曲线不贴边
var lo = Math.max(0, minP - span * 0.12);
var hi = maxP + span * 0.12;
var ySpan = hi - lo || 1;
function xs(i) {
if (pts.length === 1) return pad.l + plotW / 2;
return pad.l + (i / (pts.length - 1)) * plotW;
}
function ys(p) {
return pad.t + (1 - (p - lo) / ySpan) * plotH;
}
var coords = [];
var line = '';
for (var i = 0; i < pts.length; i++) {
var cx = xs(i);
var cy = ys(pts[i].p);
coords.push({ x: round2(cx), y: round2(cy), p: pts[i].p, t: pts[i].t, i: i });
line += (i ? ' L' : 'M') + cx.toFixed(1) + ' ' + cy.toFixed(1);
}
var baseY = (pad.t + plotH).toFixed(1);
var area = line + ' L' + xs(pts.length - 1).toFixed(1) + ' ' + baseY +
' L' + xs(0).toFixed(1) + ' ' + baseY + ' Z';
var gid = 'blg' + (Math.abs(hashString(pts.map(function (x) { return x.t + ':' + x.p; }).join(','))) % 99991);
var parts = [];
parts.push('');
return {
svg: parts.join(''),
geom: {
width: W, height: H, pad: pad,
plot: { x: pad.l, y: pad.t, w: plotW, h: plotH },
lo: lo, hi: hi,
points: coords
},
stats: buildHistoryStats(pts, o.current, o.days)
};
}
return {
VERSION: VERSION,
round2: round2,
clamp: clamp,
parsePrice: parsePrice,
parseSales: parseSales,
toHalfWidth: toHalfWidth,
normalizeTitle: normalizeTitle,
tokenize: tokenize,
similarity: similarity,
extractSpec: extractSpec,
brandToken: brandToken,
scoreMatch: scoreMatch,
computeFinalPrice: computeFinalPrice,
hotScore: hotScore,
sortItems: sortItems,
sortValue: sortValue,
SORTS: SORTS,
formatPrice: formatPrice,
escapeHtml: escapeHtml,
timeAgo: timeAgo,
detectPlatform: detectPlatform,
detectChannel: detectChannel,
platformName: platformName,
PLATFORM_NAMES: PLATFORM_NAMES,
extractItemId: extractItemId,
buildSearchUrl: buildSearchUrl,
hashString: hashString,
debounce: debounce,
formatDate: formatDate,
shortPrice: shortPrice,
dayKey: dayKey,
dailyMin: dailyMin,
buildHistoryStats: buildHistoryStats,
historyAdvice: historyAdvice,
buildChartSvg: buildChartSvg,
DAY_MS: DAY_MS
};
});
/* ==================== platform.js ==================== */
/**
* 站点规则表:每个平台一套候选选择器 + 详情页判定。
* 选择器写多套并按顺序试,站点改版时只要有一套管用就能出结果。
*/
(function (root, factory) {
var core = (typeof module === 'object' && module.exports)
? require('./core.js')
: root.BL.core;
var api = factory(core);
if (typeof module === 'object' && module.exports) module.exports = api;
else root.BL.platform = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function (core) {
'use strict';
var RULES = {
taobao: {
title: ['.tb-main-title', '[class*="ItemHeader--mainTitle"]', '[class*="mainTitle--"]', '.tb-detail-hd h1', 'h1[class*="title"]'],
price: ['.tb-rmb-num', '[class*="Price--priceText"]', '[class*="priceText--"]', '#J_PromoPriceNum', '.tm-price', '.tb-property-cont .tb-rmb-num'],
image: ['#J_ImgBooth', '[class*="PicGallery"] img', '.tb-booth img', '[class*="mainPic"] img'],
shop: ['.tb-shop-name', '[class*="ShopHeader--title"]', '.shop-name', '[class*="shopName"]']
},
tmall: {
title: ['[class*="ItemHeader--mainTitle"]', '.tb-detail-hd h1', '#J_DetailMeta h1', 'h1[class*="mainTitle"]'],
price: ['[class*="Price--priceText"]', '.tm-price', '#J_PromoPriceNum', '.tm-promo-price .tm-price', '[class*="priceText--"]'],
image: ['#J_ImgBooth', '[class*="PicGallery"] img', '.tb-booth img'],
shop: ['[class*="ShopHeader--title"]', '.slogo-shopname', '.shop-name']
},
jd: {
title: ['.sku-name', '#name h1', '[class*="sku-name"]', '.itemInfo-wrap .sku-name'],
price: ['.p-price .price', '#jd-price', '.summary-price .p-price .price', '[class*="price"] .price'],
image: ['#spec-img', '#J_ImgBooth', '.jqzoom img'],
shop: ['.J-hove-wrap .name a', '#popbox .mt .name', '.shopName .name', '.J-shop-name']
},
vip: {
title: ['.goods-name', '[class*="goods-name"]', '.p-name', '.goods-title h1', 'h1'],
price: ['.goods-price', '.vip-price', '.price .num', '#J_price', '[class*="goodsPrice"]'],
image: ['.goods-img img', '#J_img', '[class*="goods-img"] img', '.pic img'],
shop: ['.brand-name', '.shop-name', '[class*="brandName"]']
},
pdd: {
title: ['[class*="goodsName"]', '.goods-name', '[class*="title"] h1', 'h1'],
price: ['[class*="groupPrice"]', '.goods-price', '[class*="price"] span', '.price-num'],
image: ['[class*="goodsImg"] img', '.goods-img img', '.banner-img img'],
shop: ['[class*="mallName"]', '.mall-name', '.shop-name']
}
};
var EXCLUDE_PATTERNS = [
/^login\./, /^uland\./, /^cart\./, /^order\./, /^trade\./, /^pay\./,
/^wq\./, /^union\./, /^passport\./, /^sso\./
];
var EXCLUDE_PATHS = [
/^\/cart/, /^\/order/, /^\/trade/, /^\/checkout/, /^\/login/, /^\/register/,
/^\/user/, /^\/member/, /^\/my/
];
function getRule(platform) {
return RULES[platform] || null;
}
function isExcluded(host, pathname) {
var h = String(host || '').toLowerCase().split(':')[0];
var p = String(pathname || '/');
for (var i = 0; i < EXCLUDE_PATTERNS.length; i++) {
if (EXCLUDE_PATTERNS[i].test(h)) return true;
}
for (var j = 0; j < EXCLUDE_PATHS.length; j++) {
if (EXCLUDE_PATHS[j].test(p)) return true;
}
return false;
}
/** 详情页判定:能抠出商品 id 就是详情页;列表页/搜索页不弹面板 */
function isDetailPage(url, platform) {
var u;
try {
u = new URL(url);
} catch (err) {
return false;
}
if (isExcluded(u.host, u.pathname)) return false;
if (platform === 'pdd') return /goods\.html/.test(u.pathname) || Boolean(u.searchParams.get('goods_id'));
if (platform === 'vip') return /detail-\d+-\d+\.html/.test(u.pathname) || /\/detail\//.test(u.pathname);
if (platform === 'jd') return /\/\d{5,}\.html/.test(u.pathname);
return Boolean(core.extractItemId(url, platform));
}
function isListPage(url) {
var u;
try {
u = new URL(url);
} catch (err) {
return false;
}
return /(^|\.)(s|search|list|category|so)\./.test(u.host) ||
/search|list|category|suggest/.test(u.pathname);
}
return {
RULES: RULES,
getRule: getRule,
isExcluded: isExcluded,
isDetailPage: isDetailPage,
isListPage: isListPage
};
});
/* ==================== extract.js ==================== */
/**
* 商品信息抽取:四级降级
* 1) meta / og 标签(最稳)
* 2) 页面全局 JSON(__INITIAL_STATE__ 等,抗改版)
* 3) DOM 选择器(多套候选)
* 4) document.title + 正文价格正则(兜底,置信度最低)
*/
(function (root, factory) {
var core = (typeof module === 'object' && module.exports) ? require('./core.js') : root.BL.core;
var platformApi = (typeof module === 'object' && module.exports) ? require('./platform.js') : root.BL.platform;
var api = factory(core, platformApi);
if (typeof module === 'object' && module.exports) module.exports = api;
else root.BL.extract = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function (core, platformApi) {
'use strict';
var GLOBAL_KEYS = [
'__INITIAL_STATE__', '__INITIAL_DATA__', '__NEXT_DATA__', '__NUXT__',
'__APOLLO_STATE__', 'pageConfig', '__DETAIL_DATA__', '__GOODS_INFO__',
'__ITEM_DATA__', 'rawData', '__PRELOADED_STATE__'
];
var TITLE_KEYS = ['skuName', 'goodsName', 'itemTitle', 'productName', 'goods_name', 'title', 'name'];
var PRICE_KEYS = ['pPrice', 'salePrice', 'vipPrice', 'zkFinalPrice', 'finalPrice', 'lowestPrice',
'minGroupPrice', 'min_group_price', 'actPrice', 'promoPrice', 'price', 'jdPrice'];
var IMAGE_KEYS = ['image', 'imageUrl', 'picUrl', 'mainPic', 'goodsImage', 'pict_url', 'skuPic', 'pic'];
var SHOP_KEYS = ['shopName', 'shopTitle', 'mallName', 'brandName', 'storeName', 'venderName'];
function qs(doc, sel) {
try {
return doc.querySelector(sel);
} catch (err) {
return null;
}
}
function textOf(node) {
if (!node) return '';
return String(node.textContent || '').replace(/\s+/g, ' ').trim();
}
function attrOf(node, name) {
if (!node) return '';
if (typeof node.getAttribute === 'function') return node.getAttribute(name) || '';
return node[name] || '';
}
function pickText(doc, selectors) {
for (var i = 0; i < selectors.length; i++) {
var t = textOf(qs(doc, selectors[i]));
if (t) return t;
}
return '';
}
function pickAttr(doc, selectors, attr) {
for (var i = 0; i < selectors.length; i++) {
var node = qs(doc, selectors[i]);
if (!node) continue;
var v = attrOf(node, attr);
if (v) return v;
}
return '';
}
function metaContent(doc, names) {
for (var i = 0; i < names.length; i++) {
var node = qs(doc, 'meta[property="' + names[i] + '"]') || qs(doc, 'meta[name="' + names[i] + '"]');
if (node) {
var c = attrOf(node, 'content');
if (c) return c.trim();
}
}
return '';
}
function cleanTitle(raw) {
var t = String(raw || '').replace(/\s+/g, ' ').trim();
var suffixes = [
/[-_|—]\s*淘宝网.*$/i, /[-_|—]\s*天猫.*$/i, /[-_|—]\s*Tmall.*$/i,
/【行情\s*报价\s*价格\s*评测】[-_|—]?京东.*$/i, /[-_|—]\s*京东.*$/i,
/[-_|—]\s*唯品会.*$/i, /[-_|—]\s*拼多多.*$/i, /[-_|—]\s*苏宁.*$/i
];
for (var i = 0; i < suffixes.length; i++) t = t.replace(suffixes[i], '');
t = t.replace(/^\s*【[^】]{0,20}】\s*/, function (m) {
// 开头的【官方旗舰店】这类前缀去掉;【品牌名】保留
return /旗舰|专营|自营|官方|促销|包邮/.test(m) ? '' : m;
});
return t.trim();
}
/** 深度遍历页面全局对象,找同时带标题和价格的那个节点 */
function scanGlobals(win) {
if (!win) return null;
var visited = typeof WeakSet === 'function' ? new WeakSet() : null;
var queue = [];
for (var i = 0; i < GLOBAL_KEYS.length; i++) {
try {
var v = win[GLOBAL_KEYS[i]];
if (v && typeof v === 'object') queue.push({ node: v, depth: 0 });
} catch (err) {
// 跨域属性直接跳过
}
}
var found = null;
var steps = 0;
while (queue.length && steps < 4000 && !found) {
var cur = queue.shift();
steps++;
var node = cur.node;
if (!node || typeof node !== 'object') continue;
if (visited) {
if (visited.has(node)) continue;
visited.add(node);
}
if (cur.depth > 9) continue;
var title = '';
var price = 0;
var image = '';
var shop = '';
for (var t = 0; t < TITLE_KEYS.length; t++) {
var tv = node[TITLE_KEYS[t]];
if (typeof tv === 'string' && tv.trim().length >= 4 && tv.length <= 200) {
title = tv.trim();
break;
}
}
if (title) {
for (var p = 0; p < PRICE_KEYS.length; p++) {
var pv = node[PRICE_KEYS[p]];
var parsed = core.parsePrice(pv);
if (parsed > 0 && parsed < 10000000) {
price = parsed;
break;
}
}
}
if (title && price) {
for (var im = 0; im < IMAGE_KEYS.length; im++) {
var iv = node[IMAGE_KEYS[im]];
if (typeof iv === 'string' && iv.length > 4) {
image = iv;
break;
}
}
for (var s = 0; s < SHOP_KEYS.length; s++) {
var sv = node[SHOP_KEYS[s]];
if (typeof sv === 'string' && sv.trim()) {
shop = sv.trim();
break;
}
}
found = { title: title, price: price, image: image, shop: shop };
}
var keys = Object.keys(node);
for (var k = 0; k < keys.length && k < 120; k++) {
var child;
try {
child = node[keys[k]];
} catch (err) {
continue;
}
if (child && typeof child === 'object') queue.push({ node: child, depth: cur.depth + 1 });
}
}
return found;
}
function fromMeta(doc) {
var title = cleanTitle(metaContent(doc, ['og:title', 'twitter:title']));
var price = core.parsePrice(metaContent(doc, ['product:price:amount', 'og:price:amount', 'item:price']));
var image = metaContent(doc, ['og:image', 'twitter:image']);
return { title: title, price: price, image: image, shop: '' };
}
function fromDom(doc, rule) {
if (!rule) return { title: '', price: 0, image: '', shop: '' };
var title = cleanTitle(pickText(doc, rule.title));
var priceText = pickText(doc, rule.price);
var price = core.parsePrice(priceText);
if (!price) {
// 价格节点里常混着 "¥199.00" 和划线价,取第一个有效数字
var m = String(priceText || '').match(/(\d+(?:[.,]\d{1,2})?)/);
if (m) price = core.parsePrice(m[1]);
}
var image = pickAttr(doc, rule.image, 'data-origin') || pickAttr(doc, rule.image, 'src');
if (image && image.indexOf('//') === 0) image = 'https:' + image;
var shop = pickText(doc, rule.shop);
return { title: title, price: price, image: image, shop: shop };
}
function priceFromBody(doc) {
var body = doc && doc.body ? String(doc.body.textContent || '') : '';
var slice = body.slice(0, 4000);
var m = slice.match(/[¥¥]\s*(\d{1,7}(?:[.,]\d{1,2})?)/);
return m ? core.parsePrice(m[1]) : 0;
}
function titleFromDoc(doc, win) {
var t = cleanTitle(doc && doc.title ? doc.title : '');
if (t.length >= 4) return t;
var h1 = pickText(doc, ['h1']);
return h1 ? cleanTitle(h1) : '';
}
/**
* 主入口。
* @param {Document} doc document 或测试替身
* @param {Window} win 取页面全局变量
* @param {string} url
*/
function extract(doc, win, url) {
var u;
try {
u = new URL(url);
} catch (err) {
return { ok: false, reason: 'URL 非法' };
}
var platform = core.detectPlatform(u.host);
if (!platform) return { ok: false, reason: '不支持的站点' };
if (platformApi.isExcluded(u.host, u.pathname)) return { ok: false, reason: '排除的页面' };
var rule = platformApi.getRule(platform);
var sources = [];
var candidates = [];
var meta = fromMeta(doc);
if (meta.title || meta.price) {
candidates.push(Object.assign({ confidence: meta.title && meta.price ? 0.92 : 0.6, from: 'meta' }, meta));
}
var dom = fromDom(doc, rule);
if (dom.title || dom.price) {
candidates.push(Object.assign({ confidence: dom.title && dom.price ? 0.82 : 0.55, from: 'dom' }, dom));
}
var glob = scanGlobals(win);
if (glob) {
candidates.push(Object.assign({ confidence: 0.78, from: 'json' }, glob));
}
var best = null;
for (var i = 0; i < candidates.length; i++) {
var c = candidates[i];
var score = c.confidence;
if (c.title && c.title.length >= 4) score += 0.05;
if (c.price > 0) score += 0.05;
c.score = score;
if (!best || c.score > best.score) best = c;
}
var title = best && best.title ? best.title : titleFromDoc(doc, win);
var price = best && best.price ? best.price : 0;
var image = best && best.image ? best.image : '';
var shop = best && best.shop ? best.shop : '';
var from = best ? best.from : 'none';
// 标题和价格必须各有一个来源;价格缺失时只做标题搜索,仍可用
if (!title || title.length < 4) return { ok: false, reason: '未能识别商品标题' };
if (!price) {
var bp = priceFromBody(doc);
if (bp) {
price = bp;
from = 'body';
}
}
if (!image) {
var ogImage = metaContent(doc, ['og:image']);
if (ogImage) image = ogImage;
}
var itemId = core.extractItemId(url, platform);
var channel = core.detectChannel(u.host, u.pathname);
return {
ok: true,
platform: platform,
platformName: core.platformName(platform),
channel: channel,
itemId: itemId,
title: title,
price: price,
image: image,
shop: shop,
url: url,
from: from,
confidence: best ? core.round2(Math.min(1, best.score)) : 0.3
};
}
return { extract: extract, cleanTitle: cleanTitle, scanGlobals: scanGlobals };
});
/* ==================== store.js ==================== */
/** 配置与状态存储:优先 GM 存储,退回 localStorage */
(function (root, factory) {
var api = factory(root);
if (typeof module === 'object' && module.exports) module.exports = api;
else root.BL.store = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function (root) {
'use strict';
var KEYS = ['apiBase', 'panelPos', 'closed', 'collapsed', 'auto', 'sort', 'tab', 'lastQuery'];
var cache = {};
var ready = false;
function gmGet(key) {
try {
if (typeof GM_getValue === 'function') {
var v = GM_getValue(key);
return v === undefined ? undefined : v;
}
} catch (err) { /* 被沙箱拒绝就退回 localStorage */ }
try {
var raw = root.localStorage && root.localStorage.getItem('bl.' + key);
return raw === null ? undefined : JSON.parse(raw);
} catch (err2) {
return undefined;
}
}
function gmSet(key, value) {
try {
if (typeof GM_setValue === 'function') {
GM_setValue(key, value);
return;
}
} catch (err) { /* fallthrough */ }
// Violentmonkey / 新版管理器可能只提供异步 GM.setValue。
// set() 保持同步 API,持久化请求在后台完成,失败时静默退回 localStorage。
try {
if (typeof GM !== 'undefined' && GM && typeof GM.setValue === 'function') {
var pending = GM.setValue(key, value);
if (pending && typeof pending.catch === 'function') pending.catch(function () {});
return;
}
} catch (errAsync) { /* fallthrough */ }
try {
if (root.localStorage) root.localStorage.setItem('bl.' + key, JSON.stringify(value));
} catch (err2) { /* 隐私模式写不进去,忽略 */ }
}
var DEFAULTS = {
apiBase: 'http://127.0.0.1:8787',
panelPos: { right: 18, top: 96 },
closed: false,
collapsed: false,
auto: true,
sort: 'renqi',
tab: 'compare',
lastQuery: ''
};
function init() {
return new Promise(function (resolve) {
var needAsync = (typeof GM !== 'undefined' && GM && typeof GM.getValue === 'function' &&
typeof GM_getValue !== 'function');
if (!needAsync) {
KEYS.forEach(function (k) {
var v = gmGet(k);
cache[k] = v === undefined ? DEFAULTS[k] : v;
});
ready = true;
resolve(cache);
return;
}
Promise.all(KEYS.map(function (k) {
return Promise.resolve(GM.getValue(k, DEFAULTS[k])).then(function (v) {
cache[k] = v === undefined ? DEFAULTS[k] : v;
}).catch(function () {
cache[k] = DEFAULTS[k];
});
})).then(function () {
ready = true;
resolve(cache);
});
});
}
function get(key) {
if (!ready) return DEFAULTS[key];
return cache[key] === undefined ? DEFAULTS[key] : cache[key];
}
function set(key, value) {
cache[key] = value;
gmSet(key, value);
return value;
}
return { init: init, get: get, set: set, KEYS: KEYS, DEFAULTS: DEFAULTS };
});
/* ==================== api.js ==================== */
/** 后端通讯:GM_xmlhttpRequest 跨域,无 GM 时退回 fetch */
(function (root, factory) {
var api = factory(root);
if (typeof module === 'object' && module.exports) module.exports = api;
else root.BL.api = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function (root) {
'use strict';
var DEFAULT_BASE = 'http://127.0.0.1:8787';
var base = DEFAULT_BASE;
var timeout = 15000;
// 脚本只服务于本机聚合服务,限制回环地址,避免被改成远端地址后悄悄外发浏览数据
function isLoopback(url) {
try {
var host = new URL(String(url || '')).hostname.toLowerCase();
return host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '[::1]';
} catch (err) {
return false;
}
}
function setBase(url) {
var next = String(url || '').replace(/\/+$/, '');
base = isLoopback(next) ? next : DEFAULT_BASE;
}
function full(path) {
return /^https?:\/\//.test(path) ? path : base + path;
}
function parse(text) {
try {
return JSON.parse(text);
} catch (err) {
return null;
}
}
function gmRequest(url, options) {
return new Promise(function (resolve, reject) {
if (typeof GM_xmlhttpRequest !== 'function') {
reject(new Error('GM_xmlhttpRequest 不可用'));
return;
}
var timer = setTimeout(function () {
reject(new Error('请求超时,后端没响应'));
}, timeout + 2000);
GM_xmlhttpRequest({
method: options.method || 'GET',
url: url,
headers: Object.assign({ 'Accept': 'application/json' }, options.headers || {}),
data: options.body,
timeout: timeout,
onload: function (res) {
clearTimeout(timer);
var json = parse(res.responseText);
if (res.status >= 200 && res.status < 300 && json) resolve(json);
else reject(new Error((json && json.error) || ('HTTP ' + res.status)));
},
onerror: function () {
clearTimeout(timer);
reject(new Error('连不上后端,检查服务是否启动'));
},
ontimeout: function () {
clearTimeout(timer);
reject(new Error('请求超时'));
}
});
});
}
function fetchRequest(url, options) {
var controller = typeof AbortController === 'function' ? new AbortController() : null;
var timer = setTimeout(function () {
if (controller) controller.abort();
}, timeout);
return fetch(url, {
method: options.method || 'GET',
headers: Object.assign({ 'Content-Type': 'application/json' }, options.headers || {}),
body: options.body,
signal: controller ? controller.signal : undefined
}).then(function (res) {
clearTimeout(timer);
return res.json();
}).then(function (json) {
if (json && json.ok === false) throw new Error(json.error || '接口返回失败');
return json;
}).finally(function () {
clearTimeout(timer);
});
}
function request(path, options) {
var opts = options || {};
var url = full(path);
if (typeof GM_xmlhttpRequest === 'function') return gmRequest(url, opts);
return fetchRequest(url, opts);
}
function get(path, params) {
var qs = [];
Object.keys(params || {}).forEach(function (k) {
if (params[k] === undefined || params[k] === null || params[k] === '') return;
qs.push(encodeURIComponent(k) + '=' + encodeURIComponent(params[k]));
});
return request(path + (qs.length ? (path.indexOf('?') >= 0 ? '&' : '?') + qs.join('&') : ''), { method: 'GET' });
}
function post(path, body) {
return request(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {})
});
}
return {
setBase: setBase,
isLoopback: isLoopback,
getBase: function () { return base; },
setTimeout: function (ms) { timeout = ms; },
health: function () { return get('/api/health'); },
search: function (params) { return get('/api/search', params); },
compare: function (item) { return post('/api/compare', item); },
collect: function (item) { return post('/api/collect', item); },
/**
* 历史价:传 key 字符串走兼容模式,传对象则带上 url/platform/title,后端自己算 key。
* 传 url 最准 —— 商品 id 能从链接里直接提出来。
*/
history: function (query, days) {
var q = typeof query === 'string' ? { key: query } : Object.assign({}, query || {});
if (days) q.days = days;
return get('/api/history', q);
}
};
});
/* ==================== ui.js ==================== */
/**
* 面板 UI:Shadow DOM 隔离站点样式,四个视图(比价 / 搜索 / 券 / 设置)。
* 只负责渲染和事件转发,不直接发请求。
*/
(function (root, factory) {
var core = (typeof module === 'object' && module.exports) ? require('./core.js') : root.BL.core;
var api = factory(core, root);
if (typeof module === 'object' && module.exports) module.exports = api;
else root.BL.ui = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function (core, root) {
'use strict';
var STYLE = `
:host { all: initial; }
* { box-sizing: border-box; }
.bl-panel {
position: fixed; right: 18px; top: 96px; width: 392px; max-height: calc(100vh - 140px);
display: flex; flex-direction: column; overflow: hidden;
background: #fff; color: #1b1f24; border-radius: 14px;
box-shadow: 0 12px 40px rgba(15, 23, 42, .22), 0 2px 8px rgba(15, 23, 42, .10);
border: 1px solid rgba(15, 23, 42, .07);
font: 13px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
pointer-events: auto; transition: opacity .16s ease, transform .16s ease;
}
.bl-panel.hide { display: none; }
.bl-panel.min .bl-tabs, .bl-panel.min .bl-body, .bl-panel.min .bl-foot { display: none; }
.bl-panel.min { width: 236px; }
.bl-head {
display: flex; align-items: center; gap: 9px; padding: 0 12px; height: 50px; flex: 0 0 auto;
background: linear-gradient(100deg, #ff5a1f 0%, #ff2757 55%, #e01e5a 100%);
color: #fff; cursor: move; user-select: none;
}
.bl-logo {
width: 26px; height: 26px; border-radius: 8px; flex: 0 0 auto;
background: rgba(255,255,255,.18); display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 13px; letter-spacing: .5px; box-shadow: inset 0 0 0 1px rgba(255,255,255,.22);
}
.bl-titles { flex: 1; min-width: 0; }
.bl-name { font-size: 13px; font-weight: 600; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bl-sub { font-size: 11px; opacity: .82; line-height: 1.5; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bl-hbtn {
width: 24px; height: 24px; border: 0; border-radius: 7px; background: rgba(255,255,255,.14);
color: #fff; cursor: pointer; font-size: 13px; line-height: 1; display: flex; align-items: center; justify-content: center;
padding: 0; flex: 0 0 auto;
}
.bl-hbtn:hover { background: rgba(255,255,255,.28); }
.bl-tabs { display: flex; gap: 2px; padding: 7px 8px 0; border-bottom: 1px solid #eef0f3; flex: 0 0 auto; background: #fff; }
.bl-tab {
flex: 1; border: 0; background: transparent; padding: 8px 6px 9px; font-size: 12.5px; color: #6b7280;
cursor: pointer; border-radius: 8px 8px 0 0; position: relative; font-family: inherit;
}
.bl-tab:hover { color: #111827; background: #f8f9fb; }
.bl-tab.on { color: #e11d48; font-weight: 600; }
.bl-tab.on::after { content: ''; position: absolute; left: 18%; right: 18%; bottom: -1px; height: 2px; background: #e11d48; border-radius: 2px; }
.bl-body { padding: 12px; overflow-y: auto; flex: 1 1 auto; background: #fff; }
.bl-body::-webkit-scrollbar { width: 6px; }
.bl-body::-webkit-scrollbar-thumb { background: #d7dbe2; border-radius: 3px; }
.bl-foot {
flex: 0 0 auto; padding: 7px 12px; border-top: 1px solid #eef0f3; font-size: 11px; color: #8b93a1;
display: flex; align-items: center; gap: 6px; background: #fcfcfd;
}
.bl-dot { width: 6px; height: 6px; border-radius: 50%; background: #c2c8d2; flex: 0 0 auto; }
.bl-dot.ok { background: #12b76a; } .bl-dot.err { background: #f04438; } .bl-dot.mock { background: #f79009; }
.bl-foot a { color: #8b93a1; margin-left: auto; text-decoration: none; }
.bl-foot a:hover { color: #e11d48; }
.bl-item { display: flex; gap: 10px; padding: 10px; border: 1px solid #eef0f3; border-radius: 11px; background: #fcfcfd; }
.bl-item img, .bl-ph {
width: 54px; height: 54px; border-radius: 8px; object-fit: cover; flex: 0 0 auto; background: #f2f4f7;
}
.bl-ph { display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 700; font-size: 18px; }
.bl-ph.taobao { background: linear-gradient(135deg,#ff8a00,#ff5000); }
.bl-ph.tmall { background: linear-gradient(135deg,#ff5f5f,#d0021b); }
.bl-ph.jd { background: linear-gradient(135deg,#ff6a6a,#c81623); }
.bl-ph.pdd { background: linear-gradient(135deg,#ff7a6b,#e02e24); }
.bl-ph.vip { background: linear-gradient(135deg,#ff6fb5,#f10180); }
.bl-item-main { min-width: 0; flex: 1; }
.bl-item-title { font-size: 12.5px; line-height: 1.45; color: #111827; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.bl-item-meta { margin-top: 4px; font-size: 11px; color: #8b93a1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bl-item-price { margin-top: 3px; font-size: 17px; font-weight: 700; color: #e11d48; }
.bl-item-price small { font-size: 11px; font-weight: 400; color: #98a2b3; text-decoration: line-through; margin-left: 6px; }
.bl-best {
margin: 10px 0; padding: 10px 12px; border-radius: 11px;
background: linear-gradient(100deg, #fff4ed, #fff0f4); border: 1px solid #ffe0d1;
display: flex; align-items: center; gap: 10px;
}
.bl-best .bl-best-k { font-size: 11px; color: #9a3412; }
.bl-best .bl-best-v { font-size: 19px; font-weight: 750; color: #d61f3c; line-height: 1.2; }
.bl-best .bl-best-p { font-size: 11px; color: #9a3412; background: #fff; border-radius: 999px; padding: 3px 8px; margin-left: auto; white-space: nowrap; }
.bl-save { color: #067647; background: #ecfdf3; border: 1px solid #abefc6; border-radius: 8px; padding: 6px 9px; font-size: 11px; margin-bottom: 10px; }
.bl-list { display: flex; flex-direction: column; gap: 6px; }
.bl-row {
display: flex; align-items: flex-start; gap: 8px; padding: 9px 8px; border-radius: 9px; cursor: pointer;
border: 1px solid transparent; transition: background .12s ease, border-color .12s ease;
}
.bl-row:hover { background: #f8f9fb; border-color: #eef0f3; }
.bl-row.cur { background: #fffaf7; border-color: #ffe3d3; }
.bl-pf {
font-size: 10.5px; padding: 2px 6px; border-radius: 5px; color: #fff; flex: 0 0 auto; margin-top: 1px;
font-weight: 600; letter-spacing: .2px;
}
.bl-pf.taobao { background: #ff5000; } .bl-pf.tmall { background: #d0021b; }
.bl-pf.jd { background: #c81623; } .bl-pf.pdd { background: #e02e24; }
.bl-pf.vip { background: #f10180; } .bl-pf.mock { background: #98a2b3; }
.bl-row-main { flex: 1; min-width: 0; }
.bl-row-title { font-size: 12px; line-height: 1.4; color: #344054; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.bl-row-meta { font-size: 10.5px; color: #98a2b3; margin-top: 3px; display: flex; gap: 6px; flex-wrap: wrap; }
.bl-coupon-tag { color: #c11574; background: #fdf2fa; border-radius: 4px; padding: 0 4px; }
.bl-row-price { text-align: right; flex: 0 0 auto; min-width: 74px; }
.bl-row-price b { color: #e11d48; font-size: 14px; }
.bl-row-price s { display: block; color: #b0b7c3; font-size: 10.5px; }
.bl-go {
border: 0; background: #fff1f2; color: #e11d48; border-radius: 6px; font-size: 11px;
padding: 4px 7px; cursor: pointer; margin-top: 4px; font-family: inherit;
}
.bl-go:hover { background: #ffe4e6; }
.bl-skeleton { padding: 8px 2px; }
.bl-sk { height: 12px; border-radius: 6px; background: linear-gradient(90deg,#f1f3f6 25%,#e8ebf0 37%,#f1f3f6 63%); background-size: 400% 100%; animation: bl-sh 1.2s ease infinite; margin-bottom: 9px; }
.bl-sk.w40 { width: 40%; } .bl-sk.w70 { width: 70%; } .bl-sk.w90 { width: 90%; }
@keyframes bl-sh { 0% { background-position: 100% 50%; } 100% { background-position: 0 50%; } }
.bl-empty { text-align: center; color: #98a2b3; padding: 26px 10px; font-size: 12px; line-height: 1.8; }
.bl-err { background: #fef3f2; border: 1px solid #fecdca; color: #b42318; border-radius: 10px; padding: 11px 12px; font-size: 12px; line-height: 1.65; }
.bl-err code { background: rgba(180,35,24,.08); padding: 1px 5px; border-radius: 4px; font-size: 11px; }
.bl-btn {
border: 1px solid #e4e7ec; background: #fff; color: #344054; border-radius: 8px; padding: 7px 11px;
font-size: 12px; cursor: pointer; font-family: inherit;
}
.bl-btn:hover { border-color: #fda4af; color: #e11d48; }
.bl-btn.pri { background: #e11d48; border-color: #e11d48; color: #fff; }
.bl-btn.pri:hover { background: #be123c; color: #fff; }
.bl-row-btns { display: flex; gap: 8px; margin-top: 10px; }
.bl-searchbar { display: flex; gap: 6px; }
.bl-input {
flex: 1; min-width: 0; border: 1px solid #e4e7ec; border-radius: 9px; padding: 8px 10px;
font-size: 12.5px; color: #101828; background: #fff; outline: none; font-family: inherit;
}
.bl-input:focus { border-color: #fda4af; box-shadow: 0 0 0 3px rgba(225,29,72,.08); }
.bl-chips { display: flex; gap: 6px; flex-wrap: wrap; margin: 9px 0 11px; }
.bl-chip {
border: 1px solid #e4e7ec; background: #fff; color: #667085; border-radius: 999px;
font-size: 11px; padding: 4px 9px; cursor: pointer; font-family: inherit;
}
.bl-chip.on { border-color: #e11d48; color: #e11d48; background: #fff1f2; }
.bl-count { font-size: 11px; color: #98a2b3; margin-bottom: 7px; }
.bl-cpn {
border: 1px dashed #f5c2d0; border-radius: 10px; padding: 9px 10px; margin-bottom: 8px;
display: flex; align-items: center; gap: 10px; background: #fffafc;
}
.bl-cpn-amt { color: #e11d48; font-weight: 700; font-size: 18px; min-width: 58px; }
.bl-cpn-amt span { font-size: 11px; font-weight: 400; }
.bl-cpn-main { flex: 1; min-width: 0; }
.bl-cpn-desc { font-size: 11.5px; color: #475467; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bl-cpn-sub { font-size: 10.5px; color: #98a2b3; margin-top: 2px; }
.bl-cpn-btns { display: flex; flex-direction: column; gap: 4px; }
.bl-sec { font-size: 11px; color: #98a2b3; margin: 12px 0 6px; letter-spacing: .3px; }
.bl-hist { margin-top: 10px; border-top: 1px dashed #eef0f3; padding-top: 8px; }
.bl-hist svg { width: 100%; height: 60px; display: block; }
.bl-hist-meta { display: flex; justify-content: space-between; align-items: center; font-size: 10.5px; color: #98a2b3; margin-top: 2px; }
.bl-hist-more {
border: 0; background: transparent; color: #e11d48; font-size: 10.5px; cursor: pointer;
padding: 0; font-family: inherit;
}
.bl-hist-more:hover { text-decoration: underline; }
.bl-range { display: flex; gap: 5px; margin: 2px 0 4px; }
.bl-range .bl-chip { padding: 3px 9px; font-size: 10.5px; }
.bl-chart { position: relative; margin: 6px 0 2px; touch-action: pan-y; }
.bl-chart svg { width: 100%; height: 158px; display: block; }
.bl-chart-empty {
height: 158px; display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 6px; color: #98a2b3; font-size: 12px; background: #fcfcfd; border: 1px dashed #eef0f3; border-radius: 10px;
}
.bl-tip {
position: absolute; left: 0; top: 0; pointer-events: none; z-index: 6;
background: rgba(16,24,40,.94); color: #fff; font-size: 11px; line-height: 1.4;
padding: 5px 9px; border-radius: 7px; white-space: nowrap;
transform: translate(-50%, -100%); opacity: 0; transition: opacity .1s ease;
}
.bl-tip.show { opacity: 1; }
.bl-mock-tag {
position: absolute; right: 4px; top: 2px; font-size: 10px; color: #b54708;
background: #fffaeb; border: 1px solid #fedf89; border-radius: 5px; padding: 1px 5px; z-index: 3;
}
.bl-hero { display: flex; align-items: flex-end; gap: 8px; margin: 10px 0 6px; }
.bl-hero-v { font-size: 25px; font-weight: 750; color: #e11d48; line-height: 1.1; letter-spacing: -.4px; }
.bl-hero-u { font-size: 10.5px; color: #98a2b3; padding-bottom: 3px; }
.bl-advice {
display: flex; gap: 7px; align-items: flex-start; border-radius: 9px; padding: 8px 10px;
font-size: 11.5px; line-height: 1.5; margin: 8px 0 2px;
}
.bl-advice b { font-weight: 650; }
.bl-advice.best { background: #ecfdf3; color: #067647; border: 1px solid #abefc6; }
.bl-advice.good { background: #f0f9ff; color: #026aa2; border: 1px solid #b9e6fe; }
.bl-advice.normal { background: #fffaeb; color: #b54708; border: 1px solid #fedf89; }
.bl-advice.high { background: #fef3f2; color: #b42318; border: 1px solid #fecdca; }
.bl-advice.sampling, .bl-advice.unknown { background: #f8f9fb; color: #667085; border: 1px solid #eef0f3; }
.bl-grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 10px; }
.bl-cell { background: #fcfcfd; border: 1px solid #eef0f3; border-radius: 9px; padding: 7px 9px; }
.bl-cell .k { font-size: 10.5px; color: #98a2b3; }
.bl-cell .v { font-size: 14px; font-weight: 650; color: #101828; margin-top: 1px; }
.bl-cell .v small { font-size: 10px; font-weight: 400; color: #98a2b3; margin-left: 3px; }
.bl-cell .v.green { color: #067647; } .bl-cell .v.red { color: #b42318; }
.bl-src { font-size: 10.5px; color: #98a2b3; line-height: 1.6; margin-top: 9px; }
.bl-src .up { color: #b42318; }
.bl-src .down { color: #067647; }
.bl-setting { display: flex; flex-direction: column; gap: 10px; }
.bl-field { display: flex; flex-direction: column; gap: 5px; }
.bl-field label { font-size: 11.5px; color: #667085; }
.bl-check { display: flex; align-items: center; gap: 7px; font-size: 12px; color: #344054; }
.bl-kv { display: flex; justify-content: space-between; font-size: 11.5px; color: #667085; padding: 3px 0; border-bottom: 1px dashed #f2f4f7; }
.bl-kv b { color: #101828; font-weight: 600; }
.bl-fab {
position: fixed; right: 0; top: 42%; background: linear-gradient(180deg,#ff5a1f,#e01e5a); color: #fff;
border-radius: 10px 0 0 10px; padding: 10px 6px; font-size: 12px; letter-spacing: 2px; cursor: pointer;
box-shadow: 0 6px 18px rgba(224,30,90,.35); writing-mode: vertical-rl; display: none; pointer-events: auto;
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
}
.bl-fab.show { display: block; }
.bl-toast {
position: fixed; left: 50%; bottom: 40px; transform: translateX(-50%) translateY(8px);
background: rgba(16,24,40,.92); color: #fff; padding: 8px 14px; border-radius: 999px; font-size: 12px;
opacity: 0; transition: opacity .18s ease, transform .18s ease; pointer-events: none; white-space: nowrap;
font-family: -apple-system, "PingFang SC", sans-serif;
}
.bl-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
@media (prefers-color-scheme: dark) {
.bl-panel { background: #171a20; color: #e6e8ec; border-color: #262b33; }
.bl-tabs { background: #171a20; border-color: #262b33; }
.bl-tab { color: #9aa0a6; } .bl-tab:hover { background: #1d2129; color: #e6e8ec; }
.bl-tab.on { color: #ff6b81; } .bl-tab.on::after { background: #ff6b81; }
.bl-body { background: #171a20; }
.bl-item { background: #1b1f27; border-color: #262b33; }
.bl-item-title { color: #e6e8ec; }
.bl-item-meta, .bl-row-meta { color: #8b93a1; }
.bl-best { background: linear-gradient(100deg,#2a1a1f,#2a1720); border-color: #4a2530; }
.bl-best .bl-best-k, .bl-best .bl-best-p { color: #ffb4a2; }
.bl-best .bl-best-p { background: #171a20; }
.bl-save { background: #10281f; border-color: #1d5a3f; color: #6ce9a6; }
.bl-row:hover { background: #1d2129; border-color: #262b33; }
.bl-row.cur { background: #241a1c; border-color: #3b2429; }
.bl-row-title { color: #d0d5dd; }
.bl-go { background: #2a151b; color: #ff8fa3; }
.bl-input { background: #1b1f27; border-color: #2b3039; color: #e6e8ec; }
.bl-chip { background: #1b1f27; border-color: #2b3039; color: #98a2b3; }
.bl-chip.on { background: #2a151b; }
.bl-btn { background: #1b1f27; border-color: #2b3039; color: #d0d5dd; }
.bl-cpn { background: #201a1d; border-color: #3b2429; }
.bl-cpn-desc { color: #d0d5dd; }
.bl-kv b { color: #e6e8ec; }
.bl-foot { background: #14171c; border-color: #262b33; }
.bl-err { background: #2a1517; border-color: #4a2224; color: #fda29b; }
.bl-sk { background: linear-gradient(90deg,#20242c 25%,#272c35 37%,#20242c 63%); background-size: 400% 100%; }
.bl-chart-empty { background: #1b1f27; border-color: #262b33; color: #8b93a1; }
.bl-cell { background: #1b1f27; border-color: #262b33; }
.bl-cell .v { color: #e6e8ec; }
.bl-cell .v.green { color: #6ce9a6; } .bl-cell .v.red { color: #fda29b; }
.bl-advice.best { background: #10281f; border-color: #1d5a3f; color: #6ce9a6; }
.bl-advice.good { background: #0b2436; border-color: #12496b; color: #84caff; }
.bl-advice.normal { background: #2a2011; border-color: #4a3413; color: #fec84b; }
.bl-advice.high { background: #2a1517; border-color: #4a2224; color: #fda29b; }
.bl-advice.sampling, .bl-advice.unknown { background: #1b1f27; border-color: #262b33; color: #98a2b3; }
.bl-mock-tag { background: #2a2011; border-color: #4a3413; color: #fec84b; }
.bl-src { color: #8b93a1; }
}
@media (max-width: 640px) {
.bl-panel {
left: 8px; right: 8px; top: auto; bottom: 8px; width: auto; max-height: 76vh; border-radius: 16px;
}
.bl-panel.min { width: auto; left: auto; right: 10px; }
.bl-head { height: 46px; cursor: default; }
}
`;
function esc(s) {
return core.escapeHtml(s);
}
function money(n) {
return core.formatPrice(n);
}
function pfClass(item) {
return item && item.platform ? item.platform : '';
}
function badge(item, extra) {
return '' +
esc(core.platformName(item.platform)) + '' + (extra || '');
}
function imageBlock(item) {
if (item.image) {
return '';
}
var ch = (core.platformName(item.platform) || '?').slice(0, 1);
return '