// ==UserScript== // @name 电商历史价格查询 // @namespace http://shawjie.cn // @version 1.0.12.1 // @description 看看历史价格 拒绝当小白鼠 // @author ShawJie // @match https://item.jd.com/* // @match https://detail.tmall.com/item.htm* // @match https://item.taobao.com/item.htm* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_openInTab // @require https://lf6-cdn-tos.bytecdntp.com/cdn/expire-1-M/echarts/5.3.0-rc.1/echarts.common.min.js // @require https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/crypto-js/4.1.1/crypto-js.min.js // @downloadURL https://update.greasyfork.icu/scripts/416002/%E7%94%B5%E5%95%86%E5%8E%86%E5%8F%B2%E4%BB%B7%E6%A0%BC%E6%9F%A5%E8%AF%A2.user.js // @updateURL https://update.greasyfork.icu/scripts/416002/%E7%94%B5%E5%95%86%E5%8E%86%E5%8F%B2%E4%BB%B7%E6%A0%BC%E6%9F%A5%E8%AF%A2.meta.js // ==/UserScript== const __HISTORY_PRICE_CONFIG__ = { activeProvider: 'GouWuDang', mallPattern: { Jd: /^http[s]?:\/\/item\.jd\.com\/\d+\.html/, Tmall: /^http[s]:\/\/detail\.tmall\.com\/item\.htm/, Taobao: /^https?:\/\/item\.taobao\.com\/item\.htm/, }, icon: '', closeIcon: '', textDesc: '历史价格', fadeId: 'close-able-history-fade', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36', containerHeigh: 530, containerSkipLen: 580 }; const util = (function(){ function randomString(e) { e = e || 32; var t = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz1234567890", a = t.length, n = ""; for (let i = 0; i < e; i++) { n += t.charAt(Math.floor(Math.random() * a)); } return n } function syncRequest(option) { return new Promise((resolve, reject) => { option.onload = (response) => { resolve(response); }; GM_xmlhttpRequest(option); }); } function regexGroupFinder(str, regex, groupName) { const matcher = str.match(regex); return matcher.groups[groupName]; } function dateFormat(date, format) { var o = { "M+": date.getMonth() + 1, //月份 "d+": date.getDate(), //日 "H+": date.getHours(), //小时 "m+": date.getMinutes(), //分 "s+": date.getSeconds(), //秒 "q+": Math.floor((date.getMonth() + 3) / 3), //季度 "S": date.getMilliseconds() //毫秒 }; if (/(y+)/.test(format)) format = format.replace(RegExp.$1, (date.getFullYear() + "").substr(4 - RegExp.$1.length)); for (var k in o) if (new RegExp("(" + k + ")").test(format)) format = format.replace(RegExp.$1, (RegExp.$1.length == 1) ? (o[k]) : (("00" + o[k]).substr(("" + o[k]).length))); return format; } function md5(originStr) { return CryptoJS.MD5(originStr); } return { random: (len) => randomString(len), req: (option) => syncRequest(option), regFinder: (str, regex, groupName) => regexGroupFinder(str, regex, groupName), dateFormat: (date, format) => dateFormat(date, format), md5: (originStr) => md5(originStr) } })(); const dataProvider = (function(){ const cache = {}; class ProviderSupplier { constructor() { this.providerMapper = new Map(); this.providerMapper.set("GouWuDang", () => new GouWuDangDataProvider()); } get(providerName) { if (this.providerMapper.has(providerName)) { let supplier = this.providerMapper.get(providerName); return supplier(); } throw new Error(`no data provider named ${providerName}`); } } class ChartsInfo { constructor(categories, data, heighest, minimun, name, link) { this.categories = categories; this.data = data; this.heighest = heighest; this.minimun = minimun; this.name = name; this.link = link; } } class BasicDataProvider { constructor(name, link) { this.name = name; this.link = link; } async load(){} } class GouWuDangDataProvider extends BasicDataProvider { constructor() { super('购物党', 'https://www.gwdang.com/'); this.config = { main: 'https://www.gwdang.com/', firstQueryPath: '/api/url_to_dp', secondQueryPath: 'trend/data_www?show_prom=true&v=2&get_coupon=1&dp_id=', analizyPattern: { regex: /var dp_id = '(?.*)';/, groupName: 'dpid' }, userInfoBase: 'https://i.gwdang.com', userInfoReq: '/User/Detail' } this.dataCache = null; } async load() { const config = this.config; // todo: load cookie from login action let mockCookie = GM_getValue('hps.gwd.cookie-cache'); if (!mockCookie) { let hasBeenAlerted = GM_getValue('hps.gwd.doc-alert', false); if (!hasBeenAlerted) { GM_openInTab('https://greasyfork.org/en/scripts/416002-%E7%94%B5%E5%95%86%E5%8E%86%E5%8F%B2%E4%BB%B7%E6%A0%BC%E6%9F%A5%E8%AF%A2#%E9%99%84%E5%BD%95'); GM_setValue('hps.gwd.doc-alert', true); } mockCookie = prompt('历史价格查询能力依托于数据源: 购物党,\n\n需要先在购物党完成登录并提取Cookie:'); if (!mockCookie) { alert('无效的Cookie'); return; } else { try { let {nickname} = await this.#getUserInfo(mockCookie); alert(`Cookie有效,以 「${nickname}」 获取数据`); } catch(err) { alert('获取用户信息失败,或许是无效的Cookie'); return; } } GM_setValue('hps.gwd.cookie-cache', mockCookie); } if (this.dataCache === null) { const firstRequestRes = await util.req({ url: `${config.main}${config.firstQueryPath}?url=${encodeURIComponent(window.location)}&t=${parseInt(new Date().getTime() / 1e3)}`, method: 'GET', headers: { 'Cookie': mockCookie, 'user-agent': config.userAgent, 'authority': new URL(config.main).host } }); const urlToDpRes = JSON.parse(firstRequestRes.responseText); const chartsRes = await util.req({ url: `${config.main}${config.secondQueryPath}${urlToDpRes['dp_id']}`, method: 'GET', headers: { 'Cookie': mockCookie, 'user-agent': __HISTORY_PRICE_CONFIG__.userAgent, 'authority': new URL(config.main).host, 'referer': firstRequestRes.finalUrl } }); this.dataCache = JSON.parse(chartsRes.responseText); if (this.dataCache['is_ban'] != undefined) { let bandResponse = this.dataCache; this.dataCache = null; let stillFailReq = GM_getValue('hps.gwd.failed-req'); if (stillFailReq === true) { alert('数据获取失败,已清除保存的登录信息,请尝试通过Cookie重新登录'); GM_deleteValue('hps.gwd.cookie-cache'); GM_deleteValue('hps.gwd.failed-req'); throw new Error('Data fetch failed'); } GM_setValue('hps.gwd.failed-req', true); alert('需要进行验证,请在打开的新窗口完成验证后重试。'); GM_openInTab(bandResponse['action']['to'], { active: true, setParent: true, }); throw new Error('Need to verify'); } GM_deleteValue('hps.gwd.failed-req'); } return new Promise((resolve, reject) => { resolve(this.convert(this.dataCache)); }) } async #getUserInfo(inheritCookie) { const config = this.config; let time = parseInt(new Date().getTime() / 1e3); let userInfoRes = await util.req({ url: `${config.userInfoBase}${config.userInfoReq}?t=${time}&sign=${this.#makeSign({t: time, ac: "user.detail"})}`, method: 'GET', headers: { 'Cookie': inheritCookie, 'user-agent': __HISTORY_PRICE_CONFIG__.userAgent, 'authority': new URL(config.main).host } }); let data = JSON.parse(userInfoRes.responseText) if (data?.code === 1) { return data.data; } throw new Error('Get gwd user info failed'); } #makeSign(requestParam) { const keys = Object.keys(requestParam); keys.sort(); let originStr = keys.map(key => `${key}${requestParam[key]}`).join(''); return util.md5(util.md5(originStr) + requestParam.ac); } convert({series}) { const categories = new Array(); const data = new Array(); let heightest = undefined; let minimun = undefined; let longestStackItem = series[0]; for (let index = 1; index < series.length; index++) { if (longestStackItem.period < series[index].period) { longestStackItem = series[index]; } } for (const split of longestStackItem.data) { const price = split.y; if (heightest == undefined || heightest < price) { heightest = price; } if (minimun == undefined || minimun > price) { minimun = price; } categories.push(new Date(split.x * 1000)); data.push(price); } return new ChartsInfo( categories, data, heightest, minimun, this.name, this.link ); } } const providerSupplier = new ProviderSupplier(); return { allocateProvider: () => { const activeProvider = __HISTORY_PRICE_CONFIG__.activeProvider; let provider = undefined; if (cache[activeProvider] == undefined) { provider = providerSupplier.get(activeProvider); cache[activeProvider] = provider; } else { provider = cache[activeProvider]; } return provider; } } })(); class BasicProvider { constructor(){ this.defaultCallback = (container) => { let div = document.createElement('div'); div.style.cssText = `width: 35px; height: 35px; padding: 7.5px; cursor: pointer;position: fixed; background-color: beige; border-radius: 50%; box-shadow: 0px 0px 24px 0px rgba(138,138,138,0.49); right: 5rem; bottom: 3rem;`; div.title = `${__HISTORY_PRICE_CONFIG__.textDesc}`; div.innerHTML += `${__HISTORY_PRICE_CONFIG__.icon}`; div.addEventListener('click', (target) => { this.apareHistory(); }); container.parentNode.appendChild(div); }; this.defaultChartsOption = { title: { text: '商品历史价格', left: '5%', subtextStyle: { color: '#e23c63' }, }, tooltip: { trigger: 'axis' }, grid: { top: '15%' }, xAxis: { type: 'category', nameLocation: 'middle', }, yAxis: { min: (value) => value.min - 200, max: (value) => value.max + 200 }, dataZoom: [ { start: 60 } ], series: { name: '商品历史价格', type: 'line', color: '#b8c94e', markLine: { silent: true, data: [ { 'type': 'max', 'color': 'rgba(226, 60, 99, 0.6)' }, { 'type': 'min', 'color': 'rgba(226, 60, 99, 0.6)' } ] } } }; } apareHistory(customConfig) { this.abstractFade(customConfig) .then((config) => this.loadHistoryInfo(config)); } abstractRender(targetContainer) { const body = window.document; let tabContainer; let tryTime = 0; const maxTryTime = 30; return new Promise((resolve, reject) => { let interval = setInterval(() => { tabContainer = body.querySelector(targetContainer); if (tabContainer) { clearInterval(interval); resolve(tabContainer); } if ((++tryTime) == maxTryTime) { clearInterval(interval); reject(); } }, 1000); }); } abstractFade(customConfig) { const body = document.getElementsByTagName('body')[0]; if (!customConfig) { customConfig = __HISTORY_PRICE_CONFIG__; } const fadeDom = document.createElement('div'); fadeDom.id = customConfig.fadeId; fadeDom.style.cssText = `z-index: 1000000000; width: 100%; height: 100vh; background-color: rgba(0, 0, 0, 0.85); position: fixed; top: 0; left: 0;`; const closeBtn = document.createElement('div'); closeBtn.style.cssText = 'position: absolute; top: 2rem; right: 2rem; width: 35px; height: 35px; cursor: pointer'; closeBtn.innerHTML = customConfig.closeIcon; closeBtn.addEventListener('click', e => { fadeDom.parentNode.removeChild(fadeDom); }); fadeDom.appendChild(closeBtn); body.appendChild(fadeDom); return new Promise((res, rej) => { res(customConfig); }); } loadHistoryInfo(config) { const container = document.getElementById(config.fadeId); const divContainer = document.createElement('div'); divContainer.style.cssText = `position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); border: 0px; border-radius: 15px; overflow-x: hidden; background-color: #fff; overflow: hidden; text-align: center; padding: 1.5rem 0;`; divContainer.style.width = `80%`; divContainer.style.height = `${config.containerHeigh}px`; dataProvider.allocateProvider().load().then(data => { return new Promise((resolve, reject) => { container.appendChild(divContainer); const charts = this.makeCharts(data, divContainer); resolve(charts); }); }).catch(err => console.log('Render history info failed', err)); } makeCharts(data, container) { const option = this.defaultChartsOption; option.xAxis.data = data.categories.map(e => util.dateFormat(e, 'yyyy-MM-dd')); option.series.data = data.data.map(e => e / 100); option.title.subtext = `数据来源 ${data.name}${data.link} 最高价: ¥${data.heighest / 100} 最低价¥${data.minimun / 100}`; option.title.sublink = data.link; const myChart = echarts.init(container); myChart.setOption(option); return myChart; } } class JdProvider extends BasicProvider { render() { this.abstractRender('.jdm-toolbar-tabs.J-tab').then( (container) => { let div = document.createElement('div'); div.className = 'J-trigger jdm-toolbar-tab'; let em = document.createElement('em'); em.className = 'tab-text'; em.innerHTML = `${__HISTORY_PRICE_CONFIG__.textDesc}`; div.innerHTML += `${__HISTORY_PRICE_CONFIG__.icon}`; const icon = div.lastChild; icon.classList.add('hps-icon'); div.appendChild(em); const customConfig = __HISTORY_PRICE_CONFIG__; customConfig.containerHeigh = 530; customConfig.containerSkipLen = 580; div.addEventListener('click', (target) => { this.apareHistory(); }); const hpsStyle = document.createElement('style'); hpsStyle.id = 'hps-style'; hpsStyle.type = 'text/css'; hpsStyle.innerHTML = ` .hps-icon { z-index: 2; background-color: #7a6e6e; position: relative; border-radius: 3px 0 0 3px; } .hps-icon:hover { background-color: #c81623; }`; document.head.appendChild(hpsStyle); container.appendChild(div); } ).catch(e => console.warn("page load not success", e)); } } class TmallProvider extends BasicProvider { render() { this.abstractRender('body') .then(this.defaultCallback); } } class TaobaoProvider extends BasicProvider { render() { this.abstractRender('body') .then(container => { let div = document.createElement('div'); div.style.cssText = `width: 35px; height: 35px; padding: 7.5px; cursor: pointer;position: fixed; background-color: beige; border-radius: 50%; box-shadow: 0px 0px 24px 0px rgba(138,138,138,0.49); right: 5rem; bottom: 3rem;`; div.title = `${__HISTORY_PRICE_CONFIG__.textDesc}`; div.innerHTML += `${__HISTORY_PRICE_CONFIG__.icon}`; const customConfig = __HISTORY_PRICE_CONFIG__; customConfig.containerHeigh = 530; div.addEventListener('click', (target) => { this.apareHistory(customConfig); }); container.parentNode.appendChild(div); }); } } const kiana = (function() { const methods = { initialLogic: (path) => { let mallCase = undefined; for (let pattern in __HISTORY_PRICE_CONFIG__.mallPattern) { if (__HISTORY_PRICE_CONFIG__.mallPattern[pattern].test(path)) { mallCase = pattern; break; } } if (mallCase == undefined) { return; } const provider = eval(`new ${mallCase}Provider`); provider.render(); } } return { initial: () => { try { methods.initialLogic(window.location); }catch(message){ console.warn(message); } } } })(); (async function main() { kiana.initial(); })();