OzBargain User Tags & Votes

Add customisable tags and track votes against users on OzBargain

当前为 2025-06-24 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name		OzBargain User Tags & Votes
// @namespace	nategasm
// @version		1.01
// @description	Add customisable tags and track votes against users on OzBargain
// @author		nategasm
// @license		MIT
// @include		https://www.ozbargain.com.au/*
// @icon		https://www.ozbargain.com.au/favicon.ico
// @run-at		document-end
// @grant		GM_setValue
// @grant		GM_getValue
// @grant		GM_addStyle
// ==/UserScript==

GM_addStyle(`
	.user-tag {
		margin-left: 3px;
	}
	.user-tag-name {
		padding: 1px 3px;
		margin-right: 3px;
		border-radius: 4px;
		font-weight: normal;
	}
	.user-tag .fa-tag {
		cursor: pointer;
	}
	.user-tag .fa-tag:hover {
		color: color-mix(in srgb, var(--shade2-bg) 90%, black) !important;
	}
	.user-tag .user-tag-votes {
		margin-right: 3px;
	}
	#tooltip.user-tag-modal {
		display: block;
		position: absolute;
		z-index: 10000;
		transition: opacity 0.3s ease;
		opacity: 1;
	}
	#tooltip.user-tag-modal.fade-out {
		opacity: 0;
	}
	.submitted strong:has(.user-tag),
	.submitted strong:has(.user-tag) a,
	.submitted strong:has(.user-tag-name) a,
	.submitted strong:has(.user-tag) .fa-tag {
		margin-right: 0px !important
	}
	.node .submitted strong:not(:has(.user-tag-name)) a {
		margin-right: 3px !important
	}
	.user-tag-modal input[type="color"] {
		width: 40px;
		height: 20px;
		padding: 2px;
		cursor: pointer;
	}
	#tooltip.user-tag-modal.left-offset::before {
		left: var(--arrow-offset, 50%);
	}
	#tooltip.user-tag-modal.above-icon::before {
		border-bottom: none;
		border-top-color: var(--tooltip-clr);
		top: 100%;
	}
`);

const DEFAULT_BG_COLOR = '#FFA500';
const DEFAULT_TEXT_COLOR = '#000000';
const TAG_STORAGE_KEY = 'userTagsById';
const VOTE_TRACKING_KEY = 'userTagsVoteTracking';
const voteCooldown = new Set();

async function getTags() {
	return (await GM_getValue(TAG_STORAGE_KEY)) || {};
}

async function saveTags(tags) {
	await GM_setValue(TAG_STORAGE_KEY, tags);
}

async function isVoteTrackingEnabled() {
	return (await GM_getValue(VOTE_TRACKING_KEY)) !== false; //Default to true
}

async function setVoteTrackingEnabled(enabled) {
	await GM_setValue(VOTE_TRACKING_KEY, enabled);
}

async function setTag(userId, tag, bgColor, textColor) {
	const tags = await getTags();
	tags[userId] = {
		...(tags[userId] || {}),
		tag: tag || undefined,
		bgColor: bgColor || tags[userId]?.bgColor || undefined,
		textColor: textColor || tags[userId]?.textColor || undefined
	};
	if (!tags[userId].tag && !tags[userId].bgColor && !tags[userId].textColor &&
		(!tags[userId].pVotes || tags[userId].pVotes === 0) && (!tags[userId].nVotes || tags[userId].nVotes === 0)) {
		delete tags[userId];
	}
	await saveTags(tags);
}

async function clearAllTags() {
	if (confirm('Are you sure you want to delete tags for all users? \nThis cannot be reversed unless you have exported a backup')) {
	  await saveTags({});
	  alert('All tags deleted. Refresh to see changes');
	}
}

function extractUserId(anchor) {
	const match = anchor.getAttribute('href').match(/\/user\/(\d+)/);
	return match ? match[1] : null;
}

function closeExistingModal() {
	const existing = document.querySelector('.user-tag-modal');
	if (existing) existing.remove();
}

