type: custom

customType: model-snapshot

provider: anthropic

modelApi: anthropic-messages


type: message

role: user

type: text

text:
[cron:6dd85dd2-28ce-411f-b2de-8106e314da47 Deal Post - X + Pinterest (every 30 min)] SCHEDULED TASK: Post 1 deal to X and Pinterest.

## CRITICAL: Use the SAFE script (prevents $ shell expansion bugs)
```bash
cd /home/crogers2287/openclaw-workspaces/ghc-marketing
python3 scripts/post_deal_safe.py
```

This script:
- Uses pure Python (no shell $ variable expansion)
- Validates tweets before posting
- Posts to X via Maya VM twikit
- Posts to Pinterest via Maya VM py3-pinterest
- Records in posted_db

## If script fails
Check the error message and report back. DO NOT manually post using shell commands with $.

## If X cookies expired
Run: `ssh maya@maya "DISPLAY=:0 python3 /home/maya/workspace/scripts/extract_x_cookies.py"`
Current time: Monday, February 16th, 2026 — 8:27 AM (UTC)

Return your summary as plain text; it will be delivered automatically. If the task explicitly calls for messaging a specific external recipient, note who/where it should go instead of sending it yourself.


role: assistant

type: thinking

thinking:
This is a cron job firing to post a deal to X and Pinterest. The instructions are clear:
1. Use the safe script at `/home/crogers2287/openclaw-workspaces/ghc-marketing/scripts/post_deal_safe.py`
2. This prevents shell expansion bugs with $ signs in prices
3. The script handles posting to both X and Pinterest via Maya VM

I should run this script and report the results. Let me execute it.

text: Running scheduled deal post to X and Pinterest...

exec

type: toolCall

command: cd /home/crogers2287/openclaw-workspaces/ghc-marketing && python3 scripts/post_deal_safe.py

api: anthropic-messages

stopReason: toolUse


role: toolResult

toolName: exec

text: Command still running (session nova-pine, pid 414989). Use process (list/poll/log/write/kill/clear/remove) for follow-up.

status: running

sessionId: nova-pine


thinking: The command is running in the background. I should poll for results to see what happened.

process

action: poll


toolName: process

text:
(no new output)

Process still running.


thinking: Still running. Let me wait a moment and check the log to see if there's any output.

action: log


text: (no output yet)


thinking:
The process is still running but hasn't produced output yet. This could take some time as it needs to:
1. Fetch a deal from the database
2. Post to X via SSH to Maya VM
3. Post to Pinterest via SSH to Maya VM

Let me wait a bit longer and poll again.


thinking: Still running. Let me check the log again to see if there's any progress output.


thinking: The script is taking some time. Let me poll one more time to see if it completes. If not, I'll need to continue waiting or check what's happening.


thinking: The script is taking longer than expected. This might be normal if it's doing SSH operations to Maya VM. Let me wait a bit more and check the full log.


thinking: Let me check if the script file actually exists and what it does, since it's been running for a while without output.


toolName: read

text:
#!/usr/bin/env python3
"""
GHC Deal Poster - End-to-end deal posting script
NO SHELL COMMANDS WITH $ - Pure Python to avoid variable expansion bugs

Usage:
  python3 post_deal_safe.py                    # Auto-select from feed
  python3 post_deal_safe.py --deal-id 34260    # Post specific deal
  python3 post_deal_safe.py --deal-id 34260 --skip-dupe-check  # Force post even if already posted
"""

import urllib.request
import urllib.error
import json
import subprocess
import sys
import os
import hashlib
import argparse
from datetime import datetime

# Config
DEALS_API = "https://www.gottahavecoups.com/api/v1/deals/latest?limit=150"
DEAL_API = "https://www.gottahavecoups.com/api/v1/deals/{deal_id}"

# Import deal_scorer for ranking
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
try:
    from deal_scorer import score_deal as scorer_score_deal, clean_title
    USE_DEAL_SCORER = True
except ImportError:
    USE_DEAL_SCORER = False

# Deal filtering criteria - TWO-TIER SYSTEM
PRIMARY_MIN_SCORE = 35            # Primary threshold (high quality)
SECONDARY_MIN_SCORE = 25          # Fallback threshold (medium quality)
MAX_PRICE = 500                   # Skip deals over $500
MIN_PRICE = 2                     # Skip deals under $2 (junk)
SKIP_CATEGORIES = [8]             # 8 = Books - nobody cares
SKIP_STORES = ['Costco']          # Requires membership
SHARE_IMAGE_URL = "https://www.gottahavecoups.com/api/v1/share/image/"
MAYA_HOST = "maya@maya"
POSTED_DB = "/home/crogers2287/openclaw-workspaces/ghc-marketing/posted_deals_v2.json"

