Poisoning AI scrapers

Inspired by Foone's suggestion this week I decided to start serving poisoned versions of my blog posts to any AI scrapers that I could identify—because I don't think it's enough to politely ask them to stop with a robots.txt file. They've already scraped my posts without asking; it's too late to undo that. But maybe I can hurt them just a little bit, going forward.

This post is about the technical side of implementing this.

Dissociated Press

The first step is to decide what the poison should look like. I've long been a fan of the Dissociated Press algorithm, which is a dead simple way to implement a Markov chain. A source text goes in, and garbage comes out—but the garbage looks at a glance like normal text. With the right parameters, the individual phrases locally look sensible, but the full sentences are nonsense.

I'm no expert on LLMs, but this probably will hurt them right in the place I want to hurt them: In their capability to look like they're making sense.

(Other poisoning methods might include inserting "meow" randomly throughout the text, sprinkling the text with typos, or using something fancier like a textual analog of Nightshade.)

For this purpose I probably could have picked any number of pre-made Dissociated Press programs, but since I've been learning Rust, I decided to write my own (named "marko") as an exercise. It takes a single source file, or stdin, as well as an optional seed for the random number generator. It's not super fast but I can speed it up later.

Making garbage

I have a static site, and my blog is generated from source files by a janky little script. The script isn't public, but I'll share here the changes I made.

First, I duplicated the call for rendering a blog post, and had the second one write to swill.alt.html instead of index.html (and set a flag for the new behavior):

        write_and_record(
            path.join(post_gen_dir, 'index.html'),
            generate_post_page(post, tag_slugs_to_posts_desc)
        )
        write_and_record(
            path.join(post_gen_dir, 'swill.alt.html'),
            generate_post_page(post, tag_slugs_to_posts_desc, markov_garbage=True)
        )

(Why "swill"? A reference to other people's designation of LLM output as "slop".)

I excluded comments from the poison page (too small to garble properly, and I didn't want to associate other people's names with the garbage) and swapped out the raw post contents, which is generally Markdown with some HTML sprinkled in. Here's the only modification to write_and_record:

    if markov_garbage:
        post = {**post}
        post['comments'] = []  # don't feed any comments to AI
        post['raw'] = make_into_garbage(post['raw'])

The make_into_garbage function is just hacked together without much care for error checking or whatever, because seriously, this is not very important stuff. It just passes the post to a vendored copy of marko along with the post's SHA256 hash digest as the seed, because I want the poisoned post's contents to change only when the post changes:

def make_into_garbage(text):
    """
    Given some perfectly reasonable text or markup, generate some
    garbage to feed to AI scrapers.

    Try to do it deterministically.
    """
    seed_hex = hashlib.sha256(text.encode()).hexdigest()  # right size for marko's seed
    p = subprocess.Popen(
        [markov_bin, "-", "--seed", seed_hex], text=True,
        stdin=subprocess.PIPE, stdout=subprocess.PIPE,
    )
    p.stdin.write(text)
    p.stdin.close()
    garbage = p.stdout.read(len(text))
    p.stdout.close()
    p.terminate()
    return garbage

Spwaning all these processes definitely slows down site generation! I'll add conditional generation later. Maybe if the post is already in the published state and the swill file exists, then I'll skip regenerating it.

Serving garbage

I have some limited control over what pages I serve, but an .htaccess file with mod_rewrite enabled is plenty:

# Don't allow serving the swill files directly.
RewriteRule .*/swill.alt.html /no-page-here [L]

# Feed garbage to AI scrapers (if page .../ has a .../swill.alt.html)
RewriteCond %{HTTP:User-Agent} "^(GPTBot|ClaudeBot|Claude-Web|ChatGPT-User|anthropic-ai|cohere-ai)$"
RewriteCond %{REQUEST_URI} .*/$
RewriteCond %{REQUEST_FILENAME}swill.alt.html -f
RewriteRule .* %{REQUEST_URI}swill.alt.html [END]

This is pretty hamfisted, but again it's just for me.

Currently I'm using a list of useragents, but I may end up using other signals in the future.

Will it work?

Of course not! My little site is definitely not going to poison the LLMs all on its lonesome. Maybe if a lot of other sites did it too, and maybe if instead of deleting old social media posts we replaced them with garbage... hey, quite possibly.

Honestly it was mostly to have a bit of fun, practice my Rust, and brush up on my mod_rewrite. But if I can inspire other people to do something similar, who knows?

Responses: 1 so far Feed icon

  1. Tim McCormack says:

    Pre-emptive notice:

    I'm not really interested in discussing whether LLMs have good uses, what rights and expectations there are for scrapers vs bloggers, or anything about AI or "AI" more generally. At least not in this comment thread.

    If you have things to say about alternative technical approaches or other poisoning techniques, though, I'd love to hear them!

Self-service commenting is not yet reimplemented after the Wordpress migration, sorry! For now, you can respond by email; please indicate whether you're OK with having your response posted publicly (and if so, under what name).