DEV Community

Rahul Sharma
Rahul Sharma

Posted on

AI content detection script Python: how I test against 3 detectors at once

Last October a client forwarded me a screenshot. She'd written a guest post with an AI tool, submitted it to a UK marketing blog, and got the rejection email. The editor wrote: "This reads like it was generated by ChatGPT. We don't accept AI content."

What annoyed both of us: she'd tested it through one detector. Came back clean. That was enough for her.

It wasn't.

I've seen the same piece of content flagged as 85% AI by GPTZero and cleared as human by another tool in the same hour. That inconsistency is not unusual. It's how these tools work. Different models, different signals, no common standard. Editors use whatever they use, and you don't get to pick. So I built a small AI content detection script in Python that runs content through three detectors and returns a comparison. It's part of my workflow now for every client batch before anything goes out.

Why one AI detector isn't the full picture

The three tools I use in this script approach detection differently. GPTZero measures perplexity and burstiness — basically, how surprising each word is and how much sentence length varies. Sapling AI is a transformer model fine-tuned on labeled human and AI text. Originality.ai layers its own classifier on top of plagiarism checking. These aren't variations on the same method. They genuinely catch different things.

That matters. If content passes GPTZero but the editor is running Originality.ai on submissions, you've wasted a guest post slot. From my experience with clients doing guest posting at volume, that's a real thing that happens. Not rarely either.

I looked at this more closely in my breakdown of GPTZero alternatives — specifically for freelancers targeting UK, US, and Australian markets where editorial standards tend to be stricter. Detector disagreement is the norm on borderline content, not the exception.

Running three together gives you something more useful: a pattern. If all three score content above 70% AI, the writing has problems that are recognisable across different detection methods. If two say human and one says AI, you're looking at something borderline, worth a second pass. A consensus tells you more than any single score, basically.

What this AI content detection script Python does

The script accepts text strings or reads from a file, sends each piece to GPTZero, Sapling AI, and Originality.ai, calculates the average AI probability across all three, and outputs a side-by-side score comparison to the terminal. No web interface, no dashboard. Just a clean comparison you can run in a terminal from anywhere.

Adding a fourth detector or pulling from a CSV is a few extra lines.

Prerequisites

Keys required for each service. All three have free tiers or trials:

  • GPTZero API — apply at gptzero.me/api
  • Sapling AI — free key at sapling.ai
  • Originality.ai — paid but low per-scan; sign up at originality.ai

Install requests:

pip install requests
Enter fullscreen mode Exit fullscreen mode

Python 3.10+ for the type hints.

The full AI content detection script Python

import requests
import time
import json
import os


class AIDetectorBatch:
    """
    Batch AI content detection across GPTZero, Sapling AI, and Originality.ai.
    """

    def __init__(self, gptzero_key: str, originality_key: str, sapling_key: str):
        self.gptzero_key = gptzero_key
        self.originality_key = originality_key
        self.sapling_key = sapling_key

    def check_gptzero(self, text: str) -> dict:
        url = "https://api.gptzero.me/v2/predict/text"
        headers = {
            "x-api-key": self.gptzero_key,
            "Content-Type": "application/json",
            "Accept": "application/json",
        }
        payload = {"document": text, "multilingual": False}
        response = requests.post(url, headers=headers, json=payload, timeout=30)
        response.raise_for_status()
        data = response.json()
        doc = data["documents"][0]
        return {
            "detector": "GPTZero",
            "ai_probability": doc["completely_generated_prob"],
            "classification": doc["predicted_class"],
        }

    def check_sapling(self, text: str) -> dict:
        url = "https://api.sapling.ai/api/v1/aidetect"
        payload = {"key": self.sapling_key, "text": text}
        response = requests.post(url, json=payload, timeout=30)
        response.raise_for_status()
        data = response.json()
        score = data["score"]
        return {
            "detector": "Sapling AI",
            "ai_probability": score,
            "classification": "ai" if score >= 0.5 else "human",
        }

    def check_originality(self, text: str) -> dict:
        url = "https://api.originality.ai/api/v1/scan/ai"
        headers = {
            "X-OAI-API-KEY": self.originality_key,
            "Content-Type": "application/json",
        }
        payload = {"content": text, "aiModelVersion": "1", "storeScan": False}
        response = requests.post(url, headers=headers, json=payload, timeout=30)
        response.raise_for_status()
        data = response.json()
        ai_score = data["score"]["ai"]
        return {
            "detector": "Originality.ai",
            "ai_probability": ai_score,
            "classification": "ai" if ai_score >= 0.5 else "human",
        }

    def check_all(self, text: str, delay: float = 1.5) -> list[dict]:
        """Run all three detectors on a single piece of text."""
        results = []
        checks = [self.check_gptzero, self.check_sapling, self.check_originality]
        for fn in checks:
            try:
                results.append(fn(text))
            except requests.HTTPError as e:
                results.append({"detector": fn.__name__, "error": str(e)})
            time.sleep(delay)
        return results

    def batch_check(self, texts: list[str], delay: float = 1.5) -> list[dict]:
        """Run all detectors on a list of text strings."""
        all_results = []
        for i, text in enumerate(texts):
            print(f"Processing {i + 1}/{len(texts)}...")
            entry = {
                "text_preview": text[:100].replace("\n", " ") + ("..." if len(text) > 100 else ""),
                "results": self.check_all(text, delay=delay),
            }
            valid = [r for r in entry["results"] if "ai_probability" in r]
            if valid:
                entry["average_ai_probability"] = (
                    sum(r["ai_probability"] for r in valid) / len(valid)
                )
            all_results.append(entry)
        return all_results