async function createTagElement(userId, tagData = {}, username = '') {
	const wrapper = document.createElement('span');
	wrapper.classList.add('user-tag');
	//Add tag
	if (tagData.tag) {
		const span = document.createElement('span');
		span.classList.add('user-tag-name');
		span.textContent = tagData.tag;
		span.style.backgroundColor = tagData.bgColor || DEFAULT_BG_COLOR;
		span.style.color = tagData.textColor || DEFAULT_TEXT_COLOR;
		wrapper.appendChild(span);
	}
	//Add votes
	const showVotes = await isVoteTrackingEnabled();
	if (showVotes && (tagData.pVotes || tagData.nVotes)) {
		const voteCount = (tagData.pVotes || 0) - (tagData.nVotes || 0);
		const voteEl = document.createElement('span');
		voteEl.classList.add('user-tag-votes');
		voteEl.textContent = `[${voteCount >= 0 ? '+' : ''}${voteCount}]`;
		voteEl.title = `${tagData.pVotes || 0} upvotes, ${tagData.nVotes || 0} downvotes`;
		wrapper.appendChild(voteEl);
	}

	const icon = document.createElement('span');
	icon.classList.add('fa','fa-tag');
	icon.title = 'Edit tag';
	icon.addEventListener('click', async (e) => {
		e.stopPropagation();
		closeExistingModal();

		const modal = document.createElement('div');
		modal.setAttribute('id', 'tooltip');
		modal.classList.add('user-tag-modal');

		const head = document.createElement('div');
		head.setAttribute('id', 'tooltip-head');
		const headClose = document.createElement('i');
		headClose.setAttribute('id', 'tooltip-close');
		headClose.classList.add('fa','fa-times');
		headClose.onclick = () => fadeOutAndRemove(modal);
		const headTitle = document.createElement('div');
		headTitle.setAttribute('id', 'tooltip-title');
		headTitle.textContent = 'User Tag Editor';
		head.appendChild(headClose);
		head.appendChild(headTitle);

		const toggleVotesCheckbox = document.createElement('input');
		toggleVotesCheckbox.type = 'checkbox';
		toggleVotesCheckbox.id = 'toggleVotes';
		toggleVotesCheckbox.style.marginBottom = '6px';
		toggleVotesCheckbox.checked = await isVoteTrackingEnabled();
		toggleVotesCheckbox.style.cursor = 'pointer';
		toggleVotesCheckbox.addEventListener('change', async () => {
			await setVoteTrackingEnabled(toggleVotesCheckbox.checked);
			alert('Vote tracking setting saved. Refresh page to apply');
		});

		const toggleLabel = document.createElement('label');
		toggleLabel.htmlFor = 'toggleVotes';
		toggleLabel.textContent = 'Enable vote tracking';

		const voteInfo = document.createElement('div');
		const net = (tagData.pVotes || 0) - (tagData.nVotes || 0);
		voteInfo.innerHTML = `<b>Votes:</b> +${tagData.pVotes || 0} / -${tagData.nVotes || 0} [<b>${net > 0 ? '+' : ''}${net}</b>]`;

		const resetVotesBtn = document.createElement('button');
		resetVotesBtn.textContent = 'Reset Votes';
		resetVotesBtn.classList.add('btn');
		resetVotesBtn.style.fontSize = '13px';
		resetVotesBtn.style.padding = '1px 4px';
		resetVotesBtn.style.marginLeft = '10px';
		resetVotesBtn.onclick = async () => {
			if (!tagData.pVotes && !tagData.nVotes) return;
			if (confirm(`Are you sure you want to reset votes for ${username}?`)) {
				tagData.pVotes = 0;
				tagData.nVotes = 0;
				const tags = await getTags();
				tags[userId] = tagData;
				if (!tags[userId].tag && !tags[userId].bgColor && !tags[userId].textColor &&
					(!tags[userId].pVotes || tags[userId].pVotes === 0) && (!tags[userId].nVotes || tags[userId].nVotes === 0)) {
					delete tags[userId];
				}
				await saveTags(tags);
				refreshUser(userId);
				fadeOutAndRemove(modal);
			}
		};
		voteInfo.appendChild(resetVotesBtn);

		const label = document.createElement('label');
		label.innerHTML = `Tag for <b>${username}</b>`;
		const input = document.createElement('input');
		input.type = 'text';
		input.value = tagData.tag || '';
		input.style.marginTop = '5px';
		input.style.marginBottom = '10px';

		const bgColorInput = document.createElement('input');
		bgColorInput.type = 'color';
		bgColorInput.value = /^#[0-9A-Fa-f]{6}$/.test(tagData.bgColor) ? tagData.bgColor : DEFAULT_BG_COLOR;
		bgColorInput.style.marginTop = '5px';
		bgColorInput.style.marginBottom = '10px';

		const textColorInput = document.createElement('input');
		textColorInput.type = 'color';
		textColorInput.value = /^#[0-9A-Fa-f]{6}$/.test(tagData.textColor) ? tagData.textColor : DEFAULT_TEXT_COLOR;
		textColorInput.style.marginBottom = '10px';

		const preview = document.createElement('span');
		preview.textContent = input.value;
		preview.style.padding = '2px 4px';
		preview.style.borderRadius = '4px';
		preview.style.backgroundColor = bgColorInput.value;
		preview.style.color = textColorInput.value;
		preview.style.fontSize = 'smaller';

		function updatePreview() {
			preview.textContent = input.value;
			preview.style.backgroundColor = bgColorInput.value;
			preview.style.color = textColorInput.value;
		}

		input.addEventListener('input', updatePreview);
		bgColorInput.addEventListener('input', updatePreview);
		textColorInput.addEventListener('input', updatePreview);

		const resetBtn = document.createElement('button');
		resetBtn.textContent = 'Reset Colour';
		resetBtn.classList.add('btn');
		resetBtn.style.marginRight = '5px';
		resetBtn.onclick = () => {
			bgColorInput.value = DEFAULT_BG_COLOR;
			textColorInput.value = DEFAULT_TEXT_COLOR;
			updatePreview();
		};

		const saveBtn = document.createElement('button');
		saveBtn.textContent = 'Save Tag';
		saveBtn.classList.add('btn','btn-primary');
		saveBtn.onclick = async () => {
			await setTag(userId, input.value, bgColorInput.value, textColorInput.value);
			refreshUser(userId);
			fadeOutAndRemove(modal);
		};

		input.addEventListener('keydown', async (e) => {
			if (e.key === 'Enter') {
				e.preventDefault();
				await saveBtn.onclick();
			}
		});

		const exportBtn = document.createElement('button');
		exportBtn.textContent = 'Export Data';
		exportBtn.classList.add('btn');
		exportBtn.style.fontSize = '13px';
		exportBtn.style.padding = '1px 4px';
		exportBtn.style.marginRight = '5px';
		exportBtn.onclick = async () => {
			const tags = await getTags();
			const blob = new Blob([JSON.stringify(tags, null, 2)], { type: 'application/json' });
			const url = URL.createObjectURL(blob);
			const a = document.createElement('a');
			const currentDate = new Date();
			let month;
			if (currentDate.getMonth() < 9) {
				month = '0' + (currentDate.getMonth() + 1);
			} else {
				month = currentDate.getMonth() + 1;
			}
			let day;
			if (currentDate.getDate() < 10) {
				day = '0' + currentDate.getDate();
			} else {
				day = currentDate.getDate();
			}
			a.href = url;
			a.download = 'OzB_Usertags_Backup_' + currentDate.getFullYear() + month + day + '.json';
			a.click();
		};

		const importBtn = document.createElement('button');
		importBtn.textContent = 'Import Data';
		importBtn.classList.add('btn');
		importBtn.style.fontSize = '13px';
		importBtn.style.padding = '1px 4px';
		importBtn.style.marginRight = '5px';
		importBtn.onclick = () => {
			if (confirm('Are you sure you want to import a data backup? \nThis will overwrite and delete your current data')) {
				const input = document.createElement('input');
				input.type = 'file';
				input.accept = 'application/json';
				input.onchange = async (e) => {
					const file = e.target.files[0];
					if (file) {
						const text = await file.text();
						try {
							const json = JSON.parse(text);
							await saveTags(json);
							alert('Tags imported! Refresh page to see changes');
						} catch (err) {
							alert('Error: Invalid JSON');
						}
					}
				};
				input.click();
			}
		};
		const deleteAllBtn = document.createElement('button');
		deleteAllBtn.textContent = 'Delete All';
		deleteAllBtn.classList.add('btn');
		deleteAllBtn.style.fontSize = '13px';
		deleteAllBtn.style.padding = '1px 4px';
		deleteAllBtn.onclick = clearAllTags;

		modal.appendChild(head);
		const tooltip = document.createElement('div');
		tooltip.classList.add('tooltip','tooltipuser');
		tooltip.appendChild(toggleLabel);
		tooltip.appendChild(toggleVotesCheckbox);
		tooltip.appendChild(voteInfo);
		tooltip.appendChild(document.createElement('hr'));
		tooltip.appendChild(label);
		tooltip.appendChild(document.createElement('br'));
		tooltip.appendChild(input);
		tooltip.appendChild(document.createElement('br'));
		tooltip.appendChild(preview);
		tooltip.appendChild(document.createElement('br'));
		tooltip.appendChild(document.createTextNode('Background: '));
		tooltip.appendChild(bgColorInput);
		tooltip.appendChild(document.createTextNode('\u00A0\u00A0Text: '));
		tooltip.appendChild(textColorInput);
		tooltip.appendChild(document.createElement('br'));
		tooltip.appendChild(resetBtn);
		tooltip.appendChild(saveBtn);
		tooltip.appendChild(document.createElement('br'));
		tooltip.appendChild(document.createElement('hr'));
		tooltip.appendChild(exportBtn);
		tooltip.appendChild(importBtn);
		tooltip.appendChild(deleteAllBtn);
		modal.appendChild(tooltip);
		document.body.appendChild(modal);
		positionModalUnderIcon(modal, e.currentTarget);
	});
	wrapper.appendChild(icon);
	return wrapper;
}

