forked from Nick2bad4u/UserStyles
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathOldRedditNewProfilePictures-API-Key-Version.user.js
More file actions
585 lines (548 loc) · 20.6 KB
/
OldRedditNewProfilePictures-API-Key-Version.user.js
File metadata and controls
585 lines (548 loc) · 20.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
// ==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
// @downloadURL https://github.com/Nick2bad4u/UserStyles/raw/refs/heads/main/OldRedditNewProfilePictures-API-Key-Version.user.js
// @updateURL https://github.com/Nick2bad4u/UserStyles/raw/refs/heads/main/OldRedditNewProfilePictures-API-Key-Version.user.js
// ==/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);
})();