def render_results(batch_output: list[dict]) -> None:
    """Print a formatted comparison table to stdout."""
    for item in batch_output:
        print(f"\n{'=' * 65}")
        print(f"Text: {item['text_preview']}")
        print(f"{'=' * 65}")
        for r in item["results"]:
            if "error" in r:
                print(f"  {r['detector']:<15}  ERROR: {r['error']}")
                continue
            pct = r["ai_probability"] * 100
            filled = int(pct / 5)
            bar = "" * filled + "" * (20 - filled)
            label = r["classification"].upper()
            print(f"  {r['detector']:<15}  [{bar}]  {pct:5.1f}%  ({label})")
        if "average_ai_probability" in item:
            avg = item["average_ai_probability"] * 100
            print(f"\n  {'Average':<15}  {avg:.1f}% AI probability")


def save_results(results: list[dict], output_path: str) -> None:
    """Export results to JSON for logging or downstream analysis."""
    with open(output_path, "w", encoding="utf-8") as f:
        json.dump(results, f, indent=2)
    print(f"\nResults saved to {output_path}")


# Entry point

if __name__ == "__main__":
    detector = AIDetectorBatch(
        gptzero_key=os.getenv("GPTZERO_API_KEY", "your_gptzero_key"),
        originality_key=os.getenv("ORIGINALITY_API_KEY", "your_originality_key"),
        sapling_key=os.getenv("SAPLING_API_KEY", "your_sapling_key"),
    )

    sample_texts = [
        (
            "The integration of artificial intelligence into content marketing workflows "
            "has fundamentally transformed how organizations approach content creation at scale. "
            "By leveraging large language models, teams can produce high-quality written materials "
            "in a fraction of the time previously required, enabling unprecedented levels of output "
            "while maintaining consistency in brand voice and messaging across all channels."
        ),
        (
            "Last year I made a mistake that cost me three months of work. "
            "A client in Manchester needed product descriptions for over 400 items. "
            "I used AI to draft the lot, did a quick read-through, and sent them over. "
            "The site owner rejected all of them after their in-house editor flagged them as AI. "
            "I had not run a single detection check before delivery. "
            "That was my fault, and I still think about it when setting up any content workflow."
        ),
    ]

    results = detector.batch_check(sample_texts)
    render_results(results)
    save_results(results, "detection_results.json")
Enter fullscreen mode Exit fullscreen mode

How the script is structured

One class, AIDetectorBatch. A method each for GPTZero, Sapling, and Originality. Then check_all and batch_check sit on top of those.

The design decision I'd highlight: all three detector methods return the same dict shape — detector, ai_probability, classification. That's it. I kept it uniform on purpose. Means render_results and the averaging logic don't need to know which detector produced which result. One loop handles all three.

check_all runs the detectors sequentially with a delay. The default is 1.5 seconds between calls. Don't remove this unless you're on a paid plan with higher rate limits. I've hit limits on the free tiers when batching articles quickly, and the script handles HTTPError gracefully — it logs the error and keeps going rather than crashing the whole batch.