function positionModalUnderIcon(modal, iconElement, modalWidth = 253, modalHeight = 310, verticalOffset = 10) {
	const iconRect = iconElement.getBoundingClientRect();
	let top = iconRect.bottom + window.scrollY + verticalOffset;
	let left = iconRect.left + window.scrollX + (iconRect.width / 2) - (modalWidth / 2);
	const viewportWidth = document.documentElement.clientWidth;
	const viewportHeight = document.documentElement.clientHeight;
	// Adjust right overflow
	if (left + modalWidth > window.scrollX + viewportWidth) {
		left = window.scrollX + viewportWidth - modalWidth - 10;
		modal.classList.add('left-offset');
		const arrowOffset = iconRect.left + iconRect.width / 2 - left;
		modal.style.setProperty('--arrow-offset', `${arrowOffset}px`);
	}
	// Adjust left overflow
	if (left < window.scrollX) {
		left = window.scrollX + 10;
		modal.classList.add('left-offset');
		const arrowOffset = iconRect.left + iconRect.width / 2 - left;
		modal.style.setProperty('--arrow-offset', `${arrowOffset}px`);
	}
	// Adjust bottom overflow (show above icon)
	if (top + modalHeight > window.scrollY + viewportHeight) {
		top = iconRect.top + window.scrollY - modalHeight - verticalOffset;
		modal.classList.add('above-icon');
	}
	modal.style.top = `${top}px`;
	modal.style.left = `${left}px`;
}

