cross-posted from: https://reddthat.com/post/56324334
I wrote a Python script that takes a list of AO3 work IDs and builds a local database tracking how often other works appear in the bookmarks of users who bookmarked those works.
Key properties:
- Input: plaintext file with AO3 work IDs (one per line)
- Storage: local SQLite database
- Data source: AO3 HTML pages (no API)
- Login: dummy AO3 account via
requests.Session- Incremental: safe to re-run with additional work IDs; already-seen user–work pairs are not double-counted
This is intended for small–medium batches (e.g. ~40 work IDs), with rate limiting and persistence.
What the script does
Logs into AO3 using a session (credentials via environment variables).
For each input work ID:
- Scrapes
/works/<id>/bookmarksto collect all users who bookmarked it.For each of those users:
- Scrapes their bookmarks page to collect all bookmarked work IDs.
Stores data in SQLite:
- A
(user, work)table to prevent duplicate counting.- A
work_countstable that increments only for new pairs.On subsequent runs:
- Previously processed
(user, work)pairs are skipped.- Counts increase only for genuinely new relationships.
Database schema
relationships( user TEXT, work TEXT, PRIMARY KEY(user, work) ) work_counts( work TEXT PRIMARY KEY, count INTEGER )
Requirements
- Python 3.9+
requestsbeautifulsoup4Install dependencies:
pip install requests beautifulsoup4Set environment variables:
export AO3_USERNAME="your_dummy_username" export AO3_PASSWORD="your_dummy_password"
Usage
python ao3_bookmark_counter.py work_ids.txt
work_ids.txtshould contain one AO3 work ID per line.
Full script
#!/usr/bin/env python3 """ AO3 Bookmark Counter Reads AO3 work IDs from a text file, logs into AO3, collects bookmark data, and stores incremental counts in a SQLite database. Environment variables required: - AO3_USERNAME - AO3_PASSWORD """ import os import sys import time import re import logging import sqlite3 import requests from bs4 import BeautifulSoup AO3_BASE = "https://archiveofourown.org/" logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s" ) def get_authenticity_token(html): soup = BeautifulSoup(html, "html.parser") tag = soup.find("input", {"name": "authenticity_token"}) return tag["value"] if tag else None def login_to_ao3(username, password): session = requests.Session() login_page = session.get(f"{AO3_BASE}/users/login") login_page.raise_for_status() token = get_authenticity_token(login_page.text) if not token: raise RuntimeError("Could not find authenticity_token") response = session.post( f"{AO3_BASE}/user_sessions", data={ "user_session[login]": username, "user_session[password]": password, "authenticity_token": token, } ) response.raise_for_status() return session def fetch_bookmark_users(session, work_id): users = set() page = 1 while True: url = f"{AO3_BASE}/works/{work_id}/bookmarks?page={page}" logging.info("Fetching bookmarkers for work %s (page %d)", work_id, page) r = session.get(url) if r.status_code != 200: break soup = BeautifulSoup(r.text, "html.parser") for a in soup.find_all("a", href=re.compile(r"^/users/")): users.add(a.text.strip()) if soup.find("a", string=re.compile("Next")): page += 1 time.sleep(1) else: break return users def fetch_user_bookmarks(session, username): works = set() page = 1 base = f"{AO3_BASE}/users/{username}/pseuds/{username}/bookmarks" while True: url = f"{base}?page={page}" logging.info("Fetching bookmarks for user %s (page %d)", username, page) r = session.get(url) if r.status_code != 200: break soup = BeautifulSoup(r.text, "html.parser") for a in soup.find_all("a", href=re.compile(r"^/works/")): m = re.match(r"/works/(\d+)", a["href"]) if m: works.add(m.group(1)) if soup.find("a", string=re.compile("Next")): page += 1 time.sleep(1) else: break return works def main(work_file): user = os.environ.get("AO3_USERNAME") password = os.environ.get("AO3_PASSWORD") if not user or not password: sys.exit("AO3_USERNAME and AO3_PASSWORD must be set") with open(work_file) as f: work_ids = {line.strip() for line in f if line.strip()} db = sqlite3.connect("ao3_bookmarks.db") cur = db.cursor() cur.execute(""" CREATE TABLE IF NOT EXISTS relationships( user TEXT, work TEXT, PRIMARY KEY(user, work) ) """) cur.execute(""" CREATE TABLE IF NOT EXISTS work_counts( work TEXT PRIMARY KEY, count INTEGER ) """) db.commit() session = login_to_ao3(user, password) for work_id in work_ids: users = fetch_bookmark_users(session, work_id) for u in users: bookmarked_works = fetch_user_bookmarks(session, u) for w in bookmarked_works: cur.execute( "SELECT 1 FROM relationships WHERE user=? AND work=?", (u, w) ) if cur.fetchone(): continue cur.execute( "INSERT INTO relationships(user, work) VALUES(?, ?)", (u, w) ) cur.execute( "INSERT INTO work_counts(work, count) VALUES(?, 1) " "ON CONFLICT(work) DO UPDATE SET count = count + 1", (w,) ) db.commit() time.sleep(2) db.close() logging.info("Done.") if __name__ == "__main__": if len(sys.argv) != 2: print("Usage: python ao3_bookmark_counter.py work_ids.txt") sys.exit(1) main(sys.argv[1])
Notes / Caveats
AO3 has no public API; this relies on HTML scraping.
Please be polite:
- Keep request rates low.
- Use a throwaway account.
- Do not hammer the site.
Bookmark pages can be large; this is intentionally conservative and slow.
1 Comments
MxRemy@piefed.social · 3 pts · 254d
I'm very sorry to see the first post in c/fanfiction in like 10 months get super downvoted, but OP, we just reaallllyyyyy hate LLMs here. Justifiably, I would say.
However, I'm still sorry that this thing that maybe woulda been pretty cool without the AI, and an awesome contribution to the comm, had to go this way. Hopefully you won't feel too put off from this to come back and post something (non-AI) again?