Greasy Fork

来自缓存

Greasy Fork is available in English.

Ao3 Auto Bookmarker

Allows for autofilled bookmark summary and tags on Ao3.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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     3.1.1
// @author      Legovil
// @license     MIT
// ==/UserScript==

/**
 * Settings for customizing script behavior.
 * Allows enabling or disabling specific features.
 * @type {!Object}
 */
const settings = {
  /** @type {boolean} Whether to generate a title note. */
  generateTitleNote: true,

  /** @type {boolean} Whether to generate a summary note. */
  generateSummaryNote: false,

  /** @type {boolean} Whether to check the recommendation box. */
  checkRecBox: false,

  /** @type {boolean} Whether to check the private box. */
  checkPrivateBox: false,

  /** @type {boolean} Whether to retrieve the rating (currently not implemented). */
  getRating: true,

  /** @type {boolean} Whether to retrieve archive warnings. */
  getArchiveWarnings: true,

  /** @type {boolean} Whether to retrieve category tags. */
  getCategoryTags: true,

  /** @type {boolean} Whether to retrieve fandom tags. */
  getFandomTags: false,

  /** @type {boolean} Whether to retrieve relationship tags. */
  getRelationshipTags: true,

  /** @type {boolean} Whether to retrieve character tags. */
  getCharacterTags: false,

  /** @type {boolean} Whether to retrieve additional tags. */
  getAdditionalTags: false,

  /** @type {boolean} Whether to generate a Complete/Incomplete/Abandoned tag. */
  getCompletedStatus: true,

  /** @type {boolean} Whether to generate a one shot tag in addition to Complete/Incomplete tags.*/
  generateOneshotTag: true,

  /** @type {boolean} Whether to generate a word count tag. */
  generateWordCountTag: true,

  /** @type {boolean} Whether to append generated content to an existing note. */
  appendToExistingNote: false,

  /** @type {boolean} Whether to append generated tags to existing tags. */
  appendToExistingTags: false,

  /** @type {boolean} Whether AO3 extensions are being used. */
  usingAo3Extensions: false,

  /** @type {boolean} Whether to allow platonic relationship tags. */
  excludeFriendshipTags: true,

  /** @type {boolean} Whether to allow only the first relationship tag. */
  getFirstRelationshipTagOnly: true,

};

/**
 * Integer numbers to check whether a fic is older than to call it abandoned instead of incomplete.
 * E.g. 2, 0, 0 would mean any fic over 2 years old will be considered abandoned if not completed.
 * @type {!Object}
 */
const abandonedCheckBounds = {
  years: 2,
  months: 0,
  days: 0
}

/**
 * Word count boundaries used for generating word count tags.
 * Represents thresholds for different tag categories.
 * @type {!Array<number>}
 */
const wordCountBounds = [1000, 5000, 10000, 50000, 100000, 500000];

/**
 * Enum for bookmark types.
 * Specifies the type of bookmark being processed.
 * @enum {string}
 */
const BookmarkType = Object.freeze({
  /** Represents a bookmark for a work. */
  WORK: 'WORK',

  /** Represents a bookmark for a series. */
  SERIES: 'SERIES',
});

(function() {
  'use strict';

  // Get all bookmark buttons and attach event listeners for bookmarking on click.
  const buttons = document.querySelectorAll(".bookmark_form_placement_open");
  buttons.forEach(button => button.addEventListener('click', generateBookmark));
})();

/**
 * Generates a bookmark based on the current page's URL.
 * Validates the bookmark type and applies corresponding settings.
 */
function generateBookmark() {
  const bookmarkType = checkBookmarkType(window.location.href);
  if (!bookmarkType) {
    console.error('Bookmark type not found. Cancelling bookmark generation.');
    return;
  }

  // Apply relevant settings for the determined bookmark type.
  setNotes(bookmarkType);
  setTags(bookmarkType);
  handleCheckBoxes();
}