function fadeOutAndRemove(el) {
	if (!el) return;
	el.classList.add('fade-out');
	setTimeout(() => el.remove(), 300); //300ms matches the CSS transition
}

async function handleVoteClick(voteBtn, delta) {
	if (!await isVoteTrackingEnabled()) return;
	const container = voteBtn.closest('.c-vote, .n-vote');
	if (!container) return;
	const meta = voteBtn.closest('.meta, .node');
	if (!meta) return;
	const anchor = meta.querySelector('.submitted strong a[href^="/user/"]');
	const userId = extractUserId(anchor);
	if (!userId || voteCooldown.has(userId)) return;
	//Wait and see if negative deal votes get accepted due to comment requirement
	if (meta.className.includes('node') && delta < 0) {
		setTimeout(() => {
			const newContainer = voteBtn.closest('.n-vote');
			if (newContainer.classList.contains('inact')) return;
			processVoteResult(userId, container, delta);
		}, 200);
	} else {
		processVoteResult(userId, container, delta);
	}
}

async function processVoteResult(userId, container, delta) {
	const tags = await getTags();
	tags[userId] = tags[userId] || {};
	// Determine vote state
	const hasPending = container.classList.contains('pending');
	const isVoteUp = container.classList.contains('voteup');
	const isVoteDown = container.classList.contains('votedown');
	// Vote change logic
	if (hasPending) {
		// User is changing their vote
		if (delta > 0 && isVoteDown) {
			// Changing from negative to positive
			tags[userId].nVotes = Math.max((tags[userId].nVotes || 1) - 1, 0);
			tags[userId].pVotes = (tags[userId].pVotes || 0) + 1;
		} else if (delta < 0 && isVoteUp) {
			// Changing from positive to negative
			tags[userId].pVotes = Math.max((tags[userId].pVotes || 1) - 1, 0);
			tags[userId].nVotes = (tags[userId].nVotes || 0) + 1;
		} else {
			// Same vote clicked again, no changes
			return;
		}
	} else if (container.classList.contains('inact')) {
		// First time voting
		if (delta > 0) tags[userId].pVotes = (tags[userId].pVotes || 0) + 1;
		else tags[userId].nVotes = (tags[userId].nVotes || 0) + 1;
	} else {
		// Already voted and no change allowed (or multiple clicks)
		return;
	}
	await saveTags(tags);
	refreshUser(userId, tags);
	voteCooldown.add(userId);
	setTimeout(() => voteCooldown.delete(userId), 1000); //debounce
}

