contentai / article_rewriter.py
webmazta
Standardize WordPress writes on pull relay
f21ca98
Raw History Blame Contribute Delete
96.7 kB
ο»Ώ#!/usr/bin/env python3
"""
Article Rewriter Module
=======================
Scrapes articles from URLs, rewrites them with Gemma 4 AI,
finds related YouTube videos & images for embedding,
generates featured images, and publishes to WordPress.
Usage (standalone):
python article_rewriter.py <article_url>
python article_rewriter.py --file articles.txt
"""
import json
import os
import re
import textwrap
import time
import hashlib
import base64
from pathlib import Path
from io import BytesIO
from urllib.parse import urlparse, urljoin, quote_plus
from dotenv import load_dotenv
from bs4 import BeautifulSoup
import requests
import aiohttp
import asyncio
from PIL import Image, ImageDraw, ImageFont
from pydantic import BaseModel, ValidationError
from wordpress_transport import (
post_multipart_to_wordpress_origin,
pull_relay_enabled,
request_wordpress_json,
)
# ──────────────────────────────────────────────
# Structured output validation
# ──────────────────────────────────────────────
class ArticleContent(BaseModel):
"""Validates the LLM's article rewrite output has all required fields."""
title: str
paragraphs: list[str]
seo_description: str = ""
image_alt_texts: list[str] = []
tags: list[str] = []
def _validate_title(generated_title: str, original_title: str) -> str:
"""
Validate the generated title shares meaningful overlap with the original.
Falls back to original title if the model went completely off-topic.
"""
if not generated_title or not generated_title.strip():
return original_title[:70]
title = generated_title.strip()
# Length sanity check
if len(title) < 3 or len(title) > 80:
return original_title[:70]
# Word overlap check: does the generated title share at least one
# significant word (3+ chars) with the original?
def _significant_words(t):
return {w.lower().strip('.,!?;:"\'') for w in t.split() if len(w) >= 3}
orig_words = _significant_words(original_title)
gen_words = _significant_words(title)
overlap = orig_words & gen_words
if not overlap:
# Model went completely off-topic β€” revert to original
print(f" [WARN] Generated title shares no words with original. Falling back.")
return original_title[:70]
return title
# ──────────────────────────────────────────────
# Configuration
# ──────────────────────────────────────────────
load_dotenv()
from gemma_ai import gemini_generate, image_to_part, image_to_base64
WP_URL = os.getenv("WP_URL", "").rstrip("/")
WP_USERNAME = os.getenv("WP_USERNAME", "")
WP_APP_PASSWORD = "".join(os.getenv("WP_APP_PASSWORD", "").split())
WP_CATEGORY_ID = int(os.getenv("WP_CATEGORY_NEWS", os.getenv("WP_REWRITE_CATEGORY_ID", os.getenv("WP_CATEGORY_ID", "1"))))
WP_POST_STATUS = os.getenv("WP_POST_STATUS", "publish")
LLM_MODEL = os.getenv("LLM_MODEL", "gemma-4-31b-it")
# 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"
# Cache file for article rewrites (also on persistent storage)
_ARTICLE_CACHE_PATH = _DATA_DIR / "article_rewriter_cache.json"
# Common user-agent for scraping
HEADERS = {
"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": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "identity",
"Connection": "keep-alive",
}
# Only include Origin header when WP_URL is set (avoids aiohttp None serialization error)
if WP_URL:
HEADERS["Origin"] = WP_URL
# ──────────────────────────────────────────────
# 0b. Article Rewrite Result Cache
# ──────────────────────────────────────────────
def _get_article_cache_key(source_url: str, body_text: str) -> str:
"""Create a content hash from URL + body fingerprint for cache lookup."""
hasher = hashlib.md5()
hasher.update(source_url.encode())
# Hash first 2000 chars of body as content fingerprint
hasher.update(body_text[:2000].encode())
return hasher.hexdigest()
def _load_article_cache() -> dict:
if _ARTICLE_CACHE_PATH.exists():
try:
with open(_ARTICLE_CACHE_PATH, "r") as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return {}
return {}
def _save_article_cache(cache: dict):
try:
with open(_ARTICLE_CACHE_PATH, "w") as f:
json.dump(cache, f)
except IOError:
pass
# ──────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────
def _ensure_dirs():
"""Create working directories if they don't exist."""
DOWNLOAD_DIR.mkdir(exist_ok=True)
OUTPUT_DIR.mkdir(exist_ok=True)
def _wp_auth() -> tuple[str, str]:
"""Return WordPress auth tuple."""
return (WP_USERNAME, WP_APP_PASSWORD)
def _wp_auth_header() -> str:
"""Return Basic Auth header string for direct header injection."""
creds = f'{WP_USERNAME}:{WP_APP_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 _sanitize_filename(name: str) -> str:
"""Create a safe filename from a string."""
safe = re.sub(r'[^\w\-]', '_', name)
return safe[:80]
def _get_font(size: int):
"""Try to load a nice font, fall back to default."""
font_paths = [
"C:/Windows/Fonts/segoeui.ttf",
"C:/Windows/Fonts/arial.ttf",
"C:/Windows/Fonts/calibri.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/System/Library/Fonts/Helvetica.ttc",
]
for fp in font_paths:
try:
return ImageFont.truetype(fp, size)
except (IOError, OSError):
continue
try:
return ImageFont.truetype("arial.ttf", size)
except (IOError, OSError):
return ImageFont.load_default()
def _extract_embed_info(url: str) -> dict | None:
"""Extract media embed info from a URL if it's a supported platform."""
if not url:
return None
# YouTube
yt_match = re.search(
r'(?:youtube\.com/(?:watch\?v=|embed/|v/)|youtu\.be/)([\w-]{11})', url
)
if yt_match:
vid_id = yt_match.group(1)
return {
"url": f"https://www.youtube.com/watch?v={vid_id}",
"title": "YouTube Video",
"embed_url": f"https://www.youtube.com/embed/{vid_id}",
"provider": "youtube"
}
# Streamable
st_match = re.search(r'streamable\.com/(?:e/|s/)?(\w+)', url)
if st_match:
vid_id = st_match.group(1)
return {
"url": f"https://streamable.com/{vid_id}",
"title": "Streamable Video",
"embed_url": f"https://streamable.com/e/{vid_id}",
"provider": "streamable"
}
# Vimeo
vim_match = re.search(r'(?:vimeo\.com|player\.vimeo\.com/video)/(\d+)', url)
if vim_match:
vid_id = vim_match.group(1)
return {
"url": f"https://vimeo.com/{vid_id}",
"title": "Vimeo Video",
"embed_url": f"https://player.vimeo.com/video/{vid_id}",
"provider": "vimeo"
}
# Dailymotion
dm_match = re.search(r'dailymotion\.com/(?:video|embed/video)/(\w+)', url)
if dm_match:
vid_id = dm_match.group(1)
return {
"url": f"https://www.dailymotion.com/video/{vid_id}",
"title": "Dailymotion Video",
"embed_url": f"https://www.dailymotion.com/embed/video/{vid_id}",
"provider": "dailymotion"
}
# TikTok
tt_match = re.search(r'tiktok\.com/(@[\w.-]+)/video/(\d+)', url)
if tt_match:
# Clean URL
clean_url = f"https://www.tiktok.com/{tt_match.group(1)}/video/{tt_match.group(2)}"
return {
"url": clean_url,
"title": "TikTok Video",
"embed_url": clean_url,
"provider": "tiktok"
}
# Instagram
ig_match = re.search(r'instagram\.com/(?:p|reel|reels)/([\w-]+)', url)
if ig_match:
# Clean URL
clean_url = f"https://www.instagram.com/reel/{ig_match.group(1)}/"
return {
"url": clean_url,
"title": "Instagram Post",
"embed_url": clean_url,
"provider": "instagram"
}
# Facebook
fb_match = re.search(r'facebook\.com/(?:watch/?\?v=|[\w.-]+/videos/|video\.php\?v=)(\d+)', url)
if fb_match:
# Clean URL
clean_url = f"https://www.facebook.com/watch/?v={fb_match.group(1)}"
return {
"url": clean_url,
"title": "Facebook Video",
"embed_url": clean_url,
"provider": "facebook"
}
# X (Twitter)
x_match = re.search(r'(?:twitter\.com|x\.com)/([\w-]+)/status/(\d+)', url)
if x_match:
username = x_match.group(1)
tweet_id = x_match.group(2)
clean_url = f"https://x.com/{username}/status/{tweet_id}"
return {
"url": clean_url,
"title": "X Post by @{username}",
"embed_url": clean_url,
"provider": "x"
}
# Threads
threads_match = re.search(r'threads\.net/(@[\w.-]+)/post/([\w-]+)', url)
if threads_match:
clean_url = f"https://www.threads.net/{threads_match.group(1)}/post/{threads_match.group(2)}"
return {
"url": clean_url,
"title": "Threads Post",
"embed_url": clean_url,
"provider": "threads"
}
# Reddit
reddit_match = re.search(r'reddit\.com/r/[\w-]+/comments/(\w+)', url)
if reddit_match:
# Strip query params
clean_url = url.split("?")[0]
return {
"url": clean_url,
"title": "Reddit Post",
"embed_url": clean_url,
"provider": "reddit"
}
# Spotify
spotify_match = re.search(r'open\.spotify\.com/(track|album|playlist|episode|show)/([\w-]+)', url)
if spotify_match:
clean_url = f"https://open.spotify.com/{spotify_match.group(1)}/{spotify_match.group(2)}"
return {
"url": clean_url,
"title": "Spotify Media",
"embed_url": clean_url,
"provider": "spotify"
}
# SoundCloud
soundcloud_match = re.search(r'soundcloud\.com/[\w-]+/[\w-]+', url)
if soundcloud_match:
clean_url = url.split("?")[0]
return {
"url": clean_url,
"title": "SoundCloud Audio",
"embed_url": clean_url,
"provider": "soundcloud"
}
# Pinterest
pinterest_match = re.search(r'pinterest\.com/pin/(\d+)', url)
if pinterest_match:
clean_url = f"https://www.pinterest.com/pin/{pinterest_match.group(1)}/"
return {
"url": clean_url,
"title": "Pinterest Pin",
"embed_url": clean_url,
"provider": "pinterest"
}
# Bluesky
bsky_match = re.search(r'bsky\.app/profile/[\w.-]+/post/([\w-]+)', url)
if bsky_match:
return {
"url": url,
"title": "Bluesky Post",
"embed_url": url,
"provider": "bluesky"
}
return None
# ──────────────────────────────────────────────
# 1. Article Scraping
# ──────────────────────────────────────────────
def scrape_article(url: str) -> dict:
"""
Scrape an article from a URL.
Returns: {
"title": str,
"body": str, # Full article text
"images": [str], # List of image URLs found in the article body
"featured_image": str, # Source site's chosen featured image (JSON-LD/og:image)
"videos": [dict], # Embedded media (YouTube, TikTok, etc.)
"source_url": str,
"source_domain": str
}
"""
print(f"🌐 Scraping article: {url}")
# Warning for social media URLs
social_domains = ["tiktok.com", "instagram.com", "facebook.com", "x.com", "twitter.com", "threads.net"]
if any(d in url for d in social_domains):
print(f" ⚠️ [WARN] URL appears to be a social media post. Scraping may be limited.")
try:
resp = requests.get(url, headers=HEADERS, timeout=30, allow_redirects=True)
resp.raise_for_status()
except requests.RequestException as e:
raise Exception(f"Failed to fetch article: {e}")
soup = BeautifulSoup(resp.text, "html.parser")
domain = urlparse(url).netloc
# --- Extract title ---
title = ""
# Try og:title first
og_title = soup.find("meta", property="og:title")
if og_title and og_title.get("content"):
title = og_title["content"].strip()
# Fallback to <title> tag
if not title and soup.title:
title = soup.title.string.strip() if soup.title.string else ""
# Fallback to first <h1>
if not title:
h1 = soup.find("h1")
if h1:
title = h1.get_text(strip=True)
# --- Extract body text ---
body_parts = []
# Try common article containers (ordered by specificity)
article_selectors = [
"article",
'[class*="article-body"]',
'[class*="article-content"]',
'[class*="post-content"]',
'[class*="entry-content"]',
'[class*="story-body"]',
'[class*="content-body"]',
'[role="main"]',
"main",
'[class*="content"]',
]
content_elem = None
for selector in article_selectors:
found = soup.select_one(selector)
if found:
# Check it has substantial text
text = found.get_text(strip=True)
if len(text) > 200:
content_elem = found
break
if content_elem is None:
# Last resort: just use the whole body
content_elem = soup.body if soup.body else soup
# --- Noise Removal within content element ---
# Remove common junk widgets that often get caught in article containers
noise_selectors = [
'[class*="related-post"]', '[class*="related-article"]',
'[class*="popular-post"]', '[class*="recommended"]',
'[class*="suggested"]', '[class*="must-read"]',
'[class*="read-more"]', '[class*="trending"]',
'[class*="sidebar"]', '[class*="newsletter"]',
'[class*="social-share"]', '[class*="author-box"]',
'[class*="comment"]', '[class*="ad-"]', '[id*="ad-"]',
'[class*="advert"]', '[id*="advert"]', '[class*="promo"]',
'[class*="sponsor"]', '[class*="banner"]', '[class*="widget"]',
'[class*="next-post"]', '[class*="prev-post"]', '[class*="pagination"]',
'[class*="td-module-thumb"]', '[class*="td-image-wrap"]', '[class*="entry-thumb"]',
'[class*="td_module_wrap"]', '[class*="post-thumbnail"]',
'aside', 'footer', 'nav', 'script', 'style', 'noscript', 'iframe'
]
for noise_sel in noise_selectors:
for noise_node in content_elem.select(noise_sel):
noise_node.decompose()
# Get paragraphs from the content element
paragraphs = content_elem.find_all("p")
for p in paragraphs:
# Skip if paragraph is inside a blockquote (likely an embed metadata)
if p.find_parents("blockquote"):
continue
text = p.get_text(strip=True)
if len(text) > 30: # Skip tiny fragments
body_parts.append(text)
# If we got very little, also grab headers + list items
if len(body_parts) < 3:
for tag in content_elem.find_all(["h2", "h3", "h4", "li"]):
text = tag.get_text(strip=True)
if len(text) > 20:
body_parts.append(text)
body = "\n\n".join(body_parts)
# --- Extract images ---
images = []
featured_image_url = None # The source site's chosen featured image (separate from body images)
# 1. Try JSON-LD schema thumbnailUrl (most reliable for WordPress/Yoast SEO)
for script in soup.find_all("script", type="application/ld+json"):
# Use .get_text() instead of .string β€” .string returns None if there are
# comment nodes or CDATA wrappers (common in Yoast SEO output)
raw = script.string or script.get_text(strip=True)
if not raw:
continue
# Strip HTML comments and CDATA wrappers
raw = re.sub(r"<!--.*?-->", "", raw, flags=re.DOTALL)
raw = re.sub(r"<!\[CDATA\[", "", raw)
raw = re.sub(r"\]\]>", "", raw)
raw = raw.strip()
if not raw:
continue
try:
data = json.loads(raw)
# Handle both single object and @graph array
if isinstance(data, dict):
if "thumbnailUrl" in data:
img_url = data["thumbnailUrl"]
if not img_url.startswith("http"):
img_url = urljoin(url, img_url)
featured_image_url = img_url # This is the editorial featured image
images.append(img_url)
print(f" πŸ–ΌοΈ Found featured image in JSON-LD thumbnailUrl: {img_url}")
# Check image object
if "image" in data and isinstance(data["image"], dict) and "url" in data["image"]:
img_url = data["image"]["url"]
if not img_url.startswith("http"):
img_url = urljoin(url, img_url)
if img_url not in images:
images.append(img_url)
print(f" πŸ–ΌοΈ Found image in JSON-LD image.url: {img_url}")
elif isinstance(data, list):
for item in data:
if isinstance(item, dict):
if "thumbnailUrl" in item:
img_url = item["thumbnailUrl"]
if not img_url.startswith("http"):
img_url = urljoin(url, img_url)
if not featured_image_url:
featured_image_url = img_url
if img_url not in images:
images.append(img_url)
print(f" πŸ–ΌοΈ Found featured image in JSON-LD graph thumbnailUrl")
if "image" in item and isinstance(item["image"], dict) and "url" in item["image"]:
img_url = item["image"]["url"]
if not img_url.startswith("http"):
img_url = urljoin(url, img_url)
if img_url not in images:
images.append(img_url)
print(f" πŸ–ΌοΈ Found image in JSON-LD graph image.url")
except (json.JSONDecodeError, KeyError, TypeError) as e:
print(f" [WARN] JSON-LD parse error: {e}")
# 2. Try og:image (fallback for featured image if JSON-LD didn't have one)
og_image = soup.find("meta", property="og:image")
if og_image and og_image.get("content"):
img_url = og_image["content"]
if not img_url.startswith("http"):
img_url = urljoin(url, img_url)
if not featured_image_url:
featured_image_url = img_url
print(f" πŸ–ΌοΈ Found featured image in og:image: {img_url}")
if img_url not in images:
images.append(img_url)
print(f" πŸ–ΌοΈ Found additional og:image: {img_url}")
# 2b. Try twitter:image as another fallback (some sites use this instead of og:image)
twitter_image = soup.find("meta", attrs={"name": "twitter:image"})
if twitter_image and twitter_image.get("content"):
img_url = twitter_image["content"]
if not img_url.startswith("http"):
img_url = urljoin(url, img_url)
if not featured_image_url:
featured_image_url = img_url
print(f" πŸ–ΌοΈ Found featured image in twitter:image: {img_url}")
if img_url not in images:
images.append(img_url)
print(f" πŸ–ΌοΈ Found additional twitter:image: {img_url}")
# 3. Get images from article body (handle lazy-loading attributes)
if content_elem:
for img in content_elem.find_all("img"):
# Check multiple lazy-load attribute patterns (order matters: real src first, then lazy attrs)
src = None
for attr in ["src", "data-src", "data-lazy-src", "data-original", "data-srcset"]:
val = img.get(attr, "")
if attr == "data-srcset":
# Take first URL from srcset (format: "url 1024w, url2 300w")
if val:
src = val.split()[0] if val.split() else None
elif val and not val.startswith("data:image"):
src = val
break
if not src:
continue
if not src.startswith("http"):
src = urljoin(url, src)
# Skip tiny icons, avatars, tracking pixels
width = img.get("width", "")
height = img.get("height", "")
try:
if width and int(width) < 300:
continue
if height and int(height) < 200:
continue
except ValueError:
pass
if src not in images:
images.append(src)
print(f" πŸ–ΌοΈ Found image in article body: {src[:80]}...")
# --- Extract embedded media (videos, social posts, audio) ---
embeds = []
if content_elem:
# 1. Find standard iframes (YouTube, Spotify, etc.)
for iframe in content_elem.find_all("iframe"):
src = iframe.get("src") or iframe.get("data-src") or ""
embed_info = _extract_embed_info(src)
if embed_info and embed_info["url"] not in [e["url"] for e in embeds]:
embeds.append(embed_info)
print(f" 🎬 Found embedded {embed_info['provider']}: {embed_info['url']}")
# 2. Find TikTok blockquotes
for blockquote in content_elem.find_all("blockquote", class_="tiktok-embed"):
cite = blockquote.get("cite")
if cite:
embed_info = _extract_embed_info(cite)
if embed_info and embed_info["url"] not in [e["url"] for e in embeds]:
embeds.append(embed_info)
print(f" 🎬 Found TikTok cite: {embed_info['url']}")
# 3. Find Instagram blockquotes
for blockquote in content_elem.find_all("blockquote", class_="instagram-media"):
link = blockquote.find("a", href=True)
if link:
embed_info = _extract_embed_info(link["href"])
if embed_info and embed_info["url"] not in [e["url"] for e in embeds]:
embeds.append(embed_info)
print(f" 🎬 Found Instagram embed: {embed_info['url']}")
# 4. Find X (Twitter) blockquotes
for blockquote in content_elem.find_all("blockquote", class_="twitter-tweet"):
link = blockquote.find("a", href=True)
if link:
embed_info = _extract_embed_info(link["href"])
if embed_info and embed_info["url"] not in [e["url"] for e in embeds]:
embeds.append(embed_info)
print(f" 🎬 Found X embed: {embed_info['url']}")
# 5. Find Facebook video divs
for fb_div in content_elem.find_all(["div", "span"], class_=["fb-video", "fb-post"]):
href = fb_div.get("data-href")
if href:
embed_info = _extract_embed_info(href)
if embed_info and embed_info["url"] not in [e["url"] for e in embeds]:
embeds.append(embed_info)
print(f" 🎬 Found Facebook embed: {embed_info['url']}")
# 6. Also check for general media links in <a> tags
for a_tag in content_elem.find_all("a", href=True):
href = a_tag["href"]
embed_info = _extract_embed_info(href)
if embed_info and embed_info["url"] not in [e["url"] for e in embeds]:
embeds.append(embed_info)
print(f" 🎬 Found linked {embed_info['provider']}: {embed_info['url']}")
# Check for <video> tags with source
for video_tag in content_elem.find_all("video"):
src = video_tag.get("src") or ""
if not src:
source_tag = video_tag.find("source")
if source_tag:
src = source_tag.get("src", "")
if src and src.startswith("http"):
embeds.append({
"url": src,
"title": "Embedded Video",
"embed_url": src,
"provider": "video"
})
# Limit embeds
embeds = embeds[:5]
# Limit images
images = images[:10]
if not body:
raise Exception(f"Could not extract article content from {url}")
print(f" βœ… Scraped: \"{title[:60]}...\"")
print(f" πŸ“ {len(body_parts)} paragraphs, {len(images)} images, {len(embeds)} embeds found")
return {
"title": title,
"body": body,
"images": images,
"featured_image": featured_image_url,
"videos": embeds,
"source_url": url,
"source_domain": domain,
}
# ──────────────────────────────────────────────
# 2. Article Rewriting with LLM
# ──────────────────────────────────────────────
def rewrite_article(original_title: str, original_body: str, source_url: str) -> dict:
"""
Rewrite an article using Gemma 4 AI.
Uses a cache to skip the LLM call for previously processed articles.
"""
# Check cache first
body_text = original_body[:10000] if len(original_body) > 10000 else original_body
cache_key = _get_article_cache_key(source_url, body_text)
cache = _load_article_cache()
if cache_key in cache:
print(f" [CACHE] Article rewrite found for this source (skip AI call)")
result = cache[cache_key]
print(f" βœ… Rewritten (cached): \"{result['title']}\"")
print(f" πŸ“ {len(result['paragraphs'])} paragraphs generated")
return result
prompt = textwrap.dedent(f"""
Rewrite the article below into a well-written, in-depth blog post.
The tone should be professional yet warm β€” like a knowledgeable journalist explaining a story to an interested reader over coffee. Not academic, not casual. Think BBC News meets a really good Substack writer.
ORIGINAL TITLE: {original_title}
ORIGINAL ARTICLE:
{body_text}
SOURCE: {source_url}
Respond ONLY with valid JSON in this exact format (no markdown, no code blocks):
{{
"title": "A clear, professional headline that captures the essence (max 70 chars)",
"paragraphs": [
"Opening paragraph β€” set the scene. What happened, who's involved, why it matters. 3-4 sentences that pull the reader in.",
"Second paragraph β€” go deeper into the story. What led to this moment? What's the background context? 3-4 sentences.",
"Third paragraph β€” bring in details, quotes, or specific examples from the original. This is where the story gets interesting. 3-4 sentences.",
"Fourth paragraph β€” explore the implications. What does this mean going forward? What are people saying? 3-4 sentences.",
"Fifth paragraph (if relevant) β€” add additional context, reactions, or related developments. 3-4 sentences."
],
"seo_description": "A concise, professional summary of the article (150-160 chars).",
"image_alt_texts": [
"Clear, descriptive caption for the main image",
"Clear, descriptive caption for a second image if relevant"
],
"tags": ["topic1", "topic2", "topic3", "topic4"]
}}
TITLE RULES:
- Max 70 characters.
- Clear, professional, and accurate to the story.
- Keep the same key people, places, events, and angle from the original title.
- If the original names a person or place, your title should too.
- If the original title is a QUOTE, keep a version of that quote and name the person.
Example: Original -> "High dollar helps exporters - Kojo Poku insists" -> New -> "Kojo Poku: The Dollar Is Helping Exporters"
- Avoid clickbait. Sound credible and trustworthy.
QUOTE RULES (critical):
- If someone said something in the original article, KEEP the direct quote. Do NOT paraphrase it.
- Write quotes exactly as they appeared, in quotation marks, with the speaker's name.
- If the article includes a statement, opinion, or claim from a named person, ALWAYS attribute it to them.
- If no direct quotes exist, report facts without adding opinions.
DEPTH & STRUCTURE RULES:
- Write 4-5 paragraphs (not just 2-3). Go deeper than a surface summary.
- Each paragraph should be 3-4 well-crafted sentences. Substantive, not fluffy.
- Paragraph 1: The hook β€” what happened and why readers should care.
- Paragraph 2: The context β€” how we got here, background that makes the story make sense.
- Paragraph 3: The details β€” specific facts, numbers, quotes, or examples from the original.
- Paragraph 4: The implications β€” what this means, reactions from others, what's next.
- Paragraph 5 (optional): Related developments, broader context, or additional perspectives.
WRITING STYLE RULES:
- Professional but accessible β€” no academic jargon, no slang.
- Use clear, everyday words. A curious teenager should understand every sentence.
- Vary your sentence structure. Don't start every sentence with "The" or "It."
- Use contractions naturally where they fit ("it's," "don't," "they're").
- Write with authority but humility β€” present facts, don't editorialize.
- Banned words: "delve," "tapestry," "testament," "underscore," "in today's world," "in the realm of," "pivotal," "captivating," "compelling," "fascinating."
- Never start with "In a world," "Picture this," or "Imagine."
- This is a genuine rewrite, not a paraphrase. Restructure sentences, find the real story, tell it fresh.
- Do NOT include links or URLs in paragraphs.
- Do NOT reference the original source or say "according to the article."
- Write in plain, professional English.
""").strip()
try:
resp, retries = gemini_generate(prompt, temperature=0.6, json_mode=True)
raw_text = resp.text.strip()
if retries > 0:
print(f" [RETRY] Rewrite 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 = None
# Attempt 1: Full parse with Pydantic validation
try:
parsed = json.loads(raw_text)
content = ArticleContent(**parsed).model_dump()
except (json.JSONDecodeError, ValidationError) as e:
print(f" [DEBUG] Full JSON parse failed: {e}")
# Attempt 2: Regex extract a JSON substring containing "title"
if not content:
json_match = re.search(r'\{[^{}]*"title"[^{}]*\}', raw_text, re.DOTALL)
if json_match:
try:
parsed = json.loads(json_match.group())
content = ArticleContent(**parsed).model_dump()
except (json.JSONDecodeError, ValidationError) as e:
print(f" [DEBUG] Regex JSON extract failed: {e}")
# Attempt 3: Fallback to manual extraction
if not content:
content = _fallback_content(raw_text, original_title)
except Exception as e:
print(f" [ERROR] Article rewrite failed: {e}")
return {
"title": f"Rewritten: {original_title}",
"paragraphs": [original_body[:1000]],
"seo_description": original_title,
"image_alt_texts": [original_title],
"tags": []
}
# Validate title: ensure it shares words with the original
if "title" in content:
content["title"] = _validate_title(content["title"], original_title)
else:
content["title"] = original_title[:70]
# Ensure remaining fields have sensible defaults
if "paragraphs" not in content or not content["paragraphs"]:
content["paragraphs"] = [raw_text[:500]]
if "tags" not in content:
content["tags"] = []
if "seo_description" not in content:
content["seo_description"] = content["paragraphs"][0][:160]
if "image_alt_texts" not in content:
content["image_alt_texts"] = [content.get("title", original_title)]
# Save to cache
cache = _load_article_cache()
cache[cache_key] = content
_save_article_cache(cache)
print(f" [CACHE] Saved article rewrite to cache")
print(f" βœ… Rewritten: \"{content['title']}\"")
print(f" πŸ“ {len(content['paragraphs'])} paragraphs generated")
return content
def _fallback_content(raw_text: str, original_title: str) -> dict:
"""Create structured content from unstructured text."""
print("[WARN] Could not parse JSON, creating fallback content")
lines = [l.strip() for l in raw_text.split("\n") if l.strip() and l.strip() not in ('{', '}', '[', ']')]
title = original_title
if lines:
title_match = re.search(r'"title"\s*:\s*"([^"]+)"', lines[0], re.IGNORECASE)
if title_match:
title = title_match.group(1)[:80]
else:
title = lines[0].strip('",\\{}[*: ')[:80]
paragraphs = [l for l in lines if len(l) > 100]
if not paragraphs:
paragraphs = [raw_text[:500]]
return {
"title": title,
"paragraphs": paragraphs,
"seo_description": paragraphs[0][:160],
"image_alt_texts": [original_title],
"tags": [],
}
# ──────────────────────────────────────────────
# 3. YouTube Video Discovery
# ──────────────────────────────────────────────
def find_youtube_videos(queries: list[str], max_videos: int = 2) -> list[dict]:
"""
Find related YouTube videos by scraping YouTube search results.
Returns: [{"url": str, "title": str, "embed_url": str}]
"""
print("🎬 Searching for related YouTube videos...")
videos = []
for query in queries[:3]: # Limit queries
try:
search_url = f"https://www.youtube.com/results?search_query={quote_plus(query)}"
resp = requests.get(search_url, headers={
"User-Agent": HEADERS["User-Agent"],
"Accept-Language": "en-US,en;q=0.9",
}, timeout=15)
if resp.status_code != 200:
continue
# Extract video IDs from the page
video_ids = re.findall(r'"videoId":"([a-zA-Z0-9_-]{11})"', resp.text)
# Remove duplicates while preserving order
seen = set()
unique_ids = []
for vid in video_ids:
if vid not in seen:
seen.add(vid)
unique_ids.append(vid)
# Extract titles (try to match with videoIds)
for vid_id in unique_ids[:2]:
# Try to find the title near this video ID
title_match = re.search(
rf'"videoId":"{vid_id}".*?"title":\s*\{{"runs":\s*\[\{{"text":\s*"([^"]+)"',
resp.text
)
vid_title = title_match.group(1) if title_match else f"Related: {query}"
video_entry = {
"url": f"https://www.youtube.com/watch?v={vid_id}",
"title": vid_title,
"embed_url": f"https://www.youtube.com/embed/{vid_id}",
"video_id": vid_id,
}
# Avoid duplicates
if not any(v["video_id"] == vid_id for v in videos):
videos.append(video_entry)
print(f" πŸ“Ή Found: {vid_title[:60]}...")
if len(videos) >= max_videos:
break
except Exception as e:
print(f" [WARN] YouTube search failed for '{query}': {e}")
continue
if len(videos) >= max_videos:
break
if not videos:
print(" ⚠️ No YouTube videos found")
else:
print(f" βœ… Found {len(videos)} video(s)")
return videos
# ──────────────────────────────────────────────
# 4. Image Handling
# ──────────────────────────────────────────────
def filter_irrelevant_images(image_urls: list[str], article_title: str, article_body: str) -> list[str]:
"""
Filter article images using Gemma 4 AI vision as the primary judge.
Process:
1. Minimal pre-filter: drop obvious junk (.gif spacers, tracking pixels, tiny thumbnails)
2. Download each image
3. Send ALL images to Gemma 4 vision with article context
4. AI decides relevance β€” keeps content images, drops ads/logos/stock/decorations
5. Returns top 3 AI-approved images sorted by quality
No URL-pattern blocking β€” AI is the judge.
"""
if not image_urls:
return []
print(f" AI-filtering {len(image_urls)} image(s) for relevance...")
# --- Level 1: Minimal junk filter (only obvious non-images) ---
junk_patterns = [
r'\.gif$', # GIFs are usually spacers/animated junk
r'spacer', r'pixel', # Tiny tracking/spacer images
r'blank', # Blank placeholder images
r'tracking', # Analytics/tracking pixels
r'beacon', # Tracking beacons
r'1x1', # 1x1 tracking pixels
r'/pixel\.gif', # Classic tracking pixel
r'/spacer\.', # Spacer images
]
pre_filtered = []
for url in image_urls:
url_lower = url.lower()
if any(re.search(pattern, url_lower) for pattern in junk_patterns):
print(f" 🚫 Junk filter: {url.split('/')[-1][:40]}...")
continue
pre_filtered.append(url)
if not pre_filtered:
return []
print(f" πŸ“Έ {len(pre_filtered)} images to evaluate with AI...")
# --- Level 2: AI Vision Relevance Check (every image gets analyzed) ---
scored = []
for i, url in enumerate(pre_filtered):
try:
# Rate-limit friendly delays
if i > 0:
time.sleep(0.5)
resp = requests.get(url, headers=HEADERS, timeout=15)
# Handle rate limiting
if resp.status_code == 429:
print(f" ⏳ Rate limited, waiting 2s: {url.split('/')[-1][:40]}...")
time.sleep(2)
resp = requests.get(url, headers=HEADERS, timeout=15)
if resp.status_code != 200:
print(f" 🚫 HTTP {resp.status_code}: {url.split('/')[-1][:40]}...")
continue
# Convert to PIL Image
img = Image.open(BytesIO(resp.content)).convert("RGB")
# Check minimum size (skip tiny images)
if img.width < 100 or img.height < 100:
print(f" 🚫 Too small: {url.split('/')[-1][:40]} ({img.width}x{img.height})")
continue
# Check for SVG/XML that somehow slipped through
ct = resp.headers.get("Content-Type", "")
if "svg" in ct.lower() or "xml" in ct.lower():
print(f" 🚫 SVG/XML not supported: {url.split('/')[-1][:40]}...")
continue
prompt = textwrap.dedent(f"""
You are evaluating whether an image is relevant and useful for a blog article.
ARTICLE TITLE: {article_title}
ARTICLE BODY (first 1000 chars): {article_body[:1000]}
Look at the image and decide:
1. Is this image relevant to the article topic? (not an ad, logo, placeholder, or random decoration)
2. Does it add value to the article (illustrates a key point, shows a person/place mentioned, etc.)?
3. Is the image quality good enough (not blurry, not a tiny thumbnail)?
Return ONLY valid JSON:
{{"relevant": true/false, "reason": "brief explanation", "quality_score": 0.0-1.0}}
""").strip()
image_part = image_to_part(img)
resp_ai, retries = gemini_generate(prompt, images=[image_part], temperature=0.2, json_mode=True)
result = json.loads(resp_ai.text.strip())
is_relevant = result.get("relevant", False)
quality = result.get("quality_score", 0.0)
reason = result.get("reason", "")
if is_relevant and quality >= 0.3:
scored.append({"url": url, "quality": quality, "reason": reason})
print(f" βœ… KEPT: {url.split('/')[-1][:40]}... (quality={quality}, {reason[:60]})")
else:
print(f" 🚫 REJECTED: {url.split('/')[-1][:40]}... ({reason[:60]})")
except Exception as e:
print(f" 🚫 Error evaluating image: {url.split('/')[-1][:40]}... ({e})")
continue
if not scored:
print(f" ⚠️ AI found no relevant images β€” falling back to first 2")
return pre_filtered[:2]
# Sort by quality, keep top 3
scored.sort(key=lambda x: x["quality"], reverse=True)
top = scored[:3]
print(f" πŸ€– Kept {len(top)} AI-verified images (best quality: {top[0]['quality']})")
return [s["url"] for s in top]
def _generate_search_queries(article_title: str, article_body: str) -> list[str]:
"""Use Gemma 4 to generate 3 smart, specific search queries for finding relevant images."""
prompt = textwrap.dedent(f"""
Generate exactly 3 short search queries to find relevant photos for this article.
Each query should be 2-5 words, specific to the topic.
ARTICLE TITLE: {article_title}
ARTICLE BODY (first 500 chars): {article_body[:500]}
Return ONLY a JSON array of 3 strings: ["query 1", "query 2", "query 3"]
Do NOT include generic terms like "photo" or "image" β€” just the topic keywords.
""").strip()
try:
resp, _ = gemini_generate(prompt, temperature=0.3, json_mode=True)
queries = json.loads(resp.text.strip())
if isinstance(queries, list) and len(queries) >= 1:
return [q.strip() for q in queries[:3] if q.strip()]
except Exception as e:
print(f" [WARN] AI query generation failed: {e}")
# Fallback
return [article_title, article_title.split(":")[0].strip() if ":" in article_title else article_title]
def _validate_image_with_ai(img: Image.Image, article_title: str, article_body: str) -> dict:
"""Use Gemma 4 vision to validate whether an image is genuinely relevant to the article."""
prompt = textwrap.dedent(f"""
Look at this image and the article below. Is this image genuinely relevant and useful for the article?
ARTICLE: {article_title}
BODY (first 300 chars): {article_body[:300]}
Reject if: stock photo, ad/logo, unrelated topic, too blurry, or generic placeholder.
Accept if: shows people/places/events mentioned, illustrates the topic directly, or is a real editorial photo.
Return ONLY JSON: {{"relevant": true/false, "confidence": 0.0-1.0, "reason": "brief explanation"}}
""").strip()
try:
image_part = image_to_part(img)
resp, _ = gemini_generate(prompt, images=[image_part], temperature=0.2, json_mode=True)
return json.loads(resp.text.strip())
except Exception as e:
return {"relevant": True, "confidence": 0.5, "reason": f"AI validation failed ({e}), keeping image"}
def _search_ddg_images(query: str, max_results: int = 20) -> list[str]:
"""
Search DuckDuckGo for images by parsing regular search results.
DuckDuckGo's /i.js API is blocked (403), so we scrape image URLs from
the regular web search results page which includes embedded image thumbnails
and links to full-size images on source pages.
"""
import urllib.parse
# Use a session to maintain cookies
session = requests.Session()
headers = {
"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": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Cache-Control": "max-age=0",
}
session.headers.update(headers)
# First, visit DDG homepage to get cookies
try:
session.get("https://duckduckgo.com/", timeout=10)
except Exception:
pass
# Search with images keyword
search_url = f"https://html.duckduckgo.com/html/?q={urllib.parse.quote_plus(query + ' photo image')}"
try:
resp = session.get(search_url, timeout=15)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
except Exception as e:
print(f" ⚠️ DDG web search failed: {e}")
return []
img_urls = []
# Extract image URLs from DDG result snippets
# DDG HTML includes <img> tags with src pointing to external images
for img in soup.find_all("img"):
src = img.get("src") or img.get("data-src") or ""
# DDG proxies images through their own CDN - extract the actual URL
if "duckduckgo.com" in src.lower():
# DDG image proxy URL format: https://tseX.mm.bing.net/th?id=...
# or direct external URLs
if src.startswith("http") and "tse" in src.lower():
# These are Bing-style thumbnails, try to get the original
pass
elif src.startswith("http"):
img_urls.append(src)
elif src.startswith("http") and not src.startswith("data:"):
img_urls.append(src)
# Also extract URLs from result links that point to image files
for a in soup.find_all("a", class_="result__snippet"):
href = a.get("href", "")
# Decode DDG's redirect URLs
if "uddg=" in href:
from urllib.parse import unquote
actual_url = unquote(href.split("uddg=")[1].split("&")[0])
if any(actual_url.lower().endswith(ext) for ext in [".jpg", ".jpeg", ".png", ".webp", ".gif"]):
if actual_url not in img_urls:
img_urls.append(actual_url)
# Also check meta og:image tags in result snippets (DDG includes these)
for meta in soup.find_all("meta"):
content = meta.get("content", "")
if content.startswith("http") and any(ext in content.lower() for ext in [".jpg", ".jpeg", ".png", ".webp"]):
if content not in img_urls:
img_urls.append(content)
return img_urls[:max_results]
def _search_wikimedia_images(query: str, max_results: int = 10) -> list[str]:
"""
Search Wikimedia Commons API for relevant images.
Returns full-size image URLs (not thumbnails).
"""
api_url = (
"https://commons.wikimedia.org/w/api.php"
"?action=query"
"&format=json"
"&generator=search"
"&gsrnamespace=6" # File namespace
f"&gsrsearch={quote_plus(query)}"
"&prop=imageinfo"
"&iiprop=url|extmetadata|size"
f"&gsrlimit={max_results}"
)
headers = {
"User-Agent": "ContentAgent/1.0 (contact: admin@afripulsetv.com)",
}
try:
resp = requests.get(api_url, headers=headers, timeout=10)
resp.raise_for_status()
data = resp.json()
pages = data.get("query", {}).get("pages", {})
urls = []
for page_id, page in pages.items():
imageinfo = page.get("imageinfo", [])
if imageinfo:
info = imageinfo[0]
url = info.get("url", "")
width = info.get("width", 0)
height = info.get("height", 0)
# Skip tiny files (e.g. signatures, icons)
if url and url.startswith("http") and width >= 400 and height >= 300:
if url not in urls:
urls.append(url)
return urls
except Exception as e:
print(f" ⚠️ Wikimedia search failed: {e}")
return []
def _search_related_image(query: str, article_title: str = "", article_body: str = "") -> str:
"""
Search for relevant images using:
1. Wikimedia Commons API (reliable, free, no key needed)
2. DuckDuckGo web search as backup
3. Gemma 4 AI validation for relevance
If article_title and article_body are provided:
- AI generates 3 smart search queries from article content
- Each image is validated by Gemma 4 vision for relevance
- Rejects stock photos, ads, logos, placeholders
Falls back to size-only validation if AI fails.
"""
headers = {
"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",
}
# ── Step 1: Generate search queries ──
if article_title and article_body:
print(f" 🧠 AI generating search queries for: {article_title[:60]}...")
search_queries = _generate_search_queries(article_title, article_body)
print(f" πŸ” AI queries: {search_queries}")
else:
search_queries = [query]
# ── Step 2: Search Wikimedia Commons (reliable, always works) ──
all_img_urls = []
print(f" πŸ›οΈ Searching Wikimedia Commons...")
for sq in search_queries:
try:
urls = _search_wikimedia_images(sq, max_results=8)
for u in urls:
if u not in all_img_urls:
all_img_urls.append(u)
if urls:
print(f" πŸ“Έ Wikimedia query '{sq[:40]}...' β†’ {len(urls)} images")
except Exception as e:
print(f" ⚠️ Wikimedia search failed for '{sq}': {e}")
# ── Step 3: Try DDG as backup ──
if len(all_img_urls) < 5:
print(f" πŸ” Trying DuckDuckGo for more results...")
for sq in search_queries:
try:
urls = _search_ddg_images(sq, max_results=10)
for u in urls:
if u not in all_img_urls:
all_img_urls.append(u)
except Exception:
pass
if not all_img_urls:
print(" ⚠️ No images found from any source")
return ""
print(f" πŸ“Έ Total candidates: {len(all_img_urls)} image URLs")
# ── Step 3: Validate images ──
use_ai_validation = bool(article_title and article_body)
rejected_count = 0
error_count = 0
size_count = 0
for i, img_url in enumerate(all_img_urls[:12]):
try:
# Add delay between requests to avoid rate limiting
if i > 0:
time.sleep(0.5)
resp_img = requests.get(img_url, timeout=15, headers=headers, allow_redirects=True)
# Handle rate limiting with retry
if resp_img.status_code == 429:
print(f" ⏳ [{i+1}/12] Rate limited (429), waiting 2s and retrying: {img_url[:60]}...")
time.sleep(2)
resp_img = requests.get(img_url, timeout=15, headers=headers, allow_redirects=True)
if resp_img.status_code != 200:
print(f" 🚫 [{i+1}/12] HTTP {resp_img.status_code}: {img_url[:70]}...")
error_count += 1
continue
# Check content type
ct = resp_img.headers.get("Content-Type", "")
if "svg" in ct.lower() or "xml" in ct.lower():
print(f" 🚫 [{i+1}/12] SVG/XML not supported: {img_url[:70]}...")
error_count += 1
continue
img_data = BytesIO(resp_img.content)
with Image.open(img_data) as img:
w, h = img.size
if w < 400 or h < 300:
print(f" 🚫 [{i+1}/12] Too small ({w}x{h}): {img_url[:70]}...")
size_count += 1
continue
if use_ai_validation:
result = _validate_image_with_ai(img, article_title, article_body)
is_relevant = result.get("relevant", False)
confidence = result.get("confidence", 0.0)
reason = result.get("reason", "")[:60]
print(f" πŸ€– [{i+1}/12] {'βœ…' if is_relevant else '🚫'} ({w}x{h}) conf={confidence:.1f} β€” {reason}")
if is_relevant and confidence >= 0.5:
print(f" βœ… AI-validated related image: {img_url[:80]}...")
return img_url
else:
rejected_count += 1
else:
print(f" βœ… Found related image: {img_url[:80]}... ({w}x{h})")
return img_url
except Exception as e:
print(f" 🚫 [{i+1}/12] Error: {e}")
error_count += 1
continue
print(f" πŸ“Š Summary: {error_count} errors, {size_count} too small, {rejected_count} AI-rejected out of {min(12, len(all_img_urls))} checked")
# ── Step 4: Fallback β€” try remaining URLs without AI ──
if use_ai_validation:
print(f" ⚠️ AI rejected all β€” trying remaining {len(all_img_urls) - 12} images by size only...")
for i, img_url in enumerate(all_img_urls[12:20]):
try:
if i > 0:
time.sleep(0.5)
resp_img = requests.get(img_url, timeout=15, headers=headers, allow_redirects=True)
if resp_img.status_code == 429:
time.sleep(2)
resp_img = requests.get(img_url, timeout=15, headers=headers, allow_redirects=True)
if resp_img.status_code != 200:
continue
ct = resp_img.headers.get("Content-Type", "")
if "svg" in ct.lower() or "xml" in ct.lower():
continue
img_data = BytesIO(resp_img.content)
with Image.open(img_data) as img:
w, h = img.size
if w >= 600 and h >= 400:
print(f" βœ… Fallback related image (size only): {img_url[:80]}... ({w}x{h})")
return img_url
except Exception:
continue
print(" ⚠️ No suitable related image found")
return ""
def download_article_images(image_urls: list[str], max_images: int = 5, referer_url: str = None) -> list[Image.Image]:
"""
Download images from URLs and return as PIL Image objects.
Filters out broken images and tiny ones.
"""
print(f"πŸ–Ό Downloading article images ({len(image_urls)} found)...")
images = []
# Build image download headers with Referer to bypass hotlink protection
img_headers = dict(HEADERS)
img_headers["Accept"] = "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"
if referer_url:
img_headers["Referer"] = referer_url
async def download_image(session, img_url):
try:
async with session.get(img_url, headers=img_headers, timeout=aiohttp.ClientTimeout(total=15)) as resp:
resp.raise_for_status()
content_type = resp.headers.get("Content-Type", "")
if not content_type.startswith("image/"):
return None
img_data = BytesIO(await resp.read())
img = Image.open(img_data).convert("RGB")
w, h = img.size
if w < 300 or h < 200:
print(f" 🚫 Dropping tiny image: {w}x{h}")
return None
print(f" βœ… Downloaded image: {w}x{h}")
return img
except Exception as e:
print(f" [WARN] Failed to download image: {e}")
return None
async def download_all():
async with aiohttp.ClientSession() as session:
tasks = [download_image(session, url) for url in image_urls[:max_images + 3]]
results = await asyncio.gather(*tasks)
return [img for img in results if img is not None]
images = asyncio.run(download_all())
if not images:
print(" ⚠️ No usable images downloaded, will use placeholders")
# Create a simple placeholder
placeholder = Image.new("RGB", (1280, 720), color=(30, 30, 60))
draw = ImageDraw.Draw(placeholder)
font = _get_font(48)
draw.text(
(640, 360), "Article Image",
fill=(200, 200, 200), font=font, anchor="mm"
)
images.append(placeholder)
print(f" πŸ“Έ {len(images)} usable image(s) ready")
return images
def create_article_featured_image(
images: list[Image.Image], output_name: str
) -> Path:
"""
Create a 3:1 editorial collage featured image.
Always uses the 3-panel layout β€” repeats images if fewer than 3 available.
"""
print("πŸ–Ό Creating featured image for article...")
_ensure_dirs()
WIDTH, HEIGHT = 1280, 720
# Pad to 3 images by repeating the first one so we always get the 3:1 layout
padded = list(images)
while len(padded) < 3:
padded.append(images[0])
canvas = _collage_3_to_1(padded, WIDTH, HEIGHT)
# Ensure output directory exists (defensive)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
output_path = OUTPUT_DIR / f"featured_article_{output_name}.jpg"
canvas.save(str(output_path), "JPEG", quality=92)
print(f" βœ… Featured image saved: {output_path}")
return output_path
def _collage_3_to_1(images: list[Image.Image], width: int, height: int) -> Image.Image:
"""3:1 editorial layout β€” hero 75% left, supporting 25% right stacked."""
canvas = Image.new("RGB", (width, height), (15, 15, 15))
hero_w = int(width * 0.75) # 960px
hero_h = height # 720px
side_w = width - hero_w # 320px
side_h = height // 2 # 360px each
# Hero image (largest, left panel)
hero = _crop_to_fill(images[0], hero_w, hero_h)
canvas.paste(hero, (0, 0))
# Supporting images stacked on right
top = _crop_to_fill(images[1], side_w, side_h)
canvas.paste(top, (hero_w, 0))
bottom = _crop_to_fill(images[2], side_w, side_h)
canvas.paste(bottom, (hero_w, side_h))
# Thin divider lines
draw = ImageDraw.Draw(canvas)
# Vertical divider
draw.line([(hero_w, 0), (hero_w, height)], fill=(40, 40, 40), width=2)
# Horizontal divider on right panel
draw.line([(hero_w, side_h), (width, side_h)], fill=(40, 40, 40), width=2)
return canvas
def _crop_to_fill(img: Image.Image, target_w: int, target_h: int) -> Image.Image:
"""Center-crop an image to the target aspect ratio, then resize."""
img = img.convert("RGB")
src_w, src_h = img.size
target_ratio = target_w / target_h
src_ratio = src_w / src_h
if src_ratio > target_ratio:
# Image too wide β€” crop sides
new_w = int(src_h * target_ratio)
left = (src_w - new_w) // 2
img = img.crop((left, 0, left + new_w, src_h))
else:
# Image too tall β€” crop top/bottom
new_h = int(src_w / target_ratio)
top = (src_h - new_h) // 2
img = img.crop((0, top, src_w, top + new_h))
return img.resize((target_w, target_h), Image.LANCZOS)
# ──────────────────────────────────────────────
# 5. Gutenberg Content Builder
# ──────────────────────────────────────────────
def build_article_gutenberg_content(
paragraphs: list[str],
article_embeds: list[dict],
wp_image_ids: list[dict],
source_url: str,
) -> str:
"""
Build WordPress Gutenberg block content with:
- Paragraphs
- YouTube video embeds
- Image blocks
- Source link button
- Ad shortcodes
"""
blocks = []
# Simple strategy: Insert images and embeds evenly
image_count = len(wp_image_ids)
video_count = len(article_embeds)
para_count = len(paragraphs)
# Calculate insertion intervals
video_insert_points = []
if video_count > 0:
interval = max(1, para_count // (video_count + 1))
video_insert_points = [interval * (i + 1) for i in range(video_count)]
image_insert_points = []
if image_count > 0:
interval = max(1, para_count // (image_count + 1))
image_insert_points = [interval * (i + 1) for i in range(image_count)]
video_idx = 0
image_idx = 0
for i, para in enumerate(paragraphs):
blocks.append(f'<!-- wp:paragraph -->\n<p>{para}</p>\n<!-- /wp:paragraph -->')
# Insert video/media embed if this is the right spot
if video_idx < len(article_embeds) and (i + 1) in video_insert_points:
vid = article_embeds[video_idx]
vid_url = vid["url"]
provider = vid.get("provider", "youtube")
# Choose type: Most social/media embeds use 'rich'
v_type = "video" if provider in ["youtube", "vimeo", "dailymotion", "streamable", "video"] else "rich"
# Choose aspect ratio
aspect_class = "wp-embed-aspect-16-9"
if provider in ["tiktok", "instagram"]:
aspect_class = "wp-embed-aspect-9-16"
elif provider in ["spotify", "soundcloud", "threads", "reddit", "pinterest", "bluesky"]:
aspect_class = ""
# SPECIAL CASE: Instagram, Facebook, X need Custom HTML blocks
if provider in ["instagram", "facebook"]:
if provider == "instagram":
# Force clean URL if not already
clean_url = vid_url.split("?")[0].rstrip("/")
embed_html = (
f'<blockquote class="instagram-media" data-instgrm-captioned data-instgrm-permalink="{clean_url}/?utm_source=ig_embed&amp;utm_campaign=loading" data-instgrm-version="14" style=" background:#FFF; border:0; border-radius:3px; box-shadow:0 0 1px 0 rgba(0,0,0,0.5),0 1px 10px 0 rgba(0,0,0,0.15); margin: 1px; max-width:540px; min-width:326px; padding:0; width:99.375%; width:-webkit-calc(100% - 2px); width:calc(100% - 2px);">'
f'<div style="padding:16px;"> <a href="{clean_url}/?utm_source=ig_embed&amp;utm_campaign=loading" style=" background:#FFFFFF; line-height:0; padding:0 0; text-align:center; text-decoration:none; width:100%;" target="_blank">View this post on Instagram</a></div>'
f'</blockquote>\n<script async src="//www.instagram.com/embed.js"></script>'
)
else: # Facebook
clean_url = vid_url.split("?")[0]
embed_html = (
f'<div class="fb-post" data-href="{clean_url}" data-width="500" data-show-text="true">'
f'<blockquote cite="{clean_url}" class="fb-xfbml-parse-ignore">View post on Facebook</blockquote>'
f'</div>\n<script async defer src="https://connect.facebook.net/en_US/sdk.js#xfbml=1&version=v12.0"></script>'
)
blocks.append(
f'<!-- wp:html -->\n'
f'<div class="wp-block-html-embed {provider}-embed">\n{embed_html}\n</div>\n'
f'<!-- /wp:html -->'
)
else:
# X/Twitter: use a simple paragraph with the URL on its own line
# WordPress auto-embeds standalone URLs when surrounded by whitespace
if provider == "x":
clean_url = vid_url.split("?")[0].rstrip("/")
blocks.append(
f'<!-- wp:paragraph -->\n'
f'<p>{clean_url}</p>\n'
f'<!-- /wp:paragraph -->'
)
else:
# Use standard Gutenberg embed block for everything else
embed_json = {
"url": vid_url,
"type": v_type,
"providerNameSlug": provider,
"responsive": True
}
if aspect_class:
embed_json["className"] = f"{aspect_class} wp-has-aspect-ratio"
blocks.append(
f'<!-- wp:embed {json.dumps(embed_json)} -->\n'
f'<figure class="wp-block-embed is-type-{v_type} is-provider-{provider} wp-block-embed-{provider} {"" if not aspect_class else aspect_class + " wp-has-aspect-ratio"}">'
f'<div class="wp-block-embed__wrapper">\n{vid_url}\n</div></figure>\n'
f'<!-- /wp:embed -->'
)
video_idx += 1
# Insert image block if this is the right spot
if image_idx < len(wp_image_ids) and (i + 1) in image_insert_points:
img = wp_image_ids[image_idx]
img_id = img["id"]
img_url = img["url"]
alt_text = img.get("alt", "Related image")
blocks.append(
f'<!-- wp:image {{"id":{img_id},"sizeSlug":"large","linkDestination":"none"}} -->\n'
f'<figure class="wp-block-image size-large">'
f'<img src="{img_url}" alt="{alt_text}" class="wp-image-{img_id}"/>'
f'</figure>\n'
f'<!-- /wp:image -->'
)
image_idx += 1
# Add any remaining embeds
while video_idx < len(article_embeds):
vid = article_embeds[video_idx]
vid_url = vid["url"]
provider = vid.get("provider", "youtube")
v_type = "video" if provider in ["youtube", "vimeo", "dailymotion", "streamable", "video"] else "rich"
aspect_class = "wp-embed-aspect-16-9"
if provider in ["tiktok", "instagram"]:
aspect_class = "wp-embed-aspect-9-16"
elif provider in ["spotify", "soundcloud", "x", "threads", "reddit", "pinterest", "bluesky"]:
aspect_class = ""
# SPECIAL CASE: Instagram and Facebook
if provider in ["instagram", "facebook"]:
if provider == "instagram":
clean_url = vid_url.split("?")[0].rstrip("/")
embed_html = (
f'<blockquote class="instagram-media" data-instgrm-captioned data-instgrm-permalink="{clean_url}/?utm_source=ig_embed&amp;utm_campaign=loading" data-instgrm-version="14" style=" background:#FFF; border:0; border-radius:3px; box-shadow:0 0 1px 0 rgba(0,0,0,0.5),0 1px 10px 0 rgba(0,0,0,0.15); margin: 1px; max-width:540px; min-width:326px; padding:0; width:99.375%; width:-webkit-calc(100% - 2px); width:calc(100% - 2px);">'
f'<div style="padding:16px;"> <a href="{clean_url}/?utm_source=ig_embed&amp;utm_campaign=loading" style=" background:#FFFFFF; line-height:0; padding:0 0; text-align:center; text-decoration:none; width:100%;" target="_blank">View this post on Instagram</a></div>'
f'</blockquote>\n<script async src="//www.instagram.com/embed.js"></script>'
)
else:
clean_url = vid_url.split("?")[0]
embed_html = (
f'<div class="fb-post" data-href="{clean_url}" data-width="500" data-show-text="true">'
f'<blockquote cite="{clean_url}" class="fb-xfbml-parse-ignore">View post on Facebook</blockquote>'
f'</div>\n<script async defer src="https://connect.facebook.net/en_US/sdk.js#xfbml=1&version=v12.0"></script>'
)
blocks.append(
f'<!-- wp:html -->\n'
f'<div class="wp-block-html-embed {provider}-embed">\n{embed_html}\n</div>\n'
f'<!-- /wp:html -->'
)
else:
# X/Twitter: use a simple paragraph with the URL
if provider == "x":
clean_url = vid_url.split("?")[0].rstrip("/")
blocks.append(
f'<!-- wp:paragraph -->\n'
f'<p>{clean_url}</p>\n'
f'<!-- /wp:paragraph -->'
)
else:
embed_json = {
"url": vid_url,
"type": v_type,
"providerNameSlug": provider,
"responsive": True
}
if aspect_class:
embed_json["className"] = f"{aspect_class} wp-has-aspect-ratio"
blocks.append(
f'<!-- wp:embed {json.dumps(embed_json)} -->\n'
f'<figure class="wp-block-embed is-type-{v_type} is-provider-{provider} wp-block-embed-{provider} {"" if not aspect_class else aspect_class + " wp-has-aspect-ratio"}">'
f'<div class="wp-block-embed__wrapper">\n{vid_url}\n</div></figure>\n'
f'<!-- /wp:embed -->'
)
video_idx += 1
# Add any remaining images
while image_idx < len(wp_image_ids):
img = wp_image_ids[image_idx]
img_id = img["id"]
img_url = img["url"]
alt_text = img.get("alt", "Related image")
blocks.append(
f'<!-- wp:image {{"id":{img_id},"sizeSlug":"large","linkDestination":"none"}} -->\n'
f'<figure class="wp-block-image size-large">'
f'<img src="{img_url}" alt="{alt_text}" class="wp-image-{img_id}"/>'
f'</figure>\n'
f'<!-- /wp:image -->'
)
image_idx += 1
# Ad shortcodes
blocks.append(
'<!-- wp:shortcode -->\n[ads2] [ads1]\n<!-- /wp:shortcode -->'
)
return "\n\n".join(blocks)
# ──────────────────────────────────────────────
# 6. WordPress Publishing
# ──────────────────────────────────────────────
def upload_media_to_wp(file_path: Path, filename: str = None) -> dict:
"""
Upload an image to WordPress.
Returns: {"id": int, "url": str}
"""
if filename is None:
filename = file_path.name
print(f"☁️ Uploading {filename} to WordPress...")
ext = file_path.suffix.lower()
mime_map = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png"}
mime_type = mime_map.get(ext, "image/jpeg")
with open(file_path, "rb") as f:
file_data = f.read()
upload_method = os.getenv("WP_UPLOAD_METHOD", "auto").strip().lower()
if pull_relay_enabled():
return _upload_media_to_wp_bridge(file_data, filename, mime_type)
upload_errors = []
if upload_method in ("auto", "rest"):
try:
return _upload_media_to_wp_rest(file_data, filename, mime_type)
except Exception as e:
if upload_method == "rest":
raise
upload_errors.append(f"REST error: {e}")
print(f" ⚠️ REST media upload failed; trying authenticated upload bridge: {e}")
if upload_method in ("auto", "bridge"):
try:
return _upload_media_to_wp_bridge(file_data, filename, mime_type)
except Exception as e:
if upload_method == "bridge":
raise
upload_errors.append(f"Bridge error: {e}")
print(f" ⚠️ Upload bridge failed; trying XML-RPC fallback: {e}")
if upload_method in ("auto", "xmlrpc"):
try:
return _upload_media_to_wp_xmlrpc(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(file_data: bytes, filename: str, mime_type: 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",
}
response = requests.post(
url, headers=base_headers, files={"file": (filename, file_data, mime_type)},
timeout=(15, 60),
verify=_wp_ssl_verify(),
)
try:
return _parse_wp_rest_media_response(response, "REST multipart")
except Exception as multipart_error:
print(f" ⚠️ 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=(15, 60),
verify=_wp_ssl_verify(),
)
return _parse_wp_rest_media_response(response, "REST raw")
def _upload_media_to_wp_bridge(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": WP_USERNAME,
"content_agent_password": WP_APP_PASSWORD,
"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" βœ… Uploaded via authenticated bridge: {result['url']} (ID: {result['id']})")
return result
def _parse_wp_rest_media_response(response, 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", "")
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."
)
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:
print(f" [ERROR] Failed to parse WordPress JSON response: {e}")
print(f" [DEBUG] Response Status: {response.status_code}")
print(f" [DEBUG] Response Text: {response.text[:500]}")
raise Exception(f"Invalid JSON response from WordPress during media upload: {response.text[:200]}")
result = {
"id": data.get("id"),
"url": data.get("source_url", data.get("guid", {}).get("rendered", ""))
}
if not result["id"]:
raise Exception(f"WordPress upload succeeded but no ID returned. Response: {response.text[:200]}")
print(f" βœ… Uploaded via {method_label}: {result['url']} (ID: {result['id']})")
return result
def _upload_media_to_wp_xmlrpc(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_USERNAME)
xmlrpc_pass = os.getenv("WP_XMLRPC_PASSWORD", WP_APP_PASSWORD)
print(" ☁️ 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" βœ… Uploaded via XML-RPC: {result['url']} (ID: {result['id']})")
return result
def upload_image_from_url_to_wp(image_url: str, filename: str, alt_text: str = "") -> dict:
"""
Download an image from URL and upload it to WordPress.
Returns: {"id": int, "url": str, "alt": str}
"""
print(f"☁️ Uploading remote image to WordPress: {filename}")
try:
resp = requests.get(image_url, headers=HEADERS, timeout=15)
resp.raise_for_status()
content_type = resp.headers.get("Content-Type", "image/jpeg")
if "png" in content_type:
ext = ".png"
else:
ext = ".jpg"
if not filename.endswith(ext):
filename = filename + ext
upload_type = content_type if content_type.startswith("image/") else "image/jpeg"
if pull_relay_enabled():
media = _upload_media_to_wp_bridge(resp.content, filename, upload_type)
data = {"id": media["id"], "source_url": media["url"]}
else:
upload_method = os.getenv("WP_UPLOAD_METHOD", "auto").strip().lower()
rest_error = None
uploaded_data = None
if upload_method in ("auto", "rest"):
try:
media = _upload_media_to_wp_rest(resp.content, filename, upload_type)
except Exception as e:
rest_error = e
if upload_method == "rest":
raise
print(f" ⚠️ REST remote image upload failed; trying XML-RPC fallback: {e}")
else:
uploaded_data = {"id": media["id"], "source_url": media["url"]}
if upload_method in ("auto", "xmlrpc") and uploaded_data is None:
try:
media = _upload_media_to_wp_xmlrpc(resp.content, filename, upload_type)
uploaded_data = {"id": media["id"], "source_url": media["url"]}
except Exception as e:
if rest_error:
raise Exception(f"WordPress remote image upload failed. REST error: {rest_error}; XML-RPC error: {e}")
raise
if uploaded_data is None:
raise ValueError("WP_UPLOAD_METHOD must be auto, rest, or xmlrpc")
data = uploaded_data
# Set alt text if provided
if alt_text:
try:
request_wordpress_json(
"POST",
f"{WP_URL}/wp-json/wp/v2/media/{data['id']}",
{"alt_text": alt_text},
WP_USERNAME,
WP_APP_PASSWORD,
timeout=15,
)
except Exception:
pass
result = {
"id": data["id"],
"url": data.get("source_url", data.get("guid", {}).get("rendered", "")),
"alt": alt_text,
}
print(f" βœ… Uploaded: {result['url']} (ID: {result['id']})")
return result
except Exception as e:
print(f" [WARN] Failed to upload image from URL: {e}")
return None
def publish_article_post(
title: str,
content_html: str,
featured_media_id: int,
seo_description: str = "",
tags: list[str] = None,
) -> dict:
"""
Create and publish a WordPress post via REST API.
Returns: {"id": int, "url": str}
"""
print("πŸ“ Publishing rewritten article to WordPress...")
post_data = {
"title": title,
"content": content_html,
"status": WP_POST_STATUS,
"categories": [WP_CATEGORY_ID],
"featured_media": featured_media_id,
}
# Add excerpt/SEO description
if seo_description:
post_data["excerpt"] = seo_description
# Handle tags β€” create if they don't exist
if tags:
tag_ids = []
for tag_name in tags[:10]: # Max 10 tags
try:
# Check if tag exists
resp = requests.get(
f"{WP_URL}/wp-json/wp/v2/tags",
params={"search": tag_name},
auth=_wp_auth(),
timeout=10
)
existing = resp.json()
if existing:
tag_ids.append(existing[0]["id"])
else:
# Create new tag
create_resp = request_wordpress_json(
"POST",
f"{WP_URL}/wp-json/wp/v2/tags",
{"name": tag_name},
WP_USERNAME,
WP_APP_PASSWORD,
timeout=10,
)
if create_resp.status_code in (200, 201):
tag_ids.append(create_resp.json()["id"])
except Exception as e:
print(f" [WARN] Failed to handle tag '{tag_name}': {e}")
if tag_ids:
post_data["tags"] = tag_ids
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"WordPress post creation failed ({response.status_code}): "
f"{response.text[:300]}"
)
try:
data = response.json()
except Exception as e:
print(f" [ERROR] Failed to parse WordPress JSON response: {e}")
print(f" [DEBUG] Response Status: {response.status_code}")
print(f" [DEBUG] Response Text: {response.text[:500]}")
raise Exception(f"Invalid JSON response from WordPress during post creation: {response.text[:200]}")
result = {
"id": data.get("id"),
"url": data.get("link", data.get("guid", {}).get("rendered", ""))
}
if not result["id"]:
raise Exception(f"WordPress post created but no ID returned. Response: {response.text[:200]}")
print(f" βœ… Published: {result['url']}")
return result
# ──────────────────────────────────────────────
# 7. Full Article Rewrite Pipeline
# ──────────────────────────────────────────────
def process_article(url: str, dry_run: bool = False) -> dict:
"""
Full pipeline for rewriting an article:
scrape β†’ rewrite β†’ find media β†’ create featured image β†’ upload β†’ publish.
"""
print(f"\n{'='*60}")
print(f"πŸ“° Rewriting article: {url}")
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)
# Generate a safe name from the URL
url_hash = hashlib.md5(url.encode()).hexdigest()[:8]
safe_name = _sanitize_filename(
urlparse(url).path.strip("/").split("/")[-1] or url_hash
)
if not safe_name:
safe_name = url_hash
# Step 1: Scrape the article
article = scrape_article(url)
# Step 2: Rewrite with Gemini AI
rewritten = rewrite_article(
article["title"], article["body"], article["source_url"]
)
# Rate limiting is handled by gemma_ai.py (4s gap between calls)
# No additional cooldown needed
print(f" [COOLDOWN] Rate limit enforced by gemma_ai wrapper")
# Step 3: Use embeds found in the source article
article_embeds = article.get("videos", []) # Key returned by scrape_article
if article_embeds:
print(f"🎬 Found {len(article_embeds)} media embed(s) in source article")
else:
print("🎬 No media embeds found in source article β€” skipping")
# Step 4: Intelligent Image Filtering
filtered_image_urls = filter_irrelevant_images(
article["images"], rewritten["title"], article["body"]
)
# Step 4b: FALLBACK CHAIN β€” if AI filter removed everything, try recovery
source_featured = article.get("featured_image", "")
if not filtered_image_urls and source_featured:
print(f" [FALLBACK] AI filter removed all images, using source's featured image")
print(f" πŸ–ΌοΈ Source featured image: {source_featured}")
filtered_image_urls = [source_featured]
elif not filtered_image_urls:
print(f" [FALLBACK] No images after filtering, searching for related image with AI...")
related = _search_related_image(
query=rewritten["title"],
article_title=rewritten["title"],
article_body=" ".join(rewritten["paragraphs"][:2]),
)
if related:
filtered_image_urls = [related]
else:
print(f" ⚠️ No images available at all β€” featured image will use placeholder")
# Step 5: Download & process images from the article
article_images = download_article_images(filtered_image_urls, referer_url=article["source_url"])
# Step 6: Create featured image
featured_path = create_article_featured_image(
article_images, safe_name
)
if dry_run:
print(f"\nβœ… DRY RUN complete for: {url}")
print(f" New title: {rewritten['title']}")
print(f" Paragraphs: {len(rewritten['paragraphs'])}")
print(f" Media embeds: {len(article_embeds)}")
print(f" Images: {len(article_images)}")
return {
"source_url": url,
"title": rewritten["title"],
"paragraphs": rewritten["paragraphs"],
"seo_description": rewritten.get("seo_description", ""),
"youtube_videos": article_embeds, # Keep key for dashboard compatibility if needed
"image_count": len(article_images),
"featured_image": str(featured_path),
"status": "dry_run_complete",
}
# Step 7: Upload featured image to WordPress
featured_media = upload_media_to_wp(
featured_path, f"featured-article-{safe_name}.jpg"
)
# Step 8: Upload article images to WordPress for embedding
wp_images = []
alt_texts = rewritten.get("image_alt_texts", [])
# Upload up to 3 body images from the FILTERED list
for i, img_url in enumerate(filtered_image_urls[:3]):
alt_text = alt_texts[i] if i < len(alt_texts) else f"Image for {rewritten['title']}"
wp_img = upload_image_from_url_to_wp(
img_url, f"article-{safe_name}-{i}.jpg", alt_text
)
if wp_img:
wp_images.append(wp_img)
# Step 9: Build Gutenberg content
content_html = build_article_gutenberg_content(
paragraphs=rewritten["paragraphs"],
article_embeds=article_embeds,
wp_image_ids=wp_images,
source_url=url,
)
# Step 10: Publish to WordPress
post = publish_article_post(
title=rewritten["title"],
content_html=content_html,
featured_media_id=featured_media["id"],
seo_description=rewritten.get("seo_description", ""),
tags=rewritten.get("tags", []),
)
result = {
"source_url": url,
"title": rewritten["title"],
"post_url": post["url"],
"post_id": post["id"],
"featured_image_id": featured_media["id"],
"youtube_videos": len(article_embeds),
"embedded_images": len(wp_images),
}
print(f"\nπŸŽ‰ SUCCESS! Rewritten article published: {post['url']}")
return result
# ──────────────────────────────────────────────
# 8. Standalone CLI
# ──────────────────────────────────────────────
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description="Article Rewriter β€” Scrape, rewrite, and publish articles",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=textwrap.dedent("""
Examples:
python article_rewriter.py https://example.com/some-article
python article_rewriter.py --file articles.txt
python article_rewriter.py --dry-run https://example.com/article
""")
)
parser.add_argument(
"urls", nargs="*",
help="Article URLs to rewrite and publish"
)
parser.add_argument(
"--file", "-f", type=str,
help="Path to a text file containing article URLs (one per line)"
)
parser.add_argument(
"--dry-run", action="store_true",
help="Scrape and rewrite but don't publish to WordPress"
)
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}")
import sys
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.")
import sys
sys.exit(1)
# Validate config
if not os.getenv("GOOGLE_API_KEY"):
print("[ERROR] GOOGLE_API_KEY not set in .env")
import sys
sys.exit(1)
if not args.dry_run and not all([WP_URL, WP_USERNAME, WP_APP_PASSWORD]):
print("[ERROR] WordPress credentials not set in .env")
import sys
sys.exit(1)
print(f"\nπŸ“‹ Found {len(urls)} article URL(s) to process")
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:
result = process_article(url, dry_run=args.dry_run)
results.append(result)
except Exception as e:
print(f"\n❌ ERROR processing {url}: {e}")
import traceback
traceback.print_exc()
errors.append({"url": url, "error": str(e)})
# Summary
print(f"\n{'='*60}")
print("πŸ“Š ARTICLE REWRITER SUMMARY")
print(f"{'='*60}")
print(f" Total articles: {len(urls)}")
print(f" Successful: {len(results)}")
print(f" Errors: {len(errors)}")
if results:
print(f"\nβœ… Successful articles:")
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 articles:")
for e in errors:
print(f" - {e['url']}: {e['error']}")
# Save results
_ensure_dirs()
results_path = OUTPUT_DIR / "rewrite_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}")