def build_utm_url(deal_id, platform, campaign="daily_deals"):
    """Build URL with UTM tracking parameters"""
    base_url = f"https://gottahavecoups.com/deal/{deal_id}"
    utm_params = f"?utm_source={platform}&utm_medium=social&utm_campaign={campaign}&utm_content=deal_{deal_id}"
    return base_url + utm_params

def fetch_deals():
    """Fetch deals from API - pure Python, no shell"""
    try:
        req = urllib.request.Request(DEALS_API, headers={'User-Agent': 'Mozilla/5.0'})
        with urllib.request.urlopen(req, timeout=15) as resp:
            data = json.loads(resp.read())
            return data if isinstance(data, list) else data.get('deals', [])
    except Exception as e:
        print(f"Error fetching deals: {e}")
        return []

def fetch_deal_by_id(deal_id):
    """Fetch a specific deal by ID"""
    try:
        url = DEAL_API.format(deal_id=deal_id)
        req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
        with urllib.request.urlopen(req, timeout=15) as resp:
            return json.loads(resp.read())
    except Exception as e:
        print(f"Error fetching deal {deal_id}: {e}")
        return None

def score_deal(deal):
    """Score a deal using deal_scorer.py algorithm."""
    price = deal.get('price', 0) or 0
    category = deal.get('category_id')
    store = deal.get('store_name', '')

    # Hard disqualify
    if category in SKIP_CATEGORIES:
        return -1, []  # Books
    if store in SKIP_STORES:
        return -1, []  # Costco
    if price > MAX_PRICE or price < MIN_PRICE:
        return -1, []

    # Use deal_scorer if available
    if USE_DEAL_SCORER:
        score, reasons = scorer_score_deal(deal)
        return score, reasons

    # Fallback: simple scoring
    score = 0
    discount = deal.get('discount_percentage', 0) or 0
    if discount >= 50: score += 30
    elif discount >= 30: score += 20
    if 10 <= price <= 50: score += 20
    if store.lower() == 'amazon': score += 10
    return score, []

def filter_and_rank_deals(deals, min_score=None):
    """Filter out bad deals and rank by score using deal_scorer

    Args:
        deals: List of deals to filter
        min_score: Minimum score threshold (defaults to PRIMARY_MIN_SCORE)
    """
    if min_score is None:
        min_score = PRIMARY_MIN_SCORE

    scored = []
    for d in deals:
        s, reasons = score_deal(d)
        if s >= min_score:  # Only deals above minimum score
            scored.append((s, reasons, d))

    # Sort by score descending
    scored.sort(key=lambda x: x[0], reverse=True)
    return scored  # Returns (score, reasons, deal) tuples

def check_posted(deal_id, platform='x'):
    """Check if deal already posted - calls posted_db.py"""
    try:
        result = subprocess.run(
            ['python3', '/home/crogers2287/openclaw-workspaces/ghc-marketing/scripts/posted_db.py',
             'check', str(deal_id), platform],
            capture_output=True, text=True, timeout=10
        )
        return 'POSTED:YES' in result.stdout or 'POSTED:YES' in result.stderr
    except Exception as e:
        print(f"Error checking posted_db: {e}")
        return False

def smart_truncate(text, max_len=80):
    """Truncate text at word boundary, add ... if needed"""
    import re

    # Clean up common junk from titles
    text = text.split('~')[0].strip()  # Remove ~price suffix

    # Remove price prefix patterns like "6.75* | " or "5 | " at the START
    text = re.sub(r'^\d+\.?\d*\*?\s*\|\s*', '', text)

    # Remove trailing price patterns like "$28 + Free S/H" or "$55.20 + Free Shipping"
    text = re.sub(r'\s*\$[\d.]+\s*\+?\s*(Free\s*(S/H|Shipping))?$', '', text, flags=re.IGNORECASE)

    # Remove " at Amazon" or " at Walmart" suffix
    text = re.sub(r'\s+at\s+(Amazon|Walmart|Target|eBay)$', '', text, flags=re.IGNORECASE)

    text = text.strip()

    if len(text) <= max_len:
        return text

    # Find last space before max_len
    truncated = text[:max_len]
    last_space = truncated.rfind(' ')

    if last_space > max_len * 0.5:  # Only use space if it's not too early
        truncated = truncated[:last_space]

    return truncated.rstrip('.,;:-') + '...'

