scaffold out recommendation user script
This commit is contained in:
811
user_scripts/enqueuer.user.js
Normal file
811
user_scripts/enqueuer.user.js
Normal file
@@ -0,0 +1,811 @@
|
||||
// ==UserScript==
|
||||
// @name Redux Enquerer
|
||||
// @namespace http://tampermonkey.net/
|
||||
// @version 1.0
|
||||
// @description Enqeue webpages into Redux for archiving
|
||||
// @author You
|
||||
// @match https://www.furaffinity.net/*
|
||||
// @match https://twitter.com/*
|
||||
// @icon https://www.google.com/s2/favicons?sz=64&domain=furaffinity.net
|
||||
// @grant GM_xmlhttpRequest
|
||||
// @grant unsafeWindow
|
||||
// @connect scraper.local
|
||||
// ==/UserScript==
|
||||
'use strict';
|
||||
|
||||
function fa() {
|
||||
function setupNavbar() {
|
||||
const navbarStatusNode = document.createElement("li");
|
||||
navbarStatusNode.classList = "lileft";
|
||||
navbarStatusNode.style.height = "100%";
|
||||
navbarStatusNode.style.display = "flex";
|
||||
|
||||
const navbar = document.querySelector("nav");
|
||||
if (navbar != null) {
|
||||
navbar.querySelector("ul > div").after(navbarStatusNode);
|
||||
return navbarStatusNode;
|
||||
} else {
|
||||
// watch pages, etc don't have a navbar - create one at the top of the page
|
||||
const center = document.body.querySelector("div[align=center]");
|
||||
center.prepend(navbarStatusNode);
|
||||
return navbarStatusNode;
|
||||
}
|
||||
}
|
||||
|
||||
function makeLargeStatusNode(opts = {}) {
|
||||
if (opts.type == null) {
|
||||
opts.type = 'span'
|
||||
}
|
||||
if (opts.smaller == null) {
|
||||
opts.smaller = true;
|
||||
}
|
||||
if (opts.style == null) {
|
||||
opts.style = {};
|
||||
}
|
||||
|
||||
const statusNode = document.createElement(opts.type);
|
||||
statusNode.style.cssText = "margin-left: 5px; color: #b7b7b7!important; display: inline";
|
||||
if (opts.smaller) {
|
||||
statusNode.style.fontSize = "80%";
|
||||
}
|
||||
|
||||
for (const [property, value] of Object.entries(opts.style)) {
|
||||
statusNode.style.setProperty(property, value);
|
||||
}
|
||||
|
||||
statusNode.innerHTML = "(...)";
|
||||
return statusNode;
|
||||
}
|
||||
|
||||
function galleryFigureElements() {
|
||||
return [
|
||||
...document.querySelectorAll("#gallery-search-results figure"),
|
||||
...document.querySelectorAll("#gallery-browse figure"),
|
||||
...document.querySelectorAll("#gallery-favorites figure"),
|
||||
...document.querySelectorAll("#gallery-gallery figure"),
|
||||
...document.querySelectorAll("#gallery-latest-favorites figure"),
|
||||
...document.querySelectorAll("#gallery-latest-submissions figure"),
|
||||
...document.querySelectorAll("section.gallery.messagecenter figure"),
|
||||
];
|
||||
}
|
||||
|
||||
function gatherPostElements() {
|
||||
// if on a gallery page, links to the list of submissions
|
||||
const galleryFigures = galleryFigureElements().map((figure) => {
|
||||
const faId = parseInt(figure.id.split("-")[1]);
|
||||
const statusNode = document.createElement("p");
|
||||
statusNode.innerHTML = "(...)";
|
||||
const captionLabel = figure.querySelector("figcaption label p");
|
||||
const caption = figure.querySelector("figcaption");
|
||||
// gallery pages will include a figcaption
|
||||
if (captionLabel) {
|
||||
figure.style.height = `calc(${figure.style.height} + 20px)`;
|
||||
figure.querySelector("b").style.height = "auto";
|
||||
statusNode.style.cssText = "position:relative;bottom:2px;";
|
||||
captionLabel.after(statusNode);
|
||||
}
|
||||
else if (caption) {
|
||||
statusNode.style.cssText = "position:relative;bottom:2px;";
|
||||
caption.appendChild(statusNode);
|
||||
}
|
||||
else {
|
||||
const figcaption = document.createElement("div");
|
||||
figcaption.style.cssText = "display: block !important; position: absolute; bottom: 0px; font-size: 10px; width:100%";
|
||||
figcaption.appendChild(statusNode);
|
||||
figure.appendChild(figcaption);
|
||||
figure.style.height = `calc(${figure.style.height} + 20px)`;
|
||||
}
|
||||
|
||||
return { faId, statusNode };
|
||||
});
|
||||
|
||||
const featuredElem = [...document.querySelectorAll(".userpage-featured-title h2")]
|
||||
.map(elem => {
|
||||
// skip if it's a dynamically changing preview, `submissionDataElems` handles those
|
||||
if (elem.classList.contains("preview_title")) { return null; }
|
||||
const link = elem.querySelector("a");
|
||||
if (!link) { return null; }
|
||||
const faId = faIdFromViewHref(link.href);
|
||||
if (!faId) { return null; }
|
||||
const statusNode = makeLargeStatusNode();
|
||||
elem.appendChild(statusNode);
|
||||
|
||||
return {
|
||||
faId,
|
||||
statusNode
|
||||
};
|
||||
}).filter(isNotNull);
|
||||
|
||||
let submissionDataElems = []
|
||||
if (unsafeWindow.submission_data) {
|
||||
submissionDataElems = Object.entries(unsafeWindow.submission_data)
|
||||
.map(([fa_id, _]) => {
|
||||
const statusNode = makeLargeStatusNode()
|
||||
return {
|
||||
faId: fa_id,
|
||||
statusNode
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// watch for changes to preview user and swap out status node when that happens
|
||||
[...document.querySelectorAll("h2.preview_title")].forEach((previewTitleNode) => {
|
||||
const swapOutNode = document.createElement("span");
|
||||
swapOutNode.classList = "swapper";
|
||||
previewTitleNode.appendChild(swapOutNode);
|
||||
const previewTitleLink = previewTitleNode.querySelector("a");
|
||||
|
||||
const observerCb = () => {
|
||||
const previewFaId = faIdFromViewHref(previewTitleLink.href);
|
||||
swapOutNode.innerHTML = "";
|
||||
const currentSubmissionElem = submissionDataElems.find(({ faId }) => faId == previewFaId)
|
||||
if (currentSubmissionElem) {
|
||||
swapOutNode.appendChild(currentSubmissionElem.statusNode);
|
||||
}
|
||||
};
|
||||
const observer = new MutationObserver(observerCb);
|
||||
observer.observe(previewTitleLink, { childList: true, subtree: true });
|
||||
observerCb();
|
||||
});
|
||||
|
||||
// /view/<faId>/ page elements
|
||||
let primaryViewPageElems = []
|
||||
let faId = faIdFromViewHref(window.location.href);
|
||||
if (faId) {
|
||||
primaryViewPageElems = [...document.querySelectorAll(".submission-title h2")]
|
||||
.map(elem => {
|
||||
elem.querySelector("p").style.display = "inline";
|
||||
const statusNode = makeLargeStatusNode({ type: "h3" })
|
||||
elem.appendChild(statusNode);
|
||||
return {
|
||||
faId,
|
||||
statusNode
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return [...galleryFigures, ...submissionDataElems, ...featuredElem, ...primaryViewPageElems];
|
||||
}
|
||||
|
||||
function gatherUserElements() {
|
||||
// if on a gallery / browse page, the creator links from those posts
|
||||
const userSubmissionLinks = galleryFigureElements().map((figure) => {
|
||||
const userLinkElem = [...figure.querySelectorAll("figcaption a")]
|
||||
.map(elem => ({
|
||||
elem: elem,
|
||||
urlName: urlNameFromUserHref(elem.href)
|
||||
}))
|
||||
.filter(({ elem, urlName }) => urlName != null)[0];
|
||||
|
||||
if (userLinkElem == null) { return null; }
|
||||
figure.querySelector("u").style.cssText = "display: block";
|
||||
|
||||
const statusNode = document.createElement("span");;
|
||||
statusNode.style.cssText = "margin-left: 2px; color: #c1c1c1";
|
||||
statusNode.innerHTML = "(...)";
|
||||
userLinkElem.elem.after(statusNode);
|
||||
|
||||
return {
|
||||
urlName: userLinkElem.urlName,
|
||||
shouldEnqueue: true,
|
||||
statusNode,
|
||||
}
|
||||
}).filter(isNotNull);
|
||||
|
||||
// if on a /user/ page, the primary username element
|
||||
const userPageUrlName = urlNameFromUserHref(window.location.href);
|
||||
let userPageMain = userPageUrlName ? [
|
||||
...document.querySelectorAll("h1 username"),
|
||||
].map(elem => {
|
||||
const statusNode = document.createElement("span");
|
||||
statusNode.innerHTML = "(...)";
|
||||
elem.after(statusNode);
|
||||
return {
|
||||
urlName: userPageUrlName,
|
||||
shouldEnqueue: true,
|
||||
statusNode,
|
||||
}
|
||||
}) : [];
|
||||
|
||||
// comments made by users on posts or user pages
|
||||
let userComments = [
|
||||
...document.querySelectorAll("comment-username a"),
|
||||
].map(elem => {
|
||||
const statusNode = makeLargeStatusNode();
|
||||
elem.after(statusNode);
|
||||
return {
|
||||
urlName: urlNameFromUserHref(elem.href),
|
||||
shouldEnqueue: false,
|
||||
statusNode,
|
||||
}
|
||||
});
|
||||
|
||||
// users mentioned in post descriptions or user bios
|
||||
let iconUsernames = [
|
||||
...document.querySelectorAll("a.iconusername, a.linkusername")
|
||||
].map(elem => {
|
||||
const statusNode = makeLargeStatusNode()
|
||||
elem.after(statusNode);
|
||||
return {
|
||||
urlName: urlNameFromUserHref(elem.href),
|
||||
shouldEnqueue: true,
|
||||
statusNode,
|
||||
}
|
||||
});
|
||||
|
||||
// watcher / watching lists on user page
|
||||
let watchersAndWatchList = [
|
||||
...document.querySelectorAll("a > span.artist_name")
|
||||
].map(elem => {
|
||||
const link = elem.parentNode;
|
||||
const statusNode = makeLargeStatusNode();
|
||||
link.after(statusNode);
|
||||
return {
|
||||
urlName: urlNameFromUserHref(link.href),
|
||||
shouldEnqueue: false,
|
||||
statusNode,
|
||||
};
|
||||
})
|
||||
|
||||
|
||||
// users mentioned in the "hero" sections on a url page
|
||||
let submissionDataElems = []
|
||||
|
||||
if (unsafeWindow.submission_data) {
|
||||
submissionDataElems = Object.entries(unsafeWindow.submission_data)
|
||||
.map(([_, { lower }]) => {
|
||||
const statusNode = makeLargeStatusNode();
|
||||
return {
|
||||
urlName: lower,
|
||||
shouldEnqueue: true,
|
||||
statusNode
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// watch for changes to preview user and swap out status node when that happens
|
||||
const previewUser = document.querySelector("a.preview_user");
|
||||
if (previewUser) {
|
||||
const swapOutNode = document.createElement("span");
|
||||
swapOutNode.classList = "swapper";
|
||||
previewUser.after(swapOutNode);
|
||||
|
||||
const observerCb = () => {
|
||||
const previewUrlName = urlNameFromUserHref(previewUser.href);
|
||||
swapOutNode.innerHTML = "";
|
||||
const currentSubmissionElem = submissionDataElems.find(({ urlName }) => urlName == previewUrlName)
|
||||
if (currentSubmissionElem) {
|
||||
swapOutNode.appendChild(currentSubmissionElem.statusNode);
|
||||
}
|
||||
};
|
||||
const observer = new MutationObserver(observerCb);
|
||||
observer.observe(previewUser, { childList: true, subtree: true });
|
||||
observerCb();
|
||||
}
|
||||
|
||||
// on a /view/ page, the name of the user
|
||||
const submissionContainerUserLinks = [...document.querySelectorAll(".submission-id-sub-container a")]
|
||||
.map(elem => {
|
||||
const statusNode = makeLargeStatusNode({ smaller: false });
|
||||
elem.after(statusNode);
|
||||
return {
|
||||
urlName: urlNameFromUserHref(elem.href),
|
||||
shouldEnqueue: true,
|
||||
statusNode
|
||||
};
|
||||
});
|
||||
|
||||
// on a /watchlist/by/ or /watchlist/to page
|
||||
const watchListUserLinks = [...document.querySelectorAll(".watch-list-items.watch-row a")]
|
||||
.map(elem => {
|
||||
const statusNode = makeLargeStatusNode({
|
||||
smaller: false, style: {
|
||||
display: "block",
|
||||
'margin-bottom': "5px",
|
||||
'font-size': "50%",
|
||||
}
|
||||
});
|
||||
elem.parentNode.appendChild(statusNode);
|
||||
return {
|
||||
urlName: urlNameFromUserHref(elem.href),
|
||||
shouldEnqueue: true,
|
||||
statusNode
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
...userSubmissionLinks,
|
||||
...userPageMain,
|
||||
...userComments,
|
||||
...iconUsernames,
|
||||
...watchersAndWatchList,
|
||||
...submissionDataElems,
|
||||
...submissionContainerUserLinks,
|
||||
...watchListUserLinks,
|
||||
];
|
||||
}
|
||||
|
||||
function isNotNull(val) {
|
||||
return val != null;
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
function faIdFromViewHref(href) {
|
||||
const viewPageRegex = /\/(view|full)\/(\d+)/;
|
||||
const match = href.match(viewPageRegex);
|
||||
const faId = match && match[2] || null;
|
||||
if (faId) {
|
||||
return parseInt(faId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderTable(stats, styleOpts = {}) {
|
||||
if (stats.length == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (styleOpts['border-collapse'] == null) {
|
||||
styleOpts['border-collapse'] = "collapse";
|
||||
}
|
||||
|
||||
let table = document.createElement("table");
|
||||
for (const [property, value] of Object.entries(styleOpts)) {
|
||||
table.style.setProperty(property, value);
|
||||
}
|
||||
|
||||
let tbody = document.createElement("tbody");
|
||||
table.appendChild(tbody);
|
||||
|
||||
stats.each(({ name, value, sep, nameAlign, valueAlign }) => {
|
||||
if (name == "" && value == "") {
|
||||
tbody.innerHTML += `<tr><td>---</td><td>---</td></tr>`;
|
||||
}
|
||||
else {
|
||||
if (sep == null) {
|
||||
sep = ":";
|
||||
}
|
||||
if (nameAlign == null) {
|
||||
nameAlign = "right";
|
||||
}
|
||||
if (valueAlign == null) {
|
||||
valueAlign = "right";
|
||||
}
|
||||
|
||||
tbody.innerHTML += `<tr>
|
||||
<td style="text-align:${nameAlign};padding-right:5px">${name}${sep}</td>
|
||||
<td style="text-align:${valueAlign}">${value}</td>
|
||||
</tr>`;
|
||||
}
|
||||
});
|
||||
return table;
|
||||
}
|
||||
|
||||
function optsForNumRows(numRows) {
|
||||
switch (numRows) {
|
||||
case 0:
|
||||
case 1:
|
||||
case 2:
|
||||
case 3:
|
||||
return { 'line-height': '1.0em', 'font-size': "1.0em" };
|
||||
case 4:
|
||||
return { 'line-height': '0.9em', 'font-size': "0.8em" };
|
||||
default:
|
||||
return { 'line-height': "0.9em", 'font-size': "0.6em" }
|
||||
}
|
||||
}
|
||||
|
||||
const navbarNode = setupNavbar();
|
||||
const navbarPageStatsNode = document.createElement("div");
|
||||
const navbarEnqueueNode = document.createElement("div");
|
||||
const navbarLiveQueueNode = document.createElement("div");
|
||||
const navbarLiveEntityNode = document.createElement("div");
|
||||
|
||||
navbarPageStatsNode.innerHTML = "querying...";
|
||||
navbarEnqueueNode.innerHTML = "enqueueing...";
|
||||
navbarLiveQueueNode.innerHTML = "queue loading...";
|
||||
navbarLiveEntityNode.innerHTML = "entity loading...";
|
||||
|
||||
[
|
||||
navbarPageStatsNode,
|
||||
navbarEnqueueNode,
|
||||
navbarLiveQueueNode,
|
||||
navbarLiveEntityNode
|
||||
].forEach(node => {
|
||||
node.style.display = "flex";
|
||||
node.style.marginRight = "5px";
|
||||
});
|
||||
|
||||
[
|
||||
navbarPageStatsNode,
|
||||
navbarEnqueueNode,
|
||||
navbarLiveQueueNode
|
||||
].forEach(node => {
|
||||
node.style.paddingRight = "5px";
|
||||
node.style.borderRight = "1px solid #d7d7d7";
|
||||
});
|
||||
|
||||
navbarNode.append(navbarPageStatsNode);
|
||||
navbarNode.append(navbarEnqueueNode);
|
||||
navbarNode.append(navbarLiveQueueNode);
|
||||
navbarNode.append(navbarLiveEntityNode);
|
||||
|
||||
const userElements = gatherUserElements();
|
||||
const urlNames = [...new Set(userElements.map(({ urlName }) => urlName))];
|
||||
const urlNamesToEnqueue = [...new Set(userElements
|
||||
.filter(({ shouldEnqueue }) => shouldEnqueue)
|
||||
.map(({ urlName }) => urlName))
|
||||
];
|
||||
|
||||
const postElements = gatherPostElements();
|
||||
const faIds = [...new Set(postElements.map(({ faId }) => faId))];
|
||||
|
||||
function renderLiveQueueStats({
|
||||
livePostsStats,
|
||||
liveQueueStats,
|
||||
}) {
|
||||
let elemsCountsNode = document.createElement("div");
|
||||
elemsCountsNode.style.width = "100%";
|
||||
elemsCountsNode.style.height = "100%";
|
||||
elemsCountsNode.style.display = "flex";
|
||||
elemsCountsNode.style.flexDirection = "row";
|
||||
elemsCountsNode.style.gap = "1em";
|
||||
|
||||
navbarLiveQueueNode.innerHTML = "";
|
||||
navbarLiveQueueNode.appendChild(elemsCountsNode);
|
||||
|
||||
const postsStatsTable = renderTable(livePostsStats, {
|
||||
...optsForNumRows(livePostsStats.length), width: "auto"
|
||||
});
|
||||
|
||||
const queueStatsTable = renderTable(liveQueueStats, {
|
||||
...optsForNumRows(liveQueueStats.length), width: "auto"
|
||||
});
|
||||
|
||||
|
||||
postsStatsTable && elemsCountsNode.appendChild(postsStatsTable);
|
||||
queueStatsTable && elemsCountsNode.appendChild(queueStatsTable);
|
||||
}
|
||||
|
||||
function renderLiveEntityStats(liveEntityStats) {
|
||||
const liveEntityStatsTable = renderTable(liveEntityStats, {
|
||||
...optsForNumRows(liveEntityStats.length), width: "auto"
|
||||
});
|
||||
navbarLiveEntityNode.innerHTML = "";
|
||||
liveEntityStatsTable && navbarLiveEntityNode.appendChild(liveEntityStatsTable);
|
||||
}
|
||||
|
||||
let completedEnqueue = false;
|
||||
|
||||
GM_xmlhttpRequest({
|
||||
url: 'http://scraper.local:3000/api/fa/enqueue_objects',
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data: JSON.stringify({ fa_ids: faIds, url_names: urlNames, url_names_to_enqueue: urlNamesToEnqueue }),
|
||||
onload: response => {
|
||||
console.log("response: ", response);
|
||||
completedEnqueue = true;
|
||||
|
||||
if (response.status === 200) {
|
||||
const jsonResponse = JSON.parse(response.response);
|
||||
console.log("json: ", jsonResponse);
|
||||
handleEnqueueResponse(jsonResponse);
|
||||
} else {
|
||||
navbarLiveQueueNode.innerHTML = `<b>${response.status} enqueing</b>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function handleEnqueueResponse(jsonResponse) {
|
||||
navbarEnqueueNode.innerHTML = "";
|
||||
|
||||
const enqueueStats = Object
|
||||
.entries(jsonResponse)
|
||||
.map(([name, value]) => ({ name: name.split("_").join(" "), value }));
|
||||
const enqueueStatsTable = renderTable(enqueueStats, {
|
||||
...optsForNumRows(enqueueStats.length), width: "auto"
|
||||
});
|
||||
|
||||
enqueueStatsTable && navbarEnqueueNode.append(enqueueStatsTable);
|
||||
}
|
||||
|
||||
function pollLiveStats() {
|
||||
GM_xmlhttpRequest({
|
||||
url: 'http://scraper.local:3000/api/fa/object_statuses',
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data: JSON.stringify({ fa_ids: faIds, url_names: urlNames, url_names_to_enqueue: urlNamesToEnqueue }),
|
||||
onload: response => {
|
||||
console.log("response: ", response);
|
||||
if (response.status === 200) {
|
||||
const jsonResponse = JSON.parse(response.response);
|
||||
console.log("json: ", jsonResponse);
|
||||
const keepPolling = handleLiveStatsResponse(jsonResponse);
|
||||
if (!completedEnqueue || keepPolling) {
|
||||
setTimeout(() => pollLiveStats(), 2500);
|
||||
}
|
||||
else {
|
||||
console.log("reached terminal state");
|
||||
}
|
||||
} else {
|
||||
navbarStatusNode.innerHTML = `<b>${response.status} from scraper</b>`;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleLiveStatsResponse(jsonResponse) {
|
||||
let allTerminalState = true;
|
||||
|
||||
let numNotSeenPosts = 0;
|
||||
let numOkPosts = 0;
|
||||
let numScannedPosts = 0;
|
||||
let numHaveFile = 0;
|
||||
|
||||
for (const [gotFaId, postInfo] of Object.entries(jsonResponse.posts)) {
|
||||
allTerminalState = allTerminalState && postInfo.terminal_state;
|
||||
postElements.filter(({ faId }) => faId == gotFaId).forEach(({ statusNode }) => {
|
||||
statusNode.innerHTML = postInfo.state;
|
||||
|
||||
switch (postInfo.state) {
|
||||
case "not_seen": numNotSeenPosts += 1; break;
|
||||
case "ok": numOkPosts += 1; break;
|
||||
case "scanned_post": numScannedPosts += 1; break;
|
||||
case "have_file": numHaveFile += 1; break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const [gotUrlName, userInfo] of Object.entries(jsonResponse.users)) {
|
||||
allTerminalState = allTerminalState && userInfo.terminal_state;
|
||||
userElements.filter(({ urlName }) => urlName == gotUrlName).forEach(({ statusNode }) => {
|
||||
statusNode.innerHTML = userInfo.state;
|
||||
});
|
||||
}
|
||||
|
||||
const livePostsStats = [
|
||||
{ name: "not seen", value: numNotSeenPosts },
|
||||
{ name: "ok", value: numOkPosts },
|
||||
{ name: "scanned", value: numScannedPosts },
|
||||
{ name: "have file", value: numHaveFile },
|
||||
];
|
||||
|
||||
let liveQueueStats = Object
|
||||
.entries(jsonResponse.queues.depths)
|
||||
.map(([queue, depth]) => ({ name: queue, value: depth }));
|
||||
|
||||
liveQueueStats = [
|
||||
{ name: "total depth", value: `${jsonResponse.queues.total_depth}` },
|
||||
...liveQueueStats
|
||||
];
|
||||
|
||||
while (liveQueueStats.length < 4) {
|
||||
liveQueueStats.push({ name: "", value: "" });
|
||||
}
|
||||
|
||||
allTerminalState &&= jsonResponse.queues.total_depth == 0;
|
||||
|
||||
let liveEntityStats = [
|
||||
{ name: "no entity", value: "", sep: '' }
|
||||
];
|
||||
|
||||
const thisPageFaId = faIdFromViewHref(window.location.href);
|
||||
const pssCommon = { sep: '', valueAlign: 'left' };
|
||||
if (thisPageFaId != null) {
|
||||
const postData = jsonResponse.posts[thisPageFaId];
|
||||
liveEntityStats = [
|
||||
{ name: 'link', value: `<a target="_blank" href="${postData.info_url}" style="text-decoration: underline dotted">${thisPageFaId}</a>`, ...pssCommon },
|
||||
{ name: `seen`, value: postData.seen_at, ...pssCommon },
|
||||
{ name: `scanned`, value: postData.scanned_at, ...pssCommon },
|
||||
{ name: `downloaded`, value: postData.downloaded_at, ...pssCommon }
|
||||
];
|
||||
}
|
||||
|
||||
const thisPageUrlName = urlNameFromUserHref(window.location.href);
|
||||
if (thisPageUrlName != null) {
|
||||
const userData = jsonResponse.users[thisPageUrlName];
|
||||
liveEntityStats = [
|
||||
{ name: '', value: thisPageUrlName, ...pssCommon },
|
||||
{ name: 'first seen', value: userData.created_at, ...pssCommon },
|
||||
{ name: 'page scan', value: userData.scanned_page_at, ...pssCommon },
|
||||
{ name: 'gallery scan', value: userData.scanned_gallery_at, ...pssCommon }
|
||||
];
|
||||
}
|
||||
|
||||
renderLiveQueueStats({
|
||||
livePostsStats,
|
||||
liveQueueStats,
|
||||
});
|
||||
renderLiveEntityStats(liveEntityStats);
|
||||
|
||||
return !allTerminalState;
|
||||
}
|
||||
|
||||
// right off, can render the page stats table
|
||||
const pageStatsTable = renderTable([
|
||||
{ name: "page users", value: urlNames.length },
|
||||
{ name: "page posts", value: faIds.length }
|
||||
], { ...optsForNumRows(2), width: "auto" });
|
||||
navbarPageStatsNode.innerHTML = "";
|
||||
navbarPageStatsNode.append(pageStatsTable);
|
||||
|
||||
renderLiveQueueStats({
|
||||
livePostsStats: [
|
||||
{ name: "not seen", value: "---" },
|
||||
{ name: "ok", value: "---" },
|
||||
{ name: "scanned", value: "---" },
|
||||
{ name: "have file", value: "---" }
|
||||
],
|
||||
liveQueueStats: [
|
||||
{ name: "queue depths", value: "---" },
|
||||
{ name: "", value: "" },
|
||||
{ name: "", value: "" },
|
||||
{ name: "", value: "" },
|
||||
]
|
||||
});
|
||||
|
||||
pollLiveStats();
|
||||
}
|
||||
|
||||
function twitter(mainNode) {
|
||||
function onlyUnique(value, index, array) {
|
||||
return array.indexOf(value) === index;
|
||||
}
|
||||
|
||||
let seenTweets = new Set();
|
||||
let ignoreUsers = new Set(['elonmusk']);
|
||||
let nameToStatusNodes = {};
|
||||
let nameToStatus = { 'Mangoyena': 'yes!' };
|
||||
|
||||
function observerCallback() {
|
||||
// update status nodes for all the user links
|
||||
function parentN(node, times) {
|
||||
for (let i = 0; i < times; i++) {
|
||||
node = node.parentNode;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
[...document.querySelectorAll(
|
||||
"[data-testid=primaryColumn] a[role=link][tabindex='-1']:not(aria-hidden)"
|
||||
)]
|
||||
.filter(link =>
|
||||
link.hostname == 'twitter.com' &&
|
||||
!link.pathname.startsWith('/i/') &&
|
||||
!link.querySelector('img')
|
||||
).map(link => ({
|
||||
elem: link,
|
||||
name: link.pathname.split("/")[1],
|
||||
header: parentN(link, 7),
|
||||
}))
|
||||
.forEach(({ elem, name, header }) => {
|
||||
if (nameToStatusNodes[name] == null) {
|
||||
nameToStatusNodes[name] = [];
|
||||
}
|
||||
if (header.querySelector('.reduxStatusNode') == null) {
|
||||
const statusNode = document.createElement('span');
|
||||
statusNode.style.cssText = "color:white; float: right";
|
||||
statusNode.classList = ['reduxStatusNode'];
|
||||
header.insertBefore(statusNode, header.children[1]);
|
||||
nameToStatusNodes[name].push(statusNode);
|
||||
}
|
||||
|
||||
const status = nameToStatus[name];
|
||||
nameToStatusNodes[name].forEach(statusNode => {
|
||||
if (status != null) {
|
||||
statusNode.innerHTML = status;
|
||||
} else {
|
||||
statusNode.innerHTML = '(loading...)';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const tweets = [...document.querySelectorAll(
|
||||
"a[role=link]"
|
||||
)].filter(link =>
|
||||
link.hostname == 'twitter.com' &&
|
||||
link.pathname.includes("/status/")
|
||||
).map(link => {
|
||||
const parts = link.pathname.split("/");
|
||||
return {
|
||||
name: parts[1],
|
||||
tweetId: parts[3],
|
||||
};
|
||||
})
|
||||
.filter(({ name }) => !ignoreUsers.has(name))
|
||||
.map(JSON.stringify)
|
||||
.filter(onlyUnique);
|
||||
|
||||
let newTweets = [...tweets].filter(n => !seenTweets.has(n));
|
||||
if (newTweets.length > 0) {
|
||||
newTweets
|
||||
.map(JSON.parse)
|
||||
.forEach(({ name, tweetId }) =>
|
||||
console.log('new tweet', `${name} / ${tweetId}`));
|
||||
|
||||
newTweets.forEach(tweet => seenTweets.add(tweet));
|
||||
enqueueNewTweets(newTweets);
|
||||
}
|
||||
}
|
||||
|
||||
const enqueuedTweets = new Set();
|
||||
let enqueueInProgress = false;
|
||||
function enqueueNewTweets(newTweets) {
|
||||
newTweets.forEach(tweet => enqueuedTweets.add(tweet));
|
||||
|
||||
if (!enqueueInProgress && enqueuedTweets.size > 0) {
|
||||
enqueueInProgress = true;
|
||||
sendEnqueue([...enqueuedTweets].map(JSON.parse));
|
||||
enqueuedTweets.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// const enqueuedNames = new Set()
|
||||
function sendEnqueue(tweets) {
|
||||
// const newNames = tweets.map(({ name }) => name).filter(name => !enqueuedNames.has(name));
|
||||
console.log("enqueue tweets: ", tweets);
|
||||
|
||||
GM_xmlhttpRequest({
|
||||
url: 'http://scraper.local:3000/api/twitter/enqueue_objects',
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data: JSON.stringify({ names: tweets.map(({ name }) => name) }),
|
||||
onload: response => {
|
||||
|
||||
if (response.status === 200) {
|
||||
const jsonResponse = JSON.parse(response.response);
|
||||
console.log("redux json: ", jsonResponse);
|
||||
|
||||
enqueueInProgress = false;
|
||||
enqueueNewTweets([]);
|
||||
|
||||
} else {
|
||||
console.error("redux error: ", response.status, response);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function startObserving() {
|
||||
const observer = new MutationObserver(() => {
|
||||
observer.disconnect();
|
||||
observerCallback();
|
||||
startObserving();
|
||||
});
|
||||
|
||||
observer.observe(mainNode, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
startObserving();
|
||||
}
|
||||
|
||||
(function () {
|
||||
if (window.location.hostname == 'www.furaffinity.net') {
|
||||
fa();
|
||||
} else if (window.location.hostname == 'twitter.com') {
|
||||
twitter(document.querySelector('body'));
|
||||
} else {
|
||||
console.log("unhandled domain ", window.location.hostname);
|
||||
}
|
||||
})();
|
||||
229
user_scripts/furecs.user.js
Normal file
229
user_scripts/furecs.user.js
Normal file
@@ -0,0 +1,229 @@
|
||||
// ==UserScript==
|
||||
// @name FuRecs
|
||||
// @namespace https://twitter.com/DeltaNoises
|
||||
// @version 1.2
|
||||
// @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, 501];
|
||||
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);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user