user scripts improvements

This commit is contained in:
Dylan Knutson
2025-02-24 22:39:45 +00:00
parent 42f45bf8c0
commit 9a2fac1433
11 changed files with 1124 additions and 977 deletions

View File

@@ -1,878 +0,0 @@
// ==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
// @connect localhost
// ==/UserScript==
"use strict";
const HOST = "scraper:3000";
// const HOST = "localhost:3000";
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: true,
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: true,
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://${HOST}/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://${HOST}/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://${HOST}/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);
}
})();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "es2015",
"module": "CommonJS",
"lib": ["dom", "es6", "dom.iterable"],
"outDir": "./dist",
"sourceMap": true,
"noImplicitAny": true,
"baseUrl": ".",
"rootDir": "."
},
"include": ["*.ts"],
"exclude": ["../app", "../node_modules", "dist"],
"compileOnSave": true
}