def generate_product_hashtags(title, category=None):
    """Generate product-specific hashtags based on deal content"""
    title_lower = title.lower()
    hashtags = []

    # Home & Kitchen
    if any(word in title_lower for word in ['wallpaper', 'wall art', 'decor', 'mirror', 'lamp', 'rug', 'curtain', 'furniture', 'shelf', 'storage ottoman', 'headboard']):
        hashtags.append('#HomeDecor')
    if any(word in title_lower for word in ['kitchen', 'cookware', 'utensil', 'appliance', 'blender', 'air fryer', 'pot', 'pan', 'knife', 'cutting board', 'spatula', 'bowl']):
        hashtags.append('#Kitchen')
    if any(word in title_lower for word in ['storage', 'organizer', 'container', 'bin', 'basket', 'drawer', 'closet', 'shelf']):
        hashtags.append('#Storage')
    if any(word in title_lower for word in ['bathroom', 'towel', 'shower', 'faucet', 'soap', 'toilet', 'vanity']):
        hashtags.append('#Bathroom')
    if any(word in title_lower for word in ['bedding', 'pillow', 'blanket', 'comforter', 'sheet', 'mattress']):
        hashtags.append('#Bedding')

    # Electronics
    if any(word in title_lower for word in ['headphone', 'earbud', 'speaker', 'charger', 'cable', 'laptop', 'tablet', 'phone', 'smartwatch', 'keyboard', 'mouse', 'monitor', 'tv', 'camera']):
        hashtags.append('#Tech')
    if any(word in title_lower for word in ['gaming', 'game', 'controller', 'console', 'playstation', 'xbox', 'nintendo']):
        hashtags.append('#Gaming')

    # Clothing & Fashion
    if any(word in title_lower for word in ['shirt', 'polo', 't-shirt', 'sweater', 'jacket', 'hoodie', 'blazer', 'dress', 'skirt']):
        hashtags.append('#Clothing')
    if any(word in title_lower for word in ['shoe', 'sneaker', 'boot', 'sandal', 'slipper']):
        hashtags.append('#Shoes')
    if any(word in title_lower for word in ['jewelry', 'necklace', 'bracelet', 'earring', 'ring', 'watch']):
        hashtags.append('#Jewelry')
    if any(word in title_lower for word in ['bag', 'backpack', 'purse', 'tote', 'luggage']):
        hashtags.append('#Bags')

    # Toys & Kids
    if any(word in title_lower for word in ['toy', 'plush', 'doll', 'action figure', 'lego', 'nerf', 'game', 'puzzle', 'jurassic', 'dinosaur']):
        hashtags.append('#Toys')
    if any(word in title_lower for word in ['baby', 'stroller', 'car seat', 'diaper', 'bottle', 'pacifier', 'nursery']):
        hashtags.append('#Baby')
    if any(word in title_lower for word in ['kids', 'boys', 'girls', 'children', 'toddler']):
        hashtags.append('#Kids')

    # Health & Beauty
    if any(word in title_lower for word in ['makeup', 'lipstick', 'mascara', 'foundation', 'skincare', 'serum', 'cream', 'lotion']):
        hashtags.append('#Beauty')
    if any(word in title_lower for word in ['vitamin', 'supplement', 'protein', 'fitness', 'gym', 'workout', 'yoga', 'exercise']):
        hashtags.append('#Fitness')

    # Outdoor & Garden
    if any(word in title_lower for word in ['outdoor', 'patio', 'garden', 'grill', 'lawn', 'planter', 'furniture set', 'hammock', 'umbrella']):
        hashtags.append('#Outdoor')
    if any(word in title_lower for word in ['tool', 'drill', 'saw', 'hammer', 'screwdriver', 'wrench', 'ladder']):
        hashtags.append('#Tools')

    # Automotive
    if any(word in title_lower for word in ['car', 'auto', 'tire', 'oil', 'battery', 'dash cam', 'seat cover', 'floor mat', 'degreaser', 'cleaner', 'wax']):
        hashtags.append('#Automotive')

    # Pet
    if any(word in title_lower for word in ['dog', 'cat', 'pet', 'treat', 'food bowl', 'leash', 'collar', 'bed', 'toy']):
        hashtags.append('#Pet')

    # Office & School
    if any(word in title_lower for word in ['desk', 'chair', 'office', 'pen', 'notebook', 'paper', 'stapler', 'printer', 'ink']):
        hashtags.append('#Office')
    if any(word in title_lower for word in ['school', 'backpack', 'lunchbox', 'supply', 'crayon', 'marker']):
        hashtags.append('#School')

    # Food & Drink
    if any(word in title_lower for word in ['coffee', 'tea', 'snack', 'candy', 'chocolate', 'protein bar']):
        hashtags.append('#Foodie')
    if any(word in title_lower for word in ['water bottle', 'tumbler', 'mug', 'cup', 'thermos']):
        hashtags.append('#Drinkware')

    # Books & Media
    if any(word in title_lower for word in ['book', 'novel', 'audiobook', 'kindle']):
        hashtags.append('#Books')

    return hashtags