async function addTags() {
	const tags = await getTags();
	if (location.href.indexOf('/user/') > 0) {
		const userId = location.pathname.split("/")[2];
		if (!userId) return;
		if (!document.querySelector('.user-tag')) {
			const title = document.querySelector('h1#title');
			const username = title?.getAttribute('data-title')?.match(/^(.+?)\s+\(#\d+\)/)?.[1];
			const tagEl = await createTagElement(userId, tags[userId], username);
			tagEl.style.position = 'relative';
			tagEl.style.top = '-3px';
			title.style.display = 'inline-block';
			title.style.marginRight = '5px';
			title.insertAdjacentElement('afterend', tagEl);
		}
	} else {
		const anchors = document.querySelectorAll('.submitted strong a[href^="/user/"]');
		for (const a of anchors) {
			const userId = extractUserId(a);
			if (!userId) continue;
			if (!a.parentElement.querySelector('.user-tag')) {
				const username = a.textContent.trim();
				const tagEl = await createTagElement(userId, tags[userId], username);
				a.parentElement.appendChild(tagEl);
			}
		}
	}
	//Add click listeners to all clickable votes
	document.querySelectorAll('.c-vote.inact .cvb.voteup, .n-vote.inact .nvb.voteup').forEach(btn => {
		if (!btn.dataset.listenerAdded) {
			btn.dataset.listenerAdded = 'true';
			btn.addEventListener('click', () => handleVoteClick(btn, 1));
		}
	});
	document.querySelectorAll('.c-vote.inact .cvb.votedown, .n-vote.inact .nvb.votedown').forEach(btn => {
		if (!btn.dataset.listenerAdded) {
			btn.dataset.listenerAdded = 'true';
			btn.addEventListener('click', () => handleVoteClick(btn, -1));
		}
	});
}

async function refreshUser(userId, tags) {
	if (!tags) {
		tags = await getTags();
	}
	if (location.href.indexOf('/user/') > 0) {
		const title = document.querySelector('h1#title');
		const existingTag = title.parentElement.querySelector('.user-tag');
		if (existingTag) existingTag.remove();
		const username = title?.getAttribute('data-title')?.match(/^(.+?)\s+\(#\d+\)/)?.[1];
		const tagEl = await createTagElement(userId, tags[userId], username);
		tagEl.style.position = 'relative';
		tagEl.style.top = '-3px';
		title.style.display = 'inline-block';
		title.style.marginRight = '5px';
		title.insertAdjacentElement('afterend', tagEl);
	} else {
		const anchors = document.querySelectorAll(`.submitted strong a[href="/user/${userId}"]`);
		for (const a of anchors) {
			const existingTag = a.parentElement.querySelector('.user-tag');
			if (existingTag) existingTag.remove();
			const username = a.textContent.trim();
			const tagEl = await createTagElement(userId, tags[userId], username);
			a.parentElement.appendChild(tagEl);
		}
	}
}

function observePages() { //Observe pages that can dynamically load nodes/comments
	const infScroll = document.querySelector(".infscrollbtn"); //Pages with infinite scroll
	const hiddenNode = document.querySelector(".comment.hidden"); //Pages with hidden commments
	if (infScroll || hiddenNode) {
		const callback = (mutationList, observer) => {
			for (const mutation of mutationList) {
				if (mutation.addedNodes.length > 0 && mutation.addedNodes[0].className && (mutation.addedNodes[0].className.includes("comment") || mutation.addedNodes[0].className.includes("node")) ) {
					addTags();
				}
			}
		};
		const observer = new MutationObserver(callback);
		observer.observe(document.body, { childList: true, subtree: true });
	}
}

addTags();
observePages();