Files
redux-scraper/user_scripts/furecs.user.ts
2025-02-25 00:52:32 +00:00

239 lines
6.7 KiB
TypeScript

// ==UserScript==
// @name FuRecs
// @namespace https://twitter.com/DeltaNoises
// @version 1.4
// @description FurAffinity User Recommender
// @author https://twitter.com/DeltaNoises
// @match https://www.furaffinity.net/user/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=furaffinity.net
// @grant GM_xmlhttpRequest
// @grant GM.getValue
// @grant GM.setValue
// @connect refurrer.com
// @connect localhost:3001
// @connect localhost
// @updateURL https://refurrer.com/us/furecs.user.js
// @downloadURL https://refurrer.com/us/furecs.user.js
// ==/UserScript==
'use strict';
interface SimilarUserList {
name: string;
url_name: string;
profile_thumb_url: string;
external_url: string;
refurrer_url: string;
}
type SimilarUsersErrorType =
| 'recs_not_computed'
| 'user_not_found'
| 'exclude_user_not_found'
| 'exclude_user_not_scanned';
type SimilarUsersResponse =
| {
all: SimilarUserList[];
not_followed: SimilarUserList[] | null;
}
| {
error_type: SimilarUsersErrorType;
error: string;
};
function urlNameFromUserHref(href: string) {
const url = new URL(href);
if (url.host != 'www.furaffinity.net') {
return null;
}
const userPageRegex = /^\/(user|gallery|scraps|favorites|journals)\/.+/;
const match = url.pathname.match(userPageRegex);
if (!match) {
return null;
}
return url.pathname.split('/')[2];
}
async function faUserRecs(): Promise<void> {
const HOST = 'https://refurrer.com';
if (!window.location.pathname.startsWith('/user/')) {
return null;
}
const urlName = urlNameFromUserHref(window.location.href);
if (!urlName) {
return;
}
// if the user is logged in, get their user account, so we can
// also provide a list of recommendations that don't overlap with their
// current watchlist
let loggedInUrlName: string | null = null;
const loggedInLink = document.querySelector(
'.mobile-nav-content-container h2 a',
) as HTMLAnchorElement | null;
if (loggedInLink) {
loggedInUrlName = urlNameFromUserHref(loggedInLink.href);
}
function buildRecommendedContainer(): HTMLElement | null {
let leftColumn = document.querySelector(
'.userpage-layout-left-col-content',
) as HTMLElement | null;
if (!leftColumn) {
console.error("didn't find left column container, bailing");
return null;
}
let section = document.createElement('section');
section.classList.add('userpage-left-column');
section.classList.add('gallery_container');
section.innerHTML = `
<div class="userpage-section-left">
<div class="section-header" style='display:flex;align-items:center'>
<h2 style='flex-grow:1'>Similar Users</h2>
</div>
<div class="section-body">
</div>
</div>`;
leftColumn.prepend(section);
return section.querySelector('.section-body');
}
const container = buildRecommendedContainer();
if (!container) {
console.error('failed to build recommended container, bailing');
return;
}
function setContainerError(error: string) {
container.innerHTML = `Error: <pre>${error}</pre>`;
}
async function requestSimilarUsers(): Promise<SimilarUsersResponse> {
return new Promise((resolve, reject) => {
container.innerHTML = `<h2 class='aligncenter'>(Loading...)</h2>`;
let url = `${HOST}/api/fa/similar_users`;
url += `?url_name=${encodeURIComponent(urlName)}`;
if (loggedInUrlName) {
url += `&exclude_url_name=${encodeURIComponent(loggedInUrlName)}`;
}
GM_xmlhttpRequest({
url,
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
onabort: () => {
reject(new Error('request aborted'));
},
ontimeout: () => {
reject(new Error('request timed out'));
},
onerror: (response) => {
reject(new Error('server error: ' + response.responseText));
},
onload: (response) => {
const json_error_codes = [403, 404, 500];
if (response.status == 200) {
let json = JSON.parse(response.responseText);
resolve(json);
} else if (json_error_codes.includes(response.status)) {
let json = JSON.parse(response.responseText);
reject(new Error(json.error));
} else {
reject(new Error(response.responseText));
}
},
});
});
}
function populateRecommendations(recommendations: SimilarUsersResponse) {
if ('error' in recommendations) {
setContainerError(recommendations.error);
} else {
container.innerHTML = buildTwoColumns({
all: recommendations.all,
not_followed: recommendations.not_followed,
});
}
}
function buildTwoColumns({
all,
not_followed,
}: {
all: SimilarUserList[];
not_followed: SimilarUserList[] | null;
}) {
const width = not_followed ? '50%' : '100%';
let content = ``;
content += `<table>`;
content += ` <tr>`;
content += ` <th>Similar to ${urlName}</th>`;
if (not_followed) {
content += ` <th>Similar users you don't follow</th>`;
}
content += ` </tr>`;
content += ` <tr>`;
content += ` <td style='width:${width}'>`;
content += ` ` + buildOneColumn(all);
content += ` </td>`;
if (not_followed) {
content += ` <td style='width:${width}'>`;
content += ` ` + buildOneColumn(not_followed);
content += ` </td>`;
}
content += ` </tr>`;
content += `</table>`;
return content;
}
function buildOneColumn(userList: SimilarUserList[]) {
let content = ``;
content += '<ol class="user-submitted-links">';
userList.forEach(({ name, external_url, profile_thumb_url }) => {
content += `<li>`;
content += `<a
href="${external_url}"
target="_blank"
style="display: flex; align-items: center; border: dotted 1px rgba(255,255,255,0.5); padding: 4px"
>`;
if (profile_thumb_url) {
content += `<img
alt="${name} thumbnail"
title="${name}"
src="${profile_thumb_url}"
style="max-height: 40px; padding-right: 10px"
/>`;
}
content += `<span>${name}</span>`;
content += `</a>`;
content += '</li>';
});
content += '</ol>';
return content;
}
await requestSimilarUsers()
.then(populateRecommendations)
.catch((error) => {
setContainerError(error.message);
});
}
(async function () {
if (window.location.hostname == 'www.furaffinity.net') {
await faUserRecs();
} else {
console.log('unhandled domain ', window.location.hostname);
}
})();