def build_tweet(deal):
    """Build tweet text with proper $ signs - pure Python"""
    title = smart_truncate(deal.get('title', ''), max_len=80)
    price = deal.get('price', 0)
    savings = deal.get('savings') or 0
    orig_price = deal.get('original_price')  # Don't calculate fake original price
    discount = deal.get('discount_percentage', 0)
    deal_id = deal.get('id')

    # Calculate original price ONLY if we have real savings data
    if not orig_price and savings and savings > 0:
        orig_price = price + savings

    # Format price with $ sign (SAFE - no shell involved)
    price_str = f"${price:.2f}" if price else ""
    orig_price_str = f"${orig_price:.2f}" if orig_price else ""

    # Validate: original price should be HIGHER than current price
    if orig_price and orig_price <= price:
        orig_price = None  # Don't show bogus "was" price
        orig_price_str = ""

    # Build tweet
    if discount and discount >= 50:
        hook = f"🔥 {int(discount)}% OFF!"
    elif price and price < 10:
        hook = "🔥 UNDER $10!"
    else:
        hook = "🛍 Great deal!"

    tweet_parts = [hook, title]

    # Only show "was X" if we have a REAL higher original price
    if orig_price and orig_price > price:
        tweet_parts.append(f"just {price_str} (was {orig_price_str}) — {int(discount)}% OFF!")
    elif discount:
        tweet_parts.append(f"just {price_str} — {int(discount)}% OFF!")
    elif price:
        tweet_parts.append(f"just {price_str}!")

    # Add hashtags - product-specific + standard deal hashtags
    product_tags = generate_product_hashtags(title, deal.get('category_id'))
    base_tags = ["#DailyDeals", "#Savings", "#DealAlert", "#GottaHaveCoups"]

    # Combine product tags first (more relevant), then base tags
    all_tags = product_tags + base_tags
    hashtags = " ".join(all_tags[:5])  # Limit to 5 hashtags to keep tweet clean

    tweet_parts.append(hashtags)

    # Link LAST
    # Use tracked URL for X/Twitter
    tweet_parts.append(build_utm_url(deal_id, "twitter"))

    tweet = "\n\n".join(tweet_parts)

    # Validate length
    if len(tweet) > 280:
        tweet = tweet[:277] + "..."

    return tweet

def validate_tweet(tweet):
    """Validate tweet has no shell artifacts or broken prices"""
    errors = []

    if '/bin/bash' in tweet:
        errors.append("Shell artifact: /bin/bash")
    if 'python3 -c' in tweet:
        errors.append("Shell artifact: python3 -c")

    # Check for prices without $ (look for decimal patterns)
    import re
    # Pattern: number followed by decimal but no $ before it
    # This is a simple check - prices should have $ in front

    if len(tweet) > 280:
        errors.append(f"Too long: {len(tweet)} chars")

    if '�' in tweet:
        errors.append("Broken emoji detected")

    return errors

def download_image(deal_id):
    """Download share image"""
    img_path = f"/tmp/deal_{deal_id}.jpg"
    try:
        req = urllib.request.Request(f"{SHARE_IMAGE_URL}{deal_id}", headers={'User-Agent': 'Mozilla/5.0'})
        with urllib.request.urlopen(req, timeout=15) as resp, open(img_path, 'wb') as f:
            f.write(resp.read())
        return img_path
    except Exception as e:
        print(f"Error downloading image: {e}")
        return None

