Old Reddit with New Reddit Profile Pictures - API Key Version - Reddit-Stream Version

Injects new Reddit profile pictures into Old Reddit and Reddit-Stream.com next to the username. Caches in localstorage. This version requires an API key. Enter your API Key under CLIENT_ID and CLIENT_SECRET or it will not work.

目前为 2025-01-21 提交的版本。查看 最新版本

// ==UserScript==
// @name         Old Reddit with New Reddit Profile Pictures - API Key Version - Reddit-Stream Version
// @namespace    typpi.online
// @version      7.0.3
// @description  Injects new Reddit profile pictures into Old Reddit and Reddit-Stream.com next to the username. Caches in localstorage. This version requires an API key. Enter your API Key under CLIENT_ID and CLIENT_SECRET or it will not work.
// @author       Nick2bad4u
// @match        *://reddit-stream.com/*
// @connect      reddit.com
// @connect      reddit-stream.com
// @grant        GM_xmlhttpRequest
// @homepageURL  https://github.com/Nick2bad4u/UserStyles
// @license      Unlicense
// @resource     https://www.google.com/s2/favicons?sz=64&domain=reddit-stream.com
// @icon         https://www.google.com/s2/favicons?sz=64&domain=reddit-stream.com
// @icon64       https://www.google.com/s2/favicons?sz=64&domain=reddit-stream.com
// @run-at       document-start
// @tag          reddit
// ==/UserScript==

