Old Reddit with New Reddit Profile Pictures - API Key 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.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Old Reddit with New Reddit Profile Pictures - API Key Version
// @namespace    typpi.online
// @version      7.0.7
// @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.com/*
// @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.com
// @icon         https://www.google.com/s2/favicons?sz=64&domain=reddit.com
// @icon64       https://www.google.com/s2/favicons?sz=64&domain=reddit.com
// @run-at       document-start
// @tag          reddit
// ==/UserScript==

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

	// Reddit API credentials
	/**
	 * @constant {string} CLIENT_ID
	 * The client ID used for authentication with Reddit's API.
	 * This ID is required to make authenticated requests to Reddit's API endpoints.
	 * Obtain this value by registering your application at https://www.reddit.com/prefs/apps
	 */
	const CLIENT_ID = 'EnterClientIDHere';
	/**
	 * @constant {string} CLIENT_SECRET
	 * The client secret key required for Reddit API authentication.
	 * This key should be kept private and not shared publicly.
	 * Obtain this value from your Reddit API application settings.
	 */
	const CLIENT_SECRET = 'EnterClientSecretHere';
	/**
	 * User agent string used for making API requests.
	 * Format: {ApplicationName}/{Version} by {Author}
	 * @constant {string}
	 */
	const USER_AGENT = 'ProfilePictureInjector/7.0.6 by Nick2bad4u';
	/**
	 * Access token retrieved from localStorage for authentication purposes.
	 * @type {string|null}
	 */
	let accessToken = localStorage.getItem('accessToken');

	// Retrieve cached profile pictures and timestamps from localStorage
	/**
	 * Object containing cached profile picture URLs.
	 * Data is persisted in localStorage and parsed from JSON.
	 * @type {Object.<string, string>} Key-value pairs of username to profile picture URL
	 */
	let profilePictureCache = JSON.parse(
		localStorage.getItem('profilePictureCache') || '{}',
	);
	/**
	 * Object storing timestamps for cached items.
	 * Retrieved from localStorage, defaults to empty object if not found.
	 * @type {Object.<string, number>}
	 */
	let cacheTimestamps = JSON.parse(
		localStorage.getItem('cacheTimestamps') || '{}',
	);
	/**
	 * Duration in milliseconds for which profile picture data will be cached.
	 * Set to 7 days to balance between API rate limits and data freshness.
	 * @constant
	 * @type {number}
	 */
	const CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds
	/**
	 * Maximum number of entries that can be stored in the cache.
	 * Prevents memory overflow by limiting cache size.
	 * @constant {number}
	 */
	const MAX_CACHE_SIZE = 100000; // Maximum number of cache entries
	/**
	 * Array of keys from the profilePictureCache object representing cached profile picture entries
	 * @type {string[]}
	 * @const
	 */
	const cacheEntries = Object.keys(profilePictureCache);

	// Rate limit variables
	/**
	 * Remaining number of API requests allowed before hitting rate limit
	 * @type {number}
	 * @default 1000
	 */
	let rateLimitRemaining = 1000;
	/**
	 * Unix timestamp indicating when the Reddit API rate limit will reset
	 * @type {number}
	 */
	let rateLimitResetTime = 0;
	/**
	 * Date object representing when the rate limit will reset
	 * @type {Date}
	 */
	// eslint-disable-next-line @typescript-eslint/no-unused-vars
	const resetDate = new Date(rateLimitResetTime);
	/**
	 * Current timestamp in milliseconds since January 1, 1970 00:00:00 UTC.
	 * @type {number}
	 */
	// eslint-disable-next-line @typescript-eslint/no-unused-vars
	const now = Date.now();

	// Save the cache to localStorage
	/**
	 * Saves the profile picture cache and cache timestamps to localStorage.
	 * The cache is stored as stringified JSON under 'profilePictureCache' key,
	 * and timestamps are stored under 'cacheTimestamps' key.
	 */
	function saveCache() {
		localStorage.setItem(
			'profilePictureCache',
			JSON.stringify(profilePictureCache),
		);
		localStorage.setItem('cacheTimestamps', JSON.stringify(cacheTimestamps));
	}

	// Remove old cache entries
	/**
	 * Removes expired entries from the Reddit profile picture URL cache.
	 * Iterates through cached usernames and removes entries older than CACHE_DURATION.
	 * After cleaning expired entries, saves the updated cache to storage.
	 *
	 * @function flushOldCache
	 * @returns {void}
	 *
	 * @requires CACHE_DURATION - Maximum age of cache entries in milliseconds
	 * @requires cacheTimestamps - Object storing timestamps for each cached username
	 * @requires profilePictureCache - Object storing profile picture URLs by username
	 * @requires saveCache - Function to persist the cache to storage
	 */
	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
	/**
	 * Manages the size of the profile picture cache by removing oldest entries when the maximum size is exceeded.
	 * Sorts entries by timestamp and removes the oldest ones until the cache size is within the specified limit.
	 * After removal, saves the updated cache to persistent storage.
	 *
	 * @function limitCacheSize
	 * @returns {void}
	 *
	 * @uses profilePictureCache - Global object storing profile picture URLs
	 * @uses cacheTimestamps - Global object storing timestamps for each cache entry
	 * @uses MAX_CACHE_SIZE - Global constant defining maximum number of entries allowed in cache
	 * @uses saveCache - Function to persist the cache to storage
	 */
	function limitCacheSize() {
		const cacheEntries = Object.keys(profilePictureCache);
		if (cacheEntries.length > MAX_CACHE_SIZE) {
			console.log(`Current cache size: ${cacheEntries.length} URLs`);
			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`,
			);
		}
	}

	/**
	 * Calculates the total size in bytes of the profile picture cache and its timestamps.
	 * The size is estimated by serializing cache entries to JSON and measuring their byte length.
	 * Each cache entry consists of picture data and timestamp data for a username.
	 * @returns {number} The total size of the cache in bytes
	 */
	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
	}

	/**
	 * Calculates the current cache size in megabytes.
	 * @returns {number} The size of the cache in megabytes (MB)
	 */
	function getCacheSizeInMB() {
		return getCacheSizeInBytes() / (1024 * 1024); // Convert bytes to MB
	}

	/**
	 * Calculates the cache size in kilobytes (KB).
	 * @returns {number} The size of the cache in KB, calculated by dividing the size in bytes by 1024.
	 */
	function getCacheSizeInKB() {
		return getCacheSizeInBytes() / 1024; // Convert bytes to KB
	}

	// Obtain an access token from Reddit API
	/**
	 * Obtains an access token from Reddit's API using client credentials.
	 * The token is stored in localStorage along with its expiration time.
	 *
	 * @async
	 * @function getAccessToken
	 * @returns {Promise<string|null>} The access token if successful, null if the request fails
	 * @throws {Error} When the network request fails
	 *
	 * @example
	 * const token = await getAccessToken();
	 * if (token) {
	 *   // Use token for authenticated requests
	 * }
	 */
	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
	/**
	 * Fetches profile pictures for a list of Reddit usernames using Reddit's OAuth API
	 * @async
	 * @param {string[]} usernames - Array of Reddit usernames to fetch profile pictures for
	 * @returns {Promise<(string|null)[]>} Array of profile picture URLs corresponding to the input usernames. Returns null for usernames where fetching failed
	 * @description
	 * - Handles rate limiting by waiting when limit is reached
	 * - Manages OAuth token refresh when expired
	 * - Caches profile pictures to avoid redundant API calls
	 * - Filters out [deleted] and [removed] usernames
	 * - Updates rate limit tracking based on API response headers
	 * - Saves fetched profile pictures to cache
	 * @throws {Error} Possible network or API errors during fetch operations
	 */
	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
		/**
		 * Array of promises that fetch profile pictures for uncached Reddit usernames using Reddit's OAuth API
		 * @type {Promise<(string|null)>[]}
		 * @description Each promise attempts to:
		 * 1. Fetch user data from Reddit's OAuth API
		 * 2. Extract and cache the profile picture URL
		 * 3. Update rate limit tracking
		 * 4. Handle errors gracefully
		 * @returns {Promise<(string|null)>[]} Array of promises that resolve to either:
		 * - Profile picture URLs (string) for successful fetches
		 * - null for failed fetches or users without profile pictures
		 * @throws {Error} Individual promises may throw network or API errors, but these are caught and handled
		 */
		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}, 1000 more requests will be added 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;
			}
		});

		// eslint-disable-next-line @typescript-eslint/no-unused-vars
		const results = await Promise.all(fetchPromises);
		limitCacheSize();
		return usernames.map((username) => profilePictureCache[username]);
	}

	/**
	 * Injects profile pictures next to user comments and adds hover functionality for enlarged views
	 * @async
	 * @param {NodeList} comments - NodeList of comment elements to process
	 * @returns {Promise<void>}
	 *
	 * @description
	 * This function:
	 * 1. Extracts usernames from comments, filtering out deleted/removed users
	 * 2. Fetches profile picture URLs for valid usernames
	 * 3. Creates and injects profile picture elements before each comment
	 * 4. Adds click handlers to open full-size images in new tabs
	 * 5. Implements hover functionality to show enlarged previews
	 * 6. Tracks injection count and logs cache statistics
	 * 7. Reports rate limit status for API calls
	 *
	 * @requires fetchProfilePictures - External function to retrieve profile picture URLs
	 * @requires cacheEntries - Global array tracking cached URLs
	 * @requires MAX_CACHE_SIZE - Global constant for maximum cache size
	 * @requires rateLimitResetTime - Global variable tracking API rate limit reset time
	 * @requires rateLimitRemaining - Global variable tracking remaining API calls
	 * @requires getCacheSizeInMB - Function to calculate cache size in megabytes
	 * @requires getCacheSizeInKB - Function to calculate cache size in kilobytes
	 */
	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.toLocaleString()} URLs`,
		);
		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} requests, refresh in ${minutesRemaining} minutes and ${secondsRemaining} seconds`,
		);
	}

	/**
	 * Sets up a MutationObserver to watch for new comments on Reddit.
	 * The observer looks for elements with class 'author' or 'c-username'.
	 * When new comments are detected, it disconnects the observer and
	 * injects profile pictures into the found elements.
	 *
	 * The observer monitors the entire document body for DOM changes,
	 * including nested elements.
	 *
	 * @function setupObserver
	 */
	function setupObserver() {
		console.log('Setting up observer');
		// eslint-disable-next-line @typescript-eslint/no-unused-vars
		const observer = new MutationObserver((mutations) => {
			const comments = document.querySelectorAll('.author, .c-username');
			if (comments.length > 0) {
				console.log('New comments detected');
				observer.disconnect();
				injectProfilePictures(comments);
			}
		});
		observer.observe(document.body, {
			childList: true,
			subtree: true,
		});
		console.log('Observer initialized');
	}

	// Run the script
	/**
	 * Initializes and runs the main script functionality.
	 * This function performs the following operations:
	 * 1. Clears outdated cache entries
	 * 2. Logs the current state of the profile picture cache
	 * 3. Initializes the DOM observer
	 * @function runScript
	 */
	function runScript() {
		flushOldCache();
		console.log('Cache loaded:', profilePictureCache);
		setupObserver();
	}

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

	// Add CSS styles for profile pictures
	/**
	 * Creates a new style element to be injected into the document
	 * @type {HTMLStyleElement}
	 */
	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);
})();