def post_to_x(deal_id, tweet_text, img_path):
    """Post to X via Maya VM twikit - direct method"""
    # Copy image to Maya
    try:
        subprocess.run(['scp', '-q', img_path, f"{MAYA_HOST}:/home/maya/workspace/downloads/deal_{deal_id}.jpg"],
                      check=True, timeout=30)
    except subprocess.CalledProcessError as e:
        print(f"SCP failed: {e}")
        return None

    # Create Python script for Maya to execute
    maya_script = f'''
import asyncio
import json
from twikit import Client

async def post():
    client = Client()
    with open("/home/maya/workspace/x_cookies.json") as f:
        cookies = json.load(f)
    client.set_cookies(cookies)

    tweet_text = {repr(tweet_text)}

    media_id = await client.upload_media("/home/maya/workspace/downloads/deal_{deal_id}.jpg")
    print(f"Media: {{media_id}}")

    result = await client.create_tweet(tweet_text, media_ids=[media_id])
    print(f"SUCCESS Tweet: {{result.id}}")

asyncio.run(post())
'''

    # Write script to temp file and copy to Maya
    script_path = f"/tmp/post_{deal_id}.py"
    with open(script_path, 'w') as f:
        f.write(maya_script)

    try:
        subprocess.run(['scp', '-q', script_path, f"{MAYA_HOST}:/home/maya/workspace/post_deal.py"],
                      check=True, timeout=10)
    except subprocess.CalledProcessError as e:
        print(f"Script copy failed: {e}")
        return None

    # Execute on Maya
    try:
        result = subprocess.run(
            ['ssh', MAYA_HOST, 'cd /home/maya/workspace && python3 post_deal.py'],
            capture_output=True, text=True, timeout=60
        )
        print(result.stdout)

        # Extract tweet ID
        for line in result.stdout.split('\n'):
            if 'SUCCESS Tweet:' in line:
                return line.split('SUCCESS Tweet:')[1].strip()

        if result.returncode != 0 and result.stderr:
            # Filter out SSH warnings
            errors = [l for l in result.stderr.split('\n') if 'Warning' not in l and l.strip()]
            if errors:
                print(f"Error: {errors[0][:200]}")
        return None
    except Exception as e:
        print(f"Error posting to X: {e}")
        return None

def post_to_pinterest(deal_id, deal):
    """Post to Pinterest via Maya VM py3-pinterest"""
    title = deal.get('title', '').split('~')[0].strip()[:100]
    description = f"Deal from GottaHaveCoups - {title}"
    board_id = "1134485031073239531"  # Today's Best Deals
    # Use UTM tracked URL for Pinterest
    pin_url = build_utm_url(deal_id, "pinterest")

    cmd = [
        'ssh', MAYA_HOST,
        f'cd /home/maya/workspace && python3 scripts/pin_post_v2.py {deal_id} "{title}" "{description}" {board_id} "{pin_url}"'
    ]

    try:
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
        print(result.stdout)
        return result.returncode == 0
    except Exception as e:
        print(f"Error posting to Pinterest: {e}")
        return False

def record_post(deal_id, platform, post_id, url):
    """Record post in database"""
    try:
        subprocess.run([
            'python3', '/home/crogers2287/openclaw-workspaces/ghc-marketing/scripts/posted_db.py',
            'record', str(deal_id), platform, str(post_id), url
        ], check=True, timeout=10)
        return True
    except Exception as e:
        print(f"Error recording post: {e}")
        return False

def check_priority_queue():
    """Check if there's a priority queue and pop the next deal ID"""
    queue_file = "/home/crogers2287/openclaw-workspaces/ghc-marketing/config/priority_queue.json"

    if not os.path.exists(queue_file):
        return None

    try:
        with open(queue_file, 'r') as f:
            data = json.load(f)

        queue = data.get('queue', [])
        if not queue:
            return None

        # Pop first deal from queue
        next_deal_id = queue.pop(0)

        # Save updated queue
        data['queue'] = queue
        with open(queue_file, 'w') as f:
            json.dump(data, f, indent=2)

        print(f"📋 Priority queue: Using deal {next_deal_id} ({len(queue)} remaining)")
        return next_deal_id
    except Exception as e:
        print(f"Error reading priority queue: {e}")
        return None

