Origin
The term was coined by Canadian-British writer and digital rights activist Cory Doctorow, who first used it in a January 2023 post on his blog Pluralistic to describe the decline of online platforms. It gained wider traction after he used it in a piece for Wired magazine shortly after, and it spread rapidly through tech media and mainstream discourse. The American Dialect Society named it their Word of the Year for 2023.
Description
Enshittification describes the predictable, cyclical decay of online platforms and tech products. Doctorow outlined a three-stage pattern:
- Good to users A platform starts by being genuinely useful and user-friendly to attract an audience and build a network effect.
- Good to business customers Once users are locked in, the platform shifts to favouring advertisers, sellers, or business partners at users' expense, extracting value from the user base to reward those paying customers.
- Good to shareholders only Finally, the platform squeezes both users and business customers to maximise returns for investors and executives, until the product becomes so degraded that it collapses or loses relevance.
Classic examples cited include Google Search (increasingly bloated with ads and SEO spam), Facebook (throttling organic reach to push paid promotion), Amazon (burying genuine results under sponsored listings), and TikTok's feed becoming progressively more aggressive.
Why it resonated
The term filled a gap — it named something people were experiencing but lacked a precise word for. It also implies agency and inevitability: platforms don't decay by accident, but because the structural incentives of shareholder capitalism and platform lock-in make this trajectory almost inescapable. Doctorow argues that weak competition law and the erosion of interoperability are the root causes, and that regulatory intervention is the only reliable cure.
Realmac Elements
Which brings us neatly to a local example. RealMac Software, publisher of the long-running RapidWeaver web design tool and its successor Elements, has been aggressively nudging its user base toward the new product. After holding out for a while, I finally relented.
The friction revealed itself quickly. In RapidWeaver, suppressing the "Made with RapidWeaver" marketing badge injected into your published pages was a simple setting — available to any paying customer. In Elements, that same setting has been quietly moved behind the PRO subscription tier. You pay for the product, but RealMac still gets to advertise on your website unless you pay more.
Textbook enshittification: a feature that existed as a basic courtesy to customers has been repackaged as a premium unlock. The users who built loyalty over years are now the ones being squeezed.
Fortunately, the solution is straightforward. Whether you publish plain HTML or PHP, a short Python script can scan your output files, locate the injected JavaScript snippet, and strip it cleanly. No subscription required.
#!/usr/bin/env python3
"""
strip_elements_badge.py (v3)
-----------------------
Scans all index.htm / index.html / index.php files in the given directory
(recursively) for the RapidWeaver Elements "Made in Elements" badge block,
then optionally strips it from each affected file.
Usage:
python strip_elements_badge.py # scans current directory
python strip_elements_badge.py /path/to/site # scans given directory
python strip_elements_badge.py /path/to/site --strip # scan + strip
python strip_elements_badge.py /path/to/site --strip --no-backup
python strip_elements_badge.py /path/to/site --dry-run # preview without writing
"""
import argparse
import re
import shutil
import sys
from pathlib import Path
# ---------------------------------------------------------------------------
# Detection signatures
# ---------------------------------------------------------------------------
SIGNATURE = "elementsapp.io"
CONFIRM_SIGNATURES = ["enforceVisible", "Made in Elements"]
# ---------------------------------------------------------------------------
# Removal strategies (tried in order until one fully removes the badge)
# ---------------------------------------------------------------------------
def strategy_regex_doublequote(text: str) -> tuple[str, int]:
"""x-data attribute with double quotes."""
pat = re.compile(
r'[ \t]*<div\b[^>]*x-data="[^"]*enforceVisible[^"]*"[^>]*>.*?</div>[ \t]*\r?\n?',
re.DOTALL,
)
return pat.subn("", text)
def strategy_regex_singlequote(text: str) -> tuple[str, int]:
"""x-data attribute with single quotes."""
pat = re.compile(
r"[ \t]*<div\b[^>]*x-data='[^']*enforceVisible[^']*'[^>]*>.*?</div>[ \t]*\r?\n?",
re.DOTALL,
)
return pat.subn("", text)
def strategy_regex_multiline_attr(text: str) -> tuple[str, int]:
"""
x-data spans multiple lines (the attribute value contains newlines),
so [^>]* fails. Match by finding enforceVisible anywhere between
the opening <div and the matching </div>.
"""
pat = re.compile(
r'[ \t]*<div\b(?:(?!<div\b).)*?enforceVisible(?:(?!<div\b).)*?>.*?</div>[ \t]*\r?\n?',
re.DOTALL,
)
return pat.subn("", text)
def strategy_line_based(text: str) -> tuple[str, int]:
"""
Line-based depth-tracking fallback.
Finds the line containing 'enforceVisible', then tracks <div> nesting
depth to find the matching closing </div>.
"""
lines = text.splitlines(keepends=True)
result = []
depth = 0
in_block = False
removed = 0
for line in lines:
if not in_block:
if "enforceVisible" in line:
in_block = True
depth = line.count("<div") - line.count("</div")
if depth <= 0:
in_block = False
removed += 1
# Don't append — this line is part of the block
else:
result.append(line)
else:
depth += line.count("<div") - line.count("</div")
if depth <= 0:
in_block = False
removed += 1
# Don't append lines inside the block
return "".join(result), removed
def strategy_between_markers(text: str) -> tuple[str, int]:
"""
Last resort: find lines containing known badge strings and remove
the surrounding block by tracking <div> depth from the first hit.
"""
lines = text.splitlines(keepends=True)
result = []
i = 0
removed = 0
while i < len(lines):
line = lines[i]
if SIGNATURE in line or "enforceVisible" in line or "Made in Elements" in line:
depth = line.count("<div") - line.count("</div")
j = i + 1
while j < len(lines) and depth > 0:
depth += lines[j].count("<div") - lines[j].count("</div")
j += 1
i = j
removed += 1
else:
result.append(line)
i += 1
return "".join(result), removed
STRATEGIES = [
("regex double-quote attr", strategy_regex_doublequote),
("regex single-quote attr", strategy_regex_singlequote),
("regex multiline attr", strategy_regex_multiline_attr),
("line-based depth-tracking", strategy_line_based),
("between-markers", strategy_between_markers),
]
# ---------------------------------------------------------------------------
# Core helpers
# ---------------------------------------------------------------------------
def find_html_files(root: Path) -> list[Path]:
patterns = ["index.htm", "index.html", "index.php"]
seen: set[Path] = set()
unique: list[Path] = []
for pattern in patterns:
for f in sorted(root.rglob(pattern)):
if f not in seen:
seen.add(f)
unique.append(f)
return sorted(unique)
def read_file(path: Path) -> str | None:
try:
return path.read_text(encoding="utf-8", errors="replace")
except OSError as e:
print(f" [WARN] Cannot read {path}: {e}")
return None
def file_contains_badge(text: str) -> bool:
if SIGNATURE not in text:
return False
return any(sig in text for sig in CONFIRM_SIGNATURES)
def strip_badge(path: Path, backup: bool = True, dry_run: bool = False) -> tuple[bool, str]:
original = read_file(path)
if original is None:
return False, "Cannot read file"
if not file_contains_badge(original):
return False, "Signature no longer found — skipped"
cleaned = original
used_strategy = None
for name, strategy in STRATEGIES:
candidate, count = strategy(cleaned)
if count > 0 and SIGNATURE not in candidate:
cleaned = candidate
used_strategy = name
break
if used_strategy is None or SIGNATURE in cleaned:
return False, (
"All strategies failed — please share this file for analysis.\n"
f" Hint: check if x-data attribute uses a different quote style or\n"
f" if the badge HTML has changed significantly."
)
if dry_run:
before_lines = original.count("\n")
after_lines = cleaned.count("\n")
return True, f"[DRY RUN] Would remove ~{before_lines - after_lines} line(s) via '{used_strategy}'"
if backup:
backup_path = path.with_suffix(path.suffix + ".bak")
try:
shutil.copy2(path, backup_path)
except OSError as e:
return False, f"Could not create backup: {e}"
try:
path.write_text(cleaned, encoding="utf-8")
except OSError as e:
return False, f"Could not write file: {e}"
return True, f"Removed via '{used_strategy}'"
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="Find and optionally strip the RapidWeaver Elements badge from HTML files."
)
parser.add_argument(
"directory",
nargs="?",
default=".",
help="Root directory to scan (default: current directory)",
)
parser.add_argument(
"--strip",
action="store_true",
help="Strip the badge from all affected files",
)
parser.add_argument(
"--no-backup",
action="store_true",
help="Skip creating .bak backup files before stripping",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview what would be removed without writing any files",
)
args = parser.parse_args()
root = Path(args.directory).resolve()
if not root.is_dir():
print(f"Error: '{root}' is not a directory.")
sys.exit(1)
make_backup = not args.no_backup
do_strip = args.strip or args.dry_run
print(f"\n{'='*62}")
print(f" RapidWeaver Elements Badge Stripper (v3)")
print(f"{'='*62}")
print(f" Root : {root}")
if args.dry_run:
print(f" Mode : DRY RUN — no files will be changed")
elif args.strip:
print(f" Mode : STRIP")
print(f" Backup : {'YES (.bak files created)' if make_backup else 'NO'}")
else:
print(f" Mode : SCAN ONLY")
print(f"{'='*62}\n")
all_files = find_html_files(root)
print(f"Scanning {len(all_files)} index file(s) (htm/html/php)...\n")
affected: list[Path] = []
for path in all_files:
text = read_file(path)
if text and file_contains_badge(text):
print(f" [FOUND] {path.relative_to(root)}")
affected.append(path)
print(f"\n{'─'*62}")
print(f" Badge found in {len(affected)} of {len(all_files)} file(s).")
print(f"{'─'*62}")
if not affected:
print("\nNothing to do. Exiting.\n")
sys.exit(0)
if not do_strip:
print(f"\nRun with --strip to remove, or --dry-run to preview.\n")
print(f" python {Path(__file__).name} \"{args.directory}\" --strip\n")
sys.exit(0)
verb = "Previewing" if args.dry_run else "Processing"
print(f"\n{verb} {len(affected)} file(s)...\n")
ok_count = 0
fail_count = 0
for path in affected:
rel = path.relative_to(root)
success, msg = strip_badge(path, backup=make_backup, dry_run=args.dry_run)
tag = "OK " if success else "FAIL"
print(f" [{tag}] {rel} — {msg}")
if success:
ok_count += 1
else:
fail_count += 1
print(f"\n{'='*62}")
if args.dry_run:
print(f" Dry run done. {ok_count} file(s) would be modified.")
else:
print(f" Done. Stripped: {ok_count} | Failed: {fail_count}")
if make_backup and ok_count > 0:
print(f" Originals saved as <filename>.bak alongside each file.")
print(f"{'='*62}\n")
sys.exit(1 if fail_count > 0 else 0)
if __name__ == "__main__":
main()