AO3 Match Preference

See how likely you are to enjoy a story based on your preferred tags.

// ==UserScript==
// @name         AO3 Match Preference
// @version      1.0.1
// @description  See how likely you are to enjoy a story based on your preferred tags.
// @namespace    https://greasyfork.org/en/users/1353885-akira123
// @author       Akira123
// @match        http*://archiveofourown.org/*
// @license      MIT
// @grant        none
// ==/UserScript==

(function() {
    'use strict'

    // SETTINGS
    // ====================

    // Keywords are case insensitive. If a tag contains equal amounts of like and dislike keywords (e.g. Fluff and Angst), it will be considered neutral.
    const likes = {
        fandoms: ['Mashle'],
        warnings: [],
        relationships: ['Lance Crown/Mash Burnedead'],
        characters: ['Lance Crown', 'Mash Burnedead'],
        freeforms: ['Fluff', 'Slow Burn']
    }
    const dislikes = {
        fandoms: [],
        warnings: ['Major Character Death'],
        relationships: ['Finn Ames/Carpaccio Luo-Yang'],
        characters: ['Carpaccio Luo-Yang'],
        freeforms: ['Angst', 'Alpha/Beta/Omega Dynamics', 'Violence']
    }

    const highlightTags = {
        likes: true,
        neutrals: true,
        dislikes: true
    }
    const highlightMatch = true

    /*
    Default:
    const green = 'rgba(139, 195, 74, .5)'
    const yellow = 'rgba(255, 215, 0, .5)'
    const red = 'rgba(255, 111, 111, .5)'
    */
    const green = 'rgba(139, 195, 74, .5)'
    const yellow = 'rgba(255, 215, 0, .5)'
    const red = 'rgba(255, 111, 111, .5)'

    /*
    Match >= likePercentage will have green highlight
    Match >= neutralPercentage and < likePercentage will have yellow highlight
    Match < neutralPercentage will have red highlight

    Default:
    const likePercentage = 60
    const neutralPercentage = 30
    */
    const likePercentage = 60
    const neutralPercentage = 30

    // ====================
    // END SETTINGS

    function calculateMatch(tagElements) {
        const storyScores = {
            likes: 0,
            neutrals: 0,
            dislikes: 0
        }
        for (const [type, typeElements] of Object.entries(tagElements)) {
            typeElements.forEach(typeElement => {
                const tag = typeElement.textContent.toLowerCase()
                let tagLikes = 0, tagDislikes = 0
                likes[type].forEach(like => {
                    if (tag.includes(like.toLowerCase())) tagLikes++
                })
                dislikes[type].forEach(dislike => {
                    if (tag.includes(dislike.toLowerCase())) tagDislikes++
                })
                if (tagLikes === 0 && tagDislikes === 0) return

                analyzeTag(tagLikes, tagDislikes, storyScores, typeElement)
            })
        }
        const storyTotal = storyScores.likes + storyScores.neutrals + storyScores.dislikes
        if (storyTotal === 0) return -1
        return Math.floor((storyScores.likes + storyScores.neutrals / 2) / storyTotal * 100)
    }

    function analyzeTag(tagLikes, tagDislikes, storyScores, typeElement) {
        if (tagLikes > tagDislikes) {
            storyScores.likes++
            if (highlightTags.likes) typeElement.style.backgroundColor = green
        } else if (tagLikes === tagDislikes) {
            storyScores.neutrals++
            if (highlightTags.neutrals) typeElement.style.backgroundColor = yellow
        } else {
            storyScores.dislikes++
            if (highlightTags.dislikes) typeElement.style.backgroundColor = red
        }
    }

    function addMatch(storyTagElements, stats) {
        const match = calculateMatch(storyTagElements)

        const matchDt = document.createElement('dt')
        const matchDd = document.createElement('dd')
        matchDt.textContent = 'Match:'
        matchDd.textContent = `${match === -1 ? 'N/A' : `${match}%`}`
        if (highlightMatch) {
            if (match >= likePercentage) {
                matchDd.style.backgroundColor = green
            } else if (match >= neutralPercentage) {
                matchDd.style.backgroundColor = yellow
            } else if (match >= 0) {
                matchDd.style.backgroundColor = red
            }
        }
        stats.appendChild(matchDt)
        stats.appendChild(matchDd)
    }

    if (window.location.href.match(/works\/\d+/)) {
        const storyTagElements = {
            fandoms: document.querySelectorAll('.meta .fandom a'),
            warnings: document.querySelectorAll('.meta .warning a'),
            relationships: document.querySelectorAll('.meta .relationship a'),
            characters: document.querySelectorAll('.meta .character a'),
            freeforms: document.querySelectorAll('.meta .freeform a')
        }
        const stats = document.querySelector('.meta dl.stats')

        addMatch(storyTagElements, stats)
    } else {
        const storyElements = document.querySelectorAll('.blurb')
        storyElements.forEach(storyElement => {
            const storyId = storyElement.getAttribute('id')
            const storyTagElements = {
                fandoms: document.querySelectorAll(`#${storyId} .fandoms a`),
                warnings: document.querySelectorAll(`#${storyId} .warnings a`),
                relationships: document.querySelectorAll(`#${storyId} .relationships a`),
                characters: document.querySelectorAll(`#${storyId} .characters a`),
                freeforms: document.querySelectorAll(`#${storyId} .freeforms a`)
            }
            const stats = document.querySelector(`#${storyId} .stats`)

            addMatch(storyTagElements, stats)
        })
    }
})()