Files
redux-scraper/user_scripts/object_statuses.user.ts
2025-02-25 00:52:32 +00:00

1014 lines
29 KiB
TypeScript

// ==UserScript==
// @name Refurrer Object Statuses
// @namespace http://tampermonkey.net/
// @version 2.0
// @description Show FA object statuses in Refurrer
// @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 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';
type PostObjectState =
| 'not_seen'
| 'ok'
| 'removed'
| 'scan_error'
| 'file_error'
| 'scanned_post'
| 'have_file';
type UserObjectState = 'not_seen' | 'ok' | 'account_disabled' | 'error';
type ScanState = { last_at: string; due_for_scan: boolean };
interface ObjectStatusesResponse {
posts: Record<
number,
| {
state: 'not_seen';
}
| {
state: PostObjectState;
object_url: string;
seen_at: string;
post_scan: ScanState;
file_scan: ScanState;
}
>;
users: Record<
string,
| {
state: 'not_seen';
}
| {
state: UserObjectState;
object_url: string;
created_at: string;
page_scan: ScanState;
gallery_scan: ScanState;
favs_scan: ScanState;
}
>;
}
type LiveEntityType = 'post' | 'user' | 'entity_statuses';
interface LiveEntityStatTable {
type: LiveEntityType;
stats: {
name: string;
value: string;
sep?: string;
}[];
}
async function faObjectStatuses() {
const HOST = 'refurrer.com';
const LIGHT_BG_COLOR = '#353b45';
const DARK_BG_COLOR = '#20242a';
const LIGHT_BORDER_COLOR = '#69697d';
function isNotNull<T>(val: T | null | undefined): val is T {
return val != null;
}
function setupNavbar(): HTMLElement {
const navbarCssElem = document.createElement('style');
const navbarHeight = '60px';
navbarCssElem.innerHTML = `
li.refurrer-navbar {
height: 100%;
display: flex !important;
}
li.refurrer-navbar table {
height: 100% !important;
}
.refurrer-stats-tables {
display: flex;
}
.refurrer-entities-dropdown div {
font-weight: bold;
}
.refurrer-entities-dropdown ul,
.refurrer-entities-dropdown li,
.refurrer-entities-dropdown div {
float: left !important;
}
.refurrer-entities-dropdown ul {
height: auto !important;
}
.refurrer-entities-dropdown div,
.refurrer-entities-dropdown li {
line-height: 1.8em !important;
user-select: text !important;
}
.refurrer-entities-dropdown li a {
text-decoration: underline dotted !important;
}
.refurrer-entities-dropdown li a:hover {
text-decoration: solid !important;
}
li.refurrer-navbar > div,
.refurrer-stats-tables > table {
border-right: 1px solid #d7d7d7;
padding-right: 5px;
margin-right: 5px;
}
li.refurrer-navbar > div:last-child,
.refurrer-stats-tables > table:last-child {
border-right: none;
padding-right: 0;
margin-right: 0;
}
.refurrer-entities-dropdown-container {
position: relative;
}
.refurrer-entities-dropdown {
position: absolute;
background: ${LIGHT_BG_COLOR};
border: 1px solid ${LIGHT_BORDER_COLOR};
padding: 10px;
box-shadow: 0px 3px 10px 5px rgb(0 0 0 / 64%);
top: 60px;
max-height: calc(100vh - 100px);
overflow-y: auto;
}
.refurrer-entities-dropdown .dropdown-header {
background: ${DARK_BG_COLOR};
border-bottom: 1px solid ${LIGHT_BORDER_COLOR};
}
// #ddmenu ul {
// height: ${navbarHeight};
// }
// #ddmenu li {
// line-height: ${navbarHeight};
// }
// #searchbox input[type=search] {
// height: ${navbarHeight};
// }
// #ddmenu .menubar-icon-resize {
// max-height: ${navbarHeight} !important;
// }
`;
document.head.appendChild(navbarCssElem);
const navbarStatusNode = document.createElement('li');
navbarStatusNode.classList.add('refurrer-navbar');
navbarStatusNode.classList.add('lileft');
const faNativeNavbar = document.querySelector('nav');
if (faNativeNavbar != null) {
faNativeNavbar.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: {
type?: string;
smaller?: boolean;
style?: Record<string, string>;
} = {},
) {
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(): HTMLElement[] {
function queryAsHtmlElement(query: string): HTMLElement[] {
return [...document.querySelectorAll(query)].map(
(elem) => elem as HTMLElement,
);
}
return [
...queryAsHtmlElement('#gallery-search-results figure'),
...queryAsHtmlElement('#gallery-browse figure'),
...queryAsHtmlElement('#gallery-favorites figure'),
...queryAsHtmlElement('#gallery-gallery figure'),
...queryAsHtmlElement('#gallery-latest-favorites figure'),
...queryAsHtmlElement('#gallery-latest-submissions figure'),
...queryAsHtmlElement('section.gallery.messagecenter figure'),
];
}
function gatherPostElements(): { faId: number; statusNode: HTMLElement }[] {
// 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)`;
let b = figure.querySelector('b');
if (b) {
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 = faPostIdFromViewHref(link.href);
if (!faId) {
return null;
}
const statusNode = makeLargeStatusNode();
elem.appendChild(statusNode);
return {
faId,
statusNode,
};
})
.filter(isNotNull);
let submissionDataElems: { faId: number; statusNode: HTMLElement }[] = [];
if (unsafeWindow.submission_data) {
submissionDataElems = Object.entries(unsafeWindow.submission_data).map(
([fa_id, _]) => {
const statusNode = makeLargeStatusNode();
return {
faId: parseInt(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.add('swapper');
previewTitleNode.appendChild(swapOutNode);
const previewTitleLink = previewTitleNode.querySelector('a');
if (!previewTitleLink) {
return;
}
const observerCb = () => {
const previewFaId = faPostIdFromViewHref(previewTitleLink.href);
if (!previewFaId) {
return;
}
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: { faId: number; statusNode: HTMLElement }[] = [];
let faId = faPostIdFromViewHref(window.location.href);
if (faId) {
primaryViewPageElems = [
...document.querySelectorAll('.submission-title h2'),
].map((elem) => {
let p = elem.querySelector('p');
if (p) {
p.style.display = 'inline';
}
const statusNode = makeLargeStatusNode({ type: 'h3' });
elem.appendChild(statusNode);
return {
faId,
statusNode,
};
});
}
return [
...galleryFigures,
...submissionDataElems,
...featuredElem,
...primaryViewPageElems,
];
}
function gatherUserElements(): {
urlName: string;
statusNode: HTMLElement;
}[] {
// if on a gallery / browse page, the creator links from those posts
const userSubmissionLinks = galleryFigureElements()
.map((figure) => {
const userLinkElem = [...figure.querySelectorAll('figcaption a')]
.map((e) => e as HTMLAnchorElement)
.map((elem) => ({
elem: elem,
urlName: urlNameFromUserHref(elem.href),
}))
.filter(({ urlName }) => urlName != null)[0];
if (userLinkElem == null || userLinkElem.urlName == null) {
return null;
}
let u = figure.querySelector('u');
if (u) {
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,
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,
statusNode,
};
})
: [];
// comments made by users on posts or user pages
let userComments = [...document.querySelectorAll('comment-username a')]
.map((e) => e as HTMLAnchorElement)
.map((elem) => {
const statusNode = makeLargeStatusNode();
elem.after(statusNode);
const urlName = urlNameFromUserHref(elem.href);
if (urlName == null) {
return null;
}
return {
urlName,
statusNode,
};
})
.filter(isNotNull);
// users mentioned in post descriptions or user bios
let iconUsernames = [
...document.querySelectorAll('a.iconusername, a.linkusername'),
]
.map((e) => e as HTMLAnchorElement)
.map((elem) => {
const statusNode = makeLargeStatusNode();
elem.after(statusNode);
const urlName = urlNameFromUserHref(elem.href);
if (urlName == null) {
return null;
}
return {
urlName,
statusNode,
};
})
.filter(isNotNull);
// watcher / watching lists on user page
let watchersAndWatchList = [
...document.querySelectorAll('a > span.artist_name'),
]
.map((elem) => {
const link = elem.parentNode as HTMLAnchorElement;
if (!link) {
return null;
}
const statusNode = makeLargeStatusNode();
link.after(statusNode);
const urlName = urlNameFromUserHref(link.href);
if (urlName == null) {
return null;
}
return {
urlName,
statusNode,
};
})
.filter(isNotNull);
// users mentioned in the "hero" sections on a url page
let submissionDataElems: { urlName: string; statusNode: HTMLElement }[] =
[];
if (unsafeWindow.submission_data) {
submissionDataElems = Object.entries(unsafeWindow.submission_data).map(
([_, { lower }]) => {
const statusNode = makeLargeStatusNode();
return {
urlName: lower,
statusNode,
};
},
);
}
// watch for changes to preview user and swap out status node when that happens
const previewUser = document.querySelector(
'a.preview_user',
) as HTMLAnchorElement;
if (previewUser) {
const swapOutNode = document.createElement('span');
swapOutNode.classList.add('swapper');
previewUser.after(swapOutNode);
const observerCb = () => {
const previewUrlName = urlNameFromUserHref(previewUser.href);
if (!previewUrlName) {
return;
}
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((e) => e as HTMLAnchorElement)
.map((elem) => {
const statusNode = makeLargeStatusNode({ smaller: false });
elem.after(statusNode);
const urlName = urlNameFromUserHref(elem.href);
if (urlName == null) {
return null;
}
return {
urlName,
statusNode,
};
})
.filter(isNotNull);
// on a /watchlist/by/ or /watchlist/to page
const watchListUserLinks = [
...document.querySelectorAll('.watch-list-items.watch-row a'),
]
.map((e) => e as HTMLAnchorElement)
.map((elem) => {
const statusNode = makeLargeStatusNode({
smaller: false,
style: {
display: 'block',
'margin-bottom': '5px',
'font-size': '75%',
},
});
elem.parentNode?.appendChild(statusNode);
const urlName = urlNameFromUserHref(elem.href);
if (urlName == null) {
return null;
}
return {
urlName,
statusNode,
};
})
.filter(isNotNull);
return [
...userSubmissionLinks,
...userPageMain,
...userComments,
...iconUsernames,
...watchersAndWatchList,
...submissionDataElems,
...submissionContainerUserLinks,
...watchListUserLinks,
];
}
function urlNameFromUserHref(href: string): string | null {
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 faPostIdFromViewHref(href: string): number | null {
const viewPageRegex = /\/(view|full)\/(\d+)/;
const match = href.match(viewPageRegex);
const faId = (match && match[2]) || null;
if (faId) {
return parseInt(faId);
}
return null;
}
function faPostIdFromCurrentPage(): number | null {
return faPostIdFromViewHref(window.location.href);
}
function faUserUrlNameFromCurrentPage(): string | null {
const fromViewHref = urlNameFromUserHref(window.location.href);
if (fromViewHref) {
return fromViewHref;
}
let userLink = document.querySelector('.submission-id-sub-container a');
if (userLink && userLink instanceof HTMLAnchorElement) {
return urlNameFromUserHref(userLink.href);
}
return null;
}
function renderTable(
stats: {
name: string;
value: string;
sep?: string;
nameAlign?: string;
valueAlign?: string;
}[],
styleOpts: Record<string, string> = {},
): HTMLElement | null {
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 as string);
}
let tbody = document.createElement('tbody');
table.appendChild(tbody);
stats.forEach(({ 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: number) {
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' };
}
}
// gather initial entities on the page
const userElements = gatherUserElements();
const urlNames = [...new Set(userElements.map(({ urlName }) => urlName))];
const postElements = gatherPostElements();
const faIds = [...new Set(postElements.map(({ faId }) => faId))];
const navbarNode = setupNavbar();
function setupNavbarEntitiesOnPageNode() {
const node = document.createElement('div') as HTMLDivElement;
node.classList.add('refurrer-entities-dropdown-container');
const dropdownElem = document.createElement('div');
dropdownElem.classList.add('refurrer-entities-dropdown');
dropdownElem.classList.add('hidden');
const pageOverviewStatsTable = renderTable(
[
{ name: 'users', value: urlNames.length.toString() },
{ name: 'posts', value: faIds.length.toString() },
],
{ ...optsForNumRows(2), width: 'auto' },
);
node.appendChild(pageOverviewStatsTable);
if (urlNames.length > 0) {
const userHeader = document.createElement('div');
userHeader.textContent = 'Users:';
dropdownElem.appendChild(userHeader);
const userList = document.createElement('ul');
dropdownElem.appendChild(userList);
urlNames.forEach((name) => {
const li = document.createElement('li');
li.textContent = name;
userList.appendChild(li);
});
}
if (faIds.length > 0) {
const postHeader = document.createElement('div');
postHeader.textContent = 'Post IDs:';
dropdownElem.appendChild(postHeader);
const postList = document.createElement('ul');
dropdownElem.appendChild(postList);
faIds.forEach((id) => {
const li = document.createElement('li');
li.textContent = id.toString();
postList.appendChild(li);
});
}
node.appendChild(dropdownElem);
node.addEventListener('mouseenter', () => {
dropdownElem.classList.remove('hidden');
});
dropdownElem.addEventListener('mouseleave', () => {
dropdownElem.classList.add('hidden');
});
return [node, dropdownElem];
}
function setupNavbarEntityStatsTablesNode() {
const node = document.createElement('div') as HTMLDivElement;
node.classList.add('refurrer-stats-tables');
node.innerHTML = 'loading...';
return node;
}
// set up entities on page & dropdown
const [navbarEntitiesOnPageNode, navbarDropdownNode] =
setupNavbarEntitiesOnPageNode();
navbarNode.append(navbarEntitiesOnPageNode);
// set up stats tables
const navbarLiveEntityStatTablesNode = setupNavbarEntityStatsTablesNode();
navbarNode.append(navbarLiveEntityStatTablesNode);
function renderLiveEntityStatTables(
liveEntityStatTables: LiveEntityStatTable[],
) {
const tables = liveEntityStatTables.map(({ type, stats }) => {
return renderTable(stats, {
...optsForNumRows(stats.length),
width: 'auto',
});
});
navbarLiveEntityStatTablesNode.innerHTML = '';
tables.forEach((table) => {
navbarLiveEntityStatTablesNode.appendChild(table);
});
}
function fetchObjectStatuses(): Promise<ObjectStatusesResponse> {
return new Promise((resolve, reject) => {
let url = `http://${HOST}/api/fa/object_statuses`;
let params = new URLSearchParams();
faIds.forEach((id) => params.append('fa_ids[]', id.toString()));
urlNames.forEach((name) => params.append('url_names[]', name));
if (params.toString() != '') {
url += '?' + params.toString();
}
GM_xmlhttpRequest({
url,
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
onabort: () => {
reject(new Error('aborted fetching object statuses'));
},
ontimeout: () => {
reject(new Error('timeout fetching object statuses'));
},
onerror: (response) => {
reject(
new Error(
'failed to fetch object statuses: ' + response.responseText,
),
);
},
onload: (response) => {
if (response.status === 200) {
const jsonResponse = JSON.parse(
response.responseText,
) as ObjectStatusesResponse;
resolve(jsonResponse);
} else {
reject(
new Error(
'failed to fetch object statuses: ' + response.responseText,
),
);
}
},
});
});
}
function handleObjectStatusesResponse(jsonResponse: ObjectStatusesResponse) {
let numNotSeenPosts = 0;
let numOkPosts = 0;
let numScannedPosts = 0;
let numHaveFile = 0;
navbarDropdownNode.innerHTML = '';
const userHeader = document.createElement('div');
userHeader.textContent = 'Users:';
navbarDropdownNode.appendChild(userHeader);
const userList = document.createElement('ul');
navbarDropdownNode.appendChild(userList);
const postHeader = document.createElement('div');
postHeader.textContent = 'Post IDs:';
navbarDropdownNode.appendChild(postHeader);
const postList = document.createElement('ul');
navbarDropdownNode.appendChild(postList);
function createEntityListElem(
title: string,
url: string | null,
): HTMLElement {
const li = document.createElement('li');
if (url == null) {
li.textContent = title;
} else {
const a = document.createElement('a');
a.href = url;
a.target = '_blank';
a.textContent = title;
li.appendChild(a);
}
return li;
}
for (const [gotUrlName, userInfo] of Object.entries(jsonResponse.users)) {
const li = createEntityListElem(
gotUrlName,
userInfo.state == 'not_seen' ? null : userInfo.object_url,
);
userList.appendChild(li);
userElements
.filter(({ urlName }) => urlName == gotUrlName)
.forEach(({ statusNode }) => {
statusNode.innerHTML = userInfo.state;
});
}
for (const [gotFaId, postInfo] of Object.entries(jsonResponse.posts)) {
const li = createEntityListElem(
gotFaId,
postInfo.state == 'not_seen' ? null : postInfo.object_url,
);
postList.appendChild(li);
postElements
.filter(({ faId }) => faId == parseInt(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;
}
});
}
let liveEntities: LiveEntityStatTable[] = [];
liveEntities.push({
type: 'entity_statuses',
stats: [
{ name: 'not seen posts', value: numNotSeenPosts.toString() },
{ name: 'ok posts', value: numOkPosts.toString() },
{ name: 'scanned posts', value: numScannedPosts.toString() },
{ name: 'have file', value: numHaveFile.toString() },
],
});
const thisPageFaId = faPostIdFromCurrentPage();
const thisPageUrlName = faUserUrlNameFromCurrentPage();
const pssCommon = { sep: '', valueAlign: 'left' };
if (thisPageFaId != null) {
const postData = jsonResponse.posts[thisPageFaId];
if (postData.state == 'not_seen') {
liveEntities.push({
type: 'post',
stats: [{ name: 'no entity', value: '', sep: '' }],
});
} else {
liveEntities.push({
type: 'post',
stats: [
{
name: 'refurrer link',
value: `<a target="_blank" href="${postData.object_url}" style="text-decoration: underline dotted">${thisPageFaId}</a>`,
...pssCommon,
},
{ name: `seen`, value: postData.seen_at, ...pssCommon },
{
name: `scanned`,
value: postData.post_scan.last_at,
...pssCommon,
},
{
name: `downloaded`,
value: postData.file_scan.last_at,
...pssCommon,
},
],
});
}
}
if (thisPageUrlName != null) {
const userData = jsonResponse.users[thisPageUrlName];
if (userData.state == 'not_seen') {
liveEntities.push({
type: 'user',
stats: [{ name: 'no entity', value: '', sep: '' }],
});
} else {
liveEntities.push({
type: 'user',
stats: [
{
name: 'refurrer link',
value: `<a target="_blank" href="${userData.object_url}" style="text-decoration: underline dotted">${thisPageUrlName}</a>`,
...pssCommon,
},
{ name: 'first seen', value: userData.created_at, ...pssCommon },
{
name: 'page scan',
value: userData.page_scan.last_at,
...pssCommon,
},
{
name: 'gallery scan',
value: userData.gallery_scan.last_at,
...pssCommon,
},
{
name: 'favs scan',
value: userData.favs_scan.last_at,
...pssCommon,
},
],
});
}
}
renderLiveEntityStatTables(liveEntities);
}
await fetchObjectStatuses()
.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 faObjectStatuses();
} else {
console.error('unhandled domain ', window.location.hostname);
}
})();