contentai / main.py
webmazta
Standardize WordPress writes on pull relay
f21ca98
Raw History Blame Contribute Delete
85.7 kB
#!/usr/bin/env python3
"""
Social Media Video β†’ WordPress Auto-Publisher
==============================================
Downloads videos from social media, analyzes frames with Gemma 4 AI vision,
generates blog posts, creates featured images, and publishes to WordPress.
Usage:
python main.py <video_url_1> [<video_url_2> ...]
python main.py --file urls.txt
"""
import argparse
import base64
import json
from pydantic import BaseModel, ValidationError
import os
import re
import subprocess
import sys
import tempfile
import textwrap
import time
import hashlib
from pathlib import Path
from io import BytesIO
import requests
from dotenv import load_dotenv
from PIL import Image, ImageDraw, ImageFont
from wordpress_transport import (
WordPressUnavailableError,
post_multipart_to_wordpress_origin,
pull_relay_enabled,
request_wordpress_json,
)
# ──────────────────────────────────────────────
# Configuration
# ──────────────────────────────────────────────
load_dotenv()
class LLMContent(BaseModel):
title: str
paragraph1: str
paragraph2: str
WP_URL = os.getenv("WP_URL", "").rstrip("/")
WP_USERNAME = os.getenv("WP_USERNAME", "")
WP_APP_PASSWORD = os.getenv("WP_APP_PASSWORD", "")
WP_CATEGORY_ID = int(os.getenv("WP_CATEGORY_SLAY", os.getenv("WP_SLAY_CATEGORY_ID", os.getenv("WP_CATEGORY_ID", "1"))))
WP_POST_STATUS = os.getenv("WP_POST_STATUS", "publish")
IG_USERNAME = os.getenv("IG_USERNAME", "")
IG_PASSWORD = os.getenv("IG_PASSWORD", "")
APIFY_API_KEY = os.getenv("APIFY_API_KEY", "")
LLM_MODEL = os.getenv("LLM_MODEL", "gemma-4-31b-it")
from gemma_ai import gemini_generate, image_to_part, image_to_base64
# Use persistent storage directory if available (HF Spaces), otherwise local
_DATA_DIR = Path("/data") if Path("/data").is_dir() or os.getenv("HUGGING_FACE_SPACES") else Path(__file__).parent
if _DATA_DIR == Path("/data"):
_DATA_DIR.mkdir(parents=True, exist_ok=True)
DOWNLOAD_DIR = _DATA_DIR / "downloads"
OUTPUT_DIR = _DATA_DIR / "output"
# ──────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────
def _check_config():
"""Validate that all required env vars are set."""
missing = []
if not os.getenv("GOOGLE_API_KEY"):
missing.append("GOOGLE_API_KEY")
if not WP_URL:
missing.append("WP_URL")
if not WP_USERNAME:
missing.append("WP_USERNAME")
if not WP_APP_PASSWORD:
missing.append("WP_APP_PASSWORD")
if missing:
print(f"[ERROR] Missing environment variables: {', '.join(missing)}")
print(" Copy .env.template β†’ .env and fill in your values.")
sys.exit(1)
def _ensure_dirs():
"""Create working directories if they don't exist."""
DOWNLOAD_DIR.mkdir(exist_ok=True)
OUTPUT_DIR.mkdir(exist_ok=True)
def _get_font(size: int) -> ImageFont.FreeTypeFont:
"""Try to load a nice font, fall back to default."""
font_candidates = [
# Windows
"C:/Windows/Fonts/arialbd.ttf",
"C:/Windows/Fonts/arial.ttf",
"C:/Windows/Fonts/segoeui.ttf",
"C:/Windows/Fonts/calibri.ttf",
# Linux
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
# macOS
"/System/Library/Fonts/Helvetica.ttc",
"/Library/Fonts/Arial Bold.ttf",
]
for fp in font_candidates:
if os.path.exists(fp):
try:
return ImageFont.truetype(fp, size)
except Exception:
continue
# Ultimate fallback
try:
return ImageFont.truetype("arial.ttf", size)
except Exception:
return ImageFont.load_default()
def _sanitize_filename(name: str) -> str:
"""Create a safe filename from a string."""
name = re.sub(r'[^\w\s-]', '', name)
name = re.sub(r'[\s]+', '_', name)
return name[:80].strip("_")
def _image_to_base64(image: Image.Image) -> str:
"""Convert a PIL Image to a base64 encoded JPEG string."""
buffered = BytesIO()
image.save(buffered, format="JPEG", quality=85)
return base64.b64encode(buffered.getvalue()).decode('utf-8')
# ──────────────────────────────────────────────
# 1. Video Download
# ──────────────────────────────────────────────
def download_video(url: str) -> Path:
"""
Download a video from a social media URL.
Tries multiple methods and fallbacks:
- yt-dlp (with/without cookies) for most platforms
- Specialized Instagram APIs/libraries for IG reels
Returns the path to the downloaded video file.
"""
print(f"\n[DOWNLOAD] Downloading video from: {url}")
is_instagram = "instagram.com" in url.lower()
is_instagram_photo = is_instagram and "/p/" in url.lower()
# Clean the URL β€” remove tracking params
clean_url = re.sub(r'\?.*$', '', url)
import yt_dlp
output_template = str(DOWNLOAD_DIR / "%(id)s.%(ext)s")
headers = {
'Accept-Language': 'en-US,en;q=0.9',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
}
if "facebook.com" in url.lower():
headers['Referer'] = 'https://www.facebook.com/'
elif "instagram.com" in url.lower():
headers['Referer'] = 'https://www.instagram.com/'
elif "tiktok.com" in url.lower():
headers['Referer'] = 'https://www.tiktok.com/'
# ── Method -1: Instaloader FIRST if it's an Instagram Photo ──
if is_instagram_photo:
try:
print(" Method -1: Trying instaloader FIRST for Instagram Photo...")
result_path = _download_with_instaloader(clean_url)
if result_path and result_path.exists():
return result_path
except Exception as e:
print(f" [SKIP] instaloader failed for photo: {e}")
# Do NOT raise exception here, allow fallback to other methods (cookies, browser, etc.)
# ── Method 0: yt-dlp with manual cookies.txt ──
cookies_file = Path("cookies.txt")
if cookies_file.exists():
try:
print(" Method 0: Trying yt-dlp with manual cookies.txt...")
ydl_opts = {
'format': 'best',
'outtmpl': output_template,
'noplaylist': True,
'quiet': False,
'cookiefile': str(cookies_file.absolute()),
'geo_bypass': True,
'http_headers': headers,
'force_ipv4': True,
'source_address': '0.0.0.0'
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(clean_url, download=True)
filename = ydl.prepare_filename(info)
if filename and Path(filename).exists():
print(f" [OK] Downloaded (yt-dlp cookies.txt): {filename}")
return Path(filename)
except Exception as e:
err_msg = str(e)
print(f" [SKIP] Manual cookies.txt failed: {err_msg}")
if "Failed to resolve" in err_msg or "No address associated with hostname" in err_msg:
print(" [DNS-RESCUE] DNS failure detected! Attempting Apify rescue...")
if APIFY_API_KEY: return _download_with_apify(clean_url)
# ── Method 1: yt-dlp with Chrome cookies ──
try:
print(" Method 1: Trying yt-dlp with Chrome cookies...")
ydl_opts = {
'format': 'best[ext=mp4]/best',
'outtmpl': output_template,
'noplaylist': True,
'quiet': False,
'cookiesfrombrowser': ('chrome',),
'geo_bypass': True,
'http_headers': headers,
'force_ipv4': True,
'source_address': '0.0.0.0'
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(clean_url, download=True)
filename = ydl.prepare_filename(info)
if filename and Path(filename).exists():
print(f" [OK] Downloaded (yt-dlp Chrome): {filename}")
return Path(filename)
except Exception as e:
print(f" [SKIP] Chrome cookies failed: {e}")
# ── Method 2: yt-dlp with Edge cookies (fallback) ──
try:
print(" Method 2: Trying yt-dlp with Edge cookies...")
ydl_opts = {
'format': 'best[ext=mp4]/best',
'outtmpl': output_template,
'noplaylist': True,
'quiet': False,
'cookiesfrombrowser': ('edge',),
'force_ipv4': True,
'source_address': '0.0.0.0'
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(clean_url, download=True)
filename = ydl.prepare_filename(info)
if filename and Path(filename).exists():
print(f" [OK] Downloaded (yt-dlp Edge): {filename}")
return Path(filename)
except Exception as e:
print(f" [SKIP] Edge cookies failed: {e}")
# ── Method 3: Direct Instagram API (numeric ID method) ──
if is_instagram and IG_USERNAME and IG_PASSWORD:
try:
print(" Method 3: Trying Direct API method (Instagram only)...")
return _download_via_api(clean_url)
except Exception as e:
print(f" [SKIP] Direct API failed: {e}")
# ── Method 4: instaloader ──
if is_instagram:
try:
print(" Method 4: Trying instaloader (Instagram only)...")
return _download_with_instaloader(clean_url)
except Exception as e:
print(f" [SKIP] instaloader failed: {e}")
# ── Method 5: yt-dlp without cookies ──
try:
print(" Method 5: Trying yt-dlp without cookies...")
ydl_opts = {
'format': 'best[ext=mp4]/best',
'outtmpl': output_template,
'noplaylist': True,
'quiet': False,
'geo_bypass': True,
'http_headers': headers,
'force_ipv4': True,
'source_address': '0.0.0.0'
}
if IG_USERNAME and IG_PASSWORD:
ydl_opts['username'] = IG_USERNAME
ydl_opts['password'] = IG_PASSWORD
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(clean_url, download=True)
filename = ydl.prepare_filename(info)
if filename and Path(filename).exists():
print(f" [OK] Downloaded (yt-dlp basic): {filename}")
return Path(filename)
except yt_dlp.YoutubeDLError as e:
print(f" [SKIP] yt-dlp basic failed: {e}")
# ── Method 6: Apify API (Final Fallback – uses paid API credits) ──
if is_instagram and APIFY_API_KEY:
try:
print(" Method 6: Trying Apify API (Final Fallback)...")
return _download_with_apify(clean_url)
except Exception as e:
print(f" [SKIP] Apify API failed: {e}")
raise RuntimeError(
f"Could not download video from {url}. All methods failed. "
"Make sure the URL is public and supported by yt-dlp."
)
def _instagram_login_session() -> requests.Session:
"""
Create an authenticated Instagram session using the web login flow.
Returns a requests.Session with valid cookies.
"""
session = requests.Session()
session.headers.update({
'User-Agent': (
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/124.0.0.0 Safari/537.36'
),
'X-Requested-With': 'XMLHttpRequest',
'Referer': 'https://www.instagram.com/',
'Origin': 'https://www.instagram.com',
})
# Step 1: Get CSRF token
print(" Fetching Instagram CSRF token...")
resp = session.get('https://www.instagram.com/accounts/login/', timeout=15)
csrf_token = session.cookies.get('csrftoken', '')
if not csrf_token:
match = re.search(r'"csrf_token":"([^"]+)"', resp.text)
if match:
csrf_token = match.group(1)
if not csrf_token:
try:
resp2 = session.get('https://www.instagram.com/data/shared_data/', timeout=10)
csrf_token = resp2.json().get('config', {}).get('csrf_token', '')
except Exception:
pass
if not csrf_token:
raise Exception("Could not obtain CSRF token from Instagram")
# Step 2: Login
print(f" Logging into Instagram as {IG_USERNAME}...")
session.headers['X-CSRFToken'] = csrf_token
login_data = {
'username': IG_USERNAME,
'enc_password': f'#PWD_INSTAGRAM_BROWSER:0:{int(time.time())}:{IG_PASSWORD}',
'queryParams': '{}',
'optIntoOneTap': 'false',
}
login_resp = session.post(
'https://www.instagram.com/accounts/login/ajax/',
data=login_data,
timeout=15
)
try:
login_json = login_resp.json()
except Exception:
raise Exception(f"Login response was not JSON: {login_resp.status_code}")
if not login_json.get('authenticated'):
msg = login_json.get('message', 'Unknown error')
if login_json.get('two_factor_required'):
raise Exception("Two-factor auth required. Disable 2FA or use app password.")
raise Exception(f"Instagram login failed: {msg}")
new_csrf = session.cookies.get('csrftoken', csrf_token)
session.headers['X-CSRFToken'] = new_csrf
print(" [OK] Instagram login successful")
return session
def _download_via_api(url: str) -> Path:
"""Download Instagram video using the i.instagram.com private API."""
session = _instagram_login_session()
match = re.search(r'/(?:reels?|p)/([A-Za-z0-9_-]+)', url)
if not match:
raise ValueError(f"Could not extract shortcode from URL: {url}")
shortcode = match.group(1)
# Convert shortcode to numeric media ID
ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
media_id = 0
for char in shortcode:
media_id = media_id * 64 + ALPHABET.index(char)
media_id = str(media_id)
print(f" Shortcode: {shortcode} β†’ Media ID: {media_id}")
# Use the i.instagram.com API with X-IG-App-ID
session.headers["X-IG-App-ID"] = "936619743392459"
api_url = f"https://i.instagram.com/api/v1/media/{media_id}/info/"
print(f" Fetching media info...")
resp = session.get(api_url, timeout=30)
if resp.status_code != 200:
raise Exception(f"Media API returned {resp.status_code}: {resp.text[:200]}")
data = resp.json()
items = data.get("items", [])
if not items:
raise Exception("No items in media API response")
item = items[0]
video_versions = item.get("video_versions", [])
if not video_versions:
raise Exception(f"No video_versions found. Media type: {item.get('media_type')}")
video_url = video_versions[0]["url"]
print(f" Video URL found, downloading...")
video_resp = session.get(video_url, stream=True, timeout=60)
video_resp.raise_for_status()
output_path = DOWNLOAD_DIR / f"{shortcode}.mp4"
with open(output_path, "wb") as f:
for chunk in video_resp.iter_content(chunk_size=8192):
f.write(chunk)
file_size = output_path.stat().st_size / (1024 * 1024)
print(f" [OK] Downloaded: {output_path} ({file_size:.1f} MB)")
return output_path
def _download_with_instaloader(url: str) -> Path:
"""Fallback: download using instaloader (also handles pictures)."""
import instaloader
def _attempt_download(use_login: bool):
L = instaloader.Instaloader(
download_videos=True,
download_video_thumbnails=False,
download_pictures=True,
download_geotags=False,
download_comments=False,
save_metadata=False,
dirname_pattern=str(DOWNLOAD_DIR),
filename_pattern="{shortcode}"
)
if use_login and IG_USERNAME and IG_PASSWORD:
cookies_file = Path(os.getenv("IG_COOKIES_FILE", "cookies.txt"))
if cookies_file.exists():
try:
import http.cookiejar
cj = http.cookiejar.MozillaCookieJar(str(cookies_file))
cj.load(ignore_discard=True, ignore_expires=True)
L.context._session.cookies.update(cj)
print(f" [OK] Instaloader session loaded directly from cookies.txt (Bypassing Checkpoints)")
except instaloader.exceptions.InstaloaderException as eval_err:
print(f" [WARN] Could not load cookies.txt into instaloader: {eval_err}")
L.login(IG_USERNAME, IG_PASSWORD)
print(f" Logged into Instagram via POST as {IG_USERNAME}")
else:
L.login(IG_USERNAME, IG_PASSWORD)
print(f" Logged into Instagram via POST as {IG_USERNAME}")
match = re.search(r'/(?:reel|p)/([A-Za-z0-9_-]+)', url)
if not match:
raise ValueError(f"Could not extract shortcode from URL: {url}")
shortcode = match.group(1)
print(f" Using instaloader for shortcode: {shortcode} (Anonymous: {not use_login})")
post = instaloader.Post.from_shortcode(L.context, shortcode)
L.download_post(post, target="")
media_files = list(DOWNLOAD_DIR.glob(f"{shortcode}*.mp4"))
if not media_files:
media_files = list(DOWNLOAD_DIR.glob(f"{shortcode}*.jpg"))
if not media_files:
raise FileNotFoundError(f"Instaloader did not produce media wrapper for {shortcode}")
print(f" [OK] Downloaded (instaloader): {media_files[0]}")
return media_files[0]
try:
# First try with login if we have credentials
if IG_USERNAME and IG_PASSWORD:
try:
return _attempt_download(use_login=True)
except Exception as e:
print(f" [WARN] Instaloader login/fetch failed ({e}). Retrying anonymously...")
return _attempt_download(use_login=False)
else:
return _attempt_download(use_login=False)
except Exception as e:
raise Exception(f"Instaloader fully failed: {e}")
def _download_with_apify(url: str) -> Path:
from apify_client import ApifyClient
import urllib.request
print(f" [APIFY] Extracting media directly from Apify proxy grid...")
is_facebook = "facebook.com" in url.lower()
is_tiktok = "tiktok.com" in url.lower()
client = ApifyClient(APIFY_API_KEY)
run_input = {
"directUrls": [url],
"resultsType": "details"
}
if is_facebook:
# Use Facebook Reel / Video downloader
actor_id = "apify/facebook-reel-downloader" # or social-media-video-downloader
run = client.actor(actor_id).call(run_input={"urls": [url]})
else:
# Default to Instagram
actor_id = "apify/instagram-scraper"
run = client.actor(actor_id).call(run_input=run_input)
items = list(client.dataset(run["defaultDatasetId"]).iterate_items())
if not items:
raise Exception("Apify actor succeeded but returned no dataset items.")
data = items[0]
media_urls = []
# Extract carousels
if data.get("childPosts"):
for child in data.get("childPosts"):
video_url = child.get("videoUrl")
img_url = child.get("displayUrl")
if video_url:
media_urls.append(video_url)
elif img_url:
media_urls.append(img_url)
else:
# Extract single post
video_url = data.get("videoUrl")
img_url = data.get("displayUrl")
if video_url:
media_urls.append(video_url)
elif img_url:
media_urls.append(img_url)
if not media_urls:
raise Exception("Apify returned dataset but no 'videoUrl' or 'displayUrl' found.")
print(f" [APIFY] Isolated {len(media_urls)} media link(s). Downloading locally...")
downloaded_files = []
for idx, media_url in enumerate(media_urls):
is_video = "mp4" in media_url.lower() or "video" in media_url.lower()
ext = ".mp4" if is_video else ".jpg"
filename = f"{shortcode}_{idx+1}{ext}" if len(media_urls) > 1 else f"{shortcode}{ext}"
filepath = DOWNLOAD_DIR / filename
# We can bypass CF entirely for generic HTTP downloads since ig hosts on FB CDN
urllib.request.urlretrieve(media_url, filepath)
downloaded_files.append(filepath)
return downloaded_files[0]
# ──────────────────────────────────────────────
# 2. Frame Extraction
# ──────────────────────────────────────────────
def extract_frames(video_path: Path, count: int = None) -> list[Image.Image]:
"""
Extract frames from a video or load an image.
Uses 1 frame per second of video duration (capped at 30 max) for efficient
and proportional frame extraction.
Returns a list of PIL Image objects.
"""
print(f"[VIDEO] Processing {video_path.name}...")
# Check if it's an image first
try:
with Image.open(video_path) as img:
img.verify()
# If it's an image, let's load ALL images from that carousel
print(f" Detected as image. Looking for all carousel photos...")
# instaloader names them shortcode_1.jpg, shortcode_2.jpg, etc.
# find the base shortcode from the filename
base_name = video_path.stem
shortcode = re.sub(r'_\d+$', '', base_name)
image_files = sorted(video_path.parent.glob(f"{shortcode}*.*"))
# filter out text/json files, keep only images
image_files = [f for f in image_files if f.suffix.lower() in [".jpg", ".jpeg", ".webp", ".png"]]
if not image_files:
image_files = [video_path] # fallback
print(f" Found {len(image_files)} photos from carousel")
loaded_images = []
for img_path in image_files[:count or 30]:
loaded_images.append(Image.open(img_path).convert("RGB"))
return loaded_images
except Exception:
# Not an image, proceed as video
pass
import cv2
frames = []
try:
import numpy as np
cap = cv2.VideoCapture(str(video_path))
if not cap.isOpened():
print(f" [WARN] Could not open video: {video_path}")
else:
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
fps = cap.get(cv2.CAP_PROP_FPS)
# Calculate frame count: 1 frame per second, capped at 30
duration_seconds = total_frames / fps if fps > 0 else 0
if count is None:
count = min(max(int(duration_seconds), 3), 30) # min 3, max 30
pool_size = max(count * 5, 15) # Extract more frames to find distinct ones
print(f" Video: {total_frames} frames @ {fps:.1f}fps = {duration_seconds:.0f}s β†’ extracting {count} frame(s) (1/sec)")
# 1. Extract a pool of candidate frames evenly spaced
candidate_frames = []
if total_frames > 0:
for i in range(pool_size):
frame_idx = int((total_frames / (pool_size + 1)) * (i + 1))
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
ret, cv2_frame = cap.read()
if ret:
rgb_frame = cv2.cvtColor(cv2_frame, cv2.COLOR_BGR2RGB)
candidate_frames.append(Image.fromarray(rgb_frame))
cap.release()
# 2. Select the most distinct frames using MSE
if len(candidate_frames) <= count:
frames = candidate_frames
else:
print(f" Selecting {count} distinct frames from {len(candidate_frames)} candidates...")
def _mse(img1, img2):
if img1.size != img2.size:
img2 = img2.resize(img1.size)
arr1 = np.array(img1.convert('L'), dtype=np.float32)
arr2 = np.array(img2.convert('L'), dtype=np.float32)
return np.mean((arr1 - arr2) ** 2)
# Start with the first frame (often the thumbnail or intro)
selected = [candidate_frames[0]]
remaining = candidate_frames[1:]
while len(selected) < count and remaining:
best_frame = None
max_min_diff = -1
for frame in remaining:
# Find min diff to ANY already selected frame
min_diff = min(_mse(frame, sel) for sel in selected)
if min_diff > max_min_diff:
max_min_diff = min_diff
best_frame = frame
selected.append(best_frame)
remaining.remove(best_frame)
frames = selected
except Exception as e:
print(f" [WARN] OpenCV extraction failed: {e}")
# Fallback: if cv2 didn't work, try placeholder logic
if not frames:
if count is None:
count = 3
print("[WARN] Could not extract frames via OpenCV. Using placeholder images.")
for _ in range(count):
placeholder = Image.new("RGB", (1280, 720), color=(30, 30, 30))
draw = ImageDraw.Draw(placeholder)
font = _get_font(40)
draw.text(
(640, 360), "Video Frame",
fill=(200, 200, 200), font=font, anchor="mm"
)
frames.append(placeholder)
print(f" [OK] Processed {len(frames)} frames/images")
return frames
# ──────────────────────────────────────────────
# 3. Video Analysis with Gemma 4 AI Vision
# ──────────────────────────────────────────────
def analyze_video(video_path: Path, frames: list[Image.Image] = None, is_redo: bool = False) -> dict:
"""
Analyze the video by extracting frames and processing them with the LLM.
If frames are provided, use them; otherwise extract from video_path.
"""
if frames is None:
frames = extract_frames(video_path) # auto: 1 frame/sec, max 30
return _analyze_frames(frames, is_redo=is_redo)
# ──────────────────────────────────────────────
# 2c. Vision Result Cache
# ──────────────────────────────────────────────
# Cache paths (use persistent storage on HF Spaces)
_VISION_CACHE_PATH = _DATA_DIR / "vision_cache.json"
def _get_vision_cache_key(frames: list[Image.Image]) -> str:
"""Create a content hash from frames using first, middle, and last frame fingerprints."""
hasher = hashlib.md5()
for idx in [0, len(frames) // 2, -1]:
frame = frames[idx]
# Hash a downsampled 64x64 grayscale version for content similarity
thumb = frame.resize((64, 64)).convert("L").tobytes()
hasher.update(thumb)
return hasher.hexdigest()
def _load_vision_cache() -> dict:
if _VISION_CACHE_PATH.exists():
try:
with open(_VISION_CACHE_PATH, "r") as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return {}
return {}
def _save_vision_cache(cache: dict):
try:
with open(_VISION_CACHE_PATH, "w") as f:
json.dump(cache, f)
except IOError:
pass
def _score_frames(frames: list[Image.Image]) -> dict:
"""
Analyze frames locally using OpenCV heuristics β€” zero AI cost.
Scores each frame by:
- Sharpness (Laplacian variance) β€” filters blurry frames
- Face detection (Haar cascade) β€” finds front-facing shots
- Body/silhouette analysis β€” finds back-facing shots
- Brightness β€” filters too dark/bright frames
- Scene change detection β€” finds frame-to-frame differences
Returns the same JSON structure as the old vision AI analysis so the
rest of the pipeline (featured image selection, content generation)
works unchanged.
"""
import cv2
import numpy as np
from PIL import Image
print(f" [SCORER] Analyzing {len(frames)} frames locally (zero AI cost)...")
# Load Haar cascade for face detection
cascade_path = cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
face_cascade = cv2.CascadeClassifier(cascade_path)
scores = []
descriptions = []
for idx, frame in enumerate(frames):
cv2_frame = cv2.cvtColor(np.array(frame), cv2.COLOR_RGB2BGR)
gray = cv2.cvtColor(cv2_frame, cv2.COLOR_BGR2GRAY)
h, w = gray.shape
# 1. Sharpness (Laplacian variance) β€” higher = sharper
laplacian_var = float(cv2.Laplacian(gray, cv2.CV_64F).var())
sharpness_score = min(laplacian_var / 500, 1.0) # normalize 0-1
# 2. Face detection β€” counts and confidence
faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(30, 30))
face_count = int(len(faces))
face_score = min(face_count * 0.3, 1.0) # 3+ faces = max score
# 3. Brightness (mean pixel value) β€” penalize too dark or too bright
mean_brightness = float(np.mean(gray))
if 40 <= mean_brightness <= 200:
brightness_score = 1.0 # ideal range
elif mean_brightness < 40:
brightness_score = max(0, mean_brightness / 40)
else:
brightness_score = max(0, (255 - mean_brightness) / 55)
# 4. Body silhouette analysis β€” approximate front vs back
_, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
largest_contour = max(contours, key=cv2.contourArea) if len(contours) > 0 else None
contour_area_ratio = float(cv2.contourArea(largest_contour) / (w * h)) if largest_contour is not None else 0.0
# Back-facing heuristic: large silhouette but no faces detected
backside_score = 0.0
if int(face_count) == 0 and contour_area_ratio > 0.15:
backside_score = min(contour_area_ratio * 3, 1.0)
# Front-facing heuristic: faces detected + good sharpness
frontside_score = face_score * (0.6 + 0.4 * sharpness_score)
# 5. Edge density (text/caption detection)
edges = cv2.Canny(gray, 50, 150)
edge_density = float(np.sum(edges > 0)) / (w * h)
text_score = min(edge_density * 10, 1.0) # high edge density = likely text
# Combined quality score
quality = (
0.30 * sharpness_score +
0.25 * face_score +
0.20 * brightness_score +
0.15 * (1.0 - abs(contour_area_ratio - 0.25)) + # prefer medium subject size
0.10 * (1.0 - text_score) # penalize text-heavy frames
)
scores.append({
"index": idx,
"quality": round(quality, 3),
"sharpness": round(sharpness_score, 3),
"face_count": face_count,
"face_score": round(face_score, 3),
"brightness": round(brightness_score, 3),
"mean_brightness": round(mean_brightness, 1),
"backside_score": round(backside_score, 3),
"frontside_score": round(frontside_score, 3),
"contour_area_ratio": round(contour_area_ratio, 3),
"text_score": round(text_score, 3),
})
# Build a simple description fragment
if face_count > 0:
desc = f"Frame {idx}: {face_count} face(s) detected, {'sharp' if sharpness_score > 0.5 else 'soft'} focus"
elif backside_score > 0.5:
desc = f"Frame {idx}: Back/silhouette view, {'clear' if sharpness_score > 0.5 else 'soft'} outline"
else:
desc = f"Frame {idx}: {'Well-lit' if brightness_score > 0.5 else 'Dim'}, {'sharp' if sharpness_score > 0.5 else 'blurry'} frame"
descriptions.append(desc)
# Sort by quality
scores.sort(key=lambda x: x["quality"], reverse=True)
# Categorize frames
backside = [s["index"] for s in scores if s["backside_score"] > 0.3][:5]
frontside = [s["index"] for s in scores if s["face_score"] > 0.1][:5]
other = [s["index"] for s in scores if s["index"] not in backside and s["index"] not in frontside]
# Generate summary description
best = scores[0]
summary = f"Video analysis of {len(frames)} frames. "
summary += f"Best frame (idx {best['index']}): quality={best['quality']}, "
if best["face_count"] > 0:
summary += f"{best['face_count']} face(s) detected. "
if best["mean_brightness"] < 60:
summary += "Overall dim lighting. "
elif best["mean_brightness"] > 180:
summary += "Overall bright lighting. "
summary += f"Found {len(frontside)} front-facing and {len(backside)} back-facing frames."
result = {
"description": summary,
"subjects": ["person/subject in video frames"],
"mood": "dynamic social media content",
"highlights": descriptions[:3], # top 3 frame descriptions
"text_content": [],
"frame_categories": {
"backside": backside,
"frontside": frontside,
"other": other,
},
"frame_scores": scores, # Full scoring data for debugging
}
# Save to cache
cache_key = _get_vision_cache_key(frames)
cache = _load_vision_cache()
cache[cache_key] = result
_save_vision_cache(cache)
print(f" [SCORER] βœ… Analyzed {len(frames)} frames β€” best: idx {best['index']} (quality={best['quality']})")
return result
def _analyze_frames(frames: list[Image.Image], is_redo: bool = False) -> dict:
"""
Analyze provided frames using Gemma 4 AI vision model.
Returns JSON with description, subjects, mood, frame_categories (backside/frontside/other).
"""
# Check cache first
cache_key = _get_vision_cache_key(frames)
cache = _load_vision_cache()
if not is_redo and cache_key in cache:
print(f" [CACHE] Vision result found for this video content (skip analysis)")
return cache[cache_key]
print(f" [AI] Analyzing {len(frames)} frames with Gemma 4 vision...")
prompt = textwrap.dedent("""
You are an expert content analyst. These are frames from a social media video.
Analyze these frames and provide:
1. A detailed description of what appears to be happening.
2. The key subjects/people/objects visible.
3. The mood, setting, and context.
4. Any notable actions, events, or highlights.
5. Any text or captions visible in the frames.
6. Frame Categorization: For each frame provided (indexed 0 to N), identify the BEST shots for:
- "backside": subject showing their back/behind (e.g., squatting, walking away, back pose)
- "frontside": subject showing their front/face (e.g., front pose, smiling, facing camera)
- "other": anything else
Return your analysis as a JSON object with these keys:
- "description": text
- "subjects": list
- "mood": string
- "highlights": list
- "text_content": list
- "frame_categories": {
"backside": [index_integer, ...],
"frontside": [index_integer, ...],
"other": [index_integer, ...]
}
ONLY include high-quality, clear shots in the backside/frontside lists.
If a frame is blurry or low quality, categorize it as "other".
""").strip()
if is_redo:
prompt += "\n\nCRITICAL: This is a redo request because the user was unhappy with the previous frame selections for the flyer. PLEASE MAKE SURE TO SELECT DIFFERENT, FRESH FRAMES for the 'frontside' and 'backside' categories than you would normally choose. PRIORITIZE ALTERNATIVE HIGH QUALITY SHOTS."
try:
image_parts = [image_to_part(f) for f in frames[:30]]
resp, retries = gemini_generate(prompt, images=image_parts, temperature=0.2, json_mode=True)
raw_text = resp.text.strip()
if retries > 0:
print(f" [RETRY] Vision succeeded after {retries} retry(ies)")
print(f" [DEBUG] Vision returned {len(raw_text)} chars, starts with: {raw_text[:100]}")
result = json.loads(raw_text)
# Validate frame_categories structure
if "frame_categories" not in result:
result["frame_categories"] = {"backside": [], "frontside": [], "other": []}
for key in ["backside", "frontside", "other"]:
if key not in result["frame_categories"]:
result["frame_categories"][key] = []
# Save to cache
cache = _load_vision_cache()
cache[cache_key] = result
_save_vision_cache(cache)
print(f" [CACHE] Saved vision result to cache")
return result
except Exception as e:
print(f" [ERROR] Vision analysis failed: {e}")
# Fall back to local scorer
print(f" [FALLBACK] Using local frame scorer instead")
return _score_frames(frames)
# ──────────────────────────────────────────────
# 4. Content Generation
# ──────────────────────────────────────────────
def generate_content(analysis: any, source_url: str) -> dict:
"""
Generate a blog post title and two paragraphs from the video analysis.
Uses Gemma 4 via Google AI.
Returns: {"title": str, "paragraph1": str, "paragraph2": str}
"""
if isinstance(analysis, dict):
analysis_text = analysis.get("description", str(analysis))
else:
analysis_text = str(analysis)
prompt = textwrap.dedent(f"""
Based on the following video analysis, write an engaging blog post for a website.
VIDEO ANALYSIS:
{analysis_text}
SOURCE URL: {source_url}
Please respond ONLY with valid JSON in this exact format (no markdown, no code blocks):
{{
"title": "A catchy, SEO-friendly blog post title (max 80 chars)",
"paragraph1": "First paragraph β€” introduce the video content and its significance. Write 2 short, punchy sentences. Use natural, human-like language.",
"paragraph2": "Second paragraph β€” provide more details or context. Write 2 short, clear sentences. End with a simple call-to-action to watch the full video."
}}
RULES:
- Use a natural, humanized, and conversational tone.
- Use simple, clear English that is easy to understand.
- Avoid complex jargon or long, formal sentences.
- Keep paragraphs short and concise.
- The title MUST be catchy and attention-grabbing.
- Do NOT include links in the paragraphs.
- Do NOT use placeholder text.
- Write in English.
""").strip()
try:
resp, retries = gemini_generate(prompt, temperature=0.7, json_mode=True)
raw_text = resp.text.strip()
if retries > 0:
print(f" [RETRY] Content generation succeeded after {retries} retry(ies)")
# Clean potential markdown code fences
raw_text = re.sub(r'^```(?:json)?\s*', '', raw_text)
raw_text = re.sub(r'\s*```$', '', raw_text)
content = json.loads(raw_text)
content = LLMContent(**content).model_dump()
print(f" [OK] Title: {content['title']}")
return content
except Exception as e:
print(f" [ERROR] Content generation failed: {e}")
# Fallback: try to extract JSON manually
try:
json_match = re.search(r'\{[^{}]*"title"[^{}]*\}', raw_text, re.DOTALL)
if json_match:
content = json.loads(json_match.group())
content = LLMContent(**content).model_dump()
print(f" [OK] Title (regex extract): {content['title']}")
return content
except Exception:
pass
# Final fallback
print(f" [ERROR] All content generation attempts failed. Returning error response.")
return {"title": "Error Generating Content", "paragraph1": "Analysis failed.", "paragraph2": ""}
# ──────────────────────────────────────────────
# 5. Media Creation
# ──────────────────────────────────────────────
def create_featured_image(title: str, frames: list[Image.Image], output_name: str, categories: dict = None, is_redo: bool = False) -> Path:
"""
Create a 16:9 featured image.
If multiple frames: creates a 3-frame collage with smart selection.
If single frame (image): crops to 16:9.
Priority: 2 backside, 1 frontside image for collage.
"""
WIDTH, HEIGHT = 1280, 720
canvas = Image.new("RGB", (WIDTH, HEIGHT), (0, 0, 0))
if len(frames) == 1:
print("[IMAGE] Creating single featured image crop (16:9)...")
frame = frames[0].copy().convert("RGB")
src_w, src_h = frame.size
target_ratio = WIDTH / HEIGHT
src_ratio = src_w / src_h
if src_ratio > target_ratio:
# Too wide - crop sides
new_w = int(src_h * target_ratio)
left = (src_w - new_w) // 2
frame = frame.crop((left, 0, left + new_w, src_h))
else:
# Too tall - crop top/bottom
new_h = int(src_w / target_ratio)
top = (src_h - new_h) // 2
frame = frame.crop((0, top, src_w, top + new_h))
frame = frame.resize((WIDTH, HEIGHT), Image.LANCZOS)
canvas.paste(frame, (0, 0))
else:
# Smart selection based on categories
back_idxs = categories.get("backside", []) if categories else []
front_idxs = categories.get("frontside", []) if categories else []
other_idxs = categories.get("other", []) if categories else []
if is_redo:
import random
random.shuffle(back_idxs)
random.shuffle(front_idxs)
random.shuffle(other_idxs)
print(f" [DEBUG] Redo requested: Randomized featured frame selection pools.")
print(f" [DEBUG] Featured Image Selection: Backside={back_idxs}, Frontside={front_idxs}")
# Target: 2 back, 1 front
selected_idxs = []
# 1. Take up to 2 backs
selected_idxs.extend(back_idxs[:2])
# 2. Take up to 1 front
if len(selected_idxs) < 3:
selected_idxs.extend(front_idxs[:1])
# 3. Fill remaining slots with whatever is left (prioritize remaining front, then other, then remaining back)
fill_pool = [i for i in front_idxs if i not in selected_idxs]
fill_pool += [i for i in other_idxs if i not in selected_idxs]
fill_pool += [i for i in back_idxs if i not in selected_idxs]
# Absolute fallback: any index not used
all_available = list(range(len(frames)))
fill_pool += [i for i in all_available if i not in selected_idxs and i not in fill_pool]
for idx in fill_pool:
if len(selected_idxs) >= 3:
break
selected_idxs.append(idx)
# Final safety: repeat last if still short
while len(selected_idxs) < 3:
selected_idxs.append(selected_idxs[-1] if selected_idxs else 0)
print(f" [DEBUG] Final Featured Selected Indices: {selected_idxs}")
collage_frames = [frames[idx] for idx in selected_idxs[:3]]
# Width for each portrait segment (1280 / 3 = ~426.6)
segment_w = WIDTH // 3
for i in range(3):
frame = collage_frames[i].copy().convert("RGB")
src_w, src_h = frame.size
# We want to fill a segment (segment_w, HEIGHT) with a portrait crop
target_ratio = segment_w / HEIGHT
src_ratio = src_w / src_h
if src_ratio > target_ratio:
# Too wide - crop sides
new_w = int(src_h * target_ratio)
left = (src_w - new_w) // 2
frame = frame.crop((left, 0, left + new_w, src_h))
else:
# Too tall - crop top/bottom
new_h = int(src_w / target_ratio)
top = (src_h - new_h) // 2
frame = frame.crop((0, top, src_w, top + new_h))
frame = frame.resize((segment_w, HEIGHT), Image.LANCZOS)
canvas.paste(frame, (i * segment_w, 0))
# Divider lines
if i > 0:
draw = ImageDraw.Draw(canvas)
draw.line([(i * segment_w, 0), (i * segment_w, HEIGHT)], fill=(255, 255, 255), width=2)
# Accent bar at top (user previously had red, keeping it subtle)
draw_top = ImageDraw.Draw(canvas)
draw_top.rectangle([(0, 0), (WIDTH, 5)], fill=(255, 69, 58))
# Ensure output directory exists (defensive)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
output_path = OUTPUT_DIR / f"featured_{output_name}.jpg"
canvas.save(str(output_path), "JPEG", quality=92)
print(f" [OK] Featured image saved: {output_path}")
return output_path
def create_flyer(title: str, frames: list[Image.Image], output_name: str, categories: dict = None, is_redo: bool = False) -> Path:
"""
Create a 1080x1080 square flyer.
If multiple frames: 2 portrait frames side-by-side with smart selection.
If single frame: crop to square.
Priority: 1 backside, 1 frontside (or 2 backside).
"""
SIZE = 1080
WIDTH, HEIGHT = SIZE, SIZE
canvas = Image.new("RGB", (WIDTH, HEIGHT), (0, 0, 0))
if len(frames) == 1:
print("[FLYER] Creating square flyer image (1080x1080 - Single Image)...")
frame = frames[0].copy().convert("RGB")
src_w, src_h = frame.size
target_ratio = 1.0 # Square
src_ratio = src_w / src_h
if src_ratio > target_ratio:
new_w = int(src_h * target_ratio)
left = (src_w - new_w) // 2
frame = frame.crop((left, 0, left + new_w, src_h))
else:
new_h = int(src_w / target_ratio)
top = (src_h - new_h) // 2
frame = frame.crop((0, top, src_w, top + new_h))
frame = frame.resize((WIDTH, HEIGHT), Image.LANCZOS)
canvas.paste(frame, (0, 0))
else:
print("[FLYER] Creating square flyer image (1080x1080 - Smart Selection)...")
# Smart selection
back_idxs = categories.get("backside", []) if categories else []
front_idxs = categories.get("frontside", []) if categories else []
other_idxs = categories.get("other", []) if categories else []
if is_redo:
import random
random.shuffle(back_idxs)
random.shuffle(front_idxs)
random.shuffle(other_idxs)
print(f" [DEBUG] Redo requested: Randomized flyer frame selection pools.")
print(f" [DEBUG] Flyer Selection: Backside={back_idxs}, Frontside={front_idxs}")
# Target: 1 back, 1 front
selected_idxs = []
# 1. Take 1 back
if back_idxs:
selected_idxs.append(back_idxs[0])
# 2. Take 1 front
if len(selected_idxs) < 2 and front_idxs:
selected_idxs.append(front_idxs[0])
# 3. Fill remaining slots
fill_pool = [i for i in back_idxs if i not in selected_idxs]
fill_pool += [i for i in front_idxs if i not in selected_idxs]
fill_pool += [i for i in other_idxs if i not in selected_idxs]
# Absolute fallback
all_available = list(range(len(frames)))
fill_pool += [i for i in all_available if i not in selected_idxs and i not in fill_pool]
for idx in fill_pool:
if len(selected_idxs) >= 2:
break
selected_idxs.append(idx)
# Fill if needed
while len(selected_idxs) < 2:
selected_idxs.append(selected_idxs[-1] if selected_idxs else 0)
print(f" [DEBUG] Final Flyer Selected Indices: {selected_idxs}")
collage_frames = [frames[idx] for idx in selected_idxs[:2]]
# Place two portrait frames side-by-side (no gap)
frame_w = WIDTH // 2
frame_h = HEIGHT
for i in range(2):
frame = collage_frames[i].copy().convert("RGB")
src_w, src_h = frame.size
# Crop to fill the square-half slot
target_ratio = frame_w / frame_h
src_ratio = src_w / src_h
if src_ratio > target_ratio:
new_w = int(src_h * target_ratio)
left = (src_w - new_w) // 2
frame = frame.crop((left, 0, left + new_w, src_h))
else:
new_h = int(src_w / target_ratio)
top = (src_h - new_h) // 2
frame = frame.crop((0, top, src_w, top + new_h))
frame = frame.resize((frame_w, frame_h), Image.LANCZOS)
canvas.paste(frame, (i * frame_w, 0))
# Ensure output directory exists (defensive)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
output_path = OUTPUT_DIR / f"flyer_{output_name}.jpg"
canvas.save(str(output_path), "JPEG", quality=92)
print(f" [OK] Square flyer saved: {output_path}")
return output_path
def _crop_to_fill(frame: Image.Image, width: int, height: int) -> Image.Image:
frame = frame.copy().convert("RGB")
src_w, src_h = frame.size
target_ratio = width / height
src_ratio = src_w / src_h
if src_ratio > target_ratio:
new_w = int(src_h * target_ratio)
left = (src_w - new_w) // 2
frame = frame.crop((left, 0, left + new_w, src_h))
else:
new_h = int(src_w / target_ratio)
top = (src_h - new_h) // 2
frame = frame.crop((0, top, src_w, top + new_h))
return frame.resize((width, height), Image.LANCZOS)
def _wrap_draw_text(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.FreeTypeFont, max_width: int, max_lines: int = 4) -> list[str]:
words = str(text or "").split()
lines = []
current = ""
for word in words:
candidate = f"{current} {word}".strip()
bbox = draw.textbbox((0, 0), candidate, font=font)
if bbox[2] - bbox[0] <= max_width or not current:
current = candidate
else:
lines.append(current)
current = word
if len(lines) >= max_lines:
break
if current and len(lines) < max_lines:
lines.append(current)
if len(lines) == max_lines and len(words) > len(" ".join(lines).split()):
lines[-1] = lines[-1].rstrip(".,;:") + "..."
return lines
def create_story_flyer(
title: str,
frames: list[Image.Image],
output_name: str,
post_url: str,
categories: dict = None,
is_redo: bool = False,
) -> Path:
"""Create a 1080x1920 Facebook Story flyer with visible URL and QR code."""
WIDTH, HEIGHT = 1080, 1920
print("[STORY] Creating vertical Story flyer (1080x1920)...")
selected_idx = 0
if categories:
for key in ("frontside", "backside", "other"):
if categories.get(key):
selected_idx = categories[key][0]
break
if is_redo and len(frames) > 1:
import random
selected_idx = random.randrange(len(frames))
base = _crop_to_fill(frames[selected_idx], WIDTH, HEIGHT) if frames else Image.new("RGB", (WIDTH, HEIGHT), (18, 24, 38))
overlay = Image.new("RGBA", (WIDTH, HEIGHT), (0, 0, 0, 0))
odraw = ImageDraw.Draw(overlay)
odraw.rectangle((0, 0, WIDTH, HEIGHT), fill=(0, 0, 0, 90))
odraw.rectangle((0, 0, WIDTH, 360), fill=(0, 0, 0, 120))
odraw.rectangle((0, 1320, WIDTH, HEIGHT), fill=(0, 0, 0, 175))
canvas = Image.alpha_composite(base.convert("RGBA"), overlay).convert("RGB")
draw = ImageDraw.Draw(canvas)
brand_font = _get_font(44)
title_font = _get_font(70)
cta_font = _get_font(42)
url_font = _get_font(34)
accent = (255, 69, 58)
draw.rectangle((0, 0, WIDTH, 12), fill=accent)
draw.text((60, 70), "CONTENTAI", font=brand_font, fill=(255, 255, 255))
title_lines = _wrap_draw_text(draw, title, title_font, WIDTH - 120, max_lines=5)
y = 230
for line in title_lines:
bbox = draw.textbbox((0, 0), line, font=title_font)
draw.text(((WIDTH - (bbox[2] - bbox[0])) // 2, y), line, font=title_font, fill=(255, 255, 255))
y += 86
draw.rounded_rectangle((72, 1360, WIDTH - 72, HEIGHT - 90), radius=42, fill=(255, 255, 255))
draw.text((118, 1415), "Read the full story", font=cta_font, fill=(17, 24, 39))
display_url = re.sub(r"^https?://", "", post_url or "").rstrip("/")
if len(display_url) > 42:
display_url = display_url[:39] + "..."
draw.text((118, 1482), display_url, font=url_font, fill=(71, 85, 105))
try:
import qrcode
qr = qrcode.QRCode(border=1, box_size=10)
qr.add_data(post_url or "")
qr.make(fit=True)
qr_img = qr.make_image(fill_color="black", back_color="white").convert("RGB")
qr_img = qr_img.resize((300, 300), Image.NEAREST)
canvas.paste(qr_img, (WIDTH - 395, 1450))
draw.text((118, 1608), "Scan for link", font=cta_font, fill=(17, 24, 39))
except Exception as e:
print(f" [WARN] QR code generation failed: {e}")
draw.text((118, 1608), "Visit the link above", font=cta_font, fill=(17, 24, 39))
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
output_path = OUTPUT_DIR / f"story_{output_name}.jpg"
canvas.save(str(output_path), "JPEG", quality=92)
print(f" [OK] Story flyer saved: {output_path}")
return output_path
# ──────────────────────────────────────────────
# 6. WordPress Publishing
# ──────────────────────────────────────────────
def _wp_auth() -> tuple[str, str]:
"""Return WordPress auth tuple β€” reads from env at call time (not import time)."""
password = "".join(os.getenv("WP_APP_PASSWORD", "").split())
return (os.getenv("WP_USERNAME", ""), password)
def _wp_auth_header() -> str:
"""Return Basic Auth header string for direct header injection."""
import base64
password = "".join(os.getenv("WP_APP_PASSWORD", "").split())
creds = f'{os.getenv("WP_USERNAME", "")}:{password}'
return f"Basic {base64.b64encode(creds.encode()).decode()}"
def _wp_ssl_verify() -> bool:
"""Allow deployments with a broken WordPress certificate chain to opt out."""
return os.getenv("WP_SSL_VERIFY", "true").strip().lower() not in ("0", "false", "no")
def upload_media_to_wp(file_path: Path, filename: str = None) -> dict:
"""
Upload an image or video to WordPress.
Returns: {"id": int, "url": str}
"""
if filename is None:
filename = file_path.name
# Read WP_URL at call time (not import time) so _apply_settings_to_env() works
wp_url = os.getenv("WP_URL", "").rstrip("/")
wp_user = os.getenv("WP_USERNAME", "")
wp_pass = "".join(os.getenv("WP_APP_PASSWORD", "").split())
print(f" [JOB] Uploading {filename} to WordPress...")
print(f" [DEBUG] WP_URL={bool(wp_url)}, WP_USERNAME={bool(wp_user)}, WP_APP_PASSWORD={'*' + wp_pass[-4:] if wp_pass else 'EMPTY'}")
# Determine MIME type
ext = file_path.suffix.lower()
mime_map = {
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp",
".mp4": "video/mp4", ".mov": "video/quicktime", ".avi": "video/x-msvideo", ".webm": "video/webm"
}
mime_type = mime_map.get(ext, "application/octet-stream")
file_data = file_path.read_bytes()
upload_method = os.getenv("WP_UPLOAD_METHOD", "auto").strip().lower()
if pull_relay_enabled():
return _upload_media_to_wp_bridge(
wp_url, file_data, filename, mime_type
)
upload_errors = []
if upload_method in ("auto", "rest"):
try:
return _upload_media_to_wp_rest(wp_url, file_data, filename, mime_type, ext)
except Exception as e:
if upload_method == "rest":
raise
upload_errors.append(f"REST error: {e}")
print(f" [WARN] REST media upload failed; trying authenticated upload bridge: {e}")
if upload_method in ("auto", "bridge"):
try:
return _upload_media_to_wp_bridge(wp_url, file_data, filename, mime_type)
except Exception as e:
if upload_method == "bridge":
raise
upload_errors.append(f"Bridge error: {e}")
print(f" [WARN] Upload bridge failed; trying XML-RPC fallback: {e}")
if upload_method in ("auto", "xmlrpc"):
try:
return _upload_media_to_wp_xmlrpc(wp_url, wp_user, wp_pass, file_data, filename, mime_type)
except Exception as e:
upload_errors.append(f"XML-RPC error: {e}")
raise Exception("WordPress media upload failed. " + "; ".join(upload_errors))
raise ValueError("WP_UPLOAD_METHOD must be auto, rest, bridge, or xmlrpc")
def _upload_media_to_wp_rest(wp_url: str, file_data: bytes, filename: str, mime_type: str, ext: str) -> dict:
"""Upload media through the WordPress REST API."""
url = f"{wp_url}/wp-json/wp/v2/media"
base_headers = {
"Authorization": _wp_auth_header(),
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Accept": "application/json, */*",
"Origin": wp_url,
"Referer": f"{wp_url}/wp-admin/upload.php",
}
# For videos, we might need longer timeout
read_timeout = 300 if ext in [".mp4", ".mov", ".avi", ".webm"] else 60
timeout = (15, read_timeout)
# First try multipart form data, then the canonical WordPress raw upload.
response = requests.post(
url,
headers=base_headers,
files={"file": (filename, file_data, mime_type)},
timeout=timeout,
verify=_wp_ssl_verify(),
)
try:
return _parse_wp_rest_media_response(response, url, "REST multipart")
except Exception as multipart_error:
print(f" [WARN] REST multipart upload failed; trying REST raw upload: {multipart_error}")
raw_headers = base_headers | {
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Type": mime_type,
}
response = requests.post(
url,
headers=raw_headers,
data=file_data,
timeout=timeout,
verify=_wp_ssl_verify(),
)
return _parse_wp_rest_media_response(response, url, "REST raw")
def _upload_media_to_wp_bridge(
wp_url: str,
file_data: bytes,
filename: str,
mime_type: str,
) -> dict:
"""Upload through an authenticated admin-ajax action when REST is blocked."""
url = os.getenv(
"WP_BRIDGE_URL",
f"{wp_url}/wp-admin/admin-ajax.php?action=content_agent_upload",
)
fields = {
"content_agent_user": os.getenv("WP_USERNAME", ""),
"content_agent_password": "".join(os.getenv("WP_APP_PASSWORD", "").split()),
"file": (filename, file_data, mime_type),
}
status_code, content_type, response_text, origin = post_multipart_to_wordpress_origin(
url, fields, verify_ssl=_wp_ssl_verify()
)
print(f" [DEBUG] Upload bridge origin: {origin}")
if status_code != 200 or "json" not in content_type.lower():
raise Exception(f"Upload bridge failed ({status_code}): {response_text[:300]}")
payload = json.loads(response_text)
if not payload.get("success"):
raise Exception(f"Upload bridge rejected the file: {payload.get('data')}")
data = payload.get("data") or {}
result = {"id": int(data.get("id") or 0), "url": data.get("url", "")}
if not result["id"] or not result["url"]:
raise Exception(f"Upload bridge returned an incomplete response: {data}")
print(f" [OK] Uploaded via authenticated bridge: {result['url']} (ID: {result['id']})")
return result
def _parse_wp_rest_media_response(response, url: str, method_label: str) -> dict:
"""Validate and normalize a WordPress REST media response."""
# Check if we got a bot verification page instead of a response
content_type = response.headers.get("Content-Type", "")
print(f" [DEBUG] {method_label} response: Status={response.status_code}, Content-Type={content_type}, URL={response.url}")
if response.status_code == 200 and "text/html" in content_type:
print(f" [DEBUG] HTML Response preview: {response.text[:300]}")
if "text/html" in content_type or "<html" in response.text.lower() or "bot verification" in response.text.lower():
raise Exception(
f"WordPress blocked the upload (bot verification/WAF). "
f"Check your security plugin settings and whitelist the REST API. "
f"URL: {url}, Status: {response.status_code}"
)
if response.status_code not in (200, 201):
raise Exception(
f"WordPress media upload failed ({response.status_code}): "
f"{response.text[:300]}"
)
try:
data = response.json()
except Exception as e:
preview = response.text[:500] if response.text else "<EMPTY RESPONSE>"
raise Exception(f"WordPress returned non-JSON but status {response.status_code}: {preview}")
result = {
"id": data.get("id"),
"url": data.get("source_url", data.get("guid", {}).get("rendered", ""))
}
print(f" [OK] Uploaded via {method_label}: {result['url']} (ID: {result['id']})")
return result
def _upload_media_to_wp_xmlrpc(
wp_url: str,
wp_user: str,
wp_pass: str,
file_data: bytes,
filename: str,
mime_type: str,
) -> dict:
"""Upload media through WordPress XML-RPC as a fallback for REST/WAF blocks."""
import xmlrpc.client
endpoint = os.getenv("WP_XMLRPC_URL", f"{wp_url}/xmlrpc.php")
xmlrpc_user = os.getenv("WP_XMLRPC_USERNAME", wp_user)
xmlrpc_pass = os.getenv("WP_XMLRPC_PASSWORD", wp_pass)
print(f" [JOB] Retrying media upload via WordPress XML-RPC...")
payload = {
"name": filename,
"type": mime_type,
"bits": xmlrpc.client.Binary(file_data),
"overwrite": False,
}
request_body = xmlrpc.client.dumps((0, xmlrpc_user, xmlrpc_pass, payload), methodname="wp.uploadFile", allow_none=True)
response = requests.post(
endpoint,
data=request_body.encode("utf-8"),
headers={
"Content-Type": "text/xml",
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
),
},
timeout=(15, 300),
verify=_wp_ssl_verify(),
)
content_type = response.headers.get("Content-Type", "")
if response.status_code != 200 or "text/html" in content_type or "<html" in response.text.lower():
raise Exception(f"XML-RPC upload failed ({response.status_code}): {response.text[:300]}")
try:
params, _ = xmlrpc.client.loads(response.content)
except xmlrpc.client.Fault:
raise
except Exception as e:
raise Exception(f"Invalid XML-RPC response from WordPress: {e}; {response.text[:300]}")
data = params[0] if params else {}
result = {
"id": int(data.get("id") or 0),
"url": data.get("url", ""),
}
if not result["id"] or not result["url"]:
raise Exception(f"XML-RPC upload returned an incomplete response: {data}")
print(f" [OK] Uploaded via XML-RPC: {result['url']} (ID: {result['id']})")
return result
def _try_upload_original_media(file_path: Path, filename: str, is_video: bool) -> dict | None:
"""
Best-effort upload for source media. Some WordPress security layers block
larger/raw video uploads, but the job can still publish the generated post.
"""
try:
return upload_media_to_wp(file_path, filename)
except WordPressUnavailableError as e:
print(f" [ERROR] WordPress is unreachable; stopping before AI processing: {e}")
raise
except Exception as e:
print(f" [WARN] Original {'video' if is_video else 'media'} upload skipped: {e}")
return None
def publish_post(
title: str,
paragraph1: str,
paragraph2: str,
featured_media_id: int,
flyer_id: int,
flyer_url: str,
source_url: str,
is_video: bool = True,
extra_media: list[dict] = None
) -> dict:
"""
Create and publish a WordPress post via REST API.
extra_media: list of {"url": str, "id": int, "type": "image"|"video"}
"""
print("[JOB] Publishing post to WordPress...")
# Media section (Gutenberg blocks)
media_html = ""
if extra_media:
for item in extra_media:
m_url = item["url"]
m_id = item["id"]
if item.get("type") == "video":
media_html += f"""
<!-- wp:video {{"id":{m_id},"sizeSlug":"full"}} -->
<figure class="wp-block-video"><video controls src="{m_url}"></video></figure>
<!-- /wp:video -->
"""
else:
media_html += f"""
<!-- wp:image {{"id":{m_id},"sizeSlug":"full","linkDestination":"none"}} -->
<figure class="wp-block-image size-full"><img src="{m_url}" alt="" class="wp-image-{m_id}"/></figure>
<!-- /wp:image -->
"""
# Construct post body
source_button = ""
if source_url:
source_button = textwrap.dedent(f"""\
<!-- wp:buttons {{"layout":{{"type":"flex","justifyContent":"center"}}}} -->
<div class="wp-block-buttons">
<!-- wp:button -->
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="{source_url}" target="_blank" rel="noreferrer noopener">Source Link</a></div>
<!-- /wp:button -->
</div>
<!-- /wp:buttons -->
""").strip()
post_body = textwrap.dedent(f"""\
<!-- wp:paragraph -->
<p>{paragraph1}</p>
<!-- /wp:paragraph -->
<!-- wp:paragraph -->
<p>{paragraph2}</p>
<!-- /wp:paragraph -->
<!-- wp:image {{"id":{flyer_id},"sizeSlug":"full","linkDestination":"none"}} -->
<figure class="wp-block-image size-full"><img src="{flyer_url}" alt="{title}" class="wp-image-{flyer_id}"/></figure>
<!-- /wp:image -->
{media_html}
{source_button}
<!-- wp:shortcode -->
[ads2] [ads1]
<!-- /wp:shortcode -->
""").strip()
post_data = {
"title": title,
"content": post_body,
"status": WP_POST_STATUS,
"categories": [WP_CATEGORY_ID],
"featured_media": featured_media_id,
}
url = f"{WP_URL}/wp-json/wp/v2/posts"
response = request_wordpress_json(
"POST", url, post_data, WP_USERNAME, WP_APP_PASSWORD, timeout=30
)
if response.status_code not in (200, 201):
raise Exception(f"WP post failed: {response.text[:300]}")
data = response.json()
return {"id": data["id"], "url": data.get("link", "")}
# ──────────────────────────────────────────────
# 7. Full Pipeline
# ──────────────────────────────────────────────
def process_video(url_input: any) -> dict:
"""
Pipeline for video URL(s). Handles single URL string or list of URLs.
"""
is_redo = False
if isinstance(url_input, dict):
is_redo = url_input.get("is_redo", False)
urls = url_input.get("urls") or [url_input.get("url")]
elif isinstance(url_input, str):
urls = [url_input]
else:
urls = url_input
primary_url = urls[0]
print(f"\n{'='*60}")
print(f"[JOB] Processing {len(urls)} media URL(s)...")
print(f"{'='*60}")
_ensure_dirs()
# Validate WP credentials before doing expensive work
if not all([WP_URL, WP_USERNAME, WP_APP_PASSWORD]):
missing = [k for k, v in [("WP_URL", WP_URL), ("WP_USERNAME", WP_USERNAME), ("WP_APP_PASSWORD", WP_APP_PASSWORD)] if not v]
msg = f"WordPress credentials not set. Missing: {', '.join(missing)}. Set them in .env or deployment config."
print(f" [ERROR] {msg}")
raise RuntimeError(msg)
import hashlib
primary_hash = hashlib.md5(primary_url.encode()).hexdigest()[:8]
safe_name = _sanitize_filename(
re.search(r'/(?:reel|p|video|shorts)/([A-Za-z0-9_-]+)', primary_url).group(1)
if re.search(r'/(?:reel|p|video|shorts)/([A-Za-z0-9_-]+)', primary_url)
else primary_hash
)
all_frames = []
wp_extra_media = []
for i, url in enumerate(urls):
print(f"[{i+1}/{len(urls)}] Downloading: {url}")
video_path = download_video(url)
ext = video_path.suffix.lower()
is_video = ext in [".mp4", ".webm", ".avi", ".mov"]
# Extract frames (auto-calculates: 1 frame per second, max 30)
frames = extract_frames(video_path)
all_frames.extend(frames)
# Upload original to WP as an optional embed.
wp_item = _try_upload_original_media(video_path, f"grouped-{i}-{video_path.name}", is_video)
if wp_item:
wp_extra_media.append({
"id": wp_item["id"],
"url": wp_item["url"],
"type": "video" if is_video else "image"
})
# Step: Analyze Primary (first one)
# UNIFIED: Use the frames we already extracted (up to 12) for analysis
# If grouped, all_frames might have frames from multiple videos.
# We analyze the primary video's frames specifically or use the global pool.
# Use the first downloaded video's path as the reference path
primary_video_path = DOWNLOAD_DIR / f"{safe_name}.mp4"
processing_frames = all_frames[:30]
analysis = analyze_video(primary_video_path, frames=processing_frames, is_redo=is_redo)
categories = analysis.get("frame_categories", {}) if isinstance(analysis, dict) else {}
# Step: Generate Content
content = generate_content(analysis, primary_url)
title = content["title"]
# Step: Create Collages
# UNIFIED: Use the same frames that were sent to the AI
featured_path = create_featured_image(title, processing_frames, safe_name, categories, is_redo=is_redo)
flyer_path = create_flyer(title, processing_frames, safe_name, categories, is_redo=is_redo)
# Step: Upload Generated
featured_media = upload_media_to_wp(featured_path, f"featured-{safe_name}.jpg")
flyer_media = upload_media_to_wp(flyer_path, f"flyer-{safe_name}.jpg")
# Step: Publish
post = publish_post(
title=title,
paragraph1=content["paragraph1"],
paragraph2=content["paragraph2"],
featured_media_id=featured_media["id"],
flyer_id=flyer_media["id"],
flyer_url=flyer_media["url"],
source_url=primary_url,
extra_media=wp_extra_media
)
print(f"\n[DONE] Grouped post published: {post['url']}")
story_media = {"id": None, "url": ""}
try:
story_path = create_story_flyer(title, processing_frames, safe_name, post["url"], categories, is_redo=is_redo)
story_media = upload_media_to_wp(story_path, f"story-{safe_name}.jpg")
except Exception as e:
print(f" [WARN] Story flyer generation/upload failed: {e}")
return {
"source_url": primary_url,
"title": title,
"post_url": post["url"],
"post_id": post["id"],
"featured_image_url": featured_media["url"],
"featured_image_id": featured_media["id"],
"flyer_url": flyer_media["url"],
"flyer_media_id": flyer_media["id"],
"story_flyer_url": story_media.get("url", ""),
"story_flyer_media_id": story_media.get("id"),
}
# ──────────────────────────────────────────────
# 7b. Uploaded Video Pipeline (no download step)
# ──────────────────────────────────────────────
def process_uploaded_video(video_path_input: any) -> dict:
"""
Pipeline for manually uploaded media files:
analyze β†’ generate content β†’ create media β†’ publish to WP.
Handles single path or list of paths.
"""
is_redo = False
if isinstance(video_path_input, dict):
is_redo = video_path_input.get("is_redo", False)
paths_input = video_path_input.get("urls") or [video_path_input.get("url")]
elif isinstance(video_path_input, str):
paths_input = [video_path_input]
else:
paths_input = video_path_input if isinstance(video_path_input, list) else [video_path_input]
# Normalize input to list
paths = []
for p in paths_input:
p_str = str(p)
if p_str.startswith("upload://"):
paths.append(_DATA_DIR / "uploads" / p_str[9:])
else:
paths.append(Path(p_str))
# First file is treated as the "primary" for analysis/featured image
primary_path = paths[0]
if not primary_path.exists():
raise FileNotFoundError(f"Uploaded file not found: {primary_path}")
print(f"\n{'='*60}")
print(f"[JOB] Processing uploaded media: {primary_path.name} (+{len(paths)-1} more)")
print(f"{'='*60}")
_ensure_dirs()
# Validate WP credentials before doing expensive work
if not all([WP_URL, WP_USERNAME, WP_APP_PASSWORD]):
missing = [k for k, v in [("WP_URL", WP_URL), ("WP_USERNAME", WP_USERNAME), ("WP_APP_PASSWORD", WP_APP_PASSWORD)] if not v]
msg = f"WordPress credentials not set. Missing: {', '.join(missing)}. Set them in .env or deployment config."
print(f" [ERROR] {msg}")
raise RuntimeError(msg)
import hashlib
file_hash = hashlib.md5(primary_path.name.encode()).hexdigest()[:8]
safe_name = _sanitize_filename(primary_path.stem) or file_hash
# Step 1: Media Preparation (frames/images)
all_frames = []
wp_extra_media = []
is_video_primary = primary_path.suffix.lower() in [".mp4", ".webm", ".avi", ".mov"]
for i, p in enumerate(paths):
ext = p.suffix.lower()
is_video = ext in [".mp4", ".webm", ".avi", ".mov"]
# Upload original video/image to WP as an optional embed.
wp_item = _try_upload_original_media(p, f"uploaded-{i}-{p.name}", is_video)
if wp_item:
wp_extra_media.append({"id": wp_item["id"], "url": wp_item["url"], "type": "video" if is_video else "image"})
if is_video:
frames = extract_frames(p) # auto: 1 frame/sec, max 30
all_frames.extend(frames)
else:
try:
from PIL import Image
all_frames.append(Image.open(p))
except Exception as e:
print(f"[WARN] Could not open image {p}: {e}")
# Use first 30 frames for analysis/featured
frames_for_processing = all_frames[:30]
# Step 2: Analyze primary media with AI
# UNIFIED: Use the same frames we extracted above
# frames_for_processing = all_frames[:60]
analysis = analyze_video(primary_path, frames=all_frames[:30], is_redo=is_redo)
categories = analysis.get("frame_categories", {}) if isinstance(analysis, dict) else {}
# Step 3: Generate blog content
source_label = f"Uploaded: {primary_path.name}"
content = generate_content(analysis, source_label)
title = content["title"]
p1 = content["paragraph1"]
p2 = content["paragraph2"]
# Step 4: Create featured image
# UNIFIED: Use the same frames that were sent to the AI
featured_path = create_featured_image(title, all_frames[:30], safe_name, categories, is_redo=is_redo)
# Step 5: Create flyer
flyer_path = create_flyer(title, all_frames[:30], safe_name, categories, is_redo=is_redo)
# Step 6: Upload generated media
featured_media = upload_media_to_wp(featured_path, f"featured-{safe_name}.jpg")
flyer_media = upload_media_to_wp(flyer_path, f"flyer-{safe_name}.jpg")
# Step 7: Publish post
post_source_url = wp_extra_media[0]["url"] if wp_extra_media else ""
post = publish_post(
title=title,
paragraph1=p1,
paragraph2=p2,
featured_media_id=featured_media["id"],
flyer_id=flyer_media["id"],
flyer_url=flyer_media["url"],
source_url=post_source_url,
is_video=is_video_primary,
extra_media=wp_extra_media
)
result = {
"source_url": f"upload://{primary_path.name}",
"title": title,
"post_url": post["url"],
"post_id": post["id"],
"featured_image_url": featured_media["url"],
"featured_image_id": featured_media["id"],
"flyer_url": flyer_media["url"],
"flyer_media_id": flyer_media["id"],
}
try:
story_path = create_story_flyer(title, all_frames[:30], safe_name, post["url"], categories, is_redo=is_redo)
story_media = upload_media_to_wp(story_path, f"story-{safe_name}.jpg")
result["story_flyer_url"] = story_media["url"]
result["story_flyer_media_id"] = story_media["id"]
except Exception as e:
print(f" [WARN] Story flyer generation/upload failed: {e}")
result["story_flyer_url"] = ""
result["story_flyer_media_id"] = None
print(f"\n[DONE] SUCCESS! Post published: {post['url']}")
return result
def process_uploaded_image(image_path_input: any) -> dict:
"""Redirect to process_uploaded_video as it now handles both and multiple files."""
return process_uploaded_video(image_path_input)
# ──────────────────────────────────────────────
# 8. CLI Entry Point
# ──────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="Instagram Video -> WordPress Auto-Publisher + Article Rewriter",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=textwrap.dedent("""
Examples:
python main.py https://www.instagram.com/reel/ABC123/
python main.py --file urls.txt
python main.py URL1 URL2 URL3
Article Rewriter:
python main.py --rewrite https://example.com/some-article
python main.py --rewrite --file articles.txt
python main.py --rewrite --dry-run https://example.com/article
""")
)
parser.add_argument(
"urls", nargs="*",
help="Video URLs (Instagram, TikTok, YT, etc.) or Article URLs with --rewrite"
)
parser.add_argument(
"--file", "-f", type=str,
help="Path to a text file containing URLs (one per line)"
)
parser.add_argument(
"--dry-run", action="store_true",
help="Analyze and generate content but don't publish to WordPress"
)
parser.add_argument(
"--rewrite", "-r", action="store_true",
help="Article rewrite mode: scrape, rewrite, and publish articles from any URL"
)
args = parser.parse_args()
# Collect URLs
urls = list(args.urls) if args.urls else []
if args.file:
file_path = Path(args.file)
if not file_path.exists():
print(f"[ERROR] File not found: {args.file}")
sys.exit(1)
with open(file_path) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#"):
urls.append(line)
if not urls:
parser.print_help()
print("\n[ERROR] No URLs provided. Pass URLs as arguments or use --file.")
sys.exit(1)
# Validate config
_check_config()
_ensure_dirs()
mode_label = "Article Rewrite" if args.rewrite else "Instagram Video"
print(f"\n[JOB] Found {len(urls)} URL(s) to process")
print(f" Mode: {mode_label}")
print(f" WordPress: {WP_URL}")
print(f" Post status: {WP_POST_STATUS}")
print(f" Dry run: {'YES' if args.dry_run else 'NO'}")
results = []
errors = []
for i, url in enumerate(urls, 1):
print(f"\n[{i}/{len(urls)}] Processing...")
try:
if args.rewrite:
# Article rewrite mode
from article_rewriter import process_article
result = process_article(url, dry_run=args.dry_run)
results.append(result)
elif args.dry_run:
# Only download, analyze, and generate β€” no WP publishing
import hashlib
url_hash = hashlib.md5(url.encode()).hexdigest()[:8]
_ensure_dirs()
safe_name = _sanitize_filename(
re.search(r'/(?:reel|p|video|shorts)/([A-Za-z0-9_-]+)', url).group(1)
if re.search(r'/(?:reel|p|video|shorts)/([A-Za-z0-9_-]+)', url)
else url_hash
)
video_path = download_video(url)
frames = extract_frames(video_path) # auto: 1 frame/sec, max 30
analysis = analyze_video(video_path)
content = generate_content(analysis, url)
featured_path = create_featured_image(content["title"], frames[0], safe_name)
flyer_frames = frames[:2] if len(frames) >= 2 else frames + frames[:1]
flyer_path = create_flyer(content["title"], flyer_frames, safe_name)
results.append({
"source_url": url,
"title": content["title"],
"paragraph1": content["paragraph1"],
"paragraph2": content["paragraph2"],
"featured_image": str(featured_path),
"flyer_image": str(flyer_path),
"status": "dry_run_complete"
})
print(f"\n[OK] Dry run complete for: {url}")
print(f" Title: {content['title']}")
else:
result = process_video(url)
results.append(result)
except Exception as e:
print(f"\n[ERROR] processing {url}: {e}")
errors.append({"url": url, "error": str(e)})
# Summary
print(f"\n{'='*60}")
print("[SUMMARY] PROCESSING SUMMARY")
print(f"{'='*60}")
print(f" Total URLs: {len(urls)}")
print(f" Successful: {len(results)}")
print(f" Errors: {len(errors)}")
if results:
print(f"\nβœ… Successful posts:")
for r in results:
print(f" - {r.get('title', 'N/A')}")
if "post_url" in r:
print(f" β†’ {r['post_url']}")
if errors:
print(f"\n❌ Failed URLs:")
for e in errors:
print(f" - {e['url']}: {e['error']}")
# Save results to JSON
results_path = OUTPUT_DIR / "results.json"
with open(results_path, "w", encoding="utf-8") as f:
json.dump({"results": results, "errors": errors}, f, indent=2, ensure_ascii=False)
print(f"\nπŸ“ Results saved to: {results_path}")
if __name__ == "__main__":
main()