batch_check is the main entry point for most use. It takes a list of strings, runs check_all on each, stores the preview plus all results, and calculates the average probability across detectors that returned successfully. So if Sapling's API goes down mid-batch, the average is still computed from the two that responded.

render_results just makes terminal output readable. ASCII bars instead of raw floats. When you're scanning 20 articles in one go, that visual difference matters more than it sounds.

What the sample output looks like

The two texts in the sample are intentional extremes. First one is generic AI filler — "transformational", "at scale", "brand voice across all channels." All three detectors pick it up, consistently above 70%. Second one is a personal story with a specific city, a specific client mistake, a specific volume of work. Scores come in below 30% across all three.

Processing 1/2...
Processing 2/2...

=================================================================
Text: The integration of artificial intelligence into content marketing workflo...
=================================================================
  GPTZero          [████████████████░░░░]   82.3%  (AI)
  Sapling AI       [██████████████░░░░░░]   71.8%  (AI)
  Originality.ai   [███████████████░░░░░]   78.5%  (AI)

  Average           77.5% AI probability

=================================================================
Text: Last year I made a mistake that cost me three months of work. A client in...
=================================================================
  GPTZero          [████░░░░░░░░░░░░░░░░]   21.4%  (HUMAN)
  Sapling AI       [███░░░░░░░░░░░░░░░░░]   17.2%  (HUMAN)
  Originality.ai   [█████░░░░░░░░░░░░░░░]   27.9%  (HUMAN)

  Average           22.2% AI probability
Enter fullscreen mode Exit fullscreen mode

Agreement across all three is the signal to watch. One outlier is noise. Three outliers is a problem in the content.

Loading from a file instead of hardcoded strings

For actual content batches, I use a plain text file with --- as a separator between articles:

def load_texts_from_file(filepath: str, delimiter: str = "---") -> list[str]:
    with open(filepath, "r", encoding="utf-8") as f:
        content = f.read()
    return [t.strip() for t in content.split(delimiter) if t.strip()]

texts = load_texts_from_file("articles.txt")
results = detector.batch_check(texts)
render_results(results)
Enter fullscreen mode Exit fullscreen mode

I save results to JSON on every run too. Lets me compare scores between draft versions. When I wrote about API integrations for content pipelines, this score-tracking across revisions is the part people actually found useful — more than the integration setup itself.

Reading the scores correctly

The number that matters is the average, not any single detector's output. I had a situation where GPTZero flagged an article at 80%, Sapling cleared it, and Originality put it at 85%. The client wanted to publish based on the Sapling pass. The piece got rejected by the site editor. I documented similar inconsistencies in detail — same content, same day, wildly different individual scores.

What to look for: are all three pointing in the same direction? That's your real verdict.

On thresholds — different editors have different cutoffs. From my experience, under 30% average across all three is a safe zone for editorial submissions. Some sites are stricter, some are looser. Under 20% and I've never had a detection-based rejection, from my experience.

Short text gives bad results, basically across every detector I've tried. Under 150 words, the classifications start behaving randomly. Test full articles or at least full sections.

One more thing: both GPTZero and Originality.ai update their models without much announcement. A score from three months ago doesn't hold. Run this check close to the submission date, not once during initial drafting.

Ways to extend this

Winston AI has an API and is used specifically by a lot of academic editors. Worth adding as a fourth check if content goes to research or educational publications.

argparse turns this into a proper CLI tool — call it with a file path from the terminal rather than editing the script. gspread writes results to a Google Sheet in a few lines if you're managing content across multiple client accounts. And if Originality.ai is timing out during busy hours, wrapping check_originality in a retry decorator with exponential backoff fixes most of those failures.

Detection is the starting point, not the end

The script tells you where the problem is. Fixing it is separate work.

Content scoring above 60% across all three detectors tends to have the same fingerprints: sentence lengths that barely vary, transitions like "furthermore" and "it is worth noting", paragraphs that state a point and then restate it from a slightly different angle. Those are the specific patterns to go after when revising.

My roundup of humanizer tools covers what fits into the pipeline after detection flags something — if that's the next step.

Modify the script as needed. Most people add a detector or change the output format within the first week. If parsing breaks after a few weeks, check Originality.ai first. That response shape changes most often.

Leave a comment if you extend it somewhere interesting.

Top comments (0)