Greasy Fork

Greasy Fork is available in English.

DLSite Price History

Remembers and displays the prices you have seen for listings.

当前为 2024-02-24 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         DLSite Price History
// @namespace    V.L
// @version      0.2.0
// @description  Remembers and displays the prices you have seen for listings.
// @author       Valerio Lyndon
// @match        https://www.dlsite.com/*
// @run-at       document-start
// @grant        GM.setValue
// @grant        GM.getValue
// @grant        GM.listValues
// @grant        GM.addStyle
// @license      MIT
// ==/UserScript==

// change this to "true" to disable modifying any properties. useful when developing.
const debug = false;

GM.addStyle(`
.vl-phist {
	max-width: 200px;
	padding: 0 6px;
	border-radius: 2px;
	background: #c4c4c4;
	color: #fff;
	font-size: 11px;
	font-weight: bold;
	line-height: 17px;
	vertical-align: middle;
	box-sizing: border-box;
	text-align: center;
	text-transform: uppercase;
}
.vl-phist.best {
	background: #68e;
}
.vl-phist.new.best {
	background: #d78d2e;
}
.vl-phist.worse {
	background: #e69;
}
.cp_work_deals .vl-phist {
	display: inline-block;
	width: 49%;
}
.search_result_img_box_inner .vl-phist {
	margin-top: 5px;
}
`);

class HistoricalData {
	constructor( best, bestDate = Date.now(), addedDate = Date.now() ){
		this.best = best;
		this.bestDate = bestDate;
		this.addedDate = addedDate;
		if( this.best < 0 ){
			throw new RangeError('price cannot be less than zero');
		}
	}

	/**
	 * compares a new price to the best seen.
	 *
	 * returns negative if new price is worse OR positive if better
	 * use as true/false by checking >0 or <0 or as difference with the full return;
	 */
	compare( price ){
		return this.best - price;
	}

	toDict( ){
		return {
			'best': this.best,
			'best_date': this.bestDate,
			'added_date': this.addedDate
		}
	}

	toString( ){
		return JSON.stringify(this.toDict());
	}

	static from( stringOrObj ){
		let obj = typeof stringOrObj === 'string' ? JSON.parse(stringOrObj) : stringOrObj;
		return new HistoricalData(obj['best'], obj['best_date'], obj['added_date']);
	}
}

function hoursApart( unix1, unix2 = Date.now() ){
	return Math.floor(Math.abs(unix1 - unix2) / (1000 * 60 * 60));
}

function daysApart( unix1, unix2 = Date.now() ){
	return Math.floor(hoursApart(unix1,unix2) / 24);
}

function generateSeenAt( unix ){
	const date = new Date();

    let days = daysApart(date.getTime(), unix);
	let year = date.getFullYear();
	let month = Intl.DateTimeFormat('en-US', {'month': 'short'}).format(date);
	let day = date.getDate();

    let dayStr = days === 1 ? "1 day ago" : `${days} days ago`;
	let string = `Seen ${dayStr} on ${year}-${month}-${day}`;

    return string;
}

class PriceProcessor {
	constructor( parent ){
		this.parent = parent || document;
		this.processWorkPage();
		this.processCarousel();
		this.processGrid();
		this.processRecommended();
		this.processFavourites();
	}

	async insertPrice( workId, price, marker, placement = 'afterend' ){
		let previous = await GM.getValue(workId, false);
		previous = previous === false ? new HistoricalData(price) : HistoricalData.from(previous);

		let alert = document.createElement('div');
		alert.className = 'vl-phist';

		if( hoursApart(previous.bestDate) <= 12 ){
			alert.textContent = 'Newly recorded';
		}
		else if( previous.compare(price) >= 0 ){
			// update storage
			if( !debug ){ GM.setValue(workId, new HistoricalData(price).toString()); }

			alert.classList.add('best');
			if( hoursApart(previous.bestDate) <= 12 ){
				alert.textContent = 'NEW BEST';
				alert.classList.add('new');
			}
			else {
				alert.textContent = 'MATCHES BEST';
			}
		}
		else {
			alert.classList.add('worse');
			alert.textContent = `Best seen: ${previous.best} JPY`;
		}
		alert.title = generateSeenAt(previous.bestDate);

		marker.insertAdjacentElement(placement, alert);
	}

