How to use
- Paste the answer you copied from ChatGPT, Claude, Gemini or another assistant.
- Read the line under the result: it lists which cleanups changed something (Markdown removed, dashes replaced, hidden characters removed…).
- Untick any step you do not want. For code, or text with
[0]-style brackets, untick Remove citation markers. - Choose what happens to em dashes: comma, hyphen, spaced hyphen, space, or leave them.
- Copy to clipboard or Download .txt.
Why pasted AI text needs cleanup
Chat assistants format their answers in Markdown and typographic punctuation because that's what renders nicely in a chat window. The moment you copy that answer into an email, a plain-text field, a CMS that doesn't support Markdown, or a script that expects plain ASCII, the formatting stops helping and starts showing: literal **asterisks**around words, a stray ## in front of a heading, an em dash where a comma would read more naturally, and occasionally characters that don't print at all but are still sitting in the text.
This tool runs a fixed set of text transformations (no AI model involved), so the same input always produces the same output, and nothing you paste leaves your browser. The Markdown it strips is the CommonMark syntax that every major assistant emits; a 1 MB paste cleans in well under a tenth of a second.
Example
A typical answer, as copied:
## Summary
This is **really** important — so let’s be clear.
* Point one[1]
* See the docs[2, 3]…With the default options, it becomes:
Summary
This is really important, so let's be clear.
- Point one
- See the docs...The heading marker, bold markers, bullets, citation numbers, curly apostrophe, ellipsis and em dash were all changed; the words were not.
Options explained
- Strip headings, bold/italic, links, code:
## Heading→Heading;**bold**,*italic*,__x__,_x_and~~struck~~→ their inner text;[text](url)→textwith the URL dropped;`code`→code. Fenced```blocks keep their contents and lose the fence lines. Blockquote>markers,---rules and table pipes are removed. - …normalize bullet markers to “-”:
* itemand+ itembecome- item;- [ ]and- [x]task boxes are removed. Numbered lists are left as they are. - Em dash (—): Replace with comma, hyphen, " - ", space, or Leave as is. An en dash between words gets the same treatment (a spaced hyphen in comma mode); a numeric range always becomes a plain hyphen:
2010–2020 and A–B→2010-2020 and A - B. In comma mode a dash right before punctuation is dropped rather than producing, . - Straighten curly quotes (“ ” → "), also
‘ ’→',« »→", and prime marks. - Replace … with ...: the single ellipsis character becomes three periods.
- Remove invisible / zero-width characters: zero-width spaces and joiners, soft hyphens, byte-order marks, bidirectional controls and C0/C1 control codes are deleted; non-breaking and other Unicode spaces become a normal space.
- Remove citation markers ([1], 【2】):
[1],[2, 3],[4-6],[^5],【7†source】and[citation needed]. - Remove emoji (off by default): pictographic emoji, flag pairs and keycap sequences. Unicode also classes
©and™as pictographic, so they go too, while★stays:© 2024 ™ ★→2024 ★. - Tidy extra spaces and blank lines: collapses runs of spaces, removes trailing spaces, limits blank lines to one, and removes a space before
, . ; : ! ?.
Edge cases and common errors
- Array indexes look like citations.
items[0] and arr[12]becomesitems and arr. Untick Remove citation markers before cleaning code or anything with bracketed numbers you need. - Asterisks used as symbols are safe.
2 * 3 * 4anda*b*care untouched, because emphasis needs the marker touching the text on the inside and not touching a letter on the outside. Underscores inside identifiers (snake_case_name) are also safe. - Markdown tables lose their structure: cells become space-separated words, one line per row, as in
| a | b |plus a separator row and| 1 | 2 |→a b ⏎ 1 2. - Links keep only their label. Copy the URLs out first if you need them; nested formatting is unwrapped completely (
[**x**](u)→x). - Emoji stay unless you ask, because plenty of pasted text is meant to keep them.
- Windows line endings are normalised: the output always uses plain
\n. - The character count can go up, not just down:
…becomes three characters, and an em dash in comma mode becomes,.
Do the same thing in code
Python
This script runs the same steps as the tool, in the same order and with the same defaults, for cleaning files in bulk or inside a pipeline. It needs Python 3.9 or later and only the standard library; the optional emoji step also needs pip install regex, because Python's built-in re module has no emoji character classes.
ai_text_cleaner.py#!/usr/bin/env python3
"""Clean up text copied from ChatGPT, Claude, Gemini and other AI assistants.
Runs the same steps, in the same order and with the same defaults, as the
AI Text Cleaner at https://parttoolhub.com/tools/ai-text-cleaner
python ai_text_cleaner.py answer.md > clean.txt
python ai_text_cleaner.py --em-dash hyphen --keep-citations < answer.md
python ai_text_cleaner.py --help
Or import it: from ai_text_cleaner import clean_text
Python 3.9 or later, standard library only. The optional --remove-emoji step also needs
`pip install regex`, because the built-in re module has no emoji classes.
"""
import argparse
import re
import sys
# The web tool runs on JavaScript, whose idea of whitespace differs from
# Python's \s (JavaScript counts the BOM, Python counts \x1c-\x1f), so it is
# spelled out here and used wherever the tool's patterns say \s or \S.
JS_SPACE = (
"\t\n\v\f\r \u00a0\u1680"
+ "".join(chr(c) for c in range(0x2000, 0x200B))
+ "\u2028\u2029\u202f\u205f\u3000\ufeff"
)
SPACE = "[" + JS_SPACE + "]"
NOT_SPACE = "[^" + JS_SPACE + "]"
# Zero-width joins, marks and BOM: removed (they have no width).
ZERO_WIDTH = r"[\u200b-\u200f\u202a-\u202e\u2060-\u2064\ufeff\u00ad\u180e]"
# Non-breaking and typographic spaces: they do have width, so they become " ".
UNICODE_SPACE = r"[\u00a0\u2000-\u200a\u202f\u205f\u3000]"
# C0/C1 control characters other than tab and newline.
CONTROL = r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]"
EMOJI = (
r"(?:\p{Extended_Pictographic}|\p{Emoji_Presentation})[\ufe0e\ufe0f]?"
r"(?:\u200d(?:\p{Extended_Pictographic}|\p{Emoji_Presentation})[\ufe0e\ufe0f]?)*"
r"|[\U0001f1e6-\U0001f1ff]{2}" # flag pairs
r"|[#*0-9]\ufe0f?\u20e3" # keycaps
)
DASHES = {"comma": ", ", "hyphen": "-", "spaced-hyphen": " - ", "space": " ", "keep": None}
# Curly, low, angle and prime quotation marks, by code point, to straight ones.
QUOTES = str.maketrans(
dict.fromkeys(map(chr, [0x201C, 0x201D, 0x201E, 0x201F, 0x00AB, 0x00BB, 0x2033]), '"')
| dict.fromkeys(map(chr, [0x2018, 0x2019, 0x201A, 0x201B, 0x2032]), "'")
)
M = re.MULTILINE
def remove_invisible(t):
t = re.sub(ZERO_WIDTH, "", t)
t = re.sub(UNICODE_SPACE, " ", t)
return re.sub(CONTROL, "", t)
def strip_markdown(t, normalize_bullets=True):
t = re.sub(r"^[ \t]*```[^\n]*\n?", "", t, flags=M) # fence lines (code kept)
t = re.sub(r"^[ \t]{0,3}#{1,6}[ \t]+(.*?)[ \t]*#*[ \t]*$", r"\1", t, flags=M) # headings
# Table separator rows (|---|:--:|) go with their newline.
t = re.sub(r"^[ \t]*\|?[ \t]*:?-{2,}:?[ \t]*(?:\|[ \t]*:?-{2,}:?[ \t]*)*\|?[ \t]*\n?", "", t, flags=M)
t = re.sub(r"^[ \t]*(?:[-=*_][ \t]*){3,}$", "", t, flags=M) # rules, setext underlines
t = re.sub(r"!\[([^\[\]]*)\]\([^)]*\)", r"\1", t) # images: keep the alt text
t = re.sub(r"\[([^\[\]]+)\]\([^)]*\)", r"\1", t) # links: keep the label
# Bold, italic, strikethrough: the marker must touch the text on the inside.
t = re.sub(r"(\*\*\*|___)(?=" + NOT_SPACE + r")([\s\S]*?" + NOT_SPACE + r")\1", r"\2", t)
t = re.sub(r"(\*\*|__)(?=" + NOT_SPACE + r")([\s\S]*?" + NOT_SPACE + r")\1", r"\2", t)
t = re.sub(r"(?<![A-Za-z0-9_*])\*(?=" + NOT_SPACE + r")([^*\n]*?" + NOT_SPACE + r")\*(?![A-Za-z0-9_*])", r"\1", t)
t = re.sub(r"(?<![A-Za-z0-9_])_(?=" + NOT_SPACE + r")([^_\n]*?" + NOT_SPACE + r")_(?![A-Za-z0-9_])", r"\1", t)
t = re.sub(r"~~(?=" + NOT_SPACE + r")([\s\S]*?" + NOT_SPACE + r")~~", r"\1", t)
t = re.sub(r"`([^`\n]+)`", r"\1", t) # inline code
t = re.sub(r"^[ \t]*>[ \t]?", "", t, flags=M) # blockquotes
t = re.sub(r"^[ \t]*\|[ \t]*|[ \t]*\|[ \t]*$", "", t, flags=M) # outer table pipes
t = re.sub(r"[ \t]*\|[ \t]*", " ", t) # inner table pipes
if normalize_bullets:
t = re.sub(r"^([ \t]*)[*+][ \t]+", r"\1- ", t, flags=M)
return re.sub(r"^([ \t]*[-*+][ \t]+)\[[ xX]\][ \t]+", r"\1", t, flags=M) # task boxes
def remove_citations(t):
t = re.sub(r"\[\^?[0-9]+(?:" + SPACE + r"*[,\N{EN DASH}-]" + SPACE + r"*[0-9]+)*\]", "", t) # [1] [2, 3] [^4]
t = re.sub(r"\u3010[^\u3011\n]*\u3011", "", t) # ChatGPT's lenticular-bracket sources
return re.sub(r"\[(?:citation needed|source|ref)\]", "", t, flags=re.I | re.A)
def replace_dashes(t, mode):
rep = DASHES[mode]
t = re.sub(r"([0-9])[ \t]*[\N{EN DASH}\N{EM DASH}][ \t]*([0-9])", r"\1-\2", t) # number ranges: 2010-2020
t = re.sub(r"[ \t]*\N{EM DASH}[ \t]*", rep, t)
en = " - " if mode == "comma" else rep
t = re.sub(r"(?<=" + NOT_SPACE + r")[ \t]*\N{EN DASH}[ \t]*(?=" + NOT_SPACE + r")", en, t) # en dash between words
if mode == "comma":
t = re.sub(r", ([,.;:!?])", r"\1", t) # no ", ." when a dash ended the sentence
return t
def remove_emoji(t):
try:
import regex
except ImportError:
sys.exit("Removing emoji needs the regex module: pip install regex")
return regex.sub(EMOJI, "", t)
def tidy_whitespace(t):
t = re.sub(r"[ \t]+$", "", t, flags=M)
t = re.sub(r"[ \t]{2,}", " ", t)
t = re.sub(r" +([,.;:!?])", r"\1", t)
t = re.sub(r"\n{3,}", "\n\n", t)
return t.strip(JS_SPACE)
def clean_text(text, *, markdown=True, normalize_bullets=True, em_dash="comma",
straighten_quotes=True, invisible=True, emoji=False, citations=True,
ellipsis=True, whitespace=True):
"""Return the cleaned text. The defaults match the web tool's."""
if em_dash not in DASHES:
raise ValueError("em_dash must be one of: " + ", ".join(DASHES))
t = re.sub(r"\r\n?", "\n", text)
if invisible:
t = remove_invisible(t)
if markdown:
t = strip_markdown(t, normalize_bullets)
if citations:
t = remove_citations(t)
if straighten_quotes:
t = t.translate(QUOTES)
if em_dash != "keep":
t = replace_dashes(t, em_dash)
if ellipsis:
t = t.replace("\u2026", "...")
if emoji:
t = remove_emoji(t)
if whitespace:
t = tidy_whitespace(t)
return t
def main():
p = argparse.ArgumentParser(description="Clean up text copied from an AI assistant.")
p.add_argument("file", nargs="?", help="file to clean (default: standard input)")
p.add_argument("--em-dash", choices=list(DASHES), default="comma", help="what an em dash becomes")
p.add_argument("--keep-markdown", action="store_true", help="leave Markdown symbols in")
p.add_argument("--keep-bullets", action="store_true", help="leave * and + bullets as they are")
p.add_argument("--keep-quotes", action="store_true", help="leave curly quotes curly")
p.add_argument("--keep-ellipsis", action="store_true", help="leave the ellipsis character")
p.add_argument("--keep-invisible", action="store_true", help="leave zero-width and other invisible characters")
p.add_argument("--keep-citations", action="store_true", help="leave [1]-style citation markers")
p.add_argument("--keep-whitespace", action="store_true", help="leave extra spaces and blank lines")
p.add_argument("--remove-emoji", action="store_true", help="remove emoji (needs: pip install regex)")
a = p.parse_args()
if a.file:
with open(a.file, encoding="utf-8", newline="") as f:
text = f.read()
else:
sys.stdin.reconfigure(encoding="utf-8")
text = sys.stdin.read()
sys.stdout.reconfigure(encoding="utf-8")
print(clean_text(
text,
markdown=not a.keep_markdown,
normalize_bullets=not a.keep_bullets,
em_dash=a.em_dash,
straighten_quotes=not a.keep_quotes,
invisible=not a.keep_invisible,
emoji=a.remove_emoji,
citations=not a.keep_citations,
ellipsis=not a.keep_ellipsis,
whitespace=not a.keep_whitespace,
))
if __name__ == "__main__":
main()Run it on a file or pipe text through it. Each option above has a flag (--em-dash hyphen, --keep-citations, --remove-emoji and so on; --help lists them all):
python ai_text_cleaner.py answer.md > clean.txt
python ai_text_cleaner.py --em-dash hyphen --keep-citations < answer.mdTo use it from your own code, put the file next to yours, import it with from ai_text_cleaner import clean_text, then call clean_text(text, em_dash="hyphen").
The site's test suite runs this script and the tool on the same inputs (the tool's own test cases plus several hundred generated ones mixing Markdown, dashes, quotes, citation markers, invisible characters and emoji, under random option combinations) and fails if a single output differs.
JavaScript
Just the invisible-character step, the one people most often need on its own:
const clean = text
.replace(/[\u200B-\u200F\u202A-\u202E\u2060-\u2064\uFEFF\u00AD]/g, "")
.replace(/\u00A0/g, " ");