user scripts improvements
This commit is contained in:
@@ -1,229 +0,0 @@
|
||||
// ==UserScript==
|
||||
// @name FuRecs
|
||||
// @namespace https://twitter.com/DeltaNoises
|
||||
// @version 1.3
|
||||
// @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
|
||||
// @updateURL https://refurrer.com/us/furecs.user.js
|
||||
// @downloadURL https://refurrer.com/us/furecs.user.js
|
||||
// ==/UserScript==
|
||||
|
||||
"use strict";
|
||||
const API_HOST = "https://refurrer.com";
|
||||
|
||||
function urlNameFromUserHref(href) {
|
||||
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 fa() {
|
||||
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 = null;
|
||||
const loggedInLink = document.querySelector(
|
||||
".mobile-nav-content-container h2 a"
|
||||
);
|
||||
if (loggedInLink) {
|
||||
loggedInUrlName = urlNameFromUserHref(loggedInLink.href);
|
||||
}
|
||||
|
||||
function buildRecommendedContainer() {
|
||||
let leftColumn = document.querySelector(
|
||||
".userpage-layout-left-col-content"
|
||||
);
|
||||
if (!leftColumn) {
|
||||
console.log("didn't find left column container, bailing");
|
||||
return [];
|
||||
}
|
||||
|
||||
let section = document.createElement("section");
|
||||
section.classList = "userpage-left-column 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"),
|
||||
section.querySelector(".section-header"),
|
||||
];
|
||||
}
|
||||
|
||||
let [container, header] = buildRecommendedContainer();
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`getting recommended follows for ${urlName}`);
|
||||
|
||||
function addApiTokenSetForm() {
|
||||
const form = document.createElement("form");
|
||||
form.innerHTML = `
|
||||
<input style='height:32px;min-width:20em' required type=text placeholder="API Token" name="api_token" />
|
||||
<input class='button' type=submit value="Save" />
|
||||
`;
|
||||
header.appendChild(form);
|
||||
const input = form.querySelector("input[type=text]");
|
||||
form.onsubmit = async () => {
|
||||
apiToken = input.value;
|
||||
await GM.setValue("apiToken", apiToken);
|
||||
header.removeChild(form);
|
||||
addApiTokenClearButton();
|
||||
run();
|
||||
};
|
||||
}
|
||||
|
||||
function addApiTokenClearButton() {
|
||||
const button = document.createElement("button");
|
||||
const apiTokenShort =
|
||||
apiToken.length > 10 ? apiToken.slice(0, 7) + "..." : apiToken;
|
||||
button.innerHTML = `Clear Token (${apiTokenShort})`;
|
||||
header.appendChild(button);
|
||||
button.onclick = async () => {
|
||||
apiToken = null;
|
||||
await GM.setValue("apiToken", null);
|
||||
header.removeChild(button);
|
||||
addApiTokenSetForm();
|
||||
};
|
||||
}
|
||||
|
||||
let apiToken = await GM.getValue("apiToken", null);
|
||||
if (apiToken == null) {
|
||||
container.innerHTML = `<h2 class='aligncenter'>Please set API token</h2>`;
|
||||
addApiTokenSetForm();
|
||||
} else {
|
||||
addApiTokenClearButton();
|
||||
run();
|
||||
}
|
||||
|
||||
function run() {
|
||||
container.innerHTML = `<h2 class='aligncenter'>(Loading...)</h2>`;
|
||||
let url = `${API_HOST}/api/fa/similar_users`;
|
||||
url += `?url_name=${encodeURIComponent(urlName)}`;
|
||||
if (loggedInUrlName) {
|
||||
url += `&exclude_url_name=${encodeURIComponent(loggedInUrlName)}`;
|
||||
}
|
||||
url += `&api_token=${encodeURIComponent(apiToken)}`;
|
||||
|
||||
function setContainerError(error) {
|
||||
container.innerHTML = `Error: <pre>${error}</pre>`;
|
||||
}
|
||||
|
||||
GM_xmlhttpRequest({
|
||||
url,
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
onload: (response) => {
|
||||
const json_error_codes = [403, 404, 500];
|
||||
if (response.status == 200) {
|
||||
let json = JSON.parse(response.response);
|
||||
populateRecommendations(json);
|
||||
} else if (json_error_codes.includes(response.status)) {
|
||||
let json = JSON.parse(response.response);
|
||||
setContainerError(json.error);
|
||||
} else {
|
||||
setContainerError(response.response);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function populateRecommendations(recommendations) {
|
||||
if (recommendations.not_followed) {
|
||||
container.innerHTML = buildTwoColumns(recommendations);
|
||||
} else {
|
||||
container.innerHTML = buildOneColumn(recommendations.all);
|
||||
}
|
||||
}
|
||||
|
||||
function buildTwoColumns({ all, not_followed }) {
|
||||
let content = ``;
|
||||
content += `<table>`;
|
||||
content += ` <tr>`;
|
||||
content += ` <th>Similar to ${urlName}</th>`;
|
||||
content += ` <th>Users you don't follow</th>`;
|
||||
content += ` </tr>`;
|
||||
content += ` <tr>`;
|
||||
content += ` <td>`;
|
||||
content += ` ` + buildOneColumn(all);
|
||||
content += ` </td>`;
|
||||
content += ` <td>`;
|
||||
|
||||
if (not_followed.error != null) {
|
||||
content +=
|
||||
` Error getting recommended users ` +
|
||||
`you don't already follow: ${not_followed.error}`;
|
||||
} else {
|
||||
content += ` ` + buildOneColumn(not_followed);
|
||||
}
|
||||
content += ` </td>`;
|
||||
content += ` </tr>`;
|
||||
content += `</table>`;
|
||||
return content;
|
||||
}
|
||||
|
||||
function buildOneColumn(userList) {
|
||||
let content = ``;
|
||||
content += '<ol class="user-submitted-links">';
|
||||
userList.forEach(({ name, url, profile_thumb_url }) => {
|
||||
content += `<li>`;
|
||||
content += `<a
|
||||
href="${url}"
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
(async function () {
|
||||
if (window.location.hostname == "www.furaffinity.net") {
|
||||
await fa();
|
||||
} else {
|
||||
console.log("unhandled domain ", window.location.hostname);
|
||||
}
|
||||
})();
|
||||
238
user_scripts/furecs.user.ts
Normal file
238
user_scripts/furecs.user.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
// ==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';
|
||||
// const HOST = 'https://refurrer.com';
|
||||
const HOST = 'http://localhost:3001';
|
||||
|
||||
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> {
|
||||
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);
|
||||
}
|
||||
})();
|
||||
@@ -1,7 +1,7 @@
|
||||
// ==UserScript==
|
||||
// @name Refurrer Object Statuses
|
||||
// @namespace http://tampermonkey.net/
|
||||
// @version 1.0
|
||||
// @version 2.0
|
||||
// @description Show FA object statuses in Refurrer
|
||||
// @author You
|
||||
// @match https://www.furaffinity.net/*
|
||||
@@ -12,32 +12,11 @@
|
||||
// @connect refurrer.com
|
||||
// @connect localhost:3001
|
||||
// @connect localhost
|
||||
// @updateURL https://refurrer.com/us/object_statuses.user.js
|
||||
// @downloadURL https://refurrer.com/us/object_statuses.user.js
|
||||
// ==/UserScript==
|
||||
// This file is generated from a Typescript file, do not edit the js file directly.
|
||||
'use strict';
|
||||
declare const unsafeWindow: Window &
|
||||
typeof globalThis & {
|
||||
submission_data: Record<string, any>;
|
||||
};
|
||||
|
||||
interface GMResponse {
|
||||
readyState: number;
|
||||
responseHeaders: string;
|
||||
responseText: string;
|
||||
status: number;
|
||||
statusText: string;
|
||||
}
|
||||
|
||||
declare const GM_xmlhttpRequest: (details: {
|
||||
url: string;
|
||||
method: string;
|
||||
headers?: Record<string, string>;
|
||||
data?: string;
|
||||
onload: (response: GMResponse) => void;
|
||||
onabort: (response: GMResponse) => void;
|
||||
ontimeout: (response: GMResponse) => void;
|
||||
onerror: (response: GMResponse) => void;
|
||||
}) => void;
|
||||
|
||||
type PostObjectState =
|
||||
| 'not_seen'
|
||||
@@ -90,12 +69,11 @@ interface LiveEntityStatTable {
|
||||
}[];
|
||||
}
|
||||
|
||||
async function fa() {
|
||||
async function faObjectStatuses() {
|
||||
function isNotNull<T>(val: T | null | undefined): val is T {
|
||||
return val != null;
|
||||
}
|
||||
|
||||
// const HOST = 'refurrer.com';
|
||||
const HOST = 'refurrer.com';
|
||||
const LIGHT_BG_COLOR = '#353b45';
|
||||
const DARK_BG_COLOR = '#20242a';
|
||||
@@ -805,7 +783,6 @@ async function fa() {
|
||||
|
||||
function fetchObjectStatuses(): Promise<ObjectStatusesResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log('fetching object statuses');
|
||||
let url = `http://${HOST}/api/fa/object_statuses`;
|
||||
let params = new URLSearchParams();
|
||||
faIds.forEach((id) => params.append('fa_ids[]', id.toString()));
|
||||
@@ -834,7 +811,6 @@ async function fa() {
|
||||
);
|
||||
},
|
||||
onload: (response) => {
|
||||
console.log('response: ', response);
|
||||
if (response.status === 200) {
|
||||
const jsonResponse = JSON.parse(
|
||||
response.responseText,
|
||||
@@ -1023,13 +999,15 @@ async function fa() {
|
||||
.then(handleObjectStatusesResponse)
|
||||
.catch((err) => {
|
||||
console.error('error: ', err);
|
||||
navbarLiveEntityStatTablesNode.innerHTML =
|
||||
'<div style="color: red;">Error: ' + err.message + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
(async function () {
|
||||
if (window.location.hostname == 'www.furaffinity.net') {
|
||||
await fa();
|
||||
await faObjectStatuses();
|
||||
} else {
|
||||
console.log('unhandled domain ', window.location.hostname);
|
||||
console.error('unhandled domain ', window.location.hostname);
|
||||
}
|
||||
})();
|
||||
28
user_scripts/userscript_api.ts
Normal file
28
user_scripts/userscript_api.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
declare const unsafeWindow: Window &
|
||||
typeof globalThis & {
|
||||
submission_data: Record<string, any>;
|
||||
};
|
||||
|
||||
interface GMResponse {
|
||||
readyState: number;
|
||||
responseHeaders: string;
|
||||
responseText: string;
|
||||
status: number;
|
||||
statusText: string;
|
||||
}
|
||||
|
||||
declare const GM_xmlhttpRequest: (details: {
|
||||
url: string;
|
||||
method: string;
|
||||
headers?: Record<string, string>;
|
||||
data?: string;
|
||||
onload: (response: GMResponse) => void;
|
||||
onabort: (response: GMResponse) => void;
|
||||
ontimeout: (response: GMResponse) => void;
|
||||
onerror: (response: GMResponse) => void;
|
||||
}) => void;
|
||||
|
||||
declare const GM: {
|
||||
getValue: (key: string, defaultValue?: string) => Promise<string | null>;
|
||||
setValue: (key: string, value: string) => Promise<void>;
|
||||
};
|
||||
Reference in New Issue
Block a user