Greasy Fork

Greasy Fork is available in English.

Ao3 Auto Bookmarker

Allows for autofilled bookmark summary and tags on Ao3.

当前为 2025-04-04 提交的版本,查看 最新版本

// ==UserScript==
// @name        Ao3 Auto Bookmarker
// @description Allows for autofilled bookmark summary and tags on Ao3.
// @namespace   Ao3
// @match       http*://archiveofourown.org/works/*
// @match       http*://archiveofourown.org/series/*
// @grant       none
// @version     2.0
// @author      Legovil
// @license     MIT
// ==/UserScript==

// Settings for the script, allowing customization of which features are enabled.
const settings = {
  generateTitleNote: true,
  generateSummaryNote: true,
  checkRecBox: false,
  checkPrivateBox: false,
  getRating: true,
  getArchiveWarnings: true,
  getCategoryTags: true,
  getFandomTags: true,
  getRelationshipTags: true,
  getCharacterTags: true,
  getAdditionalTags: true,
  generateWordCountTag: true,
  appendToExistingNote: false,
  appendToExistingTags: true,
  usingAo3Extensions: false,
};

// Word count boundaries for generating word count tags.
const wordCountBounds = [1000, 5000, 10000, 50000, 100000, 500000];

// Enum for bookmark types.
const BookmarkType = Object.freeze({
  WORK: 'Work',
  SERIES: 'Series',
});

(function() {
  'use strict';

  const bookmarkType = checkBookmarkType(window.location.href);

  if (!bookmarkType) {
    console.error('Bookmark type not found. Cancelling bookmark generation.');
    return;
  }

  handleCheckBoxes();
  setNotes(bookmarkType);
  setTags(bookmarkType);
})();

/**
 * Sets notes based on bookmark type.
 * @param {string} bookmarkType - The type of the bookmark.
 */
function setNotes(bookmarkType) {
  const notesElement = document.getElementById('bookmark_notes');
  if (!notesElement) {
    console.error('Notes element not found. Cancelling notes generation.');
    return;
  }
  notesElement.value = generateNotes(bookmarkType, notesElement);
}

/**
 * Sets tags based on bookmark type.
 * @param {string} bookmarkType - The type of the bookmark.
 */
function setTags(bookmarkType) {
  const tagsElement = document.getElementById('bookmark_tag_string_autocomplete');
  if (!tagsElement) {
    console.error('Tags input element not found. Cancelling bookmark tag generation.');
    return;
  }
  tagsElement.value = generateTagsFromType(bookmarkType);
}

/**
 * Generates tags based on the bookmark type.
 * @param {string} bookmarkType - The type of the bookmark.
 * @return {string} - The generated tags.
 */
function generateTagsFromType(bookmarkType) {
  return (bookmarkType === BookmarkType.WORK) ? generateTags() : generateSeriesTags();
}

/**
 * Checks the type of bookmark based on the URL.
 * @param {string} url The URL to check.
 * @return {string|null} The bookmark type or null if not found.
 */
function checkBookmarkType(url) {
  const bookmarkTypes = [
    { type: '/works/', result: BookmarkType.WORK, message: 'Found Work Bookmark.' },
    { type: '/series/', result: BookmarkType.SERIES, message: 'Found Series Bookmark.' }
  ];

  const bookmarkType = bookmarkTypes.find(({ type }) => url.includes(type));

  if (!bookmarkType) {
    return null;
  }

  console.log(bookmarkType.message);
  return bookmarkType.result;
}

/**
 * Generates notes for the bookmark.
 * @param {string} bookmarkType The type of bookmark.
 */
function generateNotes(bookmarkType, notesElement) {
  const notesArray = [
    { setting: settings.generateTitleNote, note: generateTitleNote(bookmarkType) },
    { setting: settings.generateSummaryNote, note: generateSummaryNote(bookmarkType) }
  ];

  const notes = notesArray
    .filter(noteObj => noteObj.setting)
    .map(noteObj => noteObj.note)
    .join('\n\n');

  // Append or replace existing notes based on settings.
  return settings.appendToExistingNote
    ? `${notesElement.value}\n\n${notes}`
    : notes;
}

/**
 * Generates the title note for the bookmark.
 * @param {string} bookmarkType The type of bookmark.
 * @return {string} The generated title note.
 */
function generateTitleNote(bookmarkType) {
  const queries = {
    WORK: { title: '.title.heading', author: '.byline.heading a' },
    SERIES: { title: 'ul.series a', author: '.series.meta.group a' }
  };

  const query = queries[bookmarkType];
  if (!query) {
    console.warn(`Invalid bookmark type: ${bookmarkType}. Cancelling Title Note generation.`);
    return '';
  }

  const { title: titleQuery, author: authorQuery } = query;

  const title = document.querySelector(titleQuery);
  if (!title) {
    console.warn('Title not found. Cancelling Title Note generation.');
    return '';
  }

  const author = document.querySelector(authorQuery);
  if (!author) {
    console.warn('Byline not found. Cancelling Title Note generation.');
    return '';
  }

  return `${title.innerHTML.link(window.location.href)} by ${author.outerHTML}.`;
}