def main():
    # Parse arguments
    parser = argparse.ArgumentParser(description='GHC Deal Poster')
    parser.add_argument('--deal-id', type=int, help='Specific deal ID to post')
    parser.add_argument('--skip-dupe-check', action='store_true', help='Skip duplicate check (force post)')
    parser.add_argument('--x-only', action='store_true', help='Post to X only, skip Pinterest')
    parser.add_argument('--pinterest-only', action='store_true', help='Post to Pinterest only, skip X')
    args = parser.parse_args()

    print("=" * 60)
    print("GHC Deal Poster - Starting")
    print("=" * 60)

    # Check priority queue first (unless --deal-id was explicitly provided)
    if not args.deal_id:
        priority_deal_id = check_priority_queue()
        if priority_deal_id:
            args.deal_id = priority_deal_id
            args.skip_dupe_check = True  # Skip dupe check for priority queue items

    if args.deal_id:
        # Fetch specific deal
        print(f"\n[1/6] Fetching deal {args.deal_id}...")
        selected_deal = fetch_deal_by_id(args.deal_id)
        if not selected_deal:
            print(f"Deal {args.deal_id} not found. Exiting.")
            return 1
        print(f"Found: {selected_deal.get('title', '')[:60]}")

        # Check if already posted (unless skip flag)
        if not args.skip_dupe_check and check_posted(args.deal_id, 'x'):
            print(f"Deal {args.deal_id} already posted to X. Use --skip-dupe-check to force.")
            return 0
    else:
        # Auto-select from feed with quality filtering
        print("\n[1/6] Fetching deals...")
        deals = fetch_deals()
        if not deals:
            print("No deals fetched. Exiting.")
            return 1
        print(f"Fetched {len(deals)} deals")

        # TWO-TIER FILTERING SYSTEM
        # Try primary threshold first (high quality)
        print("\n[2/6] Filtering and ranking deals (PRIMARY_MIN_SCORE={})...".format(PRIMARY_MIN_SCORE))
        ranked_deals = filter_and_rank_deals(deals, PRIMARY_MIN_SCORE)
        print(f"After primary filtering: {len(ranked_deals)} high-quality deals")

        # Find first unposted deal from ranked list
        selected_deal = None
        selected_score = 0
        selected_reasons = []
        tier_used = "PRIMARY"

        for score, reasons, deal in ranked_deals:
            d_id = deal.get('id')
            if not check_posted(d_id, 'x'):
                selected_deal = deal
                selected_score = score
                selected_reasons = reasons
                print(f"✓ Selected deal {d_id} from PRIMARY tier (score: {score}/100)")
                print(f"  → {', '.join(reasons[:3])}")
                print(f"  {deal.get('title', '')[:60]}")
                break

        # FALLBACK: If no primary deals, try secondary threshold
        if not selected_deal:
            print(f"\n⚠  No unposted deals in primary tier. Trying SECONDARY tier (MIN_SCORE={SECONDARY_MIN_SCORE})...")
            ranked_deals = filter_and_rank_deals(deals, SECONDARY_MIN_SCORE)
            print(f"After secondary filtering: {len(ranked_deals)} medium-quality deals")

            for score, reasons, deal in ranked_deals:
                d_id = deal.get('id')
                if not check_posted(d_id, 'x'):
                    selected_deal = deal
                    selected_score = score
                    selected_reasons = reasons
                    tier_used = "SECONDARY"
                    print(f"✓ Selected deal {d_id} from SECONDARY tier (score: {score}/100)")
                    print(f"  → {', '.join(reasons[:3])}")
                    print(f"  {deal.get('title', '')[:60]}")
                    break

        if not selected_deal:
            print("❌ No unposted deals in either tier. Exiting.")
            return 0

    deal_id = selected_deal.get('id')

    # 3. Build tweet
    print("\n[3/6] Building tweet...")
    tweet = build_tweet(selected_deal)
    print(f"Tweet:\n{tweet}\n")

    # 4. Validate
    print("[4/6] Validating...")
    errors = validate_tweet(tweet)
    if errors:
        print(f"VALIDATION FAILED: {errors}")
        return 1
    print("✓ Validation passed")

    # 5. Download image
    print("\n[5/6] Downloading image...")
    img_path = download_image(deal_id)
    if not img_path:
        print("Failed to download image. Continuing without image...")
    else:
        print(f"✓ Image saved: {img_path}")

    # 6. Post to X
    tweet_id = None
    if not args.pinterest_only:
        print("\n[6/6] Posting to X...")
        tweet_id = post_to_x(deal_id, tweet, img_path or f"/tmp/deal_{deal_id}.jpg")
        if not tweet_id:
            print("Failed to post to X")
            if not args.x_only:  # Continue to Pinterest if X fails
                pass
            else:
                return 1
        else:
            print(f"✓ Posted to X: {tweet_id}")
            record_post(deal_id, 'x', tweet_id, f"https://x.com/GottaHaveCoups/status/{tweet_id}")

    # 7. Post to Pinterest
    if not args.x_only:
        print("\n[Bonus] Posting to Pinterest...")
        if post_to_pinterest(deal_id, selected_deal):
            print("✓ Posted to Pinterest")
            record_post(deal_id, 'pinterest', 'pinned', f"https://pinterest.com/GottaHaveCoups")
        else:
            print("⚠ Pinterest post failed (non-critical)")

    print("\n" + "=" * 60)
    print("Done!")
    print("=" * 60)
    return 0

