ChatGPT watermark remover for whole folders

Dry-run first Git hook included No upload

Short answer: paste the script below into clean_folder.py, run it on your folder without any flags, and it tells you every file that contains invisible watermark characters and how many. Add --write and it rewrites them in place, keeping your original line endings. Then install the git hook so the same characters cannot get back into the repository.

python clean_folder.py ./drafts              # report only
python clean_folder.py ./drafts --write      # rewrite in place
python clean_folder.py . --check             # exit 1 if any found (for CI)

The characters being removed are the Unicode format and control characters that travel with copied AI output — U+200B through U+200F, U+2060 through U+206F, U+FEFF, U+00AD, U+034F, U+180E, U+061C, U+2028, U+2029 and U+FFF9 through U+FFFB. They are invisible in every editor, which is exactly why a folder-wide check beats reviewing files one at a time.

The script

#!/usr/bin/env python3
"""Strip invisible watermark characters from a folder. Dry run unless --write."""
import argparse, pathlib, re, sys

PATTERN = re.compile(
    "[\u200B-\u200F\u202A-\u202E\u2060-\u206F"
    "\uFEFF\u00AD\u034F\u180E\u061C\u2028\u2029\uFFF9-\uFFFB]"
)
DEFAULT_EXT = [".txt", ".md", ".csv", ".tsv", ".json", ".html", ".htm",
               ".xml", ".svg", ".js", ".ts", ".css", ".yml", ".yaml", ".srt"]

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("root")
    ap.add_argument("--write", action="store_true", help="rewrite files in place")
    ap.add_argument("--check", action="store_true", help="exit 1 if anything is found")
    ap.add_argument("--ext", nargs="*", default=DEFAULT_EXT)
    args = ap.parse_args()

    files = chars = 0
    for path in sorted(pathlib.Path(args.root).rglob("*")):
        if not path.is_file() or path.suffix.lower() not in args.ext:
            continue
        try:
            text = path.read_bytes().decode("utf-8")
        except UnicodeDecodeError:
            continue                      # binary or non-UTF-8: skip
        hits = PATTERN.findall(text)
        if not hits:
            continue
        files += 1
        chars += len(hits)
        print(f"{path}: {len(hits)}")
        if args.write:
            with open(path, "w", encoding="utf-8", newline="") as fh:
                fh.write(PATTERN.sub("", text))

    verb = "rewrote" if args.write else "found in"
    print(f"\n{chars} characters {verb} {files} file(s)")
    if args.check and chars:
        sys.exit(1)

if __name__ == "__main__":
    main()

Three details in there matter more than they look:

Run it on copies first. The script decodes as UTF-8 and skips anything it cannot decode, but a file that is mostly text with a few binary bytes in it will still be rewritten.

Stop them coming back: the git hook

Cleaning a folder once is worth nothing if the next paste puts the characters straight back. Put the check in front of the commit instead. Save this as .githooks/pre-commit in your repository:

#!/bin/sh
# Reject commits that contain invisible watermark characters.
files=$(git diff --cached --name-only --diff-filter=ACM)
[ -z "$files" ] && exit 0

python - "$files" <<'PY'
import re, subprocess, sys

PATTERN = re.compile("[\u200B-\u200F\u202A-\u202E\u2060-\u206F"
                     "\uFEFF\u00AD\u034F\u180E\u061C\u2028\u2029\uFFF9-\uFFFB]")
bad = 0
for name in sys.argv[1:]:
    blob = subprocess.run(["git", "show", f":{name}"], capture_output=True).stdout
    try:
        text = blob.decode("utf-8")
    except UnicodeDecodeError:
        continue
    for i, ch in enumerate(text):
        if PATTERN.match(ch):
            print(f"{name}: U+{ord(ch):04X} at character {i}")
            bad += 1
if bad:
    print(f"\n{bad} invisible character(s) staged. "
          "Run: python clean_folder.py . --write  then re-add the files.")
    sys.exit(1)
PY

Then point git at the folder and make it executable:

git config core.hooksPath .githooks
chmod +x .githooks/pre-commit

Using core.hooksPath rather than dropping the file in .git/hooks means the hook is versioned with the project, so everyone on the team gets it on their next pull instead of you explaining it in chat. On Windows the chmod is optional — Git for Windows runs the hook either way.

The hook checks the staged version of each file (git show :file), not what is on disk, which is what you want: it judges exactly what the commit would contain. It prints the character index, so when it blocks you, you know whether you are looking at one stray U+200B or a file that came in wholesale from a chat window.

The same check in CI

Save as .github/workflows/invisible-characters.yml. The hook only runs on machines where someone configured it; CI is the backstop that runs whether or not anyone did.

name: invisible-characters
on: [push, pull_request]

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: python clean_folder.py . --check

Because --check exits 1 when it finds anything, the job fails and the pull request shows a red mark with the offending file list in the log. No extra tooling, no third-party action, one file.

What a folder-wide strip will break, and how to avoid it

Blanket removal is right for prose and wrong for some other files. The four cases that actually bite:

CaseWhat happensWhat to do
Arabic, Persian, Hindi, and emoji sequencesU+200C and U+200D are load-bearing there — removing them changes how the text renders and, in some words, changes the wordIf you write in those scripts, drop \u200C\u200D from PATTERN or exclude those directories with --ext
JavaScript and TypeScriptU+2028 and U+2029 are legal inside string literals since ES2019, but they still break line-based tooling and older parsersRun the script, then read the diff before committing — do not let the hook auto-fix code
CSV and JSON dataRemoving U+2028 / U+2029 is usually the fix, not the bug — they are a common cause of "unexpected end of file"Clean, then re-run your parser to confirm the row count is unchanged
.docx, .xlsx, .pptxThese are ZIP archives. The text lives in XML parts inside, so a text-file pass will skip them silentlyUnzip, clean word/document.xml (or the equivalent part), rezip — or handle those files one at a time in the browser tool

One honest limitation: the script handles text files. Image metadata (EXIF, XMP, C2PA) is a different job — it lives in binary segments, not in characters, so a regex cannot touch it. The Claude Watermark Remover on this site strips those segments from JPEG and PNG one file at a time, entirely in your browser, with no upload.

FAQ

Does this remove the ChatGPT watermark?
It removes the invisible characters that travel with copied output. If you mean a statistical watermark — a pattern in how the model chooses words — that lives in the model's output distribution, not in your file, and no script can remove it from text that is already written. Those two things get conflated constantly.

Will it change how my document looks?
No. Every character removed has zero width or is a control character. Compare the rendered page before and after if you want proof, but the visible text is identical.

Can I run it on a folder of client work?
That is what the dry run is for: run without --write first, read the list, then decide. Keep a backup until you have confirmed the output — the script rewrites in place and there is no undo.

Why not just paste everything into an online remover?
Because that means uploading the folder, one file at a time, to a server you do not control. The character list is public and printed above; there is nothing a remote service can do that this script cannot do locally.

How do I check a single file quickly?
python clean_folder.py ./one-file.md — the argument is a path, and a file path works as well as a directory.