	async processWorkPage( ){
		const item = this.parent.querySelector('#work_buy_box_wrapper');
		if( !item ){
			return;
		}

		const workId = item.dataset.productId;
		if( !workId ){
			console.log('failed to read workId number: ', item);
			return;
		}

		let currentPrice = item.querySelector('.work_buy_content .price')?.firstChild?.textContent;
		if( currentPrice === undefined ){
			console.log('failed to read price of '+workId);
			return;
		}
		currentPrice = parseInt(currentPrice.replaceAll(/\D+/g, ''));

		// janky timeout because DLSite removes and replaces some of the children here and I can't be fucked to write a mutation observer for this
		setTimeout(()=>{
			const marker = item.querySelector('.work_buy_body:has(.price)');
			this.insertPrice( workId, currentPrice, marker );
		}, 300);
	}

	async processCarousel( ){
		const listings = this.parent.querySelectorAll('.cp_work_item');
		for( let item of listings ){
			const workId = item.dataset.workno;
			if( !workId ){
				console.log('failed to read workId number: ', item);
				continue;
			}

			let currentPrice = item.querySelector('.cp_work_price')?.firstChild?.textContent;
			if( currentPrice === undefined ){
				console.log('failed to read price of '+workId);
				continue;
			}
			currentPrice = parseInt(currentPrice.replaceAll(/\D+/g, ''));

			const marker = item.querySelector('.cp_work_deals span:last-of-type') || item.querySelector('.cp_work_value');
			this.insertPrice( workId, currentPrice, marker );
		}
	}

	async processGrid( ){
		const listings = this.parent.querySelectorAll('.search_result_img_box_inner');
		for( let item of listings ){
			let workId = item.getElementsByTagName('dt')?.[0]?.id;
			workId = typeof workId === 'string' ? workId.substring(6) : undefined;
			if( !workId ){
				console.log('failed to read workId number: ', item);
				continue;
			}

			let currentPrice = item.querySelector('.work_price')?.firstChild?.textContent;
			if( currentPrice === undefined ){
				console.log('failed to read price of '+workId);
				continue;
			}
			currentPrice = parseInt(currentPrice.replaceAll(/\D+/g, ''));

			const marker = item.querySelector('.work_deals');
			this.insertPrice( workId, currentPrice, marker );
		}
	}

	async processRecommended( ){
		const listings = this.parent.querySelectorAll('.recommend_list .swiper-slide');
		for( let item of listings ){
			const workId = item.dataset.prod;
			if( !workId ){
				console.log('failed to read workId number: ', item);
				continue;
			}

			let currentPrice = item.querySelector('.work_price')?.firstChild?.textContent;
			if( currentPrice === undefined ){
				console.log('failed to read price of '+workId);
				continue;
			}
			currentPrice = parseInt(currentPrice.replaceAll(/\D+/g, ''));

			const marker = item.querySelector('.recommend_work_item div:nth-last-of-type(2)');
			this.insertPrice( workId, currentPrice, marker );
		}
	}

	async processFavourites( ){
		const listings = this.parent.querySelectorAll('._favorite_item');
		for( let item of listings ){
			let workId = item.querySelector('.work_thumb a')?.id;
			workId = typeof workId === 'string' ? workId.substring(6) : undefined;
			if( !workId ){
				console.log('failed to read workId number: ', item);
				continue;
			}

			let currentPrice = item.querySelector('.work_price')?.firstChild?.textContent;
			if( currentPrice === undefined ){
				console.log('failed to read price of '+workId);
				continue;
			}
			currentPrice = parseInt(currentPrice.replaceAll(/\D+/g, ''));

			const marker = item.querySelector('.work_price_wrap');
			this.insertPrice( workId, currentPrice, marker );
		}
	}
}

// process older versions of the script
const reserved_keys = ['version'];
async function updateVersion( ){
	if( await GM.getValue('version', '0.1.0') === '0.1.0' ){
		for( let key of await GM.listValues() ){
			let value = parseFloat(await GM.getValue(key));
			if( reserved_keys.includes(key) || value === NaN ){
				continue;
			}

			let data = new HistoricalDate(value);

			if( !debug ){ GM.setValue(key, newValue.toString()); }
		}
	}

	if( !debug ){ GM.setValue('version', '0.2.0'); }
}

// run script
document.addEventListener('DOMContentLoaded', async ()=>{
	await updateVersion();
	new PriceProcessor();

	document.querySelectorAll('.recommend_list').forEach((targetNode)=>{
		const observer = new MutationObserver((mutationsList, observer)=>{
			new PriceProcessor(targetNode);
			observer.disconnect();
		});
		observer.observe(targetNode, { childList: true });
	});
});