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.

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

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址