DLSite Price History

Remembers and displays the prices you have seen for listings.

// ==UserScript==
// @name         DLSite Price History
// @namespace    V.L
// @version      0.2.1
// @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==

const version = '0.2.1';
// 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, ''));

		let insertAgain = ()=>{
			const marker = item.querySelector('.work_buy_container');
			this.insertPrice( workId, currentPrice, marker, 'afterbegin' );
		};
		insertAgain();

		const observer = new MutationObserver((mutationsList, observer)=>{
			insertAgain();
		});
		observer.observe(item, { childList: true });
	}

	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 HistoricalData(value);

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

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

// 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 });
	});
});