user scripts improvements
This commit is contained in:
@@ -12,3 +12,4 @@ launch.json
|
|||||||
settings.json
|
settings.json
|
||||||
*.export
|
*.export
|
||||||
.devcontainer
|
.devcontainer
|
||||||
|
user_scripts/dist
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ COPY . .
|
|||||||
RUN RAILS_ENV=production bin/rails assets:precompile
|
RUN RAILS_ENV=production bin/rails assets:precompile
|
||||||
RUN mkdir -p tmp/pids
|
RUN mkdir -p tmp/pids
|
||||||
|
|
||||||
|
# build user scripts
|
||||||
|
RUN yarn build:user-scripts
|
||||||
|
|
||||||
# create user with id=1000 gid=1000
|
# create user with id=1000 gid=1000
|
||||||
RUN groupadd -g 1000 app && \
|
RUN groupadd -g 1000 app && \
|
||||||
useradd -m -d /home/app -s /bin/bash -u 1000 -g 1000 app
|
useradd -m -d /home/app -s /bin/bash -u 1000 -g 1000 app
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ class Domain::Fa::ApiController < ApplicationController
|
|||||||
url_name = params[:url_name]
|
url_name = params[:url_name]
|
||||||
exclude_url_name = params[:exclude_url_name]
|
exclude_url_name = params[:exclude_url_name]
|
||||||
|
|
||||||
user = Domain::Fa::User.find_by(url_name: url_name)
|
user = Domain::User::FaUser.find_by(url_name: url_name)
|
||||||
if user.nil?
|
if user.nil?
|
||||||
render status: 404,
|
render status: 404,
|
||||||
json: {
|
json: {
|
||||||
@@ -174,21 +174,23 @@ class Domain::Fa::ApiController < ApplicationController
|
|||||||
not_followed_similar_users = nil
|
not_followed_similar_users = nil
|
||||||
if exclude_url_name
|
if exclude_url_name
|
||||||
exclude_folowed_by_user =
|
exclude_folowed_by_user =
|
||||||
Domain::Fa::User.find_by(url_name: exclude_url_name)
|
Domain::User::FaUser.find_by(url_name: exclude_url_name)
|
||||||
not_followed_similar_users =
|
not_followed_similar_users =
|
||||||
if exclude_folowed_by_user.nil?
|
if exclude_folowed_by_user.nil?
|
||||||
# TODO - enqueue a manual UserFollowsJob for this user and have client
|
# TODO - enqueue a manual UserFollowsJob for this user and have client
|
||||||
# re-try the request later
|
# re-try the request later
|
||||||
{
|
render status: 500,
|
||||||
error: "user '#{exclude_url_name}' not found",
|
json: {
|
||||||
error_type: "exclude_user_not_found",
|
error: "user '#{exclude_url_name}' not found",
|
||||||
}
|
error_type: "exclude_user_not_found",
|
||||||
|
}
|
||||||
elsif exclude_folowed_by_user.scanned_follows_at.nil?
|
elsif exclude_folowed_by_user.scanned_follows_at.nil?
|
||||||
{
|
render status: 500,
|
||||||
error:
|
json: {
|
||||||
"user '#{exclude_url_name}' followers list hasn't been scanned",
|
error:
|
||||||
error_type: "exclude_user_not_scanned",
|
"user '#{exclude_url_name}' followers list hasn't been scanned",
|
||||||
}
|
error_type: "exclude_user_not_scanned",
|
||||||
|
}
|
||||||
else
|
else
|
||||||
users_list_to_similar_list(
|
users_list_to_similar_list(
|
||||||
helpers.similar_users_by_followed(
|
helpers.similar_users_by_followed(
|
||||||
@@ -327,9 +329,14 @@ class Domain::Fa::ApiController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig do
|
||||||
|
params(users_list: T::Array[Domain::User::FaUser]).returns(
|
||||||
|
T::Array[T::Hash[Symbol, T.untyped]],
|
||||||
|
)
|
||||||
|
end
|
||||||
def users_list_to_similar_list(users_list)
|
def users_list_to_similar_list(users_list)
|
||||||
users_list.map do |user|
|
users_list.map do |user|
|
||||||
profile_thumb_url = user.avatar&.file_uri&.to_s
|
profile_thumb_url = user.avatar&.log_entry&.uri_str
|
||||||
profile_thumb_url ||
|
profile_thumb_url ||
|
||||||
begin
|
begin
|
||||||
profile_page_response = get_best_user_page_http_log_entry_for(user)
|
profile_page_response = get_best_user_page_http_log_entry_for(user)
|
||||||
@@ -360,7 +367,8 @@ class Domain::Fa::ApiController < ApplicationController
|
|||||||
name: user.name,
|
name: user.name,
|
||||||
url_name: user.url_name,
|
url_name: user.url_name,
|
||||||
profile_thumb_url: profile_thumb_url,
|
profile_thumb_url: profile_thumb_url,
|
||||||
url: "https://www.furaffinity.net/user/#{user.url_name}/",
|
external_url: "https://www.furaffinity.net/user/#{user.url_name}/",
|
||||||
|
refurrer_url: request.base_url + helpers.domain_user_path(user),
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,20 +2,22 @@
|
|||||||
class UserScriptsController < ApplicationController
|
class UserScriptsController < ApplicationController
|
||||||
skip_before_action :authenticate_user!, only: [:get]
|
skip_before_action :authenticate_user!, only: [:get]
|
||||||
|
|
||||||
|
ALLOWED_SCRIPTS = %w[object_statuses.user.js furecs.user.js].freeze
|
||||||
|
|
||||||
def get
|
def get
|
||||||
expires_in 1.hour, public: true
|
expires_in 1.hour, public: true
|
||||||
response.cache_control[:public] = false
|
response.cache_control[:public] = false
|
||||||
response.cache_control[:private] = true
|
response.cache_control[:private] = true
|
||||||
|
|
||||||
script = params[:script]
|
script = params[:script]
|
||||||
case script
|
unless ALLOWED_SCRIPTS.include?(script)
|
||||||
when "furecs.user.js"
|
|
||||||
send_file(
|
|
||||||
Rails.root.join("user_scripts/furecs.user.js"),
|
|
||||||
type: "application/json",
|
|
||||||
)
|
|
||||||
else
|
|
||||||
render status: 404, text: "not found"
|
render status: 404, text: "not found"
|
||||||
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
send_file(
|
||||||
|
Rails.root.join("user_scripts/dist/#{script}"),
|
||||||
|
type: "application/javascript",
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -86,14 +86,38 @@ module Domain::Fa::UsersHelper
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# TODO - remove this once we've migrated similarity scores to new user model
|
||||||
def similar_users_by_followed(user, limit: 10, exclude_followed_by: nil)
|
def similar_users_by_followed(user, limit: 10, exclude_followed_by: nil)
|
||||||
if user.disco.nil?
|
old_user = Domain::Fa::User.find_by(url_name: user.url_name)
|
||||||
|
old_exclude_user =
|
||||||
|
(
|
||||||
|
if exclude_followed_by
|
||||||
|
Domain::Fa::User.find_by(url_name: exclude_followed_by.url_name)
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil if old_user.nil?
|
||||||
|
|
||||||
|
if old_user.disco.nil?
|
||||||
nil
|
nil
|
||||||
else
|
else
|
||||||
ReduxApplicationRecord.connection.execute("SET ivfflat.probes = 32")
|
ReduxApplicationRecord.connection.execute("SET ivfflat.probes = 32")
|
||||||
user.similar_users_by_followed(
|
old_users =
|
||||||
exclude_followed_by: exclude_followed_by,
|
old_user.similar_users_by_followed(
|
||||||
).limit(limit)
|
exclude_followed_by: old_exclude_user,
|
||||||
|
).limit(limit)
|
||||||
|
|
||||||
|
old_user_url_names = old_users.map(&:url_name)
|
||||||
|
new_users = Domain::User::FaUser.where(url_name: old_user_url_names).to_a
|
||||||
|
|
||||||
|
# return in same order as old_users
|
||||||
|
old_users
|
||||||
|
.map do |old_user|
|
||||||
|
new_users.find { |new_user| new_user.url_name == old_user.url_name }
|
||||||
|
end
|
||||||
|
.compact
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
2
justfile
2
justfile
@@ -26,4 +26,4 @@ tapioca *args:
|
|||||||
bundle _2.5.6_ exec tapioca {{args}}
|
bundle _2.5.6_ exec tapioca {{args}}
|
||||||
|
|
||||||
user_scripts:
|
user_scripts:
|
||||||
yarn run tsc --project user_scripts/tsconfig.json
|
yarn build:user-scripts
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:css": "tailwindcss -c ./config/tailwind.config.js -i ./app/assets/stylesheets/application.tailwind.css -o ./app/assets/builds/tailwind.css --minify",
|
"build:css": "tailwindcss -c ./config/tailwind.config.js -i ./app/assets/stylesheets/application.tailwind.css -o ./app/assets/builds/tailwind.css --minify",
|
||||||
"build:css[debug]": "tailwindcss -c ./config/tailwind.config.js -i ./app/assets/stylesheets/application.tailwind.css -o ./app/assets/builds/tailwind.css"
|
"build:css[debug]": "tailwindcss -c ./config/tailwind.config.js -i ./app/assets/stylesheets/application.tailwind.css -o ./app/assets/builds/tailwind.css",
|
||||||
|
"build:user-scripts": "tsc --project user_scripts/tsconfig.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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==
|
// ==UserScript==
|
||||||
// @name Refurrer Object Statuses
|
// @name Refurrer Object Statuses
|
||||||
// @namespace http://tampermonkey.net/
|
// @namespace http://tampermonkey.net/
|
||||||
// @version 1.0
|
// @version 2.0
|
||||||
// @description Show FA object statuses in Refurrer
|
// @description Show FA object statuses in Refurrer
|
||||||
// @author You
|
// @author You
|
||||||
// @match https://www.furaffinity.net/*
|
// @match https://www.furaffinity.net/*
|
||||||
@@ -12,32 +12,11 @@
|
|||||||
// @connect refurrer.com
|
// @connect refurrer.com
|
||||||
// @connect localhost:3001
|
// @connect localhost:3001
|
||||||
// @connect localhost
|
// @connect localhost
|
||||||
|
// @updateURL https://refurrer.com/us/object_statuses.user.js
|
||||||
|
// @downloadURL https://refurrer.com/us/object_statuses.user.js
|
||||||
// ==/UserScript==
|
// ==/UserScript==
|
||||||
// This file is generated from a Typescript file, do not edit the js file directly.
|
// This file is generated from a Typescript file, do not edit the js file directly.
|
||||||
'use strict';
|
'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 =
|
type PostObjectState =
|
||||||
| 'not_seen'
|
| 'not_seen'
|
||||||
@@ -90,12 +69,11 @@ interface LiveEntityStatTable {
|
|||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fa() {
|
async function faObjectStatuses() {
|
||||||
function isNotNull<T>(val: T | null | undefined): val is T {
|
function isNotNull<T>(val: T | null | undefined): val is T {
|
||||||
return val != null;
|
return val != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// const HOST = 'refurrer.com';
|
|
||||||
const HOST = 'refurrer.com';
|
const HOST = 'refurrer.com';
|
||||||
const LIGHT_BG_COLOR = '#353b45';
|
const LIGHT_BG_COLOR = '#353b45';
|
||||||
const DARK_BG_COLOR = '#20242a';
|
const DARK_BG_COLOR = '#20242a';
|
||||||
@@ -805,7 +783,6 @@ async function fa() {
|
|||||||
|
|
||||||
function fetchObjectStatuses(): Promise<ObjectStatusesResponse> {
|
function fetchObjectStatuses(): Promise<ObjectStatusesResponse> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
console.log('fetching object statuses');
|
|
||||||
let url = `http://${HOST}/api/fa/object_statuses`;
|
let url = `http://${HOST}/api/fa/object_statuses`;
|
||||||
let params = new URLSearchParams();
|
let params = new URLSearchParams();
|
||||||
faIds.forEach((id) => params.append('fa_ids[]', id.toString()));
|
faIds.forEach((id) => params.append('fa_ids[]', id.toString()));
|
||||||
@@ -834,7 +811,6 @@ async function fa() {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
onload: (response) => {
|
onload: (response) => {
|
||||||
console.log('response: ', response);
|
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
const jsonResponse = JSON.parse(
|
const jsonResponse = JSON.parse(
|
||||||
response.responseText,
|
response.responseText,
|
||||||
@@ -1023,13 +999,15 @@ async function fa() {
|
|||||||
.then(handleObjectStatusesResponse)
|
.then(handleObjectStatusesResponse)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error('error: ', err);
|
console.error('error: ', err);
|
||||||
|
navbarLiveEntityStatTablesNode.innerHTML =
|
||||||
|
'<div style="color: red;">Error: ' + err.message + '</div>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
(async function () {
|
(async function () {
|
||||||
if (window.location.hostname == 'www.furaffinity.net') {
|
if (window.location.hostname == 'www.furaffinity.net') {
|
||||||
await fa();
|
await faObjectStatuses();
|
||||||
} else {
|
} 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