if __name__ == '__main__':
    sys.exit(main())


thinking: Good, the script exists and looks comprehensive. It's still running. Let me check the log again to see if there's any output yet.


text:
============================================================
GHC Deal Poster - Starting
============================================================
📋 Priority queue: Using deal 34698 (1 remaining)

[1/6] Fetching deal 34698...
Found: ARM & HAMMER Plus OxiClean Fresh Scent,77 Loads Liquid Laund

[3/6] Building tweet...
Tweet:
🔥 UNDER $10!

ARM & HAMMER Plus OxiClean Fresh Scent,77 Loads Liquid Laundry Detergent,100.5...

just $6.63!

#Tools #DailyDeals #Savings #DealAlert #GottaHaveCoups

https://gottahavecoups.com/deal/34698?utm_source=twitter&utm_medium=social&utm_campaign=daily_deals&utm_content...

[4/6] Validating...
✓ Validation passed

[5/6] Downloading image...
✓ Image saved: /tmp/deal_34698.jpg

[6/6] Posting to X...
Traceback (most recent call last):
  File "/home/crogers2287/openclaw-workspaces/ghc-marketing/scripts/post_deal_safe.py", line 612, in <module>
    sys.exit(main())
             ^^^^^^
  File "/home/crogers2287/openclaw-workspaces/ghc-marketing/scripts/post_deal_safe.py", line 586, in main
    tweet_id = post_to_x(deal_id, tweet, img_path or f"/tmp/deal_{deal_id}.jpg")
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/crogers2287/openclaw-workspaces/ghc-marketing/scripts/post_deal_safe.py", line 346, in post_to_x
    subprocess.run(['scp', '-q', img_path, f"{MAYA_HOST}:/home/maya/workspace/downloads/deal_{deal_id}.jpg"],
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/subprocess.py", line 550, in run
    stdout, stderr = process.communicate(input, timeout=timeout)
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/subprocess.py", line 1209, in communicate
    stdout, stderr = self._communicate(input, endtime, timeout)
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/subprocess.py", line 2141, in _communicate
    self.wait(timeout=self._remaining_time(endtime))
  File "/usr/lib/python3.12/subprocess.py", line 1264, in wait
    return self._wait(timeout=timeout)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/subprocess.py", line 2045, in _wait
    raise TimeoutExpired(self.args, timeout)
subprocess.TimeoutExpired: Command '['scp', '-q', '/tmp/deal_34698.jpg', 'maya@maya:/home/maya/workspace/downloads/deal_34698.jpg']' timed out after 29.999964253001963 seconds


thinking:
The script failed because the SCP command to copy the image to Maya VM timed out. This could mean:
1. Maya VM is not reachable
2. SSH connection is slow/hanging
3. Network issue

Let me check if Maya is reachable and if there's a connectivity issue.

text:
**❌ Deal posting failed - SSH/SCP timeout**

The script successfully:
- ✅ Selected deal 34698 from priority queue (ARM & HAMMER laundry detergent - $6.63)
- ✅ Built and validated tweet
- ✅ Downloaded share image

But **failed** at:
- ❌ Copying image to Maya VM via SCP (30-second timeout)

**Issue:** Cannot reach Maya VM (`maya@maya`) - SSH/SCP connection timed out.

**Next steps:**
1. Check if Maya VM is online and accessible
2. Verify Tailscale/network connectivity to Maya
3. May need to restart Maya VM or check SSH service

The deal is still in the system and ready to post once connectivity is restored. Priority queue now has 1 remaining deal.

stopReason: stop