Humanizer
Paste a draft and get a pass or fail on the writing habits that make text read as machine-written: banned words and phrases, dashes, three-item lists, uniform sentence lengths, sloppy grammar and more. It is a checklist and a scorer. It does not rewrite your text.
Try it
Your text is sent to this server to be scored and is not stored. Everything it does is in the code below.
What it checks
The score starts at 100 and loses points for each problem. A draft passes when it has no hard issues and a score of 70 or more. The verdict is HUMAN_HEURISTIC or AI_OR_RULE_VIOLATION.
| Check | Fails the draft? |
|---|---|
| Em-dash, en-dash, or a spaced hyphen used as a separator | Yes |
| Banned words (112) and banned phrases (59 patterns) | Yes |
| Lists of three or more comma-separated items | Yes |
| Sentence and paragraph lengths too uniform, all long, or all short | Yes |
| The same two-word sentence start three or more times | Yes |
| Missing apostrophes, lowercase "i", lowercase sentence starts | Yes |
| Over the word cap: whatsapp 55, dm 80, email 120 | Yes |
| An email address inside the message | Yes |
| Expanded forms people usually contract ("I am", "do not") | Warning only |
| Message does not end in a period, question mark or exclamation mark | Counts as an issue |
Show the banned words
aforementioned, bolster, bolstering, bolsters, captivating, commendable, compelling, comprehensive, consequently, crucial, cutting-edge, delve, delved, delves, delving, ecosystem, elevate, elevates, elevating, elucidate, elucidates, embark, embarking, embarks, empower, empowering, empowerment, empowers, facilitate, facilitates, facilitating, foster, fostering, fosters, frameworks, furthermore, game-changer, gamechanger, groundbreaking, harness, harnesses, harnessing, henceforth, heretofore, holistic, illuminate, illuminates, indispensable, innovative, interplay, intricate, intriguing, landscape, leverage, leverages, leveraging, meticulous, meticulously, moreover, multifaceted, myriad, navigate, navigates, navigating, nestled, nestling, nevertheless, notable, noteworthy, nuanced, paradigm, paramount, pivotal, plethora, realm, realms, resonate, resonates, resonating, revolutionary, robust, seamless, seamlessly, showcase, showcases, showcasing, spearhead, spearheading, spearheads, streamline, streamlines, streamlining, synergies, synergy, tapestry, testament, thereby, transformative, underscore, underscored, underscores, underscoring, unpack, unpacking, unpacks, utilisation, utilization, utilize, utilizes, utilizing, vital, wherein
Writing tips from the guide: alternate short lines of 7 to 14 words with longer blocks of 25 to 45. Keep real grammar and use contractions. Give at most two examples in a list. Say who you are and what you want in the first screen of text. End on something concrete, not a slogan.
Command line
Save the code below as humanizer.py. It needs Python 3 and nothing else.
python3 humanizer.py draft.txt
python3 humanizer.py --text "Hi Sarah, I'm an independent broker who sources off-market deals. Are you buying this year?"
python3 humanizer.py check --channel email --text "..."
echo "short dm here" | python3 humanizer.py -
It prints JSON. Exit code 0 means it passed, 1 means it failed, and 2 means a usage error.
Use it from an AI assistant (MCP)
Two tools: check_text (the scorer) and get_rules (the full rule lists). Connect over HTTP, no key needed:
claude mcp add --transport http humanizer https://humanizer.thinginn.com/mcp
Other clients that accept a remote MCP URL can use https://humanizer.thinginn.com/mcp. To run it locally, download humanizer.py and server.py into one folder and add this to your client's config:
{"mcpServers": {"humanizer": {"command": "python3", "args": ["/path/to/server.py", "--stdio"]}}}
Then ask: "Check this cold email with the humanizer tool and fix every issue it lists."
API
curl -s https://humanizer.thinginn.com/api/check \
-H "Content-Type: application/json" \
-d '{"text":"Hi, I run a small brokerage that finds off-market planes.","channel":"email"}'
curl -s https://humanizer.thinginn.com/api/rules
Text up to 20,000 characters. Requests are rate limited.
Limits and known issues
- It is a pre-filter, not a guarantee. This is a plain heuristic. It runs no neural detector and no live check against GPTZero or similar services. A draft can pass here and still be flagged elsewhere, and the reverse.
- Known bug in the original rules: the grammar rule that flags "you buying" also matches the correct phrase "are you buying", so a well-formed question like that gets a false grammar issue. The code below is the original, unchanged.
- Context is not checked. A very short message such as "Saw your LLC owns N617CB. Want to sell?" passes, even though the guide says it is missing context about who you are.
- English first. Spanish intro patterns are described in the guide but mostly not scored.
- Do not mangle grammar to raise a score. The guide says modern detectors reward natural speech with correct grammar.
The code
This is the full scorer, humanizer.py, exactly as the web checker, the API and the MCP server run it. The web server (server.py) only wraps it.
#!/usr/bin/env python3
"""
Standalone heuristic humanizer / AI-tells checker.
Stdlib only: no pip packages, no ML models, no network, no external files.
Usage:
python3 humanizer.py <file.txt>
python3 humanizer.py --text "your draft here"
python3 humanizer.py check --channel email --text "..."
echo "draft" | python3 humanizer.py -
Exit codes: 0 = passed, 1 = failed heuristics, 2 = usage error.
"""
from __future__ import annotations
import json
import math
import re
import sys
from collections import Counter
# ---------------------------------------------------------------------------
# Banned single words (common LLM vocabulary tells)
# ---------------------------------------------------------------------------
BANNED_WORDS = [
"delve", "delves", "delved", "delving",
"tapestry", "landscape", "realm", "realms",
"testament", "pivotal", "meticulous", "meticulously",
"underscore", "underscores", "underscored", "underscoring",
"showcase", "showcases", "showcasing",
"foster", "fosters", "fostering",
"harness", "harnesses", "harnessing",
"utilize", "utilizes", "utilizing", "utilisation", "utilization",
"facilitate", "facilitates", "facilitating",
"embark", "embarks", "embarking",
"nestled", "nestling",
"elevate", "elevates", "elevating",
"empower", "empowers", "empowering", "empowerment",
"streamline", "streamlines", "streamlining",
"robust", "seamless", "seamlessly",
"cutting-edge", "groundbreaking", "game-changer", "gamechanger",
"leverage", "leverages", "leveraging",
"synergy", "synergies", "holistic",
"multifaceted", "nuanced", "intricate", "interplay",
"plethora", "myriad", "plethora",
"commendable", "notable", "noteworthy",
"comprehensive", "crucial", "vital",
"furthermore", "moreover", "consequently", "nevertheless",
"thereby", "wherein", "heretofore",
"aforementioned", "henceforth",
"intriguing", "captivating", "compelling",
"revolutionary", "transformative", "innovative",
"ecosystem", "paradigm", "frameworks",
"navigate", "navigating", "navigates",
"unpack", "unpacks", "unpacking",
"resonate", "resonates", "resonating",
"bolster", "bolsters", "bolstering",
"spearhead", "spearheads", "spearheading",
"underscore", "illuminate", "illuminates",
"elucidate", "elucidates",
"paramount", "indispensable",
]
# Multi-word / phrase AI tells (case-insensitive substring or regex)
BANNED_PHRASES = [
r"\bin today's (fast-paced|digital|ever-changing) world\b",
r"\bin the ever-evolving\b",
r"\bit'?s important to (note|remember|understand|consider)\b",
r"\bit is important to (note|remember|understand|consider)\b",
r"\bat the end of the day\b",
r"\bwhen it comes to\b",
r"\ba testament to\b",
r"\bplays? a (crucial|vital|pivotal|key) role\b",
r"\bin conclusion\b",
r"\bto summarize\b",
r"\bto sum up\b",
r"\bin summary\b",
r"\bas previously mentioned\b",
r"\bas mentioned (earlier|above|previously)\b",
r"\blet'?s dive (in|into|deeper)\b",
r"\bdive into\b",
r"\bunlock the potential\b",
r"\btake a deep dive\b",
r"\bdeep dive\b",
r"\bmoving forward\b",
r"\bgoing forward\b",
r"\bat its core\b",
r"\bin a nutshell\b",
r"\bthe landscape of\b",
r"\brich tapestry\b",
r"\bvibrant tapestry\b",
r"\bdigital landscape\b",
r"\bever-changing landscape\b",
r"\bgame[- ]changer\b",
r"\bcutting[- ]edge\b",
r"\bstate[- ]of[- ]the[- ]art\b",
r"\bbest[- ]in[- ]class\b",
r"\bworld[- ]class\b",
r"\bnot only .+ but also\b",
r"\bwhether you'?re .+ or\b",
r"\bfrom .+ to .+, .+ has\b",
r"\bin this article,? we (will|'?ll)\b",
r"\bthis comprehensive guide\b",
r"\bwithout further ado\b",
r"\bhope this helps\b",
r"\bfeel free to\b",
r"\bdon'?t hesitate to\b",
r"\bi hope this (email|message) finds you\b",
r"\breach out if\b",
r"\btouch base\b",
r"\bcircle back\b",
r"\bsynerg(?:y|ies|istic)\b",
r"\bleverage (our|your|their|this|these|the)\b",
# GPTZero 4.1o rhetorical pivots (from detector learnings)
r"\bthat is not .+, that is\b",
r"\bthat'?s not .+, that'?s\b",
r"\bit doesn'?t just .+[;,] it\b",
r"\bmeanwhile,?\b.{0,80}\bwhile\b",
r"\bcontrast that with\b",
r"\bhalf of .+ is .+, and the other half\b",
r"\bfighting over scraps\b",
r"\bexit liquidity\b",
r"\bthe math simply breaks\b",
r"\bno account needed\.?\s*no human in the loop\b",
r"\bno human in the loop\b",
]
# Grammar / apostrophe / sloppy patterns (ported from email QA)
SLOPPY_PATTERNS = [
(r"\byou buying\b", "Missing auxiliary: use 'Are you buying' instead of 'you buying'."),
(r"\byou looking\b", "Missing auxiliary: use 'Are you looking' instead of 'you looking'."),
(r"\bill\b", "Missing apostrophe/capitalization: use \"I'll\" instead of 'ill'."),
(r"\bim\b", "Missing apostrophe/capitalization: use \"I'm\" instead of 'im'."),
(r"\bi\b", "Lowercase pronoun 'i': always capitalize 'I'."),
(r"\bdont\b", "Missing apostrophe: use \"don't\" instead of 'dont'."),
(r"\bwont\b", "Missing apostrophe: use \"won't\" instead of 'wont'."),
(r"\bcant\b", "Missing apostrophe: use \"can't\" instead of 'cant'."),
(r"\bwhats\b", "Missing apostrophe: use \"what's\" instead of 'whats'."),
(r"\bits\b(?=\s+(?:a|an|the|my|our|important|clear|worth)\b)",
"Possible missing apostrophe: did you mean \"it's\"?"),
(r"[ \t]{2,}", "Multiple consecutive spaces detected."),
]
# Expected contractions for "sounds human" soft check
CONTRACTION_FORMS = [
("I am ", "I'm"),
("I will ", "I'll"),
("I would ", "I'd"),
("we are ", "we're"),
("we will ", "we'll"),
("do not ", "don't"),
("does not ", "doesn't"),
("cannot ", "can't"),
("will not ", "won't"),
("it is ", "it's"),
("that is ", "that's"),
("you are ", "you're"),
("they are ", "they're"),
]
CHANNEL_WORD_LIMITS = {
"whatsapp": 55,
"email": 120,
"dm": 80,
"generic": None,
}
EMAIL_ADDR_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
SENTENCE_SPLIT = re.compile(r"(?<=[.!?])\s+|\n+")
WORD_RE = re.compile(r"[A-Za-z0-9']+")
def _sentences(text: str) -> list[str]:
parts = [s.strip() for s in SENTENCE_SPLIT.split(text.strip()) if s.strip()]
return parts
def _words(text: str) -> list[str]:
return WORD_RE.findall(text)
def _paragraphs(text: str) -> list[str]:
return [p.strip() for p in re.split(r"\n\s*\n", text.strip()) if p.strip()]
def stddev(nums: list[float]) -> float:
if len(nums) < 2:
return 0.0
mean = sum(nums) / len(nums)
var = sum((x - mean) ** 2 for x in nums) / len(nums)
return math.sqrt(var)
def check_punctuation(text: str) -> list[str]:
issues = []
if "—" in text:
issues.append("PUNCTUATION: Contains em-dash (—). Use a period, comma, or rewrite as two sentences.")
if "–" in text:
issues.append("PUNCTUATION: Contains en-dash (–). Use a hyphen only inside compound words, or rewrite.")
if " - " in text:
issues.append("PUNCTUATION: Contains spaced hyphen separator (' - '). Prefer a period or comma.")
return issues
def check_banned(text: str) -> list[str]:
issues = []
lower = text.lower()
# words
found_words = []
for w in _words(text):
if w.lower() in BANNED_WORDS:
found_words.append(w.lower())
if found_words:
uniq = sorted(set(found_words))
issues.append(f"BANNED WORD(S): {', '.join(uniq)}")
# phrases
for pat in BANNED_PHRASES:
m = re.search(pat, text, flags=re.IGNORECASE | re.DOTALL)
if m:
snippet = m.group(0).replace("\n", " ")
if len(snippet) > 60:
snippet = snippet[:57] + "..."
issues.append(f"BANNED PHRASE: '{snippet}'")
return issues
def check_grammar(text: str) -> list[str]:
issues = []
for pat, msg in SLOPPY_PATTERNS:
flags = re.IGNORECASE if pat != r"\bi\b" else 0
if re.search(pat, text, flags=flags):
issues.append(f"GRAMMAR/STYLE: {msg}")
# sentence capitalization
for s in _sentences(text):
s_clean = s.strip()
if s_clean and s_clean[0].isalpha() and not s_clean[0].isupper():
preview = s_clean[:40] + ("..." if len(s_clean) > 40 else "")
issues.append(f"CAPITALIZATION: Sentence starts lowercase: '{preview}'")
# ending punctuation (allow bare name sign-off)
stripped = text.strip()
body_end = stripped
lines = stripped.splitlines()
if len(lines) > 1 and re.fullmatch(r"[A-Z][\w'.-]*(?:\s+[A-Z][\w'.-]*)?", lines[-1].strip() or ""):
body_end = "\n".join(lines[:-1]).rstrip()
prev = body_end.splitlines()[-1].strip() if body_end else ""
if re.fullmatch(
r"(best|thanks|thank you|cheers|regards|best regards|kind regards|"
r"all the best|talk soon|warmly),?",
prev,
flags=re.IGNORECASE,
):
body_end = "\n".join(body_end.splitlines()[:-1]).rstrip() or "."
if body_end and body_end[-1] not in ".!?":
issues.append("PUNCTUATION: Message does not end with ., ! or ?.")
return issues
def check_contractions(text: str) -> list[str]:
"""Soft flag: expanded forms that humans usually contract in casual prose."""
issues = []
hits = []
for full, short in CONTRACTION_FORMS:
if full.lower() in text.lower():
hits.append(f"'{full.strip()}' -> prefer '{short}'")
if hits:
issues.append("CONTRACTIONS (soft): " + "; ".join(hits[:6]))
return issues
def check_burstiness(text: str) -> tuple[list[str], dict]:
"""Sentence- and paragraph-length variation stats."""
issues = []
sents = _sentences(text)
lengths = [len(_words(s)) for s in sents if _words(s)]
paras = _paragraphs(text)
para_lens = [len(_words(p)) for p in paras]
stats = {
"sentence_count": len(lengths),
"sentence_word_lengths": lengths,
"sentence_mean": round(sum(lengths) / len(lengths), 2) if lengths else 0.0,
"sentence_stddev": round(stddev([float(x) for x in lengths]), 2) if lengths else 0.0,
"sentence_min": min(lengths) if lengths else 0,
"sentence_max": max(lengths) if lengths else 0,
"paragraph_count": len(para_lens),
"paragraph_word_lengths": para_lens,
"paragraph_stddev": round(stddev([float(x) for x in para_lens]), 2) if para_lens else 0.0,
}
if len(lengths) >= 4:
# Low burstiness: almost every sentence same length
if stats["sentence_stddev"] < 3.0 and stats["sentence_mean"] >= 12:
issues.append(
f"BURSTINESS: Sentence lengths are too uniform "
f"(mean={stats['sentence_mean']}, stddev={stats['sentence_stddev']}). "
"Alternate short 7-14 word lines with longer 25-45 word ones."
)
# All long run-ons
if stats["sentence_min"] >= 20 and stats["sentence_mean"] >= 30:
issues.append(
"BURSTINESS: Every sentence is long (min>=20 words). "
"Add short 1-sentence observations (7-14 words)."
)
# All tiny
if stats["sentence_max"] <= 14 and len(lengths) >= 5:
issues.append(
"BURSTINESS: Every sentence is short (max<=14). "
"Add a concrete 25-45 word paragraph."
)
if len(para_lens) >= 3:
# Uniform paragraphs
if stats["paragraph_stddev"] < 4.0 and max(para_lens) - min(para_lens) <= 8:
issues.append(
f"PARAGRAPH UNIFORMITY: Paragraph lengths nearly identical "
f"{para_lens}. Vary sharply."
)
# Single-sentence run-on paragraphs of similar length (GPTZero 4.1o trigger)
one_sent_paras = sum(1 for p in paras if len(_sentences(p)) == 1)
if one_sent_paras == len(paras) and all(25 <= n <= 70 for n in para_lens):
issues.append(
"BURSTINESS: Every paragraph is one long sentence of similar length. "
"This zero-burstiness pattern fails GPTZero Model 4.1o."
)
return issues, stats
def check_three_item_lists(text: str) -> list[str]:
"""Flag 3+ comma-separated item enumerations (GPTZero 4.1o trigger)."""
issues = []
# Rough: "a X, a Y, or a Z" / "X, Y, and Z"
pats = [
r"\b[\w$€£%-]+(?:\s+[\w$€£%-]+){0,4},\s+[\w$€£%-]+(?:\s+[\w$€£%-]+){0,4},"
r"\s+(?:and|or)\s+[\w$€£%-]+",
]
for pat in pats:
for m in re.finditer(pat, text, flags=re.IGNORECASE):
snippet = m.group(0)
if len(snippet) > 80:
snippet = snippet[:77] + "..."
issues.append(
f"LIST ENUMERATION: 3+ item list flagged ('{snippet}'). "
"Keep examples to at most two items joined by 'and'/'or'."
)
break # one is enough
return issues
def check_repeated_openers(text: str) -> list[str]:
issues = []
sents = _sentences(text)
openers = []
for s in sents:
ws = _words(s)
if not ws:
continue
# first 1-2 words as opener key
key = " ".join(w.lower() for w in ws[:2])
openers.append(key)
counts = Counter(openers)
repeats = [f"'{k}' x{v}" for k, v in counts.items() if v >= 3]
if repeats:
issues.append(
"REPEATED OPENERS: " + ", ".join(repeats) + ". Vary how sentences start."
)
return issues
def check_channel_limits(text: str, channel: str) -> list[str]:
issues = []
limit = CHANNEL_WORD_LIMITS.get((channel or "generic").lower())
n = len(text.split())
if limit is not None and n > limit:
issues.append(f"LENGTH: {channel} message exceeds {limit} words ({n} words).")
return issues
def check_privacy_generic(text: str) -> list[str]:
"""Generic privacy: flag bare email addresses in body (shareable rule)."""
issues = []
if EMAIL_ADDR_RE.search(text):
issues.append(
"PRIVACY: Email address found in message body. "
"Keep contact details out of cold copy unless required."
)
return issues
def score_heuristic(text: str) -> dict:
"""
Produce a 0-100 'human-likeness' heuristic score from rule hits.
This is NOT a neural detector. It only reflects structural/lexical tells.
"""
penalty = 0
# weight buckets by severity later via issue prefixes
return {"_placeholder": True} # filled in evaluate_text
def evaluate_text(text: str, channel: str = "generic") -> dict:
text = text or ""
issues: list[str] = []
issues.extend(check_punctuation(text))
issues.extend(check_banned(text))
issues.extend(check_grammar(text))
soft = check_contractions(text)
issues.extend(check_three_item_lists(text))
issues.extend(check_repeated_openers(text))
burst_issues, burst_stats = check_burstiness(text)
issues.extend(burst_issues)
issues.extend(check_channel_limits(text, channel))
issues.extend(check_privacy_generic(text))
# Soft contraction hints do not fail the run by themselves, but are reported
warnings = soft
# Heuristic score: start at 100, subtract
score = 100.0
for iss in issues:
if iss.startswith("BANNED WORD"):
score -= 8
elif iss.startswith("BANNED PHRASE"):
score -= 10
elif iss.startswith("BURSTINESS") or iss.startswith("PARAGRAPH"):
score -= 12
elif iss.startswith("LIST ENUMERATION"):
score -= 10
elif iss.startswith("PUNCTUATION") and ("em-dash" in iss or "en-dash" in iss):
score -= 15
elif iss.startswith("REPEATED OPENERS"):
score -= 6
elif iss.startswith("GRAMMAR") or iss.startswith("CAPITALIZATION"):
score -= 5
elif iss.startswith("LENGTH"):
score -= 5
elif iss.startswith("PRIVACY"):
score -= 10
else:
score -= 4
for _ in warnings:
score -= 2
score = max(0.0, min(100.0, score))
hard_fail_prefixes = (
"BANNED",
"PUNCTUATION: Contains em-dash",
"PUNCTUATION: Contains en-dash",
"PUNCTUATION: Contains spaced",
"GRAMMAR",
"CAPITALIZATION",
"BURSTINESS",
"PARAGRAPH",
"LIST ENUMERATION",
"REPEATED OPENERS",
"LENGTH",
"PRIVACY",
)
hard_issues = [
i for i in issues
if any(i.startswith(p) for p in hard_fail_prefixes)
]
# Ending-punctuation and soft contraction are warnings unless other hard issues
passed = len(hard_issues) == 0 and score >= 70
return {
"ok": passed,
"passed": passed,
"verdict": "HUMAN_HEURISTIC" if passed else "AI_OR_RULE_VIOLATION",
"channel": channel,
"words": len(text.split()),
"heuristic_human_score": round(score, 2),
"note": (
"Stdlib heuristic only. Neural 6-model ensemble and live GPTZero "
"are NOT run by this standalone script."
),
"burstiness": burst_stats,
"issue_count": len(issues) + len(warnings),
"issues": issues,
"warnings": warnings,
}
def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
if not argv:
print(
"Usage: python3 humanizer.py <file.txt> | --text \"...\" | -\n"
" python3 humanizer.py check --channel email --text \"...\"",
file=sys.stderr,
)
return 2
channel = "generic"
text = None
if argv[0] == "check":
i = 1
while i < len(argv):
if argv[i] == "--channel" and i + 1 < len(argv):
channel = argv[i + 1]
i += 2
elif argv[i] == "--text" and i + 1 < len(argv):
text = argv[i + 1]
i += 2
elif argv[i] == "--file" and i + 1 < len(argv):
with open(argv[i + 1], "r", encoding="utf-8") as f:
text = f.read()
i += 2
else:
i += 1
elif argv[0] == "--text" and len(argv) >= 2:
text = argv[1]
if "--channel" in argv:
j = argv.index("--channel")
if j + 1 < len(argv):
channel = argv[j + 1]
elif argv[0] == "-":
text = sys.stdin.read()
else:
path = argv[0]
try:
with open(path, "r", encoding="utf-8") as f:
text = f.read()
except OSError:
text = path # treat as literal text
if "--channel" in argv:
j = argv.index("--channel")
if j + 1 < len(argv):
channel = argv[j + 1]
if text is None:
print("Error: no text provided", file=sys.stderr)
return 2
result = evaluate_text(text, channel=channel)
print(json.dumps(result, indent=2, ensure_ascii=False))
return 0 if result["ok"] else 1
if __name__ == "__main__":
sys.exit(main())