239 lines
6.7 KiB
TypeScript
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);
|
|
}
|
|
})();
|