/**
 * 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;
  }
  if (!settings.appendToExistingTags) {
    document.querySelectorAll('.added.tag a').forEach(tagLink => tagLink.click());
  }
  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.info(bookmarkType.message);
  return bookmarkType.result;
}

/**
 * Generates notes for the bookmark.
 * @param {string} bookmarkType The type of bookmark.
 * @param {!Element} notesElement The notes input element.
 * @return {string} The generated notes.
 */
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: '.series-show.region .heading', 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('Author not found. Cancelling Title Note generation.');
    return '';
  }

  return `${title.innerText.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.
 * Extracts tag information from works and generates unique tags.
 * @return {string} A comma-separated list of generated tags.
 */
function generateSeriesTags() {
  const works = Array.from(document.querySelector('.series.work.index.group').children);

  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',
      errorMessage: 'Failed to generate Archive Warnings tags. Check to see if you have hide warnings enabled.'
    },
    {
      setting: settings.getFandomTags,
      type: 'fandoms.heading',
      errorMessage: 'Failed to generate Fandom Tags.'
    },
    {
      setting: settings.getRelationshipTags,
      type: 'relationships',
      errorMessage: 'Failed to generate Relationship Tags.'
    },
    {
      setting: settings.getCharacterTags,
      type: 'characters',
      errorMessage: 'Failed to generate Character Tags.'
    },
    {
      setting: settings.getAdditionalTags,
      type: 'freeforms',
      errorMessage: 'Failed to generate Additional Tags.'
    }
  ];

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

  if (settings.getCategoryTags) {
    tags.push(generateSeriesCategoryTags())
  }

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

  if (settings.getCompletedStatus) {
    tags.push(generateCompletedSeriesTag())
  }

  return generateUniqueTagList(tags);
}

/**
 * Generates tags for the bookmark.
 * Removes existing tags unless configured to append to them,
 * then generates new tags based on settings.
 * @return {string} A comma-separated list of generated tags.
 */
function generateTags() {
  const tagTypes = [
    {
      setting: settings.getArchiveWarnings,
      type: 'warning.tags',
      errorMessage: 'Failed to generate Archive Warnings tags. Check to see if you have hide warnings enabled.'
    },
    {
      setting: settings.getCategoryTags,
      type: 'category.tags',
      errorMessage: 'Failed to generate Category Tags.'
    },
    {
      setting: settings.getFandomTags,
      type: 'fandom.tags',
      errorMessage: 'Failed to generate Fandom Tags.'
    },
    {
      setting: settings.getRelationshipTags,
      type: 'relationship.tags',
      errorMessage: 'Failed to generate Relationship Tags.'
    },
    {
      setting: settings.getCharacterTags,
      type: 'character.tags',
      errorMessage: 'Failed to generate Character Tags.'
    },
    {
      setting: settings.getAdditionalTags,
      type: 'freeform.tags',
      errorMessage: 'Failed to generate Additional Tags.'
    }
  ];

  const tags = tagTypes
      .filter(tagType => tagType.setting)
      .flatMap(tagType => getTagsFromString(tagType));

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

  if (settings.getCompletedStatus) {
    tags.push(generateCompletedTag());
  }

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

  return tags.join(', ');
}

/**
 * Extracts text content from elements with a specific tag type and class name.
 * @param {!Object} tagType The tag type containing the class name and error message.
 * @param {string} tagType.type The class name of the tag type to search for.
 * @param {string} tagType.errorMessage The custom error message to display when no tags are found.
 * @param {(!Document|!Element)=} startNode The node to begin the search from. Defaults to the document.
 * @return {string} The concatenated text content from all matching tags, or an empty string if none are found.
 */
function getTagsFromString(tagType, startNode = document) {
  let tagList = startNode.querySelectorAll(`.${tagType.type} .tag`);

  if (tagList.length === 0) {
    console.warn(tagType.errorMessage);
    return '';
  }

  tagList = handleRelationshipTag(tagType, tagList);

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

/**
 * Handles relationship tags based on the provided settings.
 *
 * @param {Object} tagType - The type of the tag.
 * @param {Array} tagList - The list of tags.
 * @returns {Array} The filtered list of tags.
 */
function handleRelationshipTag(tagType, tagList) {
  if (!tagType.type.includes('relationship')) {
    return tagList;
  }

  if (settings.excludeFriendshipTags) {
    tagList = [...tagList].filter(tag => tag.text.includes('/'));
  }

  if (tagList.length === 0) {
    console.warn(tagType.errorMessage);
    return '';
  }

  if (settings.getFirstRelationshipTagOnly) {
    return [tagList[0]];
  }

  return tagList;
}

/**
 * Generates a list of unique category tags.
 * @return {string} A list of unique category tags.
 */
function generateSeriesCategoryTags() {
  const tagList = document.querySelectorAll(".category-slash.category");
  console.log(tagList);

  const tags = Array.from(tagList).map(tag => tag.innerText);
  return generateUniqueTagList(tags);
}

/**
 * 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.replace(/[, ]/g, '');

  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]}`;
}

/**
 * Generates a tag indicating if the series is complete.
 * @return {string} 'Complete' or 'Incomplete'
 */
function generateCompletedTag() {
  // Grab chapter counts and convert to numbers
  const [current, total] = document
    .querySelector('dd.chapters')
    .textContent
    .split('/')
    .map(Number);

  if (current === total) {
    return 'Complete';
  }

  // Parse last update date (YYYY-MM-DD)
  const [year, month, day] = document
    .querySelector('dd.status')
    .textContent
    .split('-')
    .map(Number);

  const updateDate = new Date(year, month - 1, day);

  // Build cutoff by subtracting bounds from now in UTC
  const cutoff = new Date();
  cutoff.setUTCFullYear(cutoff.getUTCFullYear() - abandonedCheckBounds.years);
  cutoff.setUTCMonth(cutoff.getUTCMonth() - abandonedCheckBounds.months);
  cutoff.setUTCDate(cutoff.getUTCDate() - abandonedCheckBounds.days);
  cutoff.setUTCHours(0, 0, 0, 0);

  return cutoff > updateDate ? 'Abandoned' : 'Incomplete';
}


/**
 * Generates a tag indicating if the series is a oneshot.
 * @return {string} 'Oneshot' or ''
 */
function generateOneshotTag() {
  const [current, total] = document.querySelector('dd.chapters').textContent.split('/');
  return current === '1' && total === '1' ? 'Oneshot' : '';
}

/**
 * Generates a tag indicating if the series is complete.
 * @return {string} 'Complete' or 'Incomplete'
 */
function generateCompletedSeriesTag() {
  const completedStatus = Array.from(document.querySelector('dl.stats').childNodes)
    .find(stat => stat.textContent === 'Yes' || stat.textContent === 'No');
  return completedStatus && completedStatus.textContent === 'Yes' ? 'Complete' : 'Incomplete';
}



/**
 * Handles the state of checkboxes based on settings.
 * Updates checkbox elements based on the user's configuration.
 */
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;
    }
  });
}

/*
 * Generates a unique tag list csv from an array.
 * @return {string} The generated unique tag list csv.
 */
function generateUniqueTagList(tags) {
  return Array.from(new Set(tags)).join(', ');
}