sub2frenz

Sub to all your spacehey friends' blogs in one click. Unsub from non-friends too.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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
  );
})();