# Slop Stops — complete build guide

A personal Chrome extension + tiny local backend that flags, and optionally
hides, **AI-generated** posts in your Substack home feed — while staying **fair to
ESL writers** who have original ideas but use an LLM for the English.

Personal use only. Runs entirely on your own machine. Your AI-detector key never
touches the browser. **Not affiliated with, or endorsed by, Substack.**

---

## How to use this file

Download it, open [Claude Code](https://claude.com/claude-code) in an empty
folder, drop this file in, and say:

> *"Build the project described in this file exactly, then walk me through loading
> the extension into my Chrome."*

Claude (or you) builds the backend and the extension from the spec below. You
bring one thing: a Pangram API key — get it first (Part 0). Reproducing this is
about 30–60 minutes.

---

## Part 0 — Get a Pangram API key

Pangram is the AI-detection engine. The free tier is enough to try this; light
real use costs a few dollars.

1. Go to **https://www.pangram.com** and create an account (email or Google).
2. Open your **account / dashboard**, find the **API** (sometimes "Developer" or
   "API keys") section.
3. **Generate an API key.** Copy it somewhere safe — you'll set it as an
   environment variable, never paste it into the browser or commit it to git.
4. **Credits & cost:** the free tier gives ~**4 checks/day** (enough to build and
   test). Beyond that, developer API credits are **$0.05 per 1,000 words**
   ($0.04 bulk); buy a small bundle ($25 = 500k words ≈ months of personal use).
5. The endpoint this project uses is the async text task API:
   - submit: `POST https://text.external-api.pangram.com/task`
     header `x-api-key: <YOUR_KEY>`, body `{"text": "...", "public_dashboard_link": false}` → returns `task_id`
   - poll: `GET https://text.external-api.pangram.com/task/{task_id}` until
     `stage == "STAGE_SUCCESS"`
   - read: `fraction_ai`, `fraction_ai_assisted`, `fraction_human`, and `windows[]`
6. Keep the key in your shell, not in code:
   ```bash
   export PANGRAM_API_KEY="your-key-here"     # or add to ~/.zshrc
   ```

> If `PANGRAM_API_KEY` exceeds its rate limit you'll get HTTP 429; out of credits
> is 402. Both are expected, handle gracefully.

---

## Part 1 — What you're building

### The Report Card (two axes)

Every post gets graded on **two independent axes**, so the tool never confuses
*who had the ideas* with *who typed the words*:

|                    | human / AI-assisted copy            | AI-generated copy            |
|--------------------|-------------------------------------|------------------------------|
| **original ideas** | ✅ Authentic (incl. ESL/AI-assisted) | ⚠️ Original ideas, AI prose  |
| **thin ideas**     | Human but thin                      | ❌ AI slop                   |

- **Composition** (human / AI-assisted / AI-generated) comes from Pangram.
- **Substance** (original vs thin) is estimated locally from named entities,
  numbers, lived first-person detail, and specificity.

### Display rules (copy these EXACTLY — they are the ethics)

```
hide a post  ⟺  composition = AI-generated
              AND substance  = thin
              AND confidence = high
              AND host not in the allowlist
              AND (NOT AI-assisted, unless the user explicitly opted in)
```

Everything else is shown. AI-assisted is shown (the ESL guard). Low-confidence is
shown. Anything uncertain is shown. The tool dims slop; it does not bury people.
Clean spelling is **never** used as evidence against a writer.

---

## Part 2 — File tree

```
substack-detector/
├── requirements.txt            # fastapi, uvicorn[standard]
├── app.py                      # FastAPI backend: POST /score, /healthz, /cache/clear
├── cli.py                      # optional: score a URL/@handle from the terminal
├── detector/
│   ├── pangram.py              # async task submit + poll, normalize fractions+windows
│   ├── substack.py             # fetch full post text (api/v1/posts/{slug} → fallback scrape); paywall detect
│   ├── features.py             # substance (Axis A) + surface tells (Axis B-support)
│   ├── report.py               # two-axis Report Card + hide rule + thresholds
│   └── pipeline.py             # fetch → pangram → features → report
├── store/cache.py              # sqlite verdict cache, keyed by post URL
└── extension/
    ├── manifest.json           # MV3, Chrome
    ├── content/
    │   ├── selectors.js        # find feed cards + extract {url,title,excerpt} (all selectors here)
    │   ├── verdict.js          # verdict+settings → badge + hide decision (ESL rule)
    │   └── feed.js             # IntersectionObserver, badge, dim/collapse, "show anyway"
    ├── content/overlay.css     # badge pills + dim/collapse styles
    ├── background/worker.js    # broker to backend; concurrency cap + in-memory cache
    ├── popup/                  # popup.html/js/css — on/off, display mode, stats, legend
    ├── options/                # options.html/js — backend URL, author allowlist, clear cache
    └── icons/                  # 16/48/128 png
```

---

## Part 3 — Backend spec

**`detector/pangram.py`** — zero-dependency (urllib). Submit text to
`POST .../task` with header `x-api-key`, poll `GET .../task/{id}` every ~1.5s to
`STAGE_SUCCESS`, return:
```python
{ "prediction", "fraction_ai", "fraction_ai_assisted", "fraction_human",
  "num_ai_segments", "windows": [ {label, confidence, score, text}, ... ] }
```
Only keep windows whose label is "AI-Generated"/"AI-Assisted".

**`detector/substack.py`** — given a post URL: parse `host` + `slug` (slug = the
part after `/p/`). Try `GET https://{host}/api/v1/posts/{slug}` → `body_html`;
fall back to fetching the page and extracting the `body markup` / `available-content`
block. Strip HTML to text. **Detect paywall truncation** (markers like "This post
is for paid subscribers", or <120 words) and flag it — never bypass. Identify the
client politely; low request rate. Also expose `enumerate_posts(host, limit)` via
`/api/v1/archive?limit=50&offset=` for the CLI.

**`detector/features.py`** — from the text compute, per 1,000 words:
- **Axis A (substance):** mid-sentence capitalized tokens (≈ named entities),
  number density, first-person density, hedge density ("I think", "my impression").
  Combine into `substance_score` 0..1 (specificity 60% + lived-voice 40%).
- **Axis B-support:** sentence-length burstiness (stdev), em-dash density,
  type-token & hapax ratio, comma splices. **Context only — never lowers a human
  read on its own.**

**`detector/report.py`** — thresholds (tune later):
```python
GEN_T = 0.50          # fraction_ai ≥ → AI-generated
ASSIST_T = 0.30       # fraction_ai_assisted ≥ (and not generated) → AI-assisted
SUBSTANCE_ORIG = 0.40 # substance_score ≥ → original ideas
# confidence: "high" if max(fraction)≥0.85 and words≥150; "medium" ≥0.70 & ≥80 words; else "low"
hide = (composition=="ai_generated" and substance=="thin" and confidence=="high" and not truncated)
```
Map the quadrant to `{verdict, emoji, badge_label, badge_class, hide, confidence,
why, esl_note}`. Badges: ✅ human, ✋ ai_assisted, ⚠️ generated+original,
🤖 generated+thin. Provide an `unknown()` (never-hide) result for failures/short text.

**`store/cache.py`** — SQLite table `verdicts(url PRIMARY KEY, verdict JSON,
scored_at)`. A post's authorship doesn't change, so cache indefinitely. Store
**only the verdict**, never the body.

**`app.py`** — FastAPI, CORS `allow_origins=["*"]` (local, no credentials). Key
from `os.environ["PANGRAM_API_KEY"]`. Endpoints:
- `POST /score {url, title?, excerpt?}` → cache hit returns instantly; on miss,
  single-flight lock per URL → `pipeline.score_url` → cache confident verdicts
  (skip caching "unknown"/low-confidence) → return.
- `GET /healthz`, `GET /stats`, `POST /cache/clear`.
- `python app.py` serves `127.0.0.1:8787`.

---

## Part 4 — Extension spec (Manifest V3, Chrome)

**`manifest.json`** — `manifest_version: 3`; permissions `["storage"]`;
`host_permissions` `["*://substack.com/*", "http://127.0.0.1:8787/*",
"http://localhost:8787/*"]`; background service worker `background/worker.js`;
content script on `*://substack.com/*` loading
`content/selectors.js, content/verdict.js, content/feed.js` + `content/overlay.css`
at `document_idle`; `action.default_popup` = popup; `options_page`.

**`content/selectors.js`** — find cards: query `a[href*="/p/"]`, climb ≤6 parents
to the smallest block taller than ~90px = the card. Extract absolute `url` (strip
query/hash), `title` (nearest h1–h3), `excerpt` (card innerText, first ~600 chars),
`host`. Degrade gracefully — skip if no card. **All selectors live here.**

**`content/verdict.js`** — `shouldFilter(verdict, settings, host)`:
- false if disabled, if `displayMode==="badge"`, or host in allowlist;
- if `composition==="ai_assisted"` → only `settings.hideAssisted`;
- else return `verdict.hide`.

**`content/feed.js`** — load settings from `chrome.storage.sync`; `IntersectionObserver`
(rootMargin 200px) scores each card **once** via `chrome.runtime.sendMessage({type:"score",...})`;
render a badge pill; apply dim/collapse or hide per settings; "show anyway" reveals;
`MutationObserver` (debounced) rescans on infinite scroll; re-apply on settings change;
write live `stats` to `chrome.storage.local` for the popup.

**`background/worker.js`** — listens for `score` messages; in-memory cache by URL +
single-flight; **max 3 concurrent**; `fetch(backendUrl + "/score")` (backendUrl from
storage, default `http://127.0.0.1:8787`). Return `{error,…,hide:false}` on failure
(fail open — never hide on error).

**`popup/`** — instrument-style panel: master on/off, display-mode submenu
(dim+collapse default | hide | badge-only), opt-in "also hide AI-assisted" **with a
warning that it buries ESL writers**, live stats, color legend, backend status dot
(pings `/healthz`). Degrade to defaults when opened outside the extension.

**`options/`** — backend URL, author allowlist (one host per line), clear-cache
button (calls `/cache/clear` + clears the worker mem-cache).

---

## Part 5 — Setup & run

```bash
cd substack-detector
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
export PANGRAM_API_KEY="your-key-here"
python app.py                      # http://127.0.0.1:8787
```

Sanity-check the pipeline without the extension:
```bash
PANGRAM_API_KEY=... python cli.py https://www.example.substack.com/p/some-post
```

## Part 6 — Load the extension in Chrome

1. `chrome://extensions` → toggle **Developer mode** (top right).
2. **Load unpacked** → select the `extension/` folder.
3. Open `https://substack.com/home`. Scroll — posts get badged; AI-slop dims.
4. Click the toolbar icon for the panel; the gear → Settings for backend URL and
   the author allowlist.

## Part 7 — Cost

Pangram is the only marginal cost: ~**$0.07 per typical post**, $0 on cache hits.
With the shared cache, steady-state is near zero. Free tier covers light reading.

## Part 8 — Posture

Public content only; never bypass paywalls; low request rate; store only derived
verdicts, never post bodies. **Personal use; do not distribute or publish to the
Chrome Web Store** — redistributing a Pangram-powered, Substack-reading tool
violates those services' terms. Verdicts are **signal, not proof** of authorship.

## Part 9 — Roadmap: building the V2 judgments

The base build keeps AI-assisted writing visible and never hides on low
confidence. These three features make the "secondary judgments" richer. Each is
additive — build them on top of the base, behind a setting.

### 9a. ESL refinement (keep an ESL author's voice, not the LLM flag)

**Goal:** a non-native English writer who uses an LLM to sound natural in English
stays visible by their *ideas*, even when the detector leans toward an AI flag —
without letting genuine slop through.

**Path:**
1. **Weight substance over surface in the keep decision.** When composition is
   AI-assisted *or* AI-generated but `substance_score` is high and first-person /
   lived-detail signals are strong, treat the post as authentic (✋/⚠️, never
   hidden). Substance is the author; prose style is not.
2. **Author baseline.** Track each author's median substance + composition over
   their last N posts (new `authors` table, below). Judge a new post *relative to
   their own norm* — a usually-substantive writer gets the benefit of the doubt
   on one cleaner-than-usual post.
3. **Language signals.** Add cheap heuristics for non-native English / translation
   (code-switching, calques, characteristic ESL syntax, non-Latin source quotes)
   to *widen* AI-assisted tolerance — never to penalize.
4. **Expose a "voice vs. polish" slider** in options so you can tune how much
   AI-assist to forgive, and show a per-post ESL-assist confidence in the badge
   tooltip.

### 9b. Writer ELO / track-record (tolerate a one-off flag)

**Goal:** don't hide a good writer over a single AI-flagged post (they were busy,
a one-off, or a detector blip). Hide on a *pattern*, not one bad day.

**Path:**
1. New SQLite table: `authors(host TEXT PRIMARY KEY, score REAL, n INTEGER,
   history TEXT, updated REAL)`.
2. **Rolling ELO update** per scored post: start each author neutral (e.g. 1500).
   A human verdict raises the score, an AI-generated verdict lowers it, ELO-style
   with a K-factor (~32) and recency weighting so authors can change.
3. **New hide rule:**
   ```
   hide ⟺ base hide rule (high-conf AI-slop)
        AND author.score < HIDE_BELOW        # e.g. 1450 — a pattern, not a one-off
   ```
   A trusted author's single AI post is flagged ⚠️, **not** hidden.
4. Surface author scores in options (transparency + manual override / pin).

### 9c. Interaction-based exemption (auto-allowlist who you read)

**Goal:** writers you engage with often are always shown, no matter the verdict.

**Path:**
1. In `content/feed.js`, listen for click-throughs on a post card (the user
   opening it) and increment an engagement counter per author in
   `chrome.storage.local`.
2. After `T` opens (e.g. 3), **auto-add the host to the allowlist** — the same
   allowlist the filter already respects, so those posts are never filtered.
3. Show auto-exempted authors (vs. manually added) in options so you can prune.

> Build order: 9b (ELO table + hide gate) is the backbone — 9a and 9c both read
> from the per-author store it introduces. Ship 9b first, then layer 9a's
> substance-forgiveness and 9c's engagement counter on top.

## Validation checklist

- A known-human essay → ✅ Human (not hidden).
- Thin generic AI marketing copy → 🤖 AI-generated, hidden.
- AI-written prose that is full of real names/numbers → ⚠️ flagged, **not** hidden.
- An AI-assisted (ESL) post → ✋ kept visible.
- Reloading the same feed issues **zero** new Pangram calls (cache works).
- Nothing is hidden on a low-confidence or short-text verdict.

— built for [slopstops.com](https://slopstops.com)
