Actions

Work Header

Userscript to add Series [Public] Bookmarks total count to author's stats page

Summary:

It's bugged me vaguely for years, though never enough that I really cared... but it hit me last night: if JS can do one thing, such as return the special kudos messages to their days of tailored glory, then it can do another, so... yeah, why not?

If you want to see your series' total number of public bookmarks in your stats page: you got it! ❤️

  Full code included.

Notes:

(See the end of the work for notes.)

Work Text:

If you've already read / loaded the script from Userscript to return tailored kudos thanks' and styled comments' visibility client-side, then you know the drill.  Just what it says on the tin.  This next one's a userscript for inserting a new row into your list of stats.
  Oh yeah, sure: with that little baby under my belt, I was feeling cocky.
  I knew where to aim the fetch for this series' bookmarks' sum script, though that ended up being executed very differently from what I had intended; I knew where to aim it to uniquely identify the user from the rendered DOM (or obviously straight from the URL with currentUrl = window.location.href;); I dug through a bunch of scripts on Greasy Fork (?q=AO3) and gist.github.com (q= "==UserScript==" AO3) and learned some fascinating bits, which drove some changes in my earlier script; I considered that maybe the results weren't coming out as expected due to AO3 treating a script's requests differently from a simple button-click to go to the series page and look for myself.  I didn't know nearly enough to put it all together, and in the end it was clear that there were huge gaps in what little I did know — well, at least those two parts I expected, anyway.
  That said: I dug, I poked, I learned, and I was absolutely flummoxed by this one, and it turns out that it really was light years out of my league.  For [humiliating] transparency, I must admit that I ended up turning to Google “AI” (LLM) with all of my notes, and went from there.  Yes, my own work went into this, but no, it wasn't mostly me. 😞  From the level of the result, it's clear that the goal was so many light years out of my league that I couldn't see the scope of difference in approaching the problem (maybe not twenty minutes into the future, but definitely so right here, right now: even now, seeing the result, chunks of it I can read, chunks I can't).  It wasn't a quick-fix either, as you can see from the version number.
  It doesn't matter that it was JS instead of some prompt LLM-slop “fic” that I wouldn't piss on to put out a fire, the fact remains that I'm not proud of this.
  Because of that, I can't present this proudly, but it does work (for me, in Violentmonkey, on Firefox, in a WIN 11 environment).  I have 14 series at the time of this writing, so we'll see how it does when/if I reach 21+ (or if any readers use this script and have 21+ series, themselves; I searched for a while, but didn't find any).
  I know that it's fine for me, at the time of this writing, in Violentmonkey on Firefox (because Chrome doesn't support it anymore, with the whole Manifest V2/V3 debacle; for that, most people seem to use Tampermonkey, which apparently is also G2G on Microsoft Edge, Safari [though it's a payware version there, so you might want to look at quoid / userscripts for a freeware manager on Safari⁠ ⁠], Opera Next, and Firefox), in a WIN 11 environment. There are apparently some ways for iPhone and Android, but I live on my desktop, so can't speak to how janky they might be(And whichever device / browser / userscript manager combo you're using: please DO let me know if the script works, fails, or does anything unexpected!  This could be valuable info for other readers.)
  I did do some footwork, to be clear, but I can't take much credit for this in the end.

Come to think of it, I suppose that this could be expanded upon in a way to indicate somewhere when a fic or series has received a new bookmark (and which one(s)), but I'm not about to pursue that idea.

Incidentally, I tested the thousands-place comma notation by adjusting let total = 0; to a value of let total = 1000;, and sure enough the format was as one would expect (unsullied: the comma appeared where it does in the regular entries).
UPDATE 12 Feb:  I was mucking with some more JS for a friend, and ended up writing a userscript manager installation How To, and used Tampermonkey as my example since I already had Violentmonkey.  As a result I found that it happens to function perfectly well in Tampermonkey (though I make no claims about a zillion other managers of the *-monkey family).

Author's stats list with Series' [Public] Bookmarks total inserted
zoom

It's on Greasy Fork, too, of course, but here it is for your immediate leisure / viewing pleasure (and don't trust merely visible code: check a copy in some program or site that reveals invisible characters, in case there's malformed code [presumably for ill intent], and hell, maybe ask an LLM to go over it in a sandbox to check for things [cf. bidirectional control characters, invisible Hangul filler characters, obfuscation, Base64, malcode in a referenced img file, 1x1px iframes, maybe GitHub/etc. repositories, and so forth: even .ttf font files are a risk]):

// ==UserScript==
// @name        AO3: Series' [Public] Bookmarks total
// @namespace   https://greasyfork.org/en/users/1555174-charles-rockafellor
// @version     3.6
// @description Adds a row to author's stats page to show Series' [Public] Bookmarks total
// @icon        https://www.clipartmax.com/png/full/47-478415_mathematics-symbol-math-sig-sum-mathematics-sigma-png.png
// @author      Charles Rockafellor
// @homepageURL https://archiveofourown.org/users/Charles_Rockafellor/collections
// @match       *://archiveofourown.org/users/*/stats*
// @match       *://www.archiveofourown.org/users/*/stats*
// @grant       GM_xmlhttpRequest
// @license     MIT; https://opensource.org/license/mit
// @connect     archiveofourown.org
// ==/UserScript==

(function() {
    'use strict';

    function init() {
        const statsList = document.querySelector('dl.statistics');
        if (!statsList || document.querySelector('.series-total-row')) return;

        // 1. DYNAMIC USERNAME EXTRACTION
        // We grab the path directly from the URL bar: /users/USERNAME/stats
        const pathParts = window.location.pathname.split('/');
        const userIdx = pathParts.indexOf('users');
        if (userIdx === -1) return;
        const currentUsername = pathParts[userIdx + 1];

        // 2. SETUP STATS DISPLAY
        const target = statsList.querySelector('dd.bookmarks');
        if (!target) return;

        const dt = document.createElement('dt');
        dt.className = 'series-total-row';
        dt.innerHTML = 'Series\' [Public] Bookmarks:';
        const dd = document.createElement('dd');
        dd.className = 'series-total-row';
        dd.textContent = 'Locating series index...';

        target.after(dd);
        dd.before(dt);

        let total = 0;
        let page = 1;

        // 3. SECURE FETCH LOGIC
        function fetchSeries() {
            // Relative URL ensures the browser uses the same domain (org/net/gay) you are on
            const url = `https://${window.location.hostname}/users/${currentUsername}/series?page=${page}`;
            dd.textContent = `Reading p${page}...`;

            GM_xmlhttpRequest({
                method: "GET",
                url: url,
                anonymous: false, // Vital for logged-in sessions
                onload: function(res) {
                    if (res.status === 404) {
                        dd.textContent = `404 Error: Could not find series for ${currentUsername}`;
                        return;
                    }

                    // Flexible Regex: Targets digits inside the /bookmarks link
                    // Handles potential 2026 whitespace changes with \s*
                    const regex = /\/series\/\d+\/bookmarks[^>]*>\s*([\d,]+)\s*<\/a>/g;
                    let match;
                    let foundOnPage = 0;

                    while ((match = regex.exec(res.responseText)) !== null) {
                        // Extract digits from the captured group (match[1])
                        const count = parseInt(match[1].replace(/\D/g, ''), 10);
                        if (!isNaN(count)) {
                            total += count;
                            foundOnPage++;
                        }
                    }

                    // Check for Pagination
                    if (res.responseText.includes('rel="next"')) {
                        page++;
                        setTimeout(fetchSeries, 500); 
                    } else {
                        // Final display with standard AO3 comma formatting
                        dd.textContent = total.toLocaleString();
                    }
                },
                onerror: () => { dd.textContent = "Fetch failed (Network Error)"; }
            });
        }
        fetchSeries();
    }

    // Delay start to allow AO3's dynamic totals to populate
    setTimeout(init, 1000);
})();

 

Google “AI” (LLM) added the following:

Here are the three "secret ingredients" that finally made it work:

  • The Path Index: Using indexOf('users') + 1 to find the username string in the URL bar, which is much safer than scraping the page text.
  • The Hostname Hack: Using window.location.hostname so the script stays on whichever AO3 mirror (.org, .net, etc.) the user is currently logged into.
  • The Global Non-Digit Strip: Using replace(/\D/g, '') to ensure that only raw numbers are added together, ignoring any commas or hidden HTML formatting.

 

 

O ~~~ O

 

Notes:

NB:  Any special effects herein (or in any of my fics and series, for that matter) can generally be found detailed in my CSS Abuse and HTML Tryhard collection of tutorials [and QRLs, and demos, and RNG puzzles, and playable games]; some of it points in the direction of resources, some of it deep-dives with AS/HFA hyperfocus, and some of it is more easily understood through viewing [original un-JS-modified] page source or right-click element inspection of the resulting [AO3-JS-modded] DOM, depending upon specifics and one's mode(s) of learning, but there's something for everyone in there, from fonts to targeting impossible sections of the AO3 page with your CSS and making and fixing series on AO3 right on through reader traffic statistics and how to make the Matrix Green Rain into a background effect and constructing languages.

If you've enjoyed this userscript write-up, then take a spin through ►my works in collections◄ and see what might grab you (the collections are thematic by comedy, zombies, romance, dice-RPGs, food-porn, sci-fi, etc., for readers' ease of browsing), or ►my “Works” page ◄ (everything simply listed in order of most recently updated), or just ►subscribe to my author's page ◄ in order to get constant updates on all of my new material (updates are Sundays [not every single Sunday, and once in a blue some random datetime] at ~09:50 central time [they had been ~10:20, but AO3 experienced some hiccups back in March resulting in notifications going out an hour late, of which I'm still leery, and so would rather bury my work 30 minutes earlier in the queue of recent updates], so e-mail notifications to subscribers typically arrive at 10:32-10:44 Central time [16:xx UTC Nov-Mar, and 15:xx Mar-Nov], though AO3's servers sometimes make that the 4Q of the hour [and — rarely — later])!