Sub to all your spacehey friends' blogs in one click. Unsub from non-friends too.
// ==UserScript==
// @name sub2frenz
// @namespace http://tampermonkey.net/
// @version 0.1.5
// @description Sub to all your spacehey friends' blogs in one click. Unsub from non-friends too.
// @match https://spacehey.com/home
// @icon https://spacehey.com/favicon.ico?v=2
// @grant GM_xmlhttpRequest
// @connect blog.spacehey.com
// @license GPL-3.0
// ==/UserScript==
// jshint esversion: 11
(function() {
'use strict';
const SCRIPT_NAME = 'sub2frenz';
const SPACEHEY_URL = 'https://spacehey.com';
const BLOG_URL = 'https://blog.spacehey.com';
const MS_PER_SECOND = 1000;
const PROPAGATION_DELAY_MS = 60 * MS_PER_SECOND;
const REQUEST_DELAY_MS = 500;
const MAX_RETRIES = 3;
const MAX_PAGES = 999;
const HEADER_RETRY_AFTER = 'retry-after';
const HTTP_GET = 'GET';
const HTTP_POST = 'POST';
const HTTP_SUCCESS_MIN = 200;
const HTTP_SUCCESS_MAX = 300;
const HTTP_TOO_MANY_REQUESTS = 429;
const CLASS_BUTTON_ROW = `${SCRIPT_NAME}-button-row`;
const CLASS_STATUS = `${SCRIPT_NAME}-status`;
const CLASS_SUB = `${SCRIPT_NAME}-sub`;
const CLASS_UNSUB = `${SCRIPT_NAME}-unsub`;
const CLASS_STOP = `${SCRIPT_NAME}-stop`;
const CLASS_UNSUBBING = `${SCRIPT_NAME}-unsubbing`;
const SEL_NEXT_PAGE = '.pagination a.next';
const SEL_USER_ID_LINK = '.profile.user-home .more-options a[href*="?id="]';
const SEL_FRIEND_LINK = '.person a[href*="/profile?id="]';
const SEL_SUBSCRIPTION_LINK = '.entry .publish-date a[href*="/user?id="]';
const SEL_LEFT_PANEL = '.profile.user-home div.left';
const SEL_CONTACT = '.contact';
const LABEL_SUB = "sub 2 \n ur frenz";
const LABEL_UNSUB = "unsub from \n non-frenz";
const LABEL_STOP = 'stop unsubbin';
const STYLES = `
.${CLASS_BUTTON_ROW} {
display: flex;
gap: 8px;
margin: 8px 0;
}
.${CLASS_BUTTON_ROW} button {
flex: 1;
display: block;
}
.${CLASS_BUTTON_ROW} .${CLASS_STOP} {
display: none;
}
.${CLASS_BUTTON_ROW}.${CLASS_UNSUBBING} .${CLASS_UNSUB} {
display: none;
}
.${CLASS_BUTTON_ROW}.${CLASS_UNSUBBING} .${CLASS_STOP} {
display: block;
}
.${CLASS_STATUS} {
font-family: 'Consolas', 'SF Mono', 'Menlo',
'DejaVu Sans Mono', 'Ubuntu Mono', monospace;
color: #ff6969;
background: #111;
padding: 4px 8px;
margin: 8px 0;
width: 100%;
box-sizing: border-box;
}
.${CLASS_STATUS}::before {
content: '> ';
}
.${CLASS_STATUS}::after {
content: '█';
animation: ${SCRIPT_NAME}-blink 1s step-end infinite;
}
@keyframes ${SCRIPT_NAME}-blink {
50% { opacity: 0; }
}
`;
function injectStyles() {
const style = document.createElement('style');
style.textContent = STYLES;
document.head.appendChild(style);
}
// easily filterable
function log(...args) {
console.log(`[${SCRIPT_NAME}]`, ...args);
}
function warn(...args) {
console.warn(`[${SCRIPT_NAME}]`, ...args);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function formatElapsed(startMs) {
const seconds = Math.floor((Date.now() - startMs) / MS_PER_SECOND);
return `${seconds}s`;
}
function isSuccessResponse(response) {
return response &&
response.status >= HTTP_SUCCESS_MIN &&
response.status < HTTP_SUCCESS_MAX;
}
function logAndReturn(response, successMessage, failMessage) {
const succeeded = isSuccessResponse(response);
if (!succeeded) {
warn(`${failMessage} (HTTP ${response?.status})`);
return succeeded;
}
log(`${successMessage} (HTTP ${response.status})`);
return succeeded;
}
function retryDelayMs(headers, attempt) {
let retryAfter = null;
const retryAfterPattern = new RegExp(
`${HEADER_RETRY_AFTER}:\\s*(\\d+)`, 'i'
);
const safeHeaders = headers || '';
const match = safeHeaders.match(retryAfterPattern);
if (match) {
retryAfter = parseInt(match[1], 10) * MS_PER_SECOND;
}
if (retryAfter) {
return retryAfter;
}
const exponentialBackoffMs = MS_PER_SECOND * 2 ** attempt;
return exponentialBackoffMs;
}
function parseHtml(text) {
return new DOMParser().parseFromString(text, 'text/html');
}
function hasNextPage(parsedDoc) {
return parsedDoc.querySelector(SEL_NEXT_PAGE) !== null;
}
function getCurrentUserId() {
const link = document.querySelector(SEL_USER_ID_LINK);
let userId = null;
if (link) {
userId = new URL(link.href).searchParams.get('id');
}
log('current user id:', userId);
return userId;
}
async function gmFetch(method, url, attempt = 0) {
let data;
if (method === HTTP_POST) {
data = 'submit=';
}
const response = await new Promise((resolve) => {
GM_xmlhttpRequest({
method,
url,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Origin: BLOG_URL,
Referer: url
},
data,
onload: resolve,
onerror: () => resolve(null)
});
});
if (response?.status === HTTP_TOO_MANY_REQUESTS && attempt < MAX_RETRIES) {
const delay = retryDelayMs(response.responseHeaders, attempt);
warn(
`rate limited, retrying ${url} in ${delay}ms` +
` (attempt ${attempt + 1}/${MAX_RETRIES})`
);
await sleep(delay);
return gmFetch(method, url, attempt + 1);
}
return response;
}
function parseFriendIds(parsedDoc) {
const ids = new Set();
for (const link of parsedDoc.querySelectorAll(SEL_FRIEND_LINK)) {
const match = link.getAttribute('href').match(/id=(\d+)/);
if (match) {
ids.add(match[1]);
}
}
return [...ids];
}
async function fetchFriendIdsOnPage(userId, page) {
const url = `${SPACEHEY_URL}/friends?id=${userId}&page=${page}`;
let response;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
response = await fetch(url);
if (response.status !== HTTP_TOO_MANY_REQUESTS) {
break;
}
const retryAfter = response.headers.get(HEADER_RETRY_AFTER);
let retryAfterHeader = null;
if (retryAfter) {
retryAfterHeader = `${HEADER_RETRY_AFTER}: ${retryAfter}`;
}
const delay = retryDelayMs(retryAfterHeader, attempt);
warn(
`rate limited on friends page ${page}, retrying in ${delay}ms` +
` (attempt ${attempt + 1}/${MAX_RETRIES})`
);
await sleep(delay);
}
if (!isSuccessResponse(response)) {
warn(`friends page ${page} returned HTTP ${response.status}`);
return { ids: [], morePages: false };
}
const parsedDoc = parseHtml(await response.text());
const ids = parseFriendIds(parsedDoc);
const morePages = hasNextPage(parsedDoc);
log(`friends page ${page}: ${ids.length} friends, morePages=${morePages}`);
return { ids, morePages };
}
async function fetchAllFriendIds(userId) {
const allFriendIds = [];
let page = 1;
while (page <= MAX_PAGES) {
const { ids, morePages } = await fetchFriendIdsOnPage(userId, page);
allFriendIds.push(...ids);
if (!morePages) {
break;
}
page++;
}
log(`fetched ${allFriendIds.length} friends total`);
return allFriendIds;
}
async function subscribeToBlog(userId) {
const response = await gmFetch(
HTTP_POST,
`${BLOG_URL}/subscribe?id=${userId}`
);
return logAndReturn(
response,
`subscribed to ${userId}`,
`failed to subscribe to ${userId}`
);
}
function buildSubSummary(done, failed, startTime) {
if (failed === 0) {
return `done: ${done} sub'd|${formatElapsed(startTime)}`;
}
return `done: ${done} sub'd, ${failed} fail'd|${formatElapsed(startTime)}`;
}
async function subscribeToFriends(friendIds, statusElement) {
const startTime = Date.now();
let done = 0;
let failed = 0;
for (const friendId of friendIds) {
statusElement.innerText =
`subbin ${done + failed + 1}/${friendIds.length}|` +
` ${formatElapsed(startTime)}`;
const succeeded = await subscribeToBlog(friendId);
if (!succeeded) {
failed++;
await sleep(REQUEST_DELAY_MS);
continue;
}
done++;
await sleep(REQUEST_DELAY_MS);
}
const summary = buildSubSummary(done, failed, startTime);
log(summary);
statusElement.innerText = summary;
}
async function unsubscribeFromBlog(userId) {
const response = await gmFetch(
HTTP_POST,
`${BLOG_URL}/unsubscribe?id=${userId}`
);
return logAndReturn(
response,
`unsubscribed from ${userId}`,
`failed to unsubscribe from ${userId}`
);
}
function parseSubscriptionUserIds(parsedDoc) {
const userIds = new Set();
for (const link of parsedDoc.querySelectorAll(SEL_SUBSCRIPTION_LINK)) {
const userId = new URL(link.href).searchParams.get('id');
if (userId) {
userIds.add(userId);
}
}
return userIds;
}
async function fetchSubscriptionsPage(page) {
const url = `${BLOG_URL}/subscriptions?page=${page}`;
const response = await gmFetch(HTTP_GET, url);
if (!isSuccessResponse(response)) {
warn(`subscriptions page ${page} returned HTTP ${response?.status}`);
return { userIds: new Set(), morePages: false };
}
const parsedDoc = parseHtml(response.responseText);
const userIds = parseSubscriptionUserIds(parsedDoc);
const morePages = hasNextPage(parsedDoc);
log(
`subscriptions page ${page}: ${userIds.size} unique users,` +
` morePages=${morePages}`
);
return { userIds, morePages };
}
async function propagationWait(label, statusElement, controller) {
const end = Date.now() + PROPAGATION_DELAY_MS;
while (!controller.cancelled) {
const remaining = Math.ceil((end - Date.now()) / MS_PER_SECOND);
if (remaining <= 0) {
break;
}
statusElement.innerText = `${label}| wait ${remaining}s`;
await sleep(MS_PER_SECOND);
}
}
// jshint ignore:start
function findNewNonFriends(userIds, friendSet, unsubscribed) {
return [...userIds].filter(
(id) => !friendSet.has(id) && !unsubscribed.has(id)
);
}
// jshint ignore:end
async function runUnsubPass(
pass,
friendSet,
allUnsubscribed,
startTime,
statusElement,
controller
) {
const unsubscribed = new Set();
let page = 1;
let passUnsubscribed = 0;
while (!controller.cancelled && page <= MAX_PAGES) {
const label =
`${pass}.${page}: ${allUnsubscribed.size} unsub'd|` +
` ${formatElapsed(startTime)}`;
statusElement.innerText = `${label}| checkin`;
const { userIds, morePages } =
await fetchSubscriptionsPage(page);
if (controller.cancelled) {
break;
}
const newNonFriends = findNewNonFriends(userIds, friendSet, unsubscribed);
if (newNonFriends.length === 0) {
if (!morePages) {
break;
}
page++;
continue;
}
statusElement.innerText = `${label}| unsub'd ${newNonFriends.length}`;
for (const friendId of newNonFriends) {
if (controller.cancelled) {
break;
}
const succeeded = await unsubscribeFromBlog(friendId);
if (succeeded) {
unsubscribed.add(friendId);
allUnsubscribed.add(friendId);
passUnsubscribed++;
}
await sleep(REQUEST_DELAY_MS);
}
if (!morePages) {
break;
}
page++;
}
return passUnsubscribed;
}
function buildUnsubSummary(cancelled, pass, unsubCount, startTime) {
if (cancelled) {
return `stop'd: ${pass}p| ${unsubCount} unsub'd|` +
` ${formatElapsed(startTime)}`;
}
return `done: ${pass}p| ${unsubCount} unsub'd| ${formatElapsed(startTime)}`;
}
async function unsubNonFriends(friendIds, statusElement, controller) {
const friendSet = new Set(friendIds);
const allUnsubscribed = new Set();
const startTime = Date.now();
let pass = 1;
while (!controller.cancelled) {
const passUnsubscribed = await runUnsubPass(
pass,
friendSet,
allUnsubscribed,
startTime,
statusElement,
controller
);
if (controller.cancelled) {
break;
}
if (passUnsubscribed === 0) {
break;
}
pass++;
const passLabel =
`p${pass - 1}: ${allUnsubscribed.size} unsub'd|` +
` ${formatElapsed(startTime)}`;
await propagationWait(passLabel, statusElement, controller);
}
const summary = buildUnsubSummary(
controller.cancelled, pass, allUnsubscribed.size, startTime
);
log(summary);
statusElement.innerText = summary;
}
function setControllerCancelled(controller, isCancelled) {
if (controller) {
controller.cancelled = isCancelled;
}
}
function setButtonsEnablement(buttons, isEnabled) {
for (const button of buttons) {
button.disabled = !isEnabled;
}
}
function setUnsubbing(buttonRow, stopButton, isUnsubbing) {
buttonRow.classList.toggle(CLASS_UNSUBBING, isUnsubbing);
stopButton.disabled = !isUnsubbing;
}
function showError(statusElement, message) {
warn(message);
statusElement.innerText = message;
const container = document.querySelector('main') || document.body;
container.prepend(statusElement);
}
function createElement(tag, className) {
const element = document.createElement(tag);
element.className = className;
return element;
}
function createButton(label, className) {
const button = document.createElement('button');
button.innerText = label;
button.className = className;
return button;
}
function createButtonRow(...buttons) {
const element = createElement('div', CLASS_BUTTON_ROW);
element.append(...buttons);
return element;
}
function createStatusElement() {
return createElement('div', CLASS_STATUS);
}
function onStopClick(controllerRef, stopButton, statusElement) {
setControllerCancelled(controllerRef.value, true);
setButtonsEnablement([stopButton], false);
statusElement.innerText = 'stoppin...';
}
async function onSubscribeClick(userId, actionButtons, statusElement) {
setButtonsEnablement(actionButtons, false);
try {
statusElement.innerText = 'fetchin frenz...';
const friendIds = await fetchAllFriendIds(userId);
if (friendIds.length === 0) {
warn('no friends found');
statusElement.innerText = 'no frenz found O__o';
return;
}
await subscribeToFriends(friendIds, statusElement);
} finally {
setButtonsEnablement(actionButtons, true);
}
}
async function onUnsubClick(
userId,
actionButtons,
buttonRow,
stopButton,
controllerRef,
statusElement
) {
setButtonsEnablement(actionButtons, false);
setUnsubbing(buttonRow, stopButton, true);
controllerRef.value = { cancelled: false };
try {
const friendIds = await fetchAllFriendIds(userId);
await unsubNonFriends(friendIds, statusElement, controllerRef.value);
} finally {
controllerRef.value = null;
setUnsubbing(buttonRow, stopButton, false);
setButtonsEnablement(actionButtons, true);
}
}
window.addEventListener('load', () => {
injectStyles();
const statusElement = createStatusElement();
const userId = getCurrentUserId();
if (!userId) {
showError(statusElement, "couldn't find user id. r u logged tf in?");
return;
}
const controllerRef = { value: null };
const subscribeButton = createButton(LABEL_SUB, CLASS_SUB);
const unsubButton = createButton(LABEL_UNSUB, CLASS_UNSUB);
const stopButton = createButton(LABEL_STOP, CLASS_STOP);
const buttonRow = createButtonRow(subscribeButton, unsubButton, stopButton);
const left = document.querySelector(SEL_LEFT_PANEL);
const contact = left?.querySelector(SEL_CONTACT);
if (!contact) {
showError(statusElement, 'ui error: has spacehey updated?');
return;
}
contact.after(buttonRow, statusElement);
const actionButtons = [subscribeButton, unsubButton];
stopButton.onclick = () =>
onStopClick(controllerRef, stopButton, statusElement);
subscribeButton.onclick = () =>
onSubscribeClick(userId, actionButtons, statusElement);
unsubButton.onclick = () => onUnsubClick(
userId,
actionButtons,
buttonRow,
stopButton,
controllerRef,
statusElement
);
},
false
);
})();