/**
 * Generates the summary note for the bookmark.
 * @param {string} bookmarkType The type of bookmark.
 * @return {string} The generated summary note.
 */
function generateSummaryNote(bookmarkType) {
  const queries = {
    WORK: '.summary.module .userstuff',
    SERIES: '.series.meta.group .userstuff'
  };

  const summaryQuery = queries[bookmarkType];
  if (!summaryQuery) {
    console.warn(`Invalid bookmark type: ${bookmarkType}. Cancelling summary note generation.`);
    return '';
  }

  const summary = document.querySelector(summaryQuery);
  if (!summary) {
    console.warn('No summary found. Cancelling summary note generation.');
    return '';
  }

  return `Summary: ${summary.innerText}`;
}

/*
 * Generates tags for the series bookmark.
 */
function generateSeriesTags() {
  const works = Array.from(document.querySelector(".series.work.index.group").children);
  console.log(works);

  if (!Array.isArray(works) || works.length === 0) {
    console.warn('No works found or invalid works array. Cancelling tag generation.');
    return '';
  }

  const tagTypes = [
    { setting: settings.getArchiveWarnings, type: 'warnings' },
    { setting: settings.getFandomTags, type: 'fandoms.heading' },
    { setting: settings.getRelationshipTags, type: 'relationships' },
    { setting: settings.getCharacterTags, type: 'characters' },
    { setting: settings.getAdditionalTags, type: 'freeforms' }
  ];

  const tags = works.flatMap(work =>
    tagTypes
      .filter(tagType => tagType.setting)
      .flatMap(tagType => getTagsFromString(tagType.type, work))
  );

  if (settings.generateWordCountTag) {
    tags.push(generateWordCountTag());
  }

  return Array.from(new Set(tags)).join(', ');
}

/**
 * Generates tags for the bookmark.
 */
function generateTags() {
  // Remove existing tags if the setting is not to append to them.
  if (!settings.appendToExistingTags) {
    document.querySelectorAll('.added.tag a').forEach(tagLink => tagLink.click());
  }

  console.log('Tags input element found.');

  const tagTypes = [
    { setting: settings.getArchiveWarnings, type: 'warning.tags' },
    { setting: settings.getCategoryTags, type: 'category.tags' },
    { setting: settings.getFandomTags, type: 'fandom.tags' },
    { setting: settings.getRelationshipTags, type: 'relationship.tags' },
    { setting: settings.getCharacterTags, type: 'character.tags' },
    { setting: settings.getAdditionalTags, type: 'freeform.tags' }
  ];

  const tags = tagTypes
    .filter(tagType => tagType.setting)
    .map(tagType => getTagsFromString(tagType.type))
    .flat();

  if (settings.generateWordCountTag) {
    tags.push(generateWordCountTag());
  }

  return tags.join(', ');
}


/**
 * Gets tags from a specific class name.
 * @param {string} tagClassName The class name to get tags from.
 * @return {string} The generated tags.
 */
function getTagsFromString(tagClassName, startNode = document) {
  const tagList = startNode.querySelectorAll(`.${tagClassName} .tag`);

  if (tagList.length === 0) {
    console.error(`Tags element not found. Cancelling ${tagClassName} generation.`);
    return "";
  }

  return Array.from(tagList, tag => tag.text);
}

/**
 * Generates a word count tag based on the word count boundaries.
 * @return {string} The generated word count tag.
 */
function generateWordCountTag() {
  const index = settings.usingAo3Extensions ? 2 : 1;
  const wordCountElement = document.getElementsByClassName('words')[index];

  if (!wordCountElement || wordCountElement.innerText === 'Words:') {
    console.error('Word count not found. Cancelling word count tag generation.');
    return '';
  }

  const wordCount = wordCountElement.innerText.replaceAll(',', '').replaceAll(' ', '');
  let lowerBound = wordCountBounds[0];

  if (wordCount < lowerBound) {
    return `< ${lowerBound}`;
  }

  for (const upperBound of wordCountBounds) {
    if (wordCount < upperBound) {
      return `${lowerBound} - ${upperBound}`;
    }
    lowerBound = upperBound;
  }

  return `> ${wordCountBounds[wordCountBounds.length - 1]}`;
}


/**
 * Handles the state of checkboxes based on settings.
 */
function handleCheckBoxes() {
  const checkBoxSettings = [
    { setting: settings.checkRecBox, elementId: 'bookmark_rec', message: 'Checking rec box.' },
    { setting: settings.checkPrivateBox, elementId: 'bookmark_private', message: 'Checking private box.' }
  ];
  checkBoxSettings.forEach(({ elementId, setting, message }) => {
    const checkBox = document.getElementById(elementId);
    if (setting && checkBox) {
      console.log(message);
      checkBox.checked = true;
    }
  });

}