(function () {
	'use strict';
	console.log(
		'Reddit Profile Picture Injector Script loaded',
	);

	// Reddit API credentials
	const CLIENT_ID = 'EnterClientIDHere';
	const CLIENT_SECRET = 'EnterClientSecretHere';
	const USER_AGENT =
		'ProfilePictureInjector/7.0.2 by Nick2bad4u';
	let accessToken = localStorage.getItem(
		'accessToken',
	);

	// Retrieve cached profile pictures and timestamps from localStorage
	let profilePictureCache = JSON.parse(
		localStorage.getItem('profilePictureCache') ||
			'{}',
	);
	let cacheTimestamps = JSON.parse(
		localStorage.getItem('cacheTimestamps') ||
			'{}',
	);
	const CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds
	const MAX_CACHE_SIZE = 100000; // Maximum number of cache entries
	const cacheEntries = Object.keys(
		profilePictureCache,
	);

	// Rate limit variables
	let rateLimitRemaining = 1000;
	let rateLimitResetTime = 0;
	const resetDate = new Date(rateLimitResetTime);
	const now = Date.now();

	// Save the cache to localStorage
	function saveCache() {
		localStorage.setItem(
			'profilePictureCache',
			JSON.stringify(profilePictureCache),
		);
		localStorage.setItem(
			'cacheTimestamps',
			JSON.stringify(cacheTimestamps),
		);
	}

	// Remove old cache entries
	function flushOldCache() {
		console.log(
			'Flushing old Reddit profile picture URL cache',
		);
		const now = Date.now();
		for (const username in cacheTimestamps) {
			if (
				now - cacheTimestamps[username] >
				CACHE_DURATION
			) {
				console.log(
					`Deleting cache for Reddit user - ${username}`,
				);
				delete profilePictureCache[username];
				delete cacheTimestamps[username];
			}
		}
		saveCache();
		console.log('Old cache entries flushed');
	}

	// Limit the size of the cache to the maximum allowed entries
	function limitCacheSize() {
		const cacheEntries = Object.keys(
			profilePictureCache,
		);
		if (cacheEntries.length > MAX_CACHE_SIZE) {
			console.log(
				`Current cache size: ${cacheEntries.length}`,
			);
			console.log(
				'Cache size exceeded, removing oldest entries',
			);
			const sortedEntries = cacheEntries.sort(
				(a, b) =>
					cacheTimestamps[a] - cacheTimestamps[b],
			);
			const entriesToRemove = sortedEntries.slice(
				0,
				cacheEntries.length - MAX_CACHE_SIZE,
			);
			entriesToRemove.forEach((username) => {
				delete profilePictureCache[username];
				delete cacheTimestamps[username];
			});
			saveCache();
			console.log(
				`Cache size limited to ${MAX_CACHE_SIZE.toLocaleString()} URLs`,
			);
		}
	}

	function getCacheSizeInBytes() {
		const cacheEntries = Object.keys(
			profilePictureCache,
		);
		let totalSize = 0;

		// Calculate size of profilePictureCache
		cacheEntries.forEach((username) => {
			const pictureData =
				profilePictureCache[username];
			const timestampData =
				cacheTimestamps[username];

			// Estimate size of data by serializing to JSON and getting the length
			totalSize += new TextEncoder().encode(
				JSON.stringify(pictureData),
			).length;
			totalSize += new TextEncoder().encode(
				JSON.stringify(timestampData),
			).length;
		});

		return totalSize; // in bytes
	}

	function getCacheSizeInMB() {
		return getCacheSizeInBytes() / (1024 * 1024); // Convert bytes to MB
	}

	function getCacheSizeInKB() {
		return getCacheSizeInBytes() / 1024; // Convert bytes to KB
	}

	// Obtain an access token from Reddit API
	async function getAccessToken() {
		console.log('Obtaining access token');
		const credentials = btoa(
			`${CLIENT_ID}:${CLIENT_SECRET}`,
		);
		try {
			const response = await fetch(
				'https://www.reddit.com/api/v1/access_token',
				{
					method: 'POST',
					headers: {
						Authorization: `Basic ${credentials}`,
						'Content-Type':
							'application/x-www-form-urlencoded',
					},
					body: 'grant_type=client_credentials',
				},
			);
			if (!response.ok) {
				console.error(
					'Failed to obtain access token:',
					response.statusText,
				);
				return null;
			}
			const data = await response.json();
			accessToken = data.access_token;
			const expiration =
				Date.now() + data.expires_in * 1000;
			localStorage.setItem(
				'accessToken',
				accessToken,
			);
			localStorage.setItem(
				'tokenExpiration',
				expiration.toString(),
			);
			console.log(
				'Access token obtained and saved',
			);
			return accessToken;
		} catch (error) {
			console.error(
				'Error obtaining access token:',
				error,
			);
			return null;
		}
	}

	// Fetch profile pictures for a list of usernames
	async function fetchProfilePictures(usernames) {
		console.log('Fetching profile pictures');
		const now = Date.now();
		const tokenExpiration = parseInt(
			localStorage.getItem('tokenExpiration'),
			10,
		);

		// Check rate limit
		if (
			rateLimitRemaining <= 0 &&
			now < rateLimitResetTime
		) {
			console.warn(
				'Rate limit reached. Waiting until reset...',
			);

			const timeRemaining =
				rateLimitResetTime - now;
			const minutesRemaining = Math.floor(
				timeRemaining / 60000,
			);
			const secondsRemaining = Math.floor(
				(timeRemaining % 60000) / 1000,
			);

			console.log(
				`Rate limit will reset in ${minutesRemaining} minutes and ${secondsRemaining} seconds.`,
			);
			await new Promise((resolve) =>
				setTimeout(
					resolve,
					rateLimitResetTime - now,
				),
			);
		}

		// Refresh access token if expired
		if (!accessToken || now > tokenExpiration) {
			accessToken = await getAccessToken();
			if (!accessToken) return null;
		}

		// Filter out cached usernames
		const uncachedUsernames = usernames.filter(
			(username) =>
				!profilePictureCache[username] &&
				username !== '[deleted]' &&
				username !== '[removed]',
		);
		if (uncachedUsernames.length === 0) {
			console.log('All usernames are cached');
			return usernames.map(
				(username) =>
					profilePictureCache[username],
			);
		}

		// Fetch profile pictures for uncached usernames
		const fetchPromises = uncachedUsernames.map(
			async (username) => {
				try {
					const response = await fetch(
						`https://oauth.reddit.com/user/${username}/about`,
						{
							headers: {
								Authorization: `Bearer ${accessToken}`,
								'User-Agent': USER_AGENT,
							},
						},
					);

					// Update rate limit
					rateLimitRemaining =
						parseInt(
							response.headers.get(
								'x-ratelimit-remaining',
							),
						) || rateLimitRemaining;
					rateLimitResetTime =
						now +
							parseInt(
								response.headers.get(
									'x-ratelimit-reset',
								),
							) *
								1000 || rateLimitResetTime;

					// Log rate limit information
					const timeRemaining =
						rateLimitResetTime - now;
					const minutesRemaining = Math.floor(
						timeRemaining / 60000,
					);
					const secondsRemaining = Math.floor(
						(timeRemaining % 60000) / 1000,
					);

					console.log(
						`Rate Limit Requests Remaining: ${rateLimitRemaining} requests, reset in ${minutesRemaining} minutes and ${secondsRemaining} seconds`,
					);

					if (!response.ok) {
						console.error(
							`Error fetching profile picture for ${username}: ${response.statusText}`,
						);
						return null;
					}
					const data = await response.json();
					if (data.data && data.data.icon_img) {
						const profilePictureUrl =
							data.data.icon_img.split('?')[0];
						profilePictureCache[username] =
							profilePictureUrl;
						cacheTimestamps[username] =
							Date.now();
						saveCache();
						console.log(
							`Fetched profile picture: ${username}`,
						);
						return profilePictureUrl;
					} else {
						console.warn(
							`No profile picture found for: ${username}`,
						);
						return null;
					}
				} catch (error) {
					console.error(
						`Error fetching profile picture for ${username}:`,
						error,
					);
					return null;
				}
			},
		);

		const results = await Promise.all(
			fetchPromises,
		);
		limitCacheSize();
		return usernames.map(
			(username) => profilePictureCache[username],
		);
	}

	// Inject profile pictures into comments
	async function injectProfilePictures(comments) {
		console.log(
			`Comments found: ${comments.length}`,
		);
		const usernames = Array.from(comments)
			.map((comment) =>
				comment.textContent.trim(),
			)
			.filter(
				(username) =>
					username !== '[deleted]' &&
					username !== '[removed]',
			);
		const profilePictureUrls =
			await fetchProfilePictures(usernames);

		let injectedCount = 0; // Counter for injected profile pictures

		comments.forEach((comment, index) => {
			const username = usernames[index];
			const profilePictureUrl =
				profilePictureUrls[index];
			if (
				profilePictureUrl &&
				!comment.previousElementSibling?.classList.contains(
					'profile-picture',
				)
			) {
				console.log(
					`Injecting profile picture: ${username}`,
				);
				const img = document.createElement('img');
				img.src = profilePictureUrl;
				img.classList.add('profile-picture');
				img.onerror = () => {
					img.style.display = 'none';
				};
				img.addEventListener('click', () => {
					window.open(
						profilePictureUrl,
						'_blank',
					);
				});
				comment.insertAdjacentElement(
					'beforebegin',
					img,
				);

				const enlargedImg =
					document.createElement('img');
				enlargedImg.src = profilePictureUrl;
				enlargedImg.classList.add(
					'enlarged-profile-picture',
				);
				document.body.appendChild(enlargedImg);
				img.addEventListener('mouseover', () => {
					enlargedImg.style.display = 'block';
					const rect =
						img.getBoundingClientRect();
					enlargedImg.style.top = `${rect.top + window.scrollY + 20}px`;
					enlargedImg.style.left = `${rect.left + window.scrollX + 20}px`;
				});
				img.addEventListener('mouseout', () => {
					enlargedImg.style.display = 'none';
				});

				injectedCount++; // Increment count after successful injection
			}
		});

		console.log(
			`Profile pictures injected this run: ${injectedCount}`,
		);
		console.log(
			`Current cache size: ${cacheEntries.length}`,
		);
		console.log(
			`Cache size limited to ${MAX_CACHE_SIZE}`,
		);
		const currentCacheSizeMB = getCacheSizeInMB();
		const currentCacheSizeKB = getCacheSizeInKB();
		console.log(
			`Current cache size: ${currentCacheSizeMB.toFixed(2)} MB or ${currentCacheSizeKB.toFixed(2)} KB`,
		);

		const timeRemaining =
			rateLimitResetTime - Date.now();
		const minutesRemaining = Math.floor(
			timeRemaining / 60000,
		);
		const secondsRemaining = Math.floor(
			(timeRemaining % 60000) / 1000,
		);
		console.log(
			`Rate Limit Requests Remaining: ${rateLimitRemaining}, refresh in ${minutesRemaining} minutes and ${secondsRemaining} seconds`,
		);
	}

	// Set up a MutationObserver to detect new comments
	function setupObserver() {
		console.log(
			'Setting up observer to detect reddit comments',
		);

		const processedComments = new Set(); // Track already processed comments
		let newCommentsBatch = []; // Store new comments temporarily
		let batchTimeout; // Timeout variable for batching
		let isFirstRun = true; // Flag to check if it's the first run

		const observer = new MutationObserver(
			(mutations) => {
				mutations.forEach((mutation) => {
					mutation.addedNodes.forEach((node) => {
						if (
							node.nodeType === Node.ELEMENT_NODE
						) {
							const newComments = Array.from(
								node.querySelectorAll(
									'.author, .c-username',
								),
							).filter(
								(comment) =>
									!processedComments.has(comment),
							);

							if (newComments.length > 0) {
								newComments.forEach((comment) => {
									processedComments.add(comment);
									newCommentsBatch.push(comment); // Add to batch
								});

								// Clear previous timeout and set a new one for batching
								clearTimeout(batchTimeout);

								// Set a delay for the first run, then use regular debounce for others
								batchTimeout = setTimeout(
									() => {
										injectProfilePictures(
											newCommentsBatch,
										);
										newCommentsBatch = []; // Reset the batch
										isFirstRun = false; // Disable first run flag after initial run
									},
									isFirstRun ? 150 : 100,
								); // First run delay: 1000ms, regular: 300ms
							}
						}
					});
				});
			},
		);

		observer.observe(document.body, {
			childList: true,
			subtree: true,
		});

		console.log('Observer initialized');
	}

	// Run the script
	function runScript() {
		flushOldCache();
		console.log(
			'Cache loaded:',
			profilePictureCache,
		);
		setupObserver();
	}

	window.addEventListener('load', () => {
		console.log('Page loaded');
		runScript();
	});

	// Add CSS styles for profile pictures
	const style = document.createElement('style');
	style.textContent = `
        .profile-picture {
            width: 20px;
            height: 20px;
            border-radius: 50%;
            margin-right: 5px;
            transition: transform 0.2s ease-in-out;
            position: relative;
            z-index: 1;
            cursor: pointer;
        }
        .enlarged-profile-picture {
            width: 250px;
            height: 250px;
            border-radius: 50%;
            position: absolute;
            display: none;
            z-index: 1000;
            pointer-events: none;
            outline: 3px solid #000;
            box-shadow: 0 4px 8px rgba(0, 0, 0, 1);
            background-color: rgba(0, 0, 0, 1);
        }
    `;
	document.head.appendChild(style);
})();