Last active
March 11, 2026 19:09
-
-
Save ajithrn/cdcc55078ec1f8194c567cdc9760482b to your computer and use it in GitHub Desktop.
WordPress post cleanup by year & month(s) using WP-CLI
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # ============================================================================= | |
| # WordPress Posts & Media Cleanup Script | |
| # ============================================================================= | |
| # | |
| # Deletes WordPress posts and their associated media attachments (including | |
| # featured images) for a given year and optional month filter. Attachments | |
| # are deleted first, then posts. A database backup is taken before any | |
| # destructive action unless explicitly skipped. | |
| # | |
| # ============================================================================= | |
| # OPTIONS | |
| # ============================================================================= | |
| # | |
| # --year=YYYY Year to target (required) | |
| # --months="M M M" Specific months to target, space-separated (1-12) | |
| # If omitted, ALL months in the year are targeted | |
| # --dry-run Preview what will be deleted (nothing is modified) | |
| # --path=/path/to/wp Path to WordPress installation | |
| # If omitted, WP-CLI uses the current directory | |
| # --allow-root Pass --allow-root flag to WP-CLI | |
| # Required when running the script as the root user | |
| # --batch=N Posts to process per batch (default: 50) | |
| # --skip-backup Skip the automatic database backup before deletion | |
| # --log=FILE Log file path (default: cleanup-YYYYMMDD-HHMMSS.log) | |
| # --help, -h Show this help text and exit | |
| # | |
| # ============================================================================= | |
| # EXAMPLES | |
| # ============================================================================= | |
| # | |
| # ── Basic Usage ────────────────────────────────────────────────────────────── | |
| # | |
| # Preview all posts from 2024 (dry run, nothing deleted): | |
| # ./wp_post_cleanup.sh --year=2024 --dry-run | |
| # | |
| # Delete all posts from January 2023: | |
| # ./wp_post_cleanup.sh --year=2023 --months="1" | |
| # | |
| # Delete all posts from Q1 2021 (Jan, Feb, Mar): | |
| # ./wp_post_cleanup.sh --year=2021 --months="1 2 3" | |
| # | |
| # Delete ALL posts from the entire year 2022: | |
| # ./wp_post_cleanup.sh --year=2022 | |
| # | |
| # ── Custom Output & Paths ──────────────────────────────────────────────────── | |
| # | |
| # Save log to a custom file: | |
| # ./wp_post_cleanup.sh --year=2020 --months="12" --log=dec2020-cleanup.log | |
| # | |
| # WordPress installed in a non-default directory: | |
| # ./wp_post_cleanup.sh --year=2023 --months="9" --path=/var/www/html | |
| # | |
| # ── Server / Root Usage ────────────────────────────────────────────────────── | |
| # | |
| # Running as root on a server (requires --allow-root): | |
| # ./wp_post_cleanup.sh --year=2023 --path=/var/www/html --allow-root | |
| # | |
| # Skip backup for faster execution (use with caution): | |
| # ./wp_post_cleanup.sh --year=2022 --months="6" --skip-backup --allow-root | |
| # | |
| # ── Automation (Cron) ──────────────────────────────────────────────────────── | |
| # | |
| # Auto-clean posts older than 6 months, run at 3 AM on the 1st of each month: | |
| # 0 3 1 * * /path/to/wp_post_cleanup.sh \ | |
| # --year=$(date -d '6 months ago' +\%Y) \ | |
| # --months=$(date -d '6 months ago' +\%m) \ | |
| # --path=/var/www/html --allow-root --skip-backup | |
| # | |
| # NOTE: The cron example uses GNU date syntax (Linux). | |
| # On macOS, replace with: $(date -v-6m +\%Y) and $(date -v-6m +\%m) | |
| # | |
| # ============================================================================= | |
| # IMPORTANT NOTES | |
| # ============================================================================= | |
| # | |
| # ⚠ ALWAYS run with --dry-run first to preview what will be affected. | |
| # | |
| # ⚠ If --months is omitted, ALL posts from the given --year are deleted. | |
| # This is intentional but can be destructive — double-check your command. | |
| # | |
| # • Months are integers 1-12 (zero-padding like 01 is accepted but optional). | |
| # • All options use "=" syntax (e.g., --year=2024, NOT --year 2024). | |
| # • The script auto-detects your database table prefix (no hardcoded wp_). | |
| # • A database backup is created before deletion unless --skip-backup is set. | |
| # • Deletion is permanent (uses wp post delete --force, bypasses trash). | |
| # • Posts are deleted with their attachments and featured images. | |
| # | |
| # ============================================================================= | |
| set -euo pipefail | |
| # ─── Configuration ─────────────────────────────────────────────────────────── | |
| DRY_RUN=false | |
| WP_PATH="" | |
| BATCH_SIZE=50 | |
| TARGET_YEAR="" | |
| TARGET_MONTHS="" | |
| ALLOW_ROOT=false | |
| SKIP_BACKUP=false | |
| LOG_FILE="cleanup-$(date +%Y%m%d-%H%M%S).log" | |
| # ─── Colors ────────────────────────────────────────────────────────────────── | |
| RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' | |
| BLUE='\033[0;34m' CYAN='\033[0;36m' NC='\033[0m' BOLD='\033[1m' | |
| # ─── Parse Arguments ───────────────────────────────────────────────────────── | |
| for arg in "$@"; do | |
| case $arg in | |
| --dry-run) DRY_RUN=true ;; | |
| --path=*) WP_PATH="${arg#*=}" ;; | |
| --batch=*) BATCH_SIZE="${arg#*=}" ;; | |
| --year=*) TARGET_YEAR="${arg#*=}" ;; | |
| --months=*) TARGET_MONTHS="${arg#*=}" ;; | |
| --allow-root) ALLOW_ROOT=true ;; | |
| --skip-backup) SKIP_BACKUP=true ;; | |
| --log=*) LOG_FILE="${arg#*=}" ;; | |
| --help|-h) sed -n '2,89p' "$0"; exit 0 ;; | |
| *) echo -e "${RED}Unknown option: $arg${NC}"; echo "Run with --help to see available options."; exit 1 ;; | |
| esac | |
| done | |
| # ─── Validation ────────────────────────────────────────────────────────────── | |
| if [ -z "$TARGET_YEAR" ]; then | |
| echo -e "${RED}✖ --year is required. Example: --year=2024${NC}"; exit 1 | |
| fi | |
| if ! [[ "$TARGET_YEAR" =~ ^[0-9]{4}$ ]]; then | |
| echo -e "${RED}✖ Invalid year: ${TARGET_YEAR} (must be a 4-digit year)${NC}"; exit 1 | |
| fi | |
| if ! [[ "$BATCH_SIZE" =~ ^[0-9]+$ ]] || [ "$BATCH_SIZE" -lt 1 ]; then | |
| echo -e "${RED}✖ Invalid batch size: ${BATCH_SIZE} (must be a positive integer)${NC}"; exit 1 | |
| fi | |
| # ─── Build WP-CLI Command ─────────────────────────────────────────────────── | |
| WP_CMD="wp" | |
| $ALLOW_ROOT && WP_CMD="$WP_CMD --allow-root" | |
| [ -n "$WP_PATH" ] && WP_CMD="$WP_CMD --path=$WP_PATH" | |
| # ─── Build Month Filter ───────────────────────────────────────────────────── | |
| MONTH_SQL_CLAUSE="" | |
| MONTH_DISPLAY="All" | |
| if [ -n "$TARGET_MONTHS" ]; then | |
| for m in $TARGET_MONTHS; do | |
| if ! [[ "$m" =~ ^[0-9]+$ ]] || [ "$m" -lt 1 ] || [ "$m" -gt 12 ]; then | |
| echo -e "${RED}✖ Invalid month: $m (must be 1-12)${NC}"; exit 1 | |
| fi | |
| done | |
| MONTH_CSV=$(echo "$TARGET_MONTHS" | tr ' ' ',') | |
| MONTH_SQL_CLAUSE="AND MONTH(post_date) IN ($MONTH_CSV)" | |
| MONTH_DISPLAY="$TARGET_MONTHS" | |
| fi | |
| # ─── Helper Functions ──────────────────────────────────────────────────────── | |
| strip_ansi() { sed $'s/\x1b\[[0-9;]*m//g'; } | |
| log() { | |
| echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | strip_ansi >> "$LOG_FILE" | |
| echo -e "$1" | |
| } | |
| separator() { echo -e "${BLUE}─────────────────────────────────────────────────────────────${NC}"; } | |
| progress_bar() { | |
| local current=$1 total=$2 label=$3 batch_info=${4:-""} | |
| local percent=0 filled empty bar="" space="" | |
| [ "$total" -gt 0 ] && percent=$(( current * 100 / total )) | |
| filled=$(( percent / 2 )); empty=$(( 50 - filled )) | |
| [ "$filled" -gt 0 ] && bar=$(printf '%0.s█' $(seq 1 "$filled")) | |
| [ "$empty" -gt 0 ] && space=$(printf '%0.s░' $(seq 1 "$empty")) | |
| if [ -n "$batch_info" ]; then | |
| printf "\r ${CYAN}%s${NC} [${GREEN}%s${NC}%s] ${BOLD}%d/%d${NC} (%d%%) ${CYAN}%s${NC} " \ | |
| "$label" "$bar" "$space" "$current" "$total" "$percent" "$batch_info" | |
| else | |
| printf "\r ${CYAN}%s${NC} [${GREEN}%s${NC}%s] ${BOLD}%d/%d${NC} (%d%%) " \ | |
| "$label" "$bar" "$space" "$current" "$total" "$percent" | |
| fi | |
| } | |
| # Parse WP-CLI SQL output into a bash array (filters to numeric IDs only) | |
| # Usage: parse_ids ARRAY_NAME "$RAW_OUTPUT" | |
| parse_ids() { | |
| local -n arr=$1 | |
| local raw="$2" | |
| arr=() | |
| while IFS= read -r line; do | |
| line=$(echo "$line" | tr -d '[:space:]') | |
| [ -n "$line" ] && [[ "$line" =~ ^[0-9]+$ ]] && arr+=("$line") | |
| done <<< "$raw" | |
| } | |
| # Batched delete with progress bar | |
| # Usage: batch_delete ARRAY_NAME "label" "type" | |
| # Sets: _BD_DELETED, _BD_FAILED | |
| batch_delete() { | |
| local -n ids=$1 | |
| local label="$2" type="$3" | |
| local total=${#ids[@]} | |
| local total_batches=$(( (total + BATCH_SIZE - 1) / BATCH_SIZE )) batch_num=0 | |
| _BD_DELETED=0; _BD_FAILED=0 | |
| if [ "$total" -eq 0 ]; then | |
| log "${YELLOW}⚠ No ${type} to delete. Skipping.${NC}"; return | |
| fi | |
| for (( i=0; i<total; i+=BATCH_SIZE )); do | |
| batch_num=$((batch_num + 1)) | |
| local batch=("${ids[@]:$i:$BATCH_SIZE}") | |
| local batch_count=${#batch[@]} | |
| local processed=$(( i + batch_count )) | |
| echo "[Batch ${batch_num}/${total_batches}] (${batch_count} ${type}s)" >> "$LOG_FILE" | |
| progress_bar "$processed" "$total" "$label" "Batch ${batch_num}/${total_batches}" | |
| if ! $DRY_RUN; then | |
| local output | |
| output=$($WP_CMD post delete "${batch[@]}" --force 2>&1) || true | |
| # Count successes from WP-CLI output (each success prints "Success: ...") | |
| local batch_ok | |
| batch_ok=$(echo "$output" | grep -ci "success" 2>/dev/null || echo 0) | |
| local batch_fail=$(( batch_count - batch_ok )) | |
| _BD_DELETED=$((_BD_DELETED + batch_ok)) | |
| _BD_FAILED=$((_BD_FAILED + batch_fail)) | |
| if [ "$batch_fail" -eq 0 ]; then | |
| echo "[OK] Batch ${batch_num}: deleted ${batch_ok} ${type}(s)" >> "$LOG_FILE" | |
| else | |
| echo "[PARTIAL] Batch ${batch_num}: deleted ${batch_ok}, failed ${batch_fail} ${type}(s)" >> "$LOG_FILE" | |
| echo "$output" | grep -iv "success" >> "$LOG_FILE" 2>/dev/null || true | |
| fi | |
| else | |
| _BD_DELETED=$((_BD_DELETED + batch_count)) | |
| fi | |
| done | |
| echo "" # newline after progress bar | |
| } | |
| # Run a WP-CLI cache command, log success/failure | |
| flush_cmd() { | |
| if $WP_CMD "$@" 2>/dev/null; then | |
| log "${GREEN}✔ $1 done.${NC}" | |
| else | |
| log "${YELLOW}⚠ $1 skipped.${NC}" | |
| fi | |
| } | |
| check_wp_cli() { | |
| if ! command -v wp &> /dev/null; then | |
| log "${RED}✖ WP-CLI is not installed or not in PATH.${NC}"; exit 1 | |
| fi | |
| log "${BLUE}► Checking WP-CLI installation...${NC}" | |
| local wp_error | |
| wp_error=$($WP_CMD core is-installed 2>&1) || { | |
| log "${RED}✖ WordPress check failed:${NC}" | |
| [ -n "$wp_error" ] && log " ${RED}$wp_error${NC}" | |
| log "${YELLOW} Hints: Check --path, ensure WP is installed, or add --allow-root if running as root.${NC}" | |
| exit 1 | |
| } | |
| } | |
| # ─── Pre-flight Checks ────────────────────────────────────────────────────── | |
| echo "" | |
| echo -e "${BOLD}${CYAN}╔═══════════════════════════════════════════════════════════╗${NC}" | |
| echo -e "${BOLD}${CYAN}║ WordPress Posts & Media Cleanup Script ║${NC}" | |
| echo -e "${BOLD}${CYAN}╚═══════════════════════════════════════════════════════════╝${NC}" | |
| echo "" | |
| $DRY_RUN && echo -e " ${YELLOW}🔍 DRY RUN MODE — Nothing will be deleted${NC}\n" | |
| if [ "$(whoami)" = "root" ] && ! $ALLOW_ROOT; then | |
| echo -e " ${RED}✖ Running as root without --allow-root!${NC}" | |
| echo -e " ${YELLOW} Add --allow-root or run as: sudo -u www-data bash $0 $@${NC}\n" | |
| exit 1 | |
| fi | |
| check_wp_cli | |
| log "${GREEN}✔ WP-CLI is working.${NC}" | |
| log "${BLUE}► Detecting table prefix...${NC}" | |
| TABLE_PREFIX=$($WP_CMD db prefix 2>/dev/null || echo "wp_") | |
| TABLE_PREFIX=$(echo "$TABLE_PREFIX" | tr -d '[:space:]') | |
| log "${GREEN}✔ Table prefix: ${TABLE_PREFIX}${NC}" | |
| # ─── Step 1: Gather Posts ──────────────────────────────────────────────────── | |
| separator | |
| log "${BLUE}► Step 1: Gathering ${TARGET_YEAR} posts (months: ${MONTH_DISPLAY})... please wait${NC}" | |
| POST_IDS_RAW=$($WP_CMD db query "SELECT ID FROM ${TABLE_PREFIX}posts \ | |
| WHERE post_type='post' AND YEAR(post_date)=${TARGET_YEAR} ${MONTH_SQL_CLAUSE} \ | |
| ORDER BY ID;" --skip-column-names 2>/dev/null || echo "") | |
| if [ -z "$POST_IDS_RAW" ]; then | |
| log "${YELLOW}⚠ No posts found for ${TARGET_YEAR} (months: ${MONTH_DISPLAY}). Nothing to do.${NC}"; exit 0 | |
| fi | |
| declare -a POST_IDS=() | |
| parse_ids POST_IDS "$POST_IDS_RAW" | |
| TOTAL_POSTS=${#POST_IDS[@]} | |
| if [ "$TOTAL_POSTS" -eq 0 ]; then | |
| log "${YELLOW}⚠ No posts found for ${TARGET_YEAR} (months: ${MONTH_DISPLAY}). Nothing to do.${NC}"; exit 0 | |
| fi | |
| log "${GREEN}✔ Found ${BOLD}${TOTAL_POSTS}${NC}${GREEN} posts from ${TARGET_YEAR} (months: ${MONTH_DISPLAY}).${NC}" | |
| # ─── Step 2: Gather Attachments ────────────────────────────────────────────── | |
| log "${BLUE}► Step 2: Scanning for media attachments... please wait${NC}" | |
| POST_IDS_CSV=$(IFS=,; echo "${POST_IDS[*]}") | |
| ATTACH_RAW=$($WP_CMD db query " | |
| SELECT DISTINCT id FROM ( | |
| SELECT ID AS id FROM ${TABLE_PREFIX}posts | |
| WHERE post_type='attachment' AND post_parent IN ($POST_IDS_CSV) | |
| UNION | |
| SELECT CAST(meta_value AS UNSIGNED) AS id FROM ${TABLE_PREFIX}postmeta | |
| WHERE post_id IN ($POST_IDS_CSV) AND meta_key='_thumbnail_id' AND meta_value REGEXP '^[0-9]+$' | |
| ) AS combined ORDER BY id;" --skip-column-names 2>/dev/null || echo "") | |
| declare -a UNIQUE_ATTACHMENT_IDS=() | |
| [ -n "$ATTACH_RAW" ] && parse_ids UNIQUE_ATTACHMENT_IDS "$ATTACH_RAW" | |
| TOTAL_ATTACHMENTS=${#UNIQUE_ATTACHMENT_IDS[@]} | |
| log "${GREEN}✔ Found ${BOLD}${TOTAL_ATTACHMENTS}${NC}${GREEN} media attachments to remove.${NC}" | |
| # ─── Summary & Confirmation ───────────────────────────────────────────────── | |
| separator | |
| echo "" | |
| echo -e " ${BOLD}Summary:${NC}" | |
| echo -e " ├── Posts to delete: ${BOLD}${TOTAL_POSTS}${NC}" | |
| echo -e " ├── Attachments to delete: ${BOLD}${TOTAL_ATTACHMENTS}${NC}" | |
| echo -e " ├── Batch size: ${BOLD}${BATCH_SIZE}${NC}" | |
| echo -e " ├── Year / Months: ${BOLD}${TARGET_YEAR}${NC} / ${BOLD}${MONTH_DISPLAY}${NC}" | |
| echo -e " ├── Log file: ${BOLD}${LOG_FILE}${NC}" | |
| echo -e " └── Mode: ${BOLD}$(if $DRY_RUN; then echo 'DRY RUN'; else echo 'LIVE DELETE'; fi)${NC}" | |
| echo "" | |
| if ! $DRY_RUN; then | |
| echo -e "${YELLOW}⚠ Are you sure you want to proceed? (y/N):${NC} " | |
| read -r response | |
| [[ ! "$response" =~ ^[Yy]$ ]] && { log "${RED}✖ Aborted by user.${NC}"; exit 0; } | |
| fi | |
| # ─── Step 3: Database Backup ──────────────────────────────────────────────── | |
| if ! $SKIP_BACKUP && ! $DRY_RUN; then | |
| separator | |
| BACKUP_FILE="db-backup-before-${TARGET_YEAR}-cleanup-$(date +%Y%m%d-%H%M%S).sql" | |
| log "${BLUE}► Step 3: Backing up database... please wait${NC}" | |
| if $WP_CMD db export "$BACKUP_FILE" 2>/dev/null; then | |
| log "${GREEN}✔ Backup complete ($(du -h "$BACKUP_FILE" | cut -f1)).${NC}" | |
| else | |
| log "${RED}✖ Database backup failed! Aborting for safety.${NC}"; exit 1 | |
| fi | |
| else | |
| log "${YELLOW}► Step 3: Skipping backup.${NC}" | |
| fi | |
| # ─── Step 4: Delete Attachments ───────────────────────────────────────────── | |
| separator | |
| log "${BLUE}► Step 4: Deleting media attachments...${NC}" | |
| batch_delete UNIQUE_ATTACHMENT_IDS "Media " "attachment" | |
| DELETED_ATTACHMENTS=$_BD_DELETED | |
| FAILED_ATTACHMENTS=$_BD_FAILED | |
| log "${GREEN}✔ Media cleanup done. Deleted: ${DELETED_ATTACHMENTS}, Failed: ${FAILED_ATTACHMENTS}${NC}" | |
| # ─── Step 5: Delete Posts ─────────────────────────────────────────────────── | |
| separator | |
| log "${BLUE}► Step 5: Deleting posts...${NC}" | |
| batch_delete POST_IDS "Posts " "post" | |
| DELETED_POSTS=$_BD_DELETED | |
| FAILED_POSTS=$_BD_FAILED | |
| log "${GREEN}✔ Post cleanup done. Deleted: ${DELETED_POSTS}, Failed: ${FAILED_POSTS}${NC}" | |
| # ─── Step 6: Flush Caches ────────────────────────────────────────────────── | |
| separator | |
| if ! $DRY_RUN; then | |
| log "${BLUE}► Step 6: Flushing caches...${NC}" | |
| flush_cmd cache flush | |
| flush_cmd transient delete --all | |
| flush_cmd rewrite flush | |
| else | |
| log "${YELLOW}► Step 6: Skipping cache flush (dry run).${NC}" | |
| fi | |
| # ─── Final Report ─────────────────────────────────────────────────────────── | |
| separator | |
| echo "" | |
| echo -e "${BOLD}${GREEN}╔═══════════════════════════════════════════════════════════╗${NC}" | |
| echo -e "${BOLD}${GREEN}║ CLEANUP COMPLETE ║${NC}" | |
| echo -e "${BOLD}${GREEN}╚═══════════════════════════════════════════════════════════╝${NC}" | |
| echo "" | |
| echo -e " ${BOLD}Results:${NC}" | |
| echo -e " ├── Posts deleted: ${BOLD}${DELETED_POSTS}${NC} / ${TOTAL_POSTS}" | |
| echo -e " ├── Posts failed: ${BOLD}${FAILED_POSTS}${NC}" | |
| echo -e " ├── Media deleted: ${BOLD}${DELETED_ATTACHMENTS}${NC} / ${TOTAL_ATTACHMENTS}" | |
| echo -e " ├── Media failed: ${BOLD}${FAILED_ATTACHMENTS}${NC}" | |
| echo -e " ├── Mode: ${BOLD}$(if $DRY_RUN; then echo 'DRY RUN (nothing deleted)'; else echo 'LIVE'; fi)${NC}" | |
| echo -e " └── Log: ${BOLD}${LOG_FILE}${NC}" | |
| echo "" | |
| $DRY_RUN && echo -e " ${YELLOW}💡 This was a dry run. Run without --dry-run to actually delete.${NC}\n" | |
| log "${GREEN}Script finished at $(date).${NC}" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment