Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
"""Celery tasks for blog post generation and scheduling"""
import asyncio
from datetime import datetime, timedelta
from celery import shared_task
from sqlalchemy import select, desc, and_
from app.core.celery_app import celery_app
from app.db.session import get_celery_session_factory
from app.models.deal import Deal as DealModel
from app.models.blog_post import BlogPost as BlogPostModel
from app.services.blog_generator import BlogPostGenerator
import re
import logging

logger = logging.getLogger(__name__)

@celery_app.task(name="app.tasks.blog_tasks.generate_weekly_roundup")
def generate_weekly_roundup():
    """
    Generate a weekly deal roundup blog post.
    Runs every Monday at 8 AM via Celery Beat.
    """
    logger.info("Starting weekly blog roundup generation...")

    try:
        # Run the async function in the event loop
        asyncio.run(_generate_weekly_roundup_async())
        logger.info("✅ Weekly roundup generated successfully")
        return {"success": True, "message": "Weekly roundup generated"}
    except Exception as e:
        logger.error(f"❌ Failed to generate weekly roundup: {e}")
        return {"success": False, "error": str(e)}

async def _generate_weekly_roundup_async():
    """Async implementation of weekly roundup generation"""
    async with get_celery_session_factory()() as session:
        # Fetch top deals from the last week
        query = (
            select(DealModel)
            .where(DealModel.is_active == True)
            .order_by(
                desc(DealModel.quality_score),
                desc(DealModel.created_at)
            )
            .limit(20)  # Top 20 deals
        )

        result = await session.execute(query)
        deals = result.scalars().all()

        if not deals:
            logger.warning("No deals found for weekly roundup")
            return

        # Convert to dict
        deal_dicts = [
            {
                "title": deal.title,
                "price": deal.price,
                "original_price": deal.original_price,
                "discount_percentage": deal.discount_percentage,
                "store_name": deal.store_name,
            }
            for deal in deals
        ]

        # Generate blog post
        generator = BlogPostGenerator()
        blog_data = await generator.generate_deal_roundup(
            category="All Categories",
            deals=deal_dicts,
            time_period="This Week"
        )

        # Create slug
        slug = re.sub(r'[^a-z0-9]+', '-', blog_data['title'].lower()).strip('-')

        # Check uniqueness
        existing = await session.execute(
            select(BlogPostModel).where(BlogPostModel.slug == slug)
        )
        if existing.scalar_one_or_none():
            slug = f"{slug}-{datetime.utcnow().strftime('%Y%m%d')}"

        # Create and save blog post
        blog_post = BlogPostModel(
            title=blog_data['title'],
            slug=slug,
            excerpt=blog_data['excerpt'],
            content=blog_data['content'],
            meta_description=blog_data['meta_description'],
            is_published=False,  # Admins review before publishing
            is_ai_generated=True,
            author_name="GottaHaveCoups Bot",
            published_at=None
        )

        session.add(blog_post)
        await session.commit()

        logger.info(f"Created draft blog post: {blog_post.title} (ID: {blog_post.id})")

@celery_app.task(name="app.tasks.blog_tasks.generate_daily_roundup")
def generate_daily_roundup():
    """
    Generate a daily deal roundup blog post.
    Runs every day at 9 AM UTC via Celery Beat.
    Auto-schedules for publishing 30 minutes later.
    """
    logger.info("Starting daily blog roundup generation...")

    try:
        result = asyncio.run(_generate_daily_roundup_async())
        logger.info(f"✅ Daily roundup generated: {result}")
        return result
    except Exception as e:
        logger.error(f"❌ Failed to generate daily roundup: {e}")
        return {"success": False, "error": str(e)}

async def _generate_daily_roundup_async():
    """Async implementation of daily roundup generation"""
    async with get_celery_session_factory()() as session:
        # Fetch top deals from the last 24 hours
        yesterday = datetime.utcnow() - timedelta(days=1)
        query = (
            select(DealModel)
            .where(
                DealModel.is_active == True,
                DealModel.created_at >= yesterday
            )
            .order_by(
                desc(DealModel.quality_score),
                desc(DealModel.created_at)
            )
            .limit(15)  # Top 15 deals from last 24h
        )

        result = await session.execute(query)
        deals = result.scalars().all()

        if not deals or len(deals) < 3:
            logger.warning(f"Not enough deals for daily roundup (found {len(deals) if deals else 0})")
            return {"success": False, "message": "Not enough deals for roundup"}

        # Convert to dict
        deal_dicts = [
            {
                "title": deal.title,
                "price": deal.price,
                "original_price": deal.original_price,
                "discount_percentage": deal.discount_percentage,
                "store_name": deal.store_name,
                "image_url": deal.image_url,
                "deal_url": deal.deal_url,
                "affiliate_link": deal.affiliate_link,
            }
            for deal in deals
        ]

        # Generate blog post
        generator = BlogPostGenerator()
        blog_data = await generator.generate_deal_roundup(
            category="Today's Top Deals",
            deals=deal_dicts,
            time_period="daily"
        )

        # Create slug with date
        date_str = datetime.utcnow().strftime('%Y-%m-%d')
        slug = f"daily-deals-{date_str}"

        # Check uniqueness
        existing = await session.execute(
            select(BlogPostModel).where(BlogPostModel.slug == slug)
        )
        if existing.scalar_one_or_none():
            slug = f"{slug}-{datetime.utcnow().strftime('%H%M')}"

        # Schedule for publishing 30 minutes from now
        scheduled_time = datetime.utcnow() + timedelta(minutes=30)

        # Create and save blog post - auto-scheduled for publishing
        blog_post = BlogPostModel(
            title=blog_data['title'],
            slug=slug,
            excerpt=blog_data['excerpt'],
            content=blog_data['content'],
            meta_description=blog_data['meta_description'],
            image_url=blog_data.get('image_url'),
            is_published=False,
            is_ai_generated=True,
            author_name="GottaHaveCoups Bot",
            scheduled_at=scheduled_time,  # Auto-schedule for publishing
            schedule_type="once",
        )

        session.add(blog_post)
        await session.commit()

        logger.info(f"Created and scheduled blog post: {blog_post.title} (ID: {blog_post.id}, publishes at {scheduled_time})")
        return {
            "success": True,
            "post_id": blog_post.id,
            "title": blog_post.title,
            "scheduled_at": scheduled_time.isoformat()
        }

@celery_app.task(name="app.tasks.blog_tasks.publish_scheduled_posts")
def publish_scheduled_posts():
    """
    Check for and publish scheduled blog posts.
    Runs every minute via Celery Beat.
    """
    logger.info("Checking for scheduled posts to publish...")

    try:
        result = asyncio.run(_publish_scheduled_posts_async())
        if result["published"] > 0:
            logger.info(f"✅ Published {result['published']} scheduled post(s)")
        return result
    except Exception as e:
        logger.error(f"❌ Failed to publish scheduled posts: {e}")
        return {"success": False, "error": str(e)}

async def _publish_scheduled_posts_async():
    """Async implementation of scheduled post publishing"""
    async with get_celery_session_factory()() as session:
        now = datetime.utcnow()

        # Find posts that are due to be published
        query = select(BlogPostModel).where(
            and_(
                BlogPostModel.scheduled_at.isnot(None),
                BlogPostModel.scheduled_at <= now,
                BlogPostModel.is_published == False,
            )
        )

        result = await session.execute(query)
        posts = result.scalars().all()

        published_count = 0
        instagram_posts = []

        for post in posts:
            logger.info(f"Publishing scheduled post: {post.title} (ID: {post.id})")

            # Publish the post
            post.is_published = True
            post.published_at = now

            # Handle recurring schedules
            if post.schedule_type and post.schedule_type != "once":
                # Calculate next scheduled time
                next_run = _calculate_next_schedule(
                    post.schedule_type,
                    post.schedule_time,
                    post.schedule_days,
                    now
                )
                post.scheduled_at = next_run
                post.is_published = False  # Keep as draft for next scheduled time
                logger.info(f"Next scheduled publish: {next_run}")
            else:
                # One-time schedule - clear scheduling fields
                post.scheduled_at = None
                post.schedule_type = None

            # Track for Instagram posting
            if post.post_to_instagram and not post.instagram_posted_at:
                instagram_posts.append(post)

            published_count += 1

        await session.commit()

        # Post to Instagram (if configured)
        for post in instagram_posts:
            try:
                await _post_to_instagram(post, session)
            except Exception as e:
                logger.error(f"Failed to post to Instagram for post {post.id}: {e}")

        return {
            "success": True,
            "published": published_count,
            "instagram_posted": len([p for p in instagram_posts if p.instagram_posted_at])
        }

def _calculate_next_schedule(schedule_type: str, schedule_time: str, schedule_days: list, from_time: datetime) -> datetime:
    """Calculate the next scheduled publish time for recurring schedules"""
    hour, minute = map(int, schedule_time.split(':'))

    if schedule_type == "daily":
        # Next day at the scheduled time
        next_run = from_time.replace(hour=hour, minute=minute, second=0, microsecond=0)
        next_run += timedelta(days=1)
        return next_run

    elif schedule_type in ("weekly", "custom") and schedule_days:
        # Find the next scheduled day
        current_day = from_time.weekday()  # 0=Mon, 6=Sun in Python
        # Convert to our format (0=Sun, 6=Sat)
        current_day_our = (current_day + 1) % 7

        # Find next scheduled day
        days_until_next = None
        for d in sorted(schedule_days):
            if d > current_day_our:
                days_until_next = d - current_day_our
                break

        if days_until_next is None:
            # Wrap to next week
            days_until_next = 7 - current_day_our + min(schedule_days)

        next_run = from_time.replace(hour=hour, minute=minute, second=0, microsecond=0)
        next_run += timedelta(days=days_until_next)
        return next_run

    # Default: next day
    return from_time + timedelta(days=1)

async def _post_to_instagram(post: BlogPostModel, session):
    """Post blog content to Instagram"""
    try:
        from app.services.instagram_service import InstagramService
        from app.core.config import settings

        if not settings.INSTAGRAM_ACCESS_TOKEN:
            logger.warning("Instagram access token not configured, skipping post")
            return

        instagram = InstagramService(
            access_token=settings.INSTAGRAM_ACCESS_TOKEN,
            instagram_user_id=settings.INSTAGRAM_USER_ID,
        )

        # Create Instagram post
        result = await instagram.create_post(
            image_url=post.image_url,
            caption=_generate_instagram_caption(post),
        )

        if result.get("id"):
            post.instagram_post_id = result["id"]
            post.instagram_posted_at = datetime.utcnow()
            await session.commit()
            logger.info(f"Posted to Instagram: {result['id']}")

    except ImportError:
        logger.warning("Instagram service not available")
    except Exception as e:
        logger.error(f"Instagram posting error: {e}")
        raise

def _generate_instagram_caption(post: BlogPostModel) -> str:
    """Generate Instagram caption from blog post"""
    # Start with title and excerpt
    caption = f"🔥 {post.title}\n\n"

    if post.excerpt:
        caption += f"{post.excerpt}\n\n"

    # Add hashtags
    caption += "💰 Check out these amazing deals!\n\n"
    caption += "#deals #savings #shopping #coupons #discounts "
    caption += "#gottahavecoups #dealsoftheday #savemoney"

    # Instagram caption limit is 2200 characters
    if len(caption) > 2200:
        caption = caption[:2197] + "..."

    return caption


Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
from celery.schedules import crontab

from app.core.config import settings

# Create Celery app
celery_app = Celery(
    "ghc_worker",
    broker=settings.REDIS_URL,
    backend=settings.REDIS_URL,
    include=["app.tasks.scraper_tasks", "app.tasks.blog_tasks", "app.tasks.qa_tasks", "app.tasks.system_health_tasks", "app.tasks.category_audit_tasks", "app.tasks.newsletter_tasks", "app.tasks.image_protection_tasks", "app.tasks.email_tasks", "app.tasks.archive_tasks"],
)

# Configure Celery
celery_app.conf.update(
    task_serializer="json",
    accept_content=["json"],
    result_serializer="json",
    timezone="UTC",
    enable_utc=True,
    task_track_started=True,
    task_time_limit=30 * 60,  # 30 minutes
--
# Periodic task schedule
celery_app.conf.beat_schedule = {
    "scrape-deals-hourly": {
        "task": "app.tasks.scraper_tasks.scrape_and_process_deals",
        "schedule": crontab(minute=0),  # Every hour
    },
    "scrape-deals-frequent": {
        "task": "app.tasks.scraper_tasks.scrape_and_process_deals",
        "schedule": crontab(minute="*/15"),  # Every 15 minutes
        "kwargs": {"quick_run": True},
    },
    "cleanup-old-deals": {
        "task": "app.tasks.scraper_tasks.cleanup_old_deals",
        "schedule": crontab(hour=3, minute=0),  # Daily at 3 AM
    },
    # DISABLED BY FRED - Maya (ghc-marketing) handles all blog content
    # "generate-weekly-blog-roundup": {
    # "task": "app.tasks.blog_tasks.generate_weekly_roundup",
    # "schedule": crontab(day_of_week=1, hour=8, minute=0),  # Every Monday at 8 AM
    # },
    # DISABLED BY FRED - Maya (ghc-marketing) handles all blog content
    # "generate-daily-blog-roundup": {
    # "task": "app.tasks.blog_tasks.generate_daily_roundup",
    # "schedule": crontab(hour=9, minute=0),  # Daily at 9 AM UTC (4 AM EST)
    # },
    # DISABLED BY FRED - Maya (ghc-marketing) handles all blog content
    # "publish-scheduled-posts": {
    # "task": "app.tasks.blog_tasks.publish_scheduled_posts",
    # "schedule": crontab(minute="*"),  # Every minute - check for scheduled posts to publish
    # },
    # QA Pipeline tasks
    "qa-process-pending": {
        "task": "app.tasks.qa_tasks.qa_process_pending",
        "schedule": crontab(minute="*"),  # Every minute - process pending QA deals
    },
    "qa-auto-approve": {
        "task": "app.tasks.qa_tasks.qa_auto_approve",
        "schedule": crontab(minute="*/5"),  # Every 5 minutes - promote approved deals
    },
    "qa-cleanup-duplicates": {
        "task": "app.tasks.qa_tasks.qa_cleanup_duplicates",
        "schedule": crontab(minute="*/15"),  # Every 15 minutes - auto cleanup duplicate deals
    },
    # System Health Monitoring
    "system-health-check": {
        "task": "app.tasks.system_health_tasks.system_health_check",
        "schedule": crontab(minute="*/30"),  # Every 30 minutes
    },
    "validate-rss-feeds": {
        "task": "app.tasks.system_health_tasks.validate_rss_feeds",
        "schedule": crontab(minute=15),  # Every hour at :15
    },
    # Category Guardian - Auto-fix miscategorized deals
    "category-audit-hourly": {
        "task": "category_audit",
        "schedule": crontab(minute=30),  # Every hour at :30
    },
    "category-fix-daily": {
        "task": "category_fix",
        "schedule": crontab(hour=4, minute=0),  # Daily at 4 AM - apply fixes
    },
    # Newsletter scheduled sending
    "send-scheduled-newsletters": {
        "task": "app.tasks.newsletter_tasks.send_scheduled_newsletters",
        "schedule": crontab(minute="*"),  # Every minute - check for scheduled newsletters
    },
    # Image Protection Tasks - TEMPORARILY DISABLED
    # "cleanup-broken-images": {
    #     "task": "app.tasks.image_protection_tasks.cleanup_broken_images",
    #     "schedule": crontab(minute="*/10"),  # Every 10 minutes
    # },
    # "validate-recent-deals": {
    #     "task": "app.tasks.image_protection_tasks.validate_recent_deals",
    #     "schedule": crontab(minute="*/5"),  # Every 5 minutes
    # },
    # Email Deal Processing
    "process-deal-emails": {
        "task": "app.tasks.email_tasks.process_deal_emails",
        "schedule": crontab(minute="*/5"),  # Every 5 minutes - check for supplier deal emails
    },
    "expire-old-deals": {
        "task": "app.tasks.email_tasks.expire_old_deals",
        "schedule": crontab(minute=0),  # Every hour - check for expired deals
    },
    # Archive Tasks
    "archive-old-deals": {
        "task": "app.tasks.archive_tasks.archive_old_deals",
        "schedule": crontab(hour=5, minute=0),  # Daily at 5 AM - archive deals older than 7 days
    },
    "cleanup-archived-images": {
        "task": "app.tasks.archive_tasks.cleanup_archived_images",
        "schedule": crontab(day_of_week=0, hour=6, minute=0),  # Weekly on Sunday at 6 AM - cleanup orphaned images
    },
}

if __name__ == "__main__":
    celery_app.start()


Oh wait — they're **already disabled** in the Celery beat schedule! The comments say "DISABLED BY FRED." But those two roundup posts (#155 and #156) from Feb 10 are still published on the site. They must have been generated before the tasks were disabled.

Let me check if there are any other published AI-generated posts still live:

exec

curl -s "https://www.gottahavecoups.com/api/v1/blog/" | python3 -c "
import sys, json
data = json.load(sys.stdin)
posts = data.get('posts', [])
for p in posts:
    ai = p.get('is_ai_generated', False)
    pub = p.get('is_published', False)
    author = p.get('author_name', '')
    print(f'ID:{p[\"id\"]} | pub:{pub} | ai:{ai} | author:{author} | {p[\"title\"][:60]}')
"


ID:158 | pub:True | ai:False | author:None | Hot Deals Roundup: Columbia, Michael Kors, and Tech Steals
ID:154 | pub:True | ai:False | author:Maya Cooper | Best Personal Care and Wellness Deals Under $50 in 2026 - Se
ID:153 | pub:True | ai:False | author:Maya Cooper | Best Home Organization Deals Under $50 in 2026 - Transform Y
ID:152 | pub:True | ai:False | author:Maya Cooper | Best Tech Accessories Under $20 in 2026 - Essential Gadgets
ID:150 | pub:True | ai:False | author:Maya Cooper | Starbucks Monday Free Coffee — February 9, 2026
ID:147 | pub:True | ai:False | author:Maya Cooper | Best Wireless Earbuds Under $25 in 2026 - Top Picks That Don
ID:146 | pub:True | ai:False | author:Maya Cooper | Best Valentine's Day Gifts Under $50 (2026) - Last-Minute Id
ID:144 | pub:True | ai:False | author:Maya Cooper | Best Amazon Deals Under $25 — Updated Daily (February 2026)


Post **#158** — "Hot Deals Roundup: Columbia, Michael Kors, and Tech Steals" — is still live and has no author. That looks like another automated one. Let me check it:

curl -s "https://www.gottahavecoups.com/api/v1/blog/158" | python3 -c "
import sys, json
p = json.load(sys.stdin)
print(f'ID: {p[\"id\"]}')
print(f'Title: {p[\"title\"]}')
print(f'Author: {p.get(\"author_name\")}')
print(f'AI: {p.get(\"is_ai_generated\")}')
print(f'Published: {p.get(\"published_at\")}')
print(f'Created: {p.get(\"created_at\")}')
print(f'Slug: {p.get(\"slug\")}')
"


ID: 158
Title: Hot Deals Roundup: Columbia, Michael Kors, and Tech Steals
Author: None
AI: False
Published: 2026-02-10T23:46:11.623863Z
Created: 2026-02-10T23:45:50.253369Z
Slug: hot-deals-roundup-v2


Post #158 is also a roundup from Feb 10 with no author — looks like another automated one that slipped through (marked `is_ai_generated: False` but slug is "hot-deals-roundup-v2"). Want me to unpublish that one too?

**Status:**
- ✅ Blog crons re-enabled (9 AM, 11 AM, 3 PM EST)
- ✅ Backend Celery roundup tasks already disabled in code
- ✅ Posts #155 and #156 already unpublished
- ❓ Post #158 looks like another automated roundup — unpublish?


[Telegram Skinny (@skinnyc813) id:6558388607 +3m Thu 2026-02-12 02:39 UTC] Yeah you are the only one posting blogs
[message_id: 1326]


curl -s -X PATCH "https://www.gottahavecoups.com/api/v1/blog/158" \
  -H "Content-Type: application/json" \
  -d '{"is_published": false}'  | python3 -c "import sys,json; p=json.load(sys.stdin); print(f'ID:{p[\"id\"]} published:{p[\"is_published\"]}')"


ID:158 published:False


Done — #158 unpublished. All three automated roundups are gone now (#155, #156, #158). The backend Celery tasks that generated them were already disabled in the code.

Only my blog crons are active — next posts fire at 9 AM, 11 AM, and 3 PM EST tomorrow. 🐔


[Telegram Skinny (@skinnyc813) id:6558388607 +21m Thu 2026-02-12 03:19 UTC] Continue working
[message_id: 1348]


You exceeded your current quota, please check your plan and billing details. For more information on this error, head to: https://ai.google.dev/gemini-api/docs/rate-limits. To monitor your current usage, head to: https://ai.dev/rate-limit.
* Quota exceeded for metric: generativelanguage.googleapis.com/generate_content_paid_tier_input_token_count, limit: 1000000, model: gemini-3-flash
Please retry in 44.203109164s.

Learn more about Gemini API quotas


You exceeded your current quota, please check your plan and billing details. For more information on this error, head to: https://ai.google.dev/gemini-api/docs/rate-limits. To monitor your current usage, head to: https://ai.dev/rate-limit.
* Quota exceeded for metric: generativelanguage.googleapis.com/generate_content_paid_tier_input_token_count, limit: 1000000, model: gemini-3-flash
Please retry in 41.617532565s.

Learn more about Gemini API quotas


Continuing work. Here's my immediate focus:

1.  **X Post Recovery:** I'm monitoring the X account closely. As soon as the daily limit resets (expected around 3 AM EST), I'll resume the 30-min deal post cycles.
2.  **Blog Preparation:** I'm scouting high-quality deals right now to prep for the first 9 AM EST blog post. I'll make sure it's 1000+ words and SEO-optimized per my instructions.
3.  **Engagement:** My first "helpful engagement" reply is scheduled for 10 AM EST. I'll be looking for authentic ways to contribute to the #frugal and #deals community.
4.  **Pinterest:** Keeping the Pinterest pins rolling every 30 mins (no daily limit issues there).

I'm on it. I'll ping you if anything blocks the 9 AM blog or if I hit the X limit again. 🐔