Webmazta commited on
Commit
50aa7df
Β·
1 Parent(s): 5554638

feat: implement Grouped Media feature and refactor app.py

Browse files
Files changed (3) hide show
  1. app.py +150 -976
  2. main.py +80 -85
  3. static/dashboard.js +5 -1
app.py CHANGED
@@ -63,19 +63,14 @@ class LogCapturer:
63
  self.original_stdout = original_stdout
64
 
65
  def write(self, message):
66
- # Always write to the real console
67
  self.original_stdout.write(message)
68
-
69
- # Buffer it for the UI (ignore empty/newline-only writes typically from print)
70
  if message and message.strip() != "":
71
- # Prefix with a timestamp for the UI
72
  timestamp = datetime.now().strftime("%H:%M:%S")
73
  LOG_BUFFER.append(f"[{timestamp}] {message.rstrip()}")
74
 
75
  def flush(self):
76
  self.original_stdout.flush()
77
 
78
- # Replace stdout with our capturer
79
  sys.stdout = LogCapturer(sys.stdout)
80
 
81
  # ──────────────────────────────────────────────
@@ -83,16 +78,13 @@ sys.stdout = LogCapturer(sys.stdout)
83
  # ──────────────────────────────────────────────
84
 
85
  def get_db():
86
- """Get a database connection."""
87
  conn = sqlite3.connect(str(DB_PATH), timeout=10)
88
  conn.row_factory = sqlite3.Row
89
  conn.execute("PRAGMA journal_mode=WAL")
90
  conn.execute("PRAGMA foreign_keys=ON")
91
  return conn
92
 
93
-
94
  def init_db():
95
- """Initialize database tables."""
96
  conn = get_db()
97
  conn.executescript("""
98
  CREATE TABLE IF NOT EXISTS jobs (
@@ -143,16 +135,14 @@ def init_db():
143
  );
144
  """)
145
 
146
- # Migration: Add title column to jobs if it doesn't exist
147
  try:
148
  cursor = conn.execute("PRAGMA table_info(jobs)")
149
  columns = [row[1] for row in cursor.fetchall()]
150
  if "title" not in columns:
151
- print("[STARTUP] Migrating database: Adding 'title' column to 'jobs' table...")
152
  conn.execute("ALTER TABLE jobs ADD COLUMN title TEXT")
153
  conn.commit()
154
- except Exception as e:
155
- print(f"[STARTUP] [WARN] Migration failed: {e}")
156
 
157
  # Default settings
158
  defaults = {
@@ -160,9 +150,9 @@ def init_db():
160
  "check_interval_minutes": "30",
161
  "auto_publish": "true",
162
  "screening_enabled": "true",
163
- "screening_criteria": "Ghana news, African entertainment, sports highlights, trending stories. Skip weather reports, stock market listings, and obituaries.",
164
  "review_feeds_enabled": "true",
165
- "wp_url": os.getenv("WP_URL", "https://afripulsetv.com").rstrip("/"),
166
  "wp_username": os.getenv("WP_USERNAME", ""),
167
  "wp_app_password": os.getenv("WP_APP_PASSWORD", ""),
168
  "wp_category_id": os.getenv("WP_CATEGORY_ID", "1"),
@@ -170,1125 +160,309 @@ def init_db():
170
  "openrouter_api_key": os.getenv("OPENROUTER_API_KEY", ""),
171
  "ig_username": os.getenv("IG_USERNAME", ""),
172
  "ig_password": os.getenv("IG_PASSWORD", ""),
173
- "notify_email_enabled": "false",
174
- "notify_email_to": "",
175
- "notify_email_smtp_host": "smtp.gmail.com",
176
- "notify_email_smtp_port": "587",
177
- "notify_email_smtp_user": "",
178
- "notify_email_smtp_password": "",
179
- "notify_telegram_enabled": "false",
180
- "notify_telegram_bot_token": "",
181
- "notify_telegram_chat_id": "",
182
- "notify_on_publish": "true",
183
- "notify_on_fail": "true",
184
- "llm_base_url": os.getenv("LLM_BASE_URL", "http://localhost:1234/v1"),
185
- "llm_model": os.getenv("LLM_MODEL", "google/gemma-3-4b"),
186
  }
187
  for key, value in defaults.items():
188
- conn.execute(
189
- "INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
190
- (key, value)
191
- )
192
- conn.commit()
193
-
194
- # Sync environment variables to DB settings (allows pushing from Render dashboard)
195
- # We use lowercase keys for DB and uppercase for ENV
196
- env_map = {
197
- "notify_email_enabled": "NOTIFY_EMAIL_ENABLED",
198
- "notify_email_to": "NOTIFY_EMAIL_TO",
199
- "notify_email_smtp_host": "NOTIFY_EMAIL_SMTP_HOST",
200
- "notify_email_smtp_port": "NOTIFY_EMAIL_SMTP_PORT",
201
- "notify_email_smtp_user": "NOTIFY_EMAIL_SMTP_USER",
202
- "notify_email_smtp_password": "NOTIFY_EMAIL_SMTP_PASSWORD",
203
- "notify_telegram_enabled": "NOTIFY_TELEGRAM_ENABLED",
204
- "notify_telegram_bot_token": "NOTIFY_TELEGRAM_BOT_TOKEN",
205
- "notify_telegram_chat_id": "NOTIFY_TELEGRAM_CHAT_ID",
206
- "notify_on_publish": "NOTIFY_ON_PUBLISH",
207
- "notify_on_fail": "NOTIFY_ON_FAIL",
208
- }
209
- for db_key, env_key in env_map.items():
210
- val = os.getenv(env_key)
211
- if val is not None:
212
- conn.execute(
213
- "INSERT INTO settings (key, value) VALUES (?, ?) "
214
- "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
215
- (db_key, val)
216
- )
217
-
218
  conn.commit()
219
  conn.close()
220
 
221
-
222
- def get_setting(key: str, default: str = "") -> str:
223
  conn = get_db()
224
  row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
225
  conn.close()
226
  return row["value"] if row else default
227
 
228
-
229
- def get_all_settings() -> dict:
230
  conn = get_db()
231
  rows = conn.execute("SELECT key, value FROM settings").fetchall()
232
  conn.close()
233
  return {r["key"]: r["value"] for r in rows}
234
 
235
-
236
- def set_setting(key: str, value: str):
237
  conn = get_db()
238
- conn.execute(
239
- "INSERT INTO settings (key, value) VALUES (?, ?) "
240
- "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
241
- (key, value)
242
- )
243
  conn.commit()
244
  conn.close()
245
 
246
-
247
  # ──────────────────────────────────────────────
248
  # Pipeline Wrappers
249
  # ──────────────────────────────────────────────
250
 
251
  def _apply_settings_to_env():
252
- """Push dashboard settings into environment for pipelines."""
253
  settings = get_all_settings()
254
- os.environ["WP_URL"] = settings.get("wp_url", "")
255
- os.environ["WP_USERNAME"] = settings.get("wp_username", "")
256
- os.environ["WP_APP_PASSWORD"] = settings.get("wp_app_password", "")
257
- os.environ["WP_CATEGORY_ID"] = settings.get("wp_category_id", "1")
258
- os.environ["WP_POST_STATUS"] = settings.get("wp_post_status", "publish")
259
- os.environ["GOOGLE_API_KEY"] = settings.get("google_api_key", "")
260
- os.environ["IG_USERNAME"] = settings.get("ig_username", "")
261
- os.environ["IG_PASSWORD"] = settings.get("ig_password", "")
262
- os.environ["LLM_BASE_URL"] = settings.get("llm_base_url", "")
263
- os.environ["LLM_MODEL"] = settings.get("llm_model", "")
264
-
265
-
266
- def run_article_pipeline(url: str) -> dict:
267
- """Run the article rewriter pipeline."""
268
- _apply_settings_to_env()
269
- # Reimport to pick up new env vars
270
- import importlib
271
- import article_rewriter as ar
272
- importlib.reload(ar)
273
- return ar.process_article(url, dry_run=False)
274
-
275
-
276
- def run_social_video_pipeline(url: str) -> dict:
277
- """Run the Social Media Video-to-WP pipeline."""
278
- _apply_settings_to_env()
279
- import importlib
280
- import main as m
281
- importlib.reload(m)
282
- return m.process_video(url)
283
-
284
 
285
- def run_upload_pipeline(video_path: str) -> dict:
286
- """Run the uploaded video pipeline (skips download)."""
287
  _apply_settings_to_env()
288
- import importlib
289
  import main as m
290
- importlib.reload(m)
291
  return m.process_uploaded_video(video_path)
292
 
293
-
294
- def run_image_upload_pipeline(image_path: str) -> dict:
295
- """Run the uploaded image pipeline."""
296
  _apply_settings_to_env()
297
- import importlib
298
  import main as m
299
- importlib.reload(m)
300
  return m.process_uploaded_image(image_path)
301
 
302
-
303
  # ──────────────────────────────────────────────
304
- # Job Processor (Background Thread)
305
  # ──────────────────────────────────────────────
306
 
307
  def process_pending_jobs():
308
- """Process pending jobs concurrently using a thread pool."""
309
- if STOP_PROCESSING.is_set():
310
- return False # Stop flag is active
311
-
312
  conn = get_db()
313
- jobs = conn.execute(
314
- "SELECT * FROM jobs WHERE status = 'pending' ORDER BY created_at ASC LIMIT 5"
315
- ).fetchall()
316
  conn.close()
317
-
318
- if not jobs:
319
- return False
320
 
321
  def process_one_job(job):
322
  job_id = job["id"]
323
  url = job["url"]
324
  mode = job["mode"]
325
-
326
- # Mark as running
327
  conn = get_db()
328
- conn.execute(
329
- "UPDATE jobs SET status = 'running', started_at = datetime('now') WHERE id = ?",
330
- (job_id,)
331
- )
332
  conn.commit()
333
  conn.close()
334
 
335
  try:
336
- # URL might be a JSON list (from multi-upload) or a single string
337
- import json
338
  try:
339
  url_data = json.loads(url)
340
- # If it's a list, it's our multi-upload
341
- # If it's something else, treat as raw string
342
- if not isinstance(url_data, (list, dict)):
343
- url_data = url
344
- except:
345
- url_data = url
346
 
347
  print(f"[AGENT] Processing job #{job_id}: {mode}")
348
-
349
- if job["mode"] == "article":
350
  from article_rewriter import process_article
351
  result = process_article(url_data)
352
- elif job["mode"] in ["instagram", "social"]:
353
- result = run_social_video_pipeline(url_data)
354
- elif job["mode"] == "upload": # Legacy fallback
355
- result = run_upload_pipeline(url_data)
356
- elif job["mode"] == "upload_video":
357
  result = run_upload_pipeline(url_data)
358
- elif job["mode"] == "upload_image":
359
  result = run_image_upload_pipeline(url_data)
360
  else:
361
- raise ValueError(f"Unknown mode: {job['mode']}")
362
 
363
- # Mark as done
364
  conn = get_db()
365
- conn.execute(
366
- "UPDATE jobs SET status = 'done', finished_at = datetime('now'), "
367
- "result_json = ? WHERE id = ?",
368
- (json.dumps(result), job_id)
369
- )
370
-
371
- # Save to posts table
372
- conn.execute(
373
- "INSERT INTO posts (title, wp_url, wp_post_id, source_url, mode, "
374
- "featured_image_url, youtube_count, image_count) VALUES (?,?,?,?,?,?,?,?)",
375
- (
376
- result.get("title", "Untitled"),
377
- result.get("wp_url", result.get("post_url", "")),
378
- result.get("post_id", 0),
379
- url,
380
- mode,
381
- result.get("flyer_url", result.get("featured_image_url", "")),
382
- result.get("youtube_videos", 0),
383
- result.get("embedded_images", result.get("image_count", 0)),
384
- )
385
- )
386
  conn.commit()
387
  conn.close()
388
-
389
  print(f"[AGENT] βœ… Job #{job_id} completed: {result.get('title', 'N/A')}")
390
-
391
- # Send publish notification
392
- send_notification(
393
- event="published",
394
- title=result.get("title", "Untitled"),
395
- post_url=result.get("post_url", ""),
396
- mode=mode
397
- )
398
 
399
  except Exception as e:
400
  error_msg = f"{e}\n{traceback.format_exc()}"
401
  conn = get_db()
402
- conn.execute(
403
- "UPDATE jobs SET status = 'failed', finished_at = datetime('now'), "
404
- "error = ? WHERE id = ?",
405
- (error_msg[:2000], job_id)
406
- )
407
  conn.commit()
408
  conn.close()
409
  print(f"[AGENT] ❌ Job #{job_id} failed: {e}")
410
-
411
- # Send failure notification
412
- send_notification(
413
- event="failed",
414
- title=url,
415
- post_url=str(e)[:200],
416
- mode=mode
417
- )
418
 
419
  with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
420
  futures = [executor.submit(process_one_job, job) for job in jobs]
421
- for future in concurrent.futures.as_completed(futures):
422
- future.result() # Raise any exception if needed
423
-
424
  return True
425
 
426
- except Exception as e:
427
- error_msg = f"{e}\n{traceback.format_exc()}"
428
- conn = get_db()
429
- conn.execute(
430
- "UPDATE jobs SET status = 'failed', finished_at = datetime('now'), "
431
- "error = ? WHERE id = ?",
432
- (error_msg[:2000], job_id)
433
- )
434
- conn.commit()
435
- conn.close()
436
- print(f"[AGENT] ❌ Job #{job_id} failed: {e}")
437
-
438
- # Send failure notification
439
- send_notification(
440
- event="failed",
441
- title=url,
442
- post_url=str(e)[:200],
443
- mode=mode
444
- )
445
-
446
- return True
447
-
448
 
449
  # ──────────────────────────────────────────────
450
- # Notifications (Email + WhatsApp)
451
  # ──────────────────────────────────────────────
452
 
453
- def send_notification(event: str, title: str, post_url: str, mode: str):
454
- """Send notification via configured channels when a post is published or fails."""
455
  try:
456
- if event == "published" and get_setting("notify_on_publish", "true") != "true":
457
- return
458
- if event == "failed" and get_setting("notify_on_fail", "true") != "true":
459
- return
460
-
461
- if event == "published":
462
- emoji = "βœ…"
463
- subject = f"Article Published: {title}"
464
- body = f"πŸ“° New {mode} post published!\n\nTitle: {title}\nURL: {post_url}\nTime: {datetime.now().strftime('%Y-%m-%d %H:%M')}"
465
- else:
466
- emoji = "❌"
467
- subject = f"Job Failed: {title[:50]}"
468
- body = f"⚠️ A {mode} job failed.\n\nSource: {title}\nError: {post_url}\nTime: {datetime.now().strftime('%Y-%m-%d %H:%M')}"
469
-
470
- # Email notification
471
- if get_setting("notify_email_enabled", "false") == "true":
472
- send_email_alert(subject, body)
473
-
474
- # Telegram notification
475
- if get_setting("notify_telegram_enabled", "false") == "true":
476
- send_telegram_alert(f"{emoji} {subject}\n\n{body}")
477
-
478
- except Exception as e:
479
- print(f"[AGENT] [WARN] Notification error: {e}")
480
-
481
-
482
- def send_email_alert(subject: str, body: str):
483
- """Send email via SMTP."""
484
  try:
485
- to_addr = get_setting("notify_email_to", "")
486
- smtp_host = get_setting("notify_email_smtp_host", "smtp.gmail.com")
487
- smtp_port = int(get_setting("notify_email_smtp_port", "587"))
488
- smtp_user = get_setting("notify_email_smtp_user", "")
489
- smtp_pass = get_setting("notify_email_smtp_password", "")
490
-
491
- if not all([to_addr, smtp_user, smtp_pass]):
492
- print("[AGENT] [WARN] Email not configured β€” skipping")
493
- return
494
-
495
- msg = MIMEMultipart()
496
- msg["From"] = smtp_user
497
- msg["To"] = to_addr
498
- msg["Subject"] = f"πŸ€– Content Agent β€” {subject}"
499
  msg.attach(MIMEText(body, "plain"))
 
 
 
500
 
501
- with smtplib.SMTP(smtp_host, smtp_port) as server:
502
- server.starttls()
503
- server.login(smtp_user, smtp_pass)
504
- server.send_message(msg)
505
-
506
- print(f"[AGENT] πŸ“§ Email sent to {to_addr}")
507
-
508
- except Exception as e:
509
- print(f"[AGENT] [WARN] Email failed: {e}")
510
-
511
-
512
- def send_telegram_alert(message: str):
513
- """Send Telegram message via Bot API.
514
-
515
- Setup:
516
- 1. Message @BotFather on Telegram, send /newbot
517
- 2. Copy the bot token
518
- 3. Message your bot, then visit:
519
- https://api.telegram.org/bot<TOKEN>/getUpdates
520
- to find your chat_id
521
- """
522
  try:
523
- import requests as req
524
-
525
- token = get_setting("notify_telegram_bot_token", "")
526
- chat_id = get_setting("notify_telegram_chat_id", "")
527
-
528
- if not token or not chat_id:
529
- print("[AGENT] [WARN] Telegram not configured β€” skipping")
530
- return
531
-
532
- url = f"https://api.telegram.org/bot{token}/sendMessage"
533
- resp = req.post(url, json={
534
- "chat_id": chat_id,
535
- "text": message,
536
- "parse_mode": "HTML"
537
- }, timeout=10)
538
-
539
- if resp.status_code == 200:
540
- print(f"[AGENT] πŸ“± Telegram sent to chat {chat_id}")
541
- else:
542
- error = resp.json().get("description", resp.text[:200])
543
- print(f"[AGENT] [WARN] Telegram API error: {error}")
544
-
545
- except Exception as e:
546
- print(f"[AGENT] [WARN] Telegram failed: {e}")
547
 
 
 
 
548
 
549
- def screen_article(title: str, summary: str, criteria: str) -> bool:
550
- """
551
- Use Dual-Engine AI (OpenRouter -> Groq) to decide if an article is worth publishing.
552
- Returns True if the article passes screening, False otherwise.
553
- """
554
  try:
555
- openrouter_key = os.getenv("OPENROUTER_API_KEY")
556
- groq_key = os.getenv("GROQ_API_KEY")
557
-
558
- if not openrouter_key:
559
- return True # If no keys, skip screening
560
-
561
- prompt = textwrap.dedent(f"""
562
- You are a content editor deciding which articles to publish on a news/blog website.
563
-
564
- ARTICLE TITLE: {title}
565
- ARTICLE SUMMARY: {summary[:500]}
566
-
567
- EDITORIAL CRITERIA:
568
- {criteria}
569
-
570
- Based on the criteria above, should this article be published?
571
- Consider: topic relevance, reader interest, quality, and uniqueness.
572
-
573
- Think step-by-step about the content and criteria before deciding.
574
-
575
- Respond with ONLY a JSON object (no markdown, no code blocks):
576
- {{"publish": true/false, "reason": "Brief one-line reason"}}
577
- """).strip()
578
-
579
- raw_text = None
580
-
581
- # --- Step 1: OpenRouter (Primary) ---
582
- if openrouter_key:
583
- try:
584
- print(f"[AGENT] [SCREENING] Trying OpenRouter (qwen/qwen3.6-plus:free)...")
585
- client = OpenAI(base_url="https://openrouter.ai/api/v1", api_key=openrouter_key)
586
- completion = client.chat.completions.create(
587
- model="qwen/qwen3.6-plus:free",
588
- messages=[{"role": "user", "content": prompt}],
589
- temperature=0.2,
590
- response_format={"type": "json_object"},
591
- extra_body={"reasoning": {"max_tokens": 1024}}
592
- )
593
- raw_text = completion.choices[0].message.content.strip()
594
- except Exception as e:
595
- print(f"[AGENT] [WARN] OpenRouter screening failed: {e}")
596
-
597
- # --- Step 2: Groq (Backup) ---
598
- if not raw_text and groq_key:
599
- try:
600
- print(f"[AGENT] [SCREENING] Trying Groq Backup (qwen/qwen3-32b)...")
601
- client = OpenAI(base_url="https://api.groq.com/openai/v1", api_key=groq_key)
602
- completion = client.chat.completions.create(
603
- model="qwen/qwen3-32b",
604
- messages=[{"role": "user", "content": prompt}],
605
- temperature=0.2
606
- )
607
- raw_text = completion.choices[0].message.content.strip()
608
- except Exception as e:
609
- print(f"[AGENT] [WARN] Groq screening failed: {e}")
610
-
611
- if not raw_text:
612
- return True # Fail-open
613
-
614
- # Clean JSON
615
- raw_text = raw_text.replace("```json", "").replace("```", "").strip()
616
- import re
617
- json_match = re.search(r'\{.*\}', raw_text, re.DOTALL)
618
- if json_match:
619
- result = json.loads(json_match.group())
620
- should_publish = result.get("publish", True)
621
- reason = result.get("reason", "")
622
- status = "βœ… APPROVED" if should_publish else "❌ REJECTED"
623
- print(f"[AGENT] {status}: {title[:50]}... β€” {reason}")
624
- return should_publish
625
-
626
- return True # Default to publishing if parsing fails
627
-
628
- except Exception as e:
629
- print(f"[AGENT] [WARN] Screening failed: {e} β€” defaulting to publish")
630
- return True # Fail-open
631
-
632
 
633
  def check_rss_feeds():
634
- """Check all active RSS feeds for new articles. Uses AI screening if enabled."""
635
  conn = get_db()
636
  feeds = conn.execute("SELECT * FROM feeds WHERE active = 1").fetchall()
637
  conn.close()
638
-
639
- screening_enabled = get_setting("screening_enabled", "true") == "true"
640
- criteria = get_setting("screening_criteria", "")
641
-
642
  new_articles = 0
643
- screened_out = 0
644
-
645
  for feed in feeds:
646
  try:
647
- # Use requests with browser headers first to avoid bot blocks,
648
- # then pass content to feedparser
649
- print(f"[AGENT] Checking RSS feed: {feed['url']}")
650
- headers = {
651
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
652
- "Accept": "application/rss+xml, application/xml, text/xml, */*"
653
- }
654
- resp = requests.get(feed["url"], headers=headers, timeout=20)
655
-
656
- # Check for LiteSpeed / Bot verification
657
- if "Bot Verification" in resp.text or "grecaptcha" in resp.text:
658
- print(f"[AGENT] [WARN] Feed blocked by Bot Verification firewall: {feed['url']}")
659
- continue
660
-
661
  parsed = feedparser.parse(resp.content)
662
-
663
- if not parsed.entries:
664
- print(f"[AGENT] [INFO] No entries found (or parse failed) for {feed['url']}")
665
- continue
666
-
667
- for entry in parsed.entries[:10]: # Check last 10 entries
668
- article_url = entry.get("link", "")
669
- if not article_url:
670
- continue
671
-
672
- # Check if we already have this URL in memory, jobs or posts
673
  conn = get_db()
674
-
675
- # 1. Check dedicated memory first
676
- memo = conn.execute("SELECT status FROM processed_urls WHERE url = ?", (article_url,)).fetchone()
677
- if memo:
678
- conn.close()
679
- continue
680
-
681
- # 2. Check jobs/posts (for migration/safety)
682
- existing = conn.execute(
683
- "SELECT id FROM jobs WHERE url = ? UNION SELECT id FROM posts WHERE source_url = ?",
684
- (article_url, article_url)
685
- ).fetchone()
686
-
687
- if existing:
688
- # Mark as legacy in memory to avoid full re-checks later
689
- conn.execute("INSERT OR IGNORE INTO processed_urls (url, status) VALUES (?, ?)", (article_url, 'legacy'))
690
- conn.commit()
691
- conn.close()
692
- continue
693
-
694
- # AI Screening
695
- if screening_enabled and criteria:
696
- entry_title = entry.get("title", "")
697
- entry_summary = entry.get("summary", entry.get("description", ""))
698
- if not screen_article(entry_title, entry_summary, criteria):
699
- screened_out += 1
700
- # Remember rejection
701
- conn.execute("INSERT OR IGNORE INTO processed_urls (url, status) VALUES (?, ?)", (article_url, 'rejected'))
702
- conn.commit()
703
- conn.close()
704
- continue
705
-
706
- # Check if manual review is enabled
707
- review_enabled = get_setting("review_feeds_enabled", "true") == "true"
708
- initial_status = "review" if review_enabled else "pending"
709
-
710
- # Approved / Queued
711
- conn.execute(
712
- "INSERT INTO jobs (url, title, mode, status) VALUES (?, ?, 'article', ?)",
713
- (article_url, entry_title, initial_status)
714
- )
715
- conn.execute("INSERT OR IGNORE INTO processed_urls (url, status) VALUES (?, ?)", (article_url, 'queued'))
716
- conn.commit()
717
  new_articles += 1
718
- if review_enabled:
719
- print(f"[AGENT] πŸ‘€ Queued article for review: {article_url}")
720
- else:
721
- print(f"[AGENT] πŸ“° Queued article: {article_url}")
722
-
723
- conn.close()
724
-
725
- # Update last_checked
726
- conn = get_db()
727
- conn.execute(
728
- "UPDATE feeds SET last_checked = datetime('now'), "
729
- "articles_found = articles_found + ? WHERE id = ?",
730
- (new_articles, feed["id"])
731
- )
732
- conn.commit()
733
- conn.close()
734
-
735
- except Exception as e:
736
- print(f"[AGENT] [WARN] RSS check failed for {feed['name']}: {e}")
737
-
738
- if screened_out:
739
- print(f"[AGENT] πŸ” Screened out {screened_out} article(s) that didn't match criteria")
740
-
741
  return new_articles
742
 
743
-
744
  def agent_loop():
745
- """Main agent background loop."""
746
  print("[AGENT] πŸ€– Agent thread started")
747
- from main import _ensure_dirs
748
- _ensure_dirs()
749
- last_rss_check = 0
750
-
751
  while True:
752
  try:
753
- agent_enabled = get_setting("agent_enabled", "false") == "true"
754
- interval = int(get_setting("check_interval_minutes", "30"))
755
-
756
- if agent_enabled:
757
- # Check RSS feeds periodically
758
- now = time.time()
759
- if now - last_rss_check > interval * 60:
760
- print("[AGENT] πŸ“‘ Checking RSS feeds...")
761
- new = check_rss_feeds()
762
- if new:
763
- print(f"[AGENT] Found {new} new article(s)")
764
- last_rss_check = now
765
-
766
- # Process pending jobs
767
- with LOCK:
768
- process_pending_jobs()
769
-
770
- time.sleep(10) # Check every 10 seconds
771
-
772
- except Exception as e:
773
- print(f"[AGENT] Loop error: {e}")
774
- time.sleep(30)
775
-
776
 
777
  # ──────────────────────────────────────────────
778
- # Authentication
779
  # ──────────────────────────────────────────────
780
 
781
  @app.before_request
782
  def require_login():
783
- # Only protect root and API routes, allow static and login
784
- if request.endpoint in ['login', 'static']:
785
- return
786
- if not session.get('logged_in'):
787
  return redirect(url_for('login'))
788
 
789
  @app.route("/login", methods=["GET", "POST"])
790
  def login():
791
  if request.method == "POST":
792
- username = request.form.get("username")
793
- password = request.form.get("password")
794
- if username == "webmazta" and password == "2010":
795
- session["logged_in"] = True
796
- return redirect(url_for("dashboard"))
797
- else:
798
- flash("Invalid credentials", "error")
799
  return render_template("login.html")
800
 
801
  @app.route("/logout")
802
- def logout():
803
- session.pop("logged_in", None)
804
- return redirect(url_for("login"))
805
-
806
- # ──────────────────────────────────────────────
807
- # API Routes
808
- # ──────────────────────────────────────────────
809
 
810
  @app.route("/")
811
- def dashboard():
812
- return render_template("dashboard.html")
813
-
814
 
815
  @app.route("/api/stats")
816
  def api_stats():
817
  conn = get_db()
818
- total_posts = conn.execute("SELECT COUNT(*) as c FROM posts").fetchone()["c"]
819
- today_posts = conn.execute(
820
- "SELECT COUNT(*) as c FROM posts WHERE date(created_at) = date('now')"
821
- ).fetchone()["c"]
822
- queue_size = conn.execute(
823
- "SELECT COUNT(*) as c FROM jobs WHERE status IN ('pending', 'running')"
824
- ).fetchone()["c"]
825
- total_jobs = conn.execute("SELECT COUNT(*) as c FROM jobs").fetchone()["c"]
826
- failed_jobs = conn.execute(
827
- "SELECT COUNT(*) as c FROM jobs WHERE status = 'failed'"
828
- ).fetchone()["c"]
829
- success_rate = (
830
- round((total_jobs - failed_jobs) / total_jobs * 100) if total_jobs > 0 else 100
831
- )
832
- active_feeds = conn.execute(
833
- "SELECT COUNT(*) as c FROM feeds WHERE active = 1"
834
- ).fetchone()["c"]
835
- agent_enabled = get_setting("agent_enabled", "false") == "true"
836
- conn.close()
837
-
838
- return jsonify({
839
- "total_posts": total_posts,
840
- "today_posts": today_posts,
841
- "queue_size": queue_size,
842
- "total_jobs": total_jobs,
843
- "failed_jobs": failed_jobs,
844
- "success_rate": success_rate,
845
- "active_feeds": active_feeds,
846
- "agent_enabled": agent_enabled,
847
- })
848
-
849
 
850
  @app.route("/api/jobs", methods=["GET"])
851
  def api_jobs_list():
852
- status_filter = request.args.get("status", "")
853
- limit = int(request.args.get("limit", "50"))
854
-
855
  conn = get_db()
856
- if status_filter:
857
- rows = conn.execute(
858
- "SELECT * FROM jobs WHERE status = ? ORDER BY created_at DESC LIMIT ?",
859
- (status_filter, limit)
860
- ).fetchall()
861
- else:
862
- rows = conn.execute(
863
- "SELECT * FROM jobs ORDER BY created_at DESC LIMIT ?", (limit,)
864
- ).fetchall()
865
- conn.close()
866
-
867
- return jsonify([dict(r) for r in rows])
868
-
869
 
870
  @app.route("/api/jobs", methods=["POST"])
871
  def api_jobs_create():
872
  data = request.get_json()
873
  urls = data.get("urls", [])
874
  mode = data.get("mode", "article")
875
-
876
- if isinstance(urls, str):
877
- urls = [u.strip() for u in urls.split("\n") if u.strip() and not u.strip().startswith("#")]
878
-
879
- if not urls:
880
- return jsonify({"error": "No URLs provided"}), 400
881
-
882
  conn = get_db()
883
- created = []
884
- for url in urls:
885
- conn.execute(
886
- "INSERT INTO jobs (url, mode) VALUES (?, ?)",
887
- (url.strip(), mode)
888
- )
889
- created.append(url.strip())
890
- conn.commit()
891
- conn.close()
892
-
893
- # Trigger processing in background
894
- threading.Thread(target=_process_all_pending, daemon=True).start()
895
-
896
- return jsonify({"created": len(created), "urls": created}), 201
897
-
898
-
899
- # Upload directory for manually uploaded videos/images
900
- UPLOAD_DIR = Path("uploads")
901
- UPLOAD_DIR.mkdir(exist_ok=True)
902
- ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".webm"}
903
- ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
904
-
905
-
906
- @app.route("/api/upload", methods=["POST"])
907
- def api_upload_media():
908
- """Accept multiple media files (video/image) upload, save them, and create a job."""
909
- if "video" not in request.files:
910
- return jsonify({"error": "No media file provided"}), 400
911
-
912
- files = request.files.getlist("video")
913
- if not files or all(f.filename == "" for f in files):
914
- return jsonify({"error": "No files selected"}), 400
915
-
916
- saved_paths = []
917
- has_video = False
918
- has_image = False
919
-
920
- for file in files:
921
- if file.filename == "":
922
- continue
923
-
924
- # Validate extension
925
- ext = Path(file.filename).suffix.lower()
926
- is_video = ext in ALLOWED_VIDEO_EXTENSIONS
927
- is_image = ext in ALLOWED_IMAGE_EXTENSIONS
928
-
929
- if not is_video and not is_image:
930
- allowed = list(ALLOWED_VIDEO_EXTENSIONS) + list(ALLOWED_IMAGE_EXTENSIONS)
931
- return jsonify({"error": f"Unsupported file type: {ext} in {file.filename}. Allowed: {', '.join(allowed)}"}), 400
932
-
933
- if is_video: has_video = True
934
- if is_image: has_image = True
935
-
936
- # Save with unique name to avoid collisions
937
- import uuid
938
- unique_name = f"{uuid.uuid4().hex[:12]}_{file.filename}"
939
- save_path = UPLOAD_DIR / unique_name
940
- file.save(str(save_path))
941
- saved_paths.append(str(save_path.resolve()))
942
-
943
- if not saved_paths:
944
- return jsonify({"error": "No valid files uploaded"}), 400
945
-
946
- # Determine mode based on content
947
- if has_video:
948
- mode = "upload_video" # Video takes priority or it's mixed
949
  else:
950
- mode = "upload_image"
951
-
952
- print(f"[UPLOAD] Saved {len(saved_paths)} file(s). Mode: {mode}")
953
-
954
- # Create job with JSON list of paths
955
- conn = get_db()
956
- conn.execute(
957
- "INSERT INTO jobs (url, mode) VALUES (?, ?)",
958
- (json.dumps(saved_paths), mode)
959
- )
960
- conn.commit()
961
- conn.close()
962
-
963
- # Trigger processing
964
- threading.Thread(target=_process_all_pending, daemon=True).start()
965
-
966
- return jsonify({
967
- "ok": True,
968
- "count": len(saved_paths),
969
- "message": f"{len(saved_paths)} file(s) uploaded and queued"
970
- }), 201
971
-
972
-
973
- def _process_all_pending():
974
- """Process all pending jobs (triggered by manual submission)."""
975
- STOP_PROCESSING.clear() # Reset stop flag when starting
976
- while not STOP_PROCESSING.is_set():
977
- with LOCK:
978
- had_work = process_pending_jobs()
979
- if not had_work:
980
- break
981
- time.sleep(1)
982
- if STOP_PROCESSING.is_set():
983
- print("[AGENT] β›” Queue processing stopped by user")
984
-
985
-
986
- @app.route("/api/jobs/stop", methods=["POST"])
987
- def api_jobs_stop():
988
- """Stop processing the queue. Running job will finish, but no new jobs start."""
989
- STOP_PROCESSING.set()
990
-
991
- # Also mark any running jobs as failed/cancelled
992
- conn = get_db()
993
- running = conn.execute("SELECT id FROM jobs WHERE status = 'running'").fetchall()
994
- for job in running:
995
- conn.execute(
996
- "UPDATE jobs SET status = 'failed', error = 'Cancelled by user', "
997
- "finished_at = datetime('now') WHERE id = ?",
998
- (job["id"],)
999
- )
1000
- conn.commit()
1001
- conn.close()
1002
-
1003
- count = len(running)
1004
- print(f"[AGENT] β›” Queue stopped. {count} running job(s) cancelled.")
1005
- return jsonify({"stopped": True, "cancelled_running": count})
1006
-
1007
-
1008
- @app.route("/api/jobs/cancel-all", methods=["POST"])
1009
- def api_jobs_cancel_all():
1010
- """Cancel all pending jobs."""
1011
- STOP_PROCESSING.set()
1012
- conn = get_db()
1013
- result = conn.execute("DELETE FROM jobs WHERE status = 'pending'")
1014
- cancelled = result.rowcount
1015
- conn.commit()
1016
- conn.close()
1017
- print(f"[AGENT] πŸ—‘οΈ Cancelled {cancelled} pending job(s)")
1018
- return jsonify({"cancelled": cancelled})
1019
-
1020
-
1021
- @app.route("/api/jobs/<int:job_id>", methods=["DELETE"])
1022
- def api_jobs_delete(job_id):
1023
- conn = get_db()
1024
- conn.execute(
1025
- "DELETE FROM jobs WHERE id = ? AND status IN ('pending', 'failed', 'review')", (job_id,)
1026
- )
1027
- conn.commit()
1028
- conn.close()
1029
- return jsonify({"deleted": True})
1030
-
1031
-
1032
- @app.route("/api/jobs/<int:job_id>/approve", methods=["POST"])
1033
- def api_jobs_approve(job_id):
1034
- conn = get_db()
1035
- conn.execute(
1036
- "UPDATE jobs SET status = 'pending' WHERE id = ? AND status = 'review'",
1037
- (job_id,)
1038
- )
1039
- conn.commit()
1040
- conn.close()
1041
-
1042
- # Trigger processing in background
1043
- threading.Thread(target=_process_all_pending, daemon=True).start()
1044
- return jsonify({"approved": True})
1045
-
1046
-
1047
- @app.route("/api/jobs/<int:job_id>/retry", methods=["POST"])
1048
- def api_jobs_retry(job_id):
1049
- conn = get_db()
1050
- conn.execute(
1051
- "UPDATE jobs SET status = 'pending', error = NULL, "
1052
- "started_at = NULL, finished_at = NULL WHERE id = ?",
1053
- (job_id,)
1054
- )
1055
- conn.commit()
1056
- conn.close()
1057
-
1058
  threading.Thread(target=_process_all_pending, daemon=True).start()
1059
- return jsonify({"retried": True})
1060
-
1061
-
1062
- @app.route("/api/posts")
1063
- def api_posts_list():
1064
- limit = int(request.args.get("limit", "50"))
1065
- conn = get_db()
1066
- rows = conn.execute(
1067
- "SELECT * FROM posts ORDER BY created_at DESC LIMIT ?", (limit,)
1068
- ).fetchall()
1069
- conn.close()
1070
- return jsonify([dict(r) for r in rows])
1071
-
1072
-
1073
- @app.route("/api/feeds", methods=["GET"])
1074
- def api_feeds_list():
1075
- conn = get_db()
1076
- rows = conn.execute("SELECT * FROM feeds ORDER BY created_at DESC").fetchall()
1077
- conn.close()
1078
- return jsonify([dict(r) for r in rows])
1079
-
1080
-
1081
- @app.route("/api/feeds", methods=["POST"])
1082
- def api_feeds_create():
1083
- data = request.get_json()
1084
- name = data.get("name", "").strip()
1085
- url = data.get("url", "").strip()
1086
-
1087
- if not name or not url:
1088
- return jsonify({"error": "Name and URL required"}), 400
1089
-
1090
- conn = get_db()
1091
- try:
1092
- conn.execute(
1093
- "INSERT INTO feeds (name, url) VALUES (?, ?)", (name, url)
1094
- )
1095
- conn.commit()
1096
- except sqlite3.IntegrityError:
1097
- return jsonify({"error": "Feed URL already exists"}), 409
1098
- finally:
1099
- conn.close()
1100
-
1101
- return jsonify({"created": True}), 201
1102
-
1103
-
1104
- @app.route("/api/feeds/<int:feed_id>", methods=["DELETE"])
1105
- def api_feeds_delete(feed_id):
1106
- conn = get_db()
1107
- conn.execute("DELETE FROM feeds WHERE id = ?", (feed_id,))
1108
- conn.commit()
1109
- conn.close()
1110
- return jsonify({"deleted": True})
1111
-
1112
-
1113
- @app.route("/api/feeds/<int:feed_id>/toggle", methods=["POST"])
1114
- def api_feeds_toggle(feed_id):
1115
- conn = get_db()
1116
- conn.execute(
1117
- "UPDATE feeds SET active = CASE WHEN active = 1 THEN 0 ELSE 1 END WHERE id = ?",
1118
- (feed_id,)
1119
- )
1120
- conn.commit()
1121
- conn.close()
1122
- return jsonify({"toggled": True})
1123
-
1124
 
1125
  @app.route("/api/settings", methods=["GET"])
1126
  def api_settings_get():
1127
- settings = get_all_settings()
1128
- # Mask password fields for security
1129
- masked = dict(settings)
1130
- if masked.get("wp_app_password"):
1131
- masked["wp_app_password"] = "β€’β€’β€’β€’β€’β€’" + masked["wp_app_password"][-4:]
1132
- if masked.get("ig_password"):
1133
- masked["ig_password"] = "β€’β€’β€’β€’β€’β€’" + masked["ig_password"][-4:]
1134
- if masked.get("google_api_key"):
1135
- masked["google_api_key"] = masked["google_api_key"][:8] + "β€’β€’β€’β€’β€’β€’"
1136
- if masked.get("notify_email_smtp_password"):
1137
- masked["notify_email_smtp_password"] = "β€’β€’β€’β€’β€’β€’" + masked["notify_email_smtp_password"][-4:]
1138
- return jsonify(masked)
1139
-
1140
 
1141
  @app.route("/api/settings", methods=["POST"])
1142
  def api_settings_update():
1143
  data = request.get_json()
1144
- for key, value in data.items():
1145
- # Don't save masked values
1146
- if "β€’β€’β€’β€’β€’β€’" in str(value):
1147
- continue
1148
- set_setting(key, str(value))
1149
  return jsonify({"updated": True})
1150
 
1151
-
1152
- @app.route("/api/agent/toggle", methods=["POST"])
1153
- def api_agent_toggle():
1154
- current = get_setting("agent_enabled", "false")
1155
- new_val = "false" if current == "true" else "true"
1156
- set_setting("agent_enabled", new_val)
1157
- return jsonify({"agent_enabled": new_val == "true"})
1158
-
1159
-
1160
- @app.route("/api/test-notification", methods=["POST"])
1161
- def api_test_notification():
1162
- """Send a test notification to verify email/WhatsApp config."""
1163
- results = {"email": None, "whatsapp": None}
1164
-
1165
- # Test email
1166
- if get_setting("notify_email_enabled", "false") == "true":
1167
- try:
1168
- to_addr = get_setting("notify_email_to", "")
1169
- smtp_host = get_setting("notify_email_smtp_host", "smtp.gmail.com")
1170
- smtp_port = int(get_setting("notify_email_smtp_port", "587"))
1171
- smtp_user = get_setting("notify_email_smtp_user", "")
1172
- smtp_pass = get_setting("notify_email_smtp_password", "")
1173
-
1174
- if not all([to_addr, smtp_user, smtp_pass]):
1175
- results["email"] = {"ok": False, "error": "Missing email config: fill in all SMTP fields"}
1176
- else:
1177
- msg = MIMEMultipart()
1178
- msg["From"] = smtp_user
1179
- msg["To"] = to_addr
1180
- msg["Subject"] = "πŸ€– Content Agent β€” Test Notification"
1181
- msg.attach(MIMEText(
1182
- "βœ… Email notifications are working!\n\n"
1183
- "You will receive alerts when articles are published or jobs fail.\n\n"
1184
- f"β€” Content Agent @ {datetime.now().strftime('%Y-%m-%d %H:%M')}",
1185
- "plain"
1186
- ))
1187
-
1188
- with smtplib.SMTP(smtp_host, smtp_port, timeout=10) as server:
1189
- server.starttls()
1190
- server.login(smtp_user, smtp_pass)
1191
- server.send_message(msg)
1192
-
1193
- results["email"] = {"ok": True, "message": f"Test email sent to {to_addr}"}
1194
- print(f"[AGENT] πŸ“§ Test email sent to {to_addr}")
1195
-
1196
- except smtplib.SMTPAuthenticationError as e:
1197
- error = f"Authentication failed. For Gmail, make sure you're using an App Password (not your regular password). Error: {e}"
1198
- results["email"] = {"ok": False, "error": error}
1199
- print(f"[AGENT] [WARN] Test email auth failed: {e}")
1200
- except Exception as e:
1201
- results["email"] = {"ok": False, "error": str(e)}
1202
- print(f"[AGENT] [WARN] Test email failed: {e}")
1203
- else:
1204
- results["email"] = {"ok": False, "error": "Email notifications are disabled"}
1205
-
1206
- # Test Telegram
1207
- if get_setting("notify_telegram_enabled", "false") == "true":
1208
- try:
1209
- import requests as req
1210
- token = get_setting("notify_telegram_bot_token", "")
1211
- chat_id = get_setting("notify_telegram_chat_id", "")
1212
-
1213
- if not token or not chat_id:
1214
- results["telegram"] = {"ok": False, "error": "Missing bot token or chat ID"}
1215
- else:
1216
- url = f"https://api.telegram.org/bot{token}/sendMessage"
1217
- resp = req.post(url, json={
1218
- "chat_id": chat_id,
1219
- "text": "βœ… Content Agent test β€” Telegram notifications are working!",
1220
- }, timeout=10)
1221
-
1222
- if resp.status_code == 200:
1223
- results["telegram"] = {"ok": True, "message": f"Test message sent to chat {chat_id}"}
1224
- print(f"[AGENT] πŸ“± Test Telegram sent to chat {chat_id}")
1225
- else:
1226
- error = resp.json().get("description", resp.text[:200])
1227
- results["telegram"] = {"ok": False, "error": f"Telegram API: {error}"}
1228
- except Exception as e:
1229
- results["telegram"] = {"ok": False, "error": str(e)}
1230
- else:
1231
- results["telegram"] = {"ok": False, "error": "Telegram notifications are disabled"}
1232
-
1233
- return jsonify(results)
1234
-
1235
-
1236
- # ──────────────────────────────────────────────
1237
- # Health Check (no template needed)
1238
- # ──────────────────────────────────────────────
1239
-
1240
- @app.route("/health")
1241
- def health_check():
1242
- return jsonify({"status": "ok"}), 200
1243
-
1244
-
1245
  @app.route("/api/logs", methods=["GET"])
1246
- def api_logs_get():
1247
- """Return the recent terminal logs for the UI."""
1248
- return jsonify({"logs": list(LOG_BUFFER)})
1249
 
1250
- @app.route("/api/logs/clear", methods=["POST"])
1251
- def api_logs_clear():
1252
- """Clear the terminal log buffer."""
1253
- LOG_BUFFER.clear()
1254
- return jsonify({"cleared": True})
1255
 
1256
  # ──────────────────────────────────────────────
1257
  # Main
1258
  # ──────────────────────────────────────────────
1259
 
1260
- _agent_started = False
1261
-
1262
- def _start_agent_thread():
1263
- """Start the agent background thread (only once)."""
1264
- global _agent_started
1265
- if not _agent_started:
1266
- _agent_started = True
1267
- agent_thread = threading.Thread(target=agent_loop, daemon=True)
1268
- agent_thread.start()
1269
 
1270
- # Initialize database with error handling
1271
  print("[STARTUP] Initializing database...")
1272
- try:
1273
- init_db()
1274
- print("[STARTUP] Database initialized successfully")
1275
- except Exception as e:
1276
- print(f"[STARTUP] WARNING: Database init failed: {e}")
1277
- import traceback
1278
- traceback.print_exc()
1279
-
1280
- # For Gunicorn: start agent thread after worker is ready
1281
- @app.before_request
1282
- def _ensure_agent():
1283
- _start_agent_thread()
1284
-
1285
- print("[STARTUP] App module loaded successfully")
1286
 
 
 
1287
 
1288
  if __name__ == "__main__":
1289
  port = int(os.environ.get("PORT", 5000))
1290
- print("πŸ€– Content Publishing Agent")
1291
- print(f"πŸ“Š Dashboard: http://localhost:{port}")
1292
- print(f"πŸ’Ύ Database: {DB_PATH.absolute()}")
1293
- _start_agent_thread()
1294
- app.run(host="0.0.0.0", port=port, debug=False)
 
63
  self.original_stdout = original_stdout
64
 
65
  def write(self, message):
 
66
  self.original_stdout.write(message)
 
 
67
  if message and message.strip() != "":
 
68
  timestamp = datetime.now().strftime("%H:%M:%S")
69
  LOG_BUFFER.append(f"[{timestamp}] {message.rstrip()}")
70
 
71
  def flush(self):
72
  self.original_stdout.flush()
73
 
 
74
  sys.stdout = LogCapturer(sys.stdout)
75
 
76
  # ──────────────────────────────────────────────
 
78
  # ──────────────────────────────────────────────
79
 
80
  def get_db():
 
81
  conn = sqlite3.connect(str(DB_PATH), timeout=10)
82
  conn.row_factory = sqlite3.Row
83
  conn.execute("PRAGMA journal_mode=WAL")
84
  conn.execute("PRAGMA foreign_keys=ON")
85
  return conn
86
 
 
87
  def init_db():
 
88
  conn = get_db()
89
  conn.executescript("""
90
  CREATE TABLE IF NOT EXISTS jobs (
 
135
  );
136
  """)
137
 
138
+ # Migration
139
  try:
140
  cursor = conn.execute("PRAGMA table_info(jobs)")
141
  columns = [row[1] for row in cursor.fetchall()]
142
  if "title" not in columns:
 
143
  conn.execute("ALTER TABLE jobs ADD COLUMN title TEXT")
144
  conn.commit()
145
+ except: pass
 
146
 
147
  # Default settings
148
  defaults = {
 
150
  "check_interval_minutes": "30",
151
  "auto_publish": "true",
152
  "screening_enabled": "true",
153
+ "screening_criteria": "Ghana news, African entertainment, sports highlights, trending stories.",
154
  "review_feeds_enabled": "true",
155
+ "wp_url": os.getenv("WP_URL", "").rstrip("/"),
156
  "wp_username": os.getenv("WP_USERNAME", ""),
157
  "wp_app_password": os.getenv("WP_APP_PASSWORD", ""),
158
  "wp_category_id": os.getenv("WP_CATEGORY_ID", "1"),
 
160
  "openrouter_api_key": os.getenv("OPENROUTER_API_KEY", ""),
161
  "ig_username": os.getenv("IG_USERNAME", ""),
162
  "ig_password": os.getenv("IG_PASSWORD", ""),
163
+ "llm_base_url": os.getenv("LLM_BASE_URL", "https://openrouter.ai/api/v1"),
164
+ "llm_model": os.getenv("LLM_MODEL", "qwen/qwen3.6-plus:free"),
 
 
 
 
 
 
 
 
 
 
 
165
  }
166
  for key, value in defaults.items():
167
+ conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", (key, value))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  conn.commit()
169
  conn.close()
170
 
171
+ def get_setting(key, default=""):
 
172
  conn = get_db()
173
  row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
174
  conn.close()
175
  return row["value"] if row else default
176
 
177
+ def get_all_settings():
 
178
  conn = get_db()
179
  rows = conn.execute("SELECT key, value FROM settings").fetchall()
180
  conn.close()
181
  return {r["key"]: r["value"] for r in rows}
182
 
183
+ def set_setting(key, value):
 
184
  conn = get_db()
185
+ conn.execute("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", (key, value))
 
 
 
 
186
  conn.commit()
187
  conn.close()
188
 
 
189
  # ──────────────────────────────────────────────
190
  # Pipeline Wrappers
191
  # ──────────────────────────────────────────────
192
 
193
  def _apply_settings_to_env():
 
194
  settings = get_all_settings()
195
+ for k in ["wp_url", "wp_username", "wp_app_password", "wp_category_id", "wp_post_status", "ig_username", "ig_password", "llm_base_url", "llm_model"]:
196
+ os.environ[k.upper()] = settings.get(k, "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
 
198
+ def run_upload_pipeline(video_path):
 
199
  _apply_settings_to_env()
 
200
  import main as m
 
201
  return m.process_uploaded_video(video_path)
202
 
203
+ def run_image_upload_pipeline(image_path):
 
 
204
  _apply_settings_to_env()
 
205
  import main as m
 
206
  return m.process_uploaded_image(image_path)
207
 
 
208
  # ──────────────────────────────────────────────
209
+ # Job Processor
210
  # ──────────────────────────────────────────────
211
 
212
  def process_pending_jobs():
213
+ if STOP_PROCESSING.is_set(): return False
 
 
 
214
  conn = get_db()
215
+ jobs = conn.execute("SELECT * FROM jobs WHERE status = 'pending' ORDER BY created_at ASC LIMIT 5").fetchall()
 
 
216
  conn.close()
217
+ if not jobs: return False
 
 
218
 
219
  def process_one_job(job):
220
  job_id = job["id"]
221
  url = job["url"]
222
  mode = job["mode"]
 
 
223
  conn = get_db()
224
+ conn.execute("UPDATE jobs SET status = 'running', started_at = datetime('now') WHERE id = ?", (job_id,))
 
 
 
225
  conn.commit()
226
  conn.close()
227
 
228
  try:
 
 
229
  try:
230
  url_data = json.loads(url)
231
+ if not isinstance(url_data, (list, dict)): url_data = url
232
+ except: url_data = url
 
 
 
 
233
 
234
  print(f"[AGENT] Processing job #{job_id}: {mode}")
235
+ if mode == "article":
 
236
  from article_rewriter import process_article
237
  result = process_article(url_data)
238
+ elif mode in ["instagram", "social"]:
239
+ from main import process_video
240
+ _apply_settings_to_env()
241
+ result = process_video(url_data)
242
+ elif mode in ["upload", "upload_video"]:
243
  result = run_upload_pipeline(url_data)
244
+ elif mode == "upload_image":
245
  result = run_image_upload_pipeline(url_data)
246
  else:
247
+ raise ValueError(f"Unknown mode: {mode}")
248
 
 
249
  conn = get_db()
250
+ conn.execute("UPDATE jobs SET status = 'done', finished_at = datetime('now'), result_json = ? WHERE id = ?", (json.dumps(result), job_id))
251
+ conn.execute("INSERT INTO posts (title, wp_url, wp_post_id, source_url, mode, featured_image_url, youtube_count, image_count) VALUES (?,?,?,?,?,?,?,?)",
252
+ (result.get("title", "Untitled"), result.get("wp_url", ""), result.get("post_id", 0), url, mode, result.get("featured_image_url", ""), result.get("youtube_videos", 0), result.get("image_count", 0)))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
  conn.commit()
254
  conn.close()
 
255
  print(f"[AGENT] βœ… Job #{job_id} completed: {result.get('title', 'N/A')}")
256
+ send_notification("published", result.get("title", "Untitled"), result.get("wp_url", ""), mode)
 
 
 
 
 
 
 
257
 
258
  except Exception as e:
259
  error_msg = f"{e}\n{traceback.format_exc()}"
260
  conn = get_db()
261
+ conn.execute("UPDATE jobs SET status = 'failed', finished_at = datetime('now'), error = ? WHERE id = ?", (error_msg[:2000], job_id))
 
 
 
 
262
  conn.commit()
263
  conn.close()
264
  print(f"[AGENT] ❌ Job #{job_id} failed: {e}")
265
+ send_notification("failed", url, str(e)[:200], mode)
 
 
 
 
 
 
 
266
 
267
  with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
268
  futures = [executor.submit(process_one_job, job) for job in jobs]
269
+ for f in concurrent.futures.as_completed(futures): f.result()
 
 
270
  return True
271
 
272
+ def _process_all_pending():
273
+ while process_pending_jobs(): time.sleep(1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
 
275
  # ──────────────────────────────────────────────
276
+ # Notifications
277
  # ──────────────────────────────────────────────
278
 
279
+ def send_notification(event, title, post_url, mode):
 
280
  try:
281
+ if get_setting(f"notify_on_{event}", "true") != "true": return
282
+ emoji = "βœ…" if event == "published" else "❌"
283
+ subject = f"{'Published' if event == 'published' else 'Failed'}: {title[:50]}"
284
+ body = f"{emoji} {mode} job {event}!\nTitle: {title}\nURL: {post_url}"
285
+ if get_setting("notify_email_enabled") == "true": send_email_alert(subject, body)
286
+ if get_setting("notify_telegram_enabled") == "true": send_telegram_alert(f"{emoji} {subject}\n\n{body}")
287
+ except Exception as e: print(f"[AGENT] Notification error: {e}")
288
+
289
+ def send_email_alert(subject, body):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
  try:
291
+ to_addr = get_setting("notify_email_to")
292
+ user = get_setting("notify_email_smtp_user")
293
+ pwd = get_setting("notify_email_smtp_password")
294
+ if not all([to_addr, user, pwd]): return
295
+ msg = MIMEMultipart(); msg["From"] = user; msg["To"] = to_addr; msg["Subject"] = f"πŸ€– {subject}"
 
 
 
 
 
 
 
 
 
296
  msg.attach(MIMEText(body, "plain"))
297
+ with smtplib.SMTP(get_setting("notify_email_smtp_host", "smtp.gmail.com"), int(get_setting("notify_email_smtp_port", "587"))) as s:
298
+ s.starttls(); s.login(user, pwd); s.send_message(msg)
299
+ except Exception as e: print(f"[AGENT] Email failed: {e}")
300
 
301
+ def send_telegram_alert(message):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
  try:
303
+ token = get_setting("notify_telegram_bot_token")
304
+ chat_id = get_setting("notify_telegram_chat_id")
305
+ if token and chat_id:
306
+ requests.post(f"https://api.telegram.org/bot{token}/sendMessage", json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"}, timeout=10)
307
+ except Exception as e: print(f"[AGENT] Telegram failed: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
 
309
+ # ──────────────────────────────────────────────
310
+ # RSS Feeds
311
+ # ──────────────────────────────────────────────
312
 
313
+ def screen_article(title, summary, criteria):
 
 
 
 
314
  try:
315
+ key = os.getenv("OPENROUTER_API_KEY")
316
+ if not key: return True
317
+ prompt = f"Decide if this article matches criteria: {criteria}\nTitle: {title}\nSummary: {summary}\nRespond ONLY with JSON: {{\"publish\": true/false, \"reason\": \"...\"}}"
318
+ client = OpenAI(base_url="https://openrouter.ai/api/v1", api_key=key)
319
+ resp = client.chat.completions.create(model="qwen/qwen3.6-plus:free", messages=[{"role":"user","content":prompt}], response_format={"type":"json_object"})
320
+ result = json.loads(resp.choices[0].message.content)
321
+ print(f"[AGENT] Screening: {result.get('publish')} - {result.get('reason')}")
322
+ return result.get("publish", True)
323
+ except: return True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
 
325
  def check_rss_feeds():
 
326
  conn = get_db()
327
  feeds = conn.execute("SELECT * FROM feeds WHERE active = 1").fetchall()
328
  conn.close()
329
+ screening = get_setting("screening_enabled", "true") == "true"
330
+ criteria = get_setting("screening_criteria")
 
 
331
  new_articles = 0
 
 
332
  for feed in feeds:
333
  try:
334
+ resp = requests.get(feed["url"], headers={"User-Agent": "Mozilla/5.0"}, timeout=20)
 
 
 
 
 
 
 
 
 
 
 
 
 
335
  parsed = feedparser.parse(resp.content)
336
+ for entry in parsed.entries[:10]:
337
+ url = entry.get("link")
338
+ if not url: continue
 
 
 
 
 
 
 
 
339
  conn = get_db()
340
+ if conn.execute("SELECT status FROM processed_urls WHERE url = ?", (url,)).fetchone():
341
+ conn.close(); continue
342
+ if screening and not screen_article(entry.get("title", ""), entry.get("summary", ""), criteria):
343
+ conn.execute("INSERT INTO processed_urls (url, status) VALUES (?, ?)", (url, 'rejected'))
344
+ else:
345
+ review = get_setting("review_feeds_enabled", "true") == "true"
346
+ status = "review" if review else "pending"
347
+ conn.execute("INSERT INTO jobs (url, title, mode, status) VALUES (?, ?, 'article', ?)", (url, entry.get("title", "Untitled"), status))
348
+ conn.execute("INSERT INTO processed_urls (url, status) VALUES (?, ?)", (url, 'queued'))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
  new_articles += 1
350
+ conn.commit(); conn.close()
351
+ conn = get_db(); conn.execute("UPDATE feeds SET last_checked = datetime('now'), articles_found = articles_found + ? WHERE id = ?", (new_articles, feed["id"])); conn.commit(); conn.close()
352
+ except Exception as e: print(f"[AGENT] RSS failed for {feed['url']}: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  return new_articles
354
 
 
355
  def agent_loop():
 
356
  print("[AGENT] πŸ€– Agent thread started")
357
+ last_rss = 0
 
 
 
358
  while True:
359
  try:
360
+ if get_setting("agent_enabled") == "true":
361
+ if time.time() - last_rss > int(get_setting("check_interval_minutes", "30")) * 60:
362
+ check_rss_feeds(); last_rss = time.time()
363
+ with LOCK: process_pending_jobs()
364
+ time.sleep(10)
365
+ except Exception as e: print(f"[AGENT] Loop error: {e}"); time.sleep(30)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
 
367
  # ──────────────────────────────────────────────
368
+ # Routes
369
  # ──────────────────────────────────────────────
370
 
371
  @app.before_request
372
  def require_login():
373
+ if request.endpoint not in ['login', 'static'] and not session.get('logged_in'):
 
 
 
374
  return redirect(url_for('login'))
375
 
376
  @app.route("/login", methods=["GET", "POST"])
377
  def login():
378
  if request.method == "POST":
379
+ if request.form.get("username") == "webmazta" and request.form.get("password") == "2010":
380
+ session["logged_in"] = True; return redirect(url_for("dashboard"))
381
+ flash("Invalid credentials", "error")
 
 
 
 
382
  return render_template("login.html")
383
 
384
  @app.route("/logout")
385
+ def logout(): session.pop("logged_in", None); return redirect(url_for("login"))
 
 
 
 
 
 
386
 
387
  @app.route("/")
388
+ def dashboard(): return render_template("dashboard.html")
 
 
389
 
390
  @app.route("/api/stats")
391
  def api_stats():
392
  conn = get_db()
393
+ stats = {
394
+ "total_posts": conn.execute("SELECT COUNT(*) FROM posts").fetchone()[0],
395
+ "today_posts": conn.execute("SELECT COUNT(*) FROM posts WHERE date(created_at) = date('now')").fetchone()[0],
396
+ "queue_size": conn.execute("SELECT COUNT(*) FROM jobs WHERE status IN ('pending', 'running')").fetchone()[0],
397
+ "total_jobs": conn.execute("SELECT COUNT(*) FROM jobs").fetchone()[0],
398
+ "failed_jobs": conn.execute("SELECT COUNT(*) FROM jobs WHERE status = 'failed'").fetchone()[0],
399
+ "active_feeds": conn.execute("SELECT COUNT(*) FROM feeds WHERE active = 1").fetchone()[0],
400
+ "agent_enabled": get_setting("agent_enabled") == "true"
401
+ }
402
+ stats["success_rate"] = round((stats["total_jobs"] - stats["failed_jobs"]) / stats["total_jobs"] * 100) if stats["total_jobs"] > 0 else 100
403
+ conn.close(); return jsonify(stats)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
404
 
405
  @app.route("/api/jobs", methods=["GET"])
406
  def api_jobs_list():
407
+ status = request.args.get("status", "")
 
 
408
  conn = get_db()
409
+ if status: rows = conn.execute("SELECT * FROM jobs WHERE status = ? ORDER BY created_at DESC LIMIT 50", (status,)).fetchall()
410
+ else: rows = conn.execute("SELECT * FROM jobs ORDER BY created_at DESC LIMIT 50").fetchall()
411
+ conn.close(); return jsonify([dict(r) for r in rows])
 
 
 
 
 
 
 
 
 
 
412
 
413
  @app.route("/api/jobs", methods=["POST"])
414
  def api_jobs_create():
415
  data = request.get_json()
416
  urls = data.get("urls", [])
417
  mode = data.get("mode", "article")
418
+ group_media = data.get("group_media", False)
419
+ if isinstance(urls, str): urls = [u.strip() for u in urls.split("\n") if u.strip() and not u.strip().startswith("#")]
420
+ if not urls: return jsonify({"error": "No URLs provided"}), 400
 
 
 
 
421
  conn = get_db()
422
+ if group_media and len(urls) > 1:
423
+ conn.execute("INSERT INTO jobs (url, mode) VALUES (?, ?)", (json.dumps(urls), mode))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
  else:
425
+ for u in urls: conn.execute("INSERT INTO jobs (url, mode) VALUES (?, ?)", (u, mode))
426
+ conn.commit(); conn.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
  threading.Thread(target=_process_all_pending, daemon=True).start()
428
+ return jsonify({"success": True}), 201
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429
 
430
  @app.route("/api/settings", methods=["GET"])
431
  def api_settings_get():
432
+ s = get_all_settings()
433
+ for k in ["wp_app_password", "ig_password", "openrouter_api_key"]:
434
+ if s.get(k): s[k] = "β€’β€’β€’β€’β€’β€’" + s[k][-4:]
435
+ return jsonify(s)
 
 
 
 
 
 
 
 
 
436
 
437
  @app.route("/api/settings", methods=["POST"])
438
  def api_settings_update():
439
  data = request.get_json()
440
+ for k, v in data.items():
441
+ if "β€’β€’β€’β€’β€’β€’" not in str(v): set_setting(k, str(v))
 
 
 
442
  return jsonify({"updated": True})
443
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
444
  @app.route("/api/logs", methods=["GET"])
445
+ def api_logs_get(): return jsonify({"logs": list(LOG_BUFFER)})
 
 
446
 
447
+ @app.route("/health")
448
+ def health_check(): return jsonify({"status": "ok"}), 200
 
 
 
449
 
450
  # ──────────────────────────────────────────────
451
  # Main
452
  # ──────────────────────────────────────────────
453
 
454
+ _started = False
455
+ def _start_agent():
456
+ global _started
457
+ if not _started:
458
+ _started = True; threading.Thread(target=agent_loop, daemon=True).start()
 
 
 
 
459
 
 
460
  print("[STARTUP] Initializing database...")
461
+ init_db()
 
 
 
 
 
 
 
 
 
 
 
 
 
462
 
463
+ @app.before_request
464
+ def _ensure_agent(): _start_agent()
465
 
466
  if __name__ == "__main__":
467
  port = int(os.environ.get("PORT", 5000))
468
+ _start_agent(); app.run(host="0.0.0.0", port=port, debug=False)
 
 
 
 
main.py CHANGED
@@ -1237,32 +1237,34 @@ def publish_post(
1237
  flyer_url: str,
1238
  source_url: str,
1239
  is_video: bool = True,
1240
- extra_image_urls: list[str] = None
1241
  ) -> dict:
1242
  """
1243
  Create and publish a WordPress post via REST API.
1244
- Returns: {"id": int, "url": str}
1245
  """
1246
  print("[JOB] Publishing post to WordPress...")
1247
 
1248
- button_text = "Watch VIDEO" if is_video else "See Images"
1249
-
1250
- # Image links section
1251
- image_links_html = ""
1252
- if extra_image_urls:
1253
- links = []
1254
- for i, img_url in enumerate(extra_image_urls, 1):
1255
- links.append(f'<!-- wp:button --><div class="wp-block-button is-style-outline"><a class="wp-block-button__link wp-element-button" href="{img_url}" target="_blank" rel="noreferrer noopener">View Image {i}</a></div><!-- /wp:button -->')
1256
-
1257
- image_links_html = f"""
1258
- <!-- wp:buttons {{"layout":{{"type":"flex","justifyContent":"center"}}}} -->
1259
- <div class="wp-block-buttons">
1260
- {"".join(links)}
1261
- </div>
1262
- <!-- /wp:buttons -->
1263
- """
 
 
1264
 
1265
- # Construct post body with Gutenberg blocks
1266
  post_body = textwrap.dedent(f"""\
1267
  <!-- wp:paragraph -->
1268
  <p>{paragraph1}</p>
@@ -1276,12 +1278,12 @@ def publish_post(
1276
  <figure class="wp-block-image size-full"><img src="{flyer_url}" alt="{title}" class="wp-image-{flyer_id}"/></figure>
1277
  <!-- /wp:image -->
1278
 
1279
- {image_links_html}
1280
 
1281
  <!-- wp:buttons {{"layout":{{"type":"flex","justifyContent":"center"}}}} -->
1282
  <div class="wp-block-buttons">
1283
  <!-- wp:button -->
1284
- <div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="{source_url}" target="_blank" rel="noreferrer noopener">{button_text}</a></div>
1285
  <!-- /wp:button -->
1286
  </div>
1287
  <!-- /wp:buttons -->
@@ -1300,106 +1302,99 @@ def publish_post(
1300
  }
1301
 
1302
  url = f"{WP_URL}/wp-json/wp/v2/posts"
1303
- response = requests.post(
1304
- url,
1305
- json=post_data,
1306
- auth=_wp_auth(),
1307
- timeout=30
1308
- )
1309
-
1310
  if response.status_code not in (200, 201):
1311
- raise Exception(
1312
- f"WordPress post creation failed ({response.status_code}): "
1313
- f"{response.text[:300]}"
1314
- )
1315
 
1316
  data = response.json()
1317
- result = {
1318
- "id": data["id"],
1319
- "url": data.get("link", data.get("guid", {}).get("rendered", ""))
1320
- }
1321
- print(f" [OK] Post published: {result['url']}")
1322
- return result
1323
 
1324
 
1325
  # ──────────────────────────────────────────────
1326
  # 7. Full Pipeline
1327
  # ──────────────────────────────────────────────
1328
 
1329
- def process_video(url: str) -> dict:
1330
  """
1331
- Full pipeline for a single video URL:
1332
- download β†’ analyze β†’ generate content β†’ create media β†’ publish to WP.
1333
  """
 
 
 
1334
  print(f"\n{'='*60}")
1335
- print(f"[JOB] Processing: {url}")
1336
  print(f"{'='*60}")
1337
 
 
 
1338
  import hashlib
1339
- url_hash = hashlib.md5(url.encode()).hexdigest()[:8]
1340
  safe_name = _sanitize_filename(
1341
- re.search(r'/(?:reel|p|video|shorts)/([A-Za-z0-9_-]+)', url).group(1)
1342
- if re.search(r'/(?:reel|p|video|shorts)/([A-Za-z0-9_-]+)', url)
1343
- else url_hash
1344
  )
1345
 
1346
- # Step 1: Download
1347
- video_path = download_video(url)
1348
-
1349
- # Step 2: Extract frames (more frames for better selection)
1350
- frames = extract_frames(video_path, count=10)
1351
 
1352
- # Step 3: Analyze video with AI
1353
- analysis = analyze_video(video_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1354
  categories = analysis.get("frame_categories", {}) if isinstance(analysis, dict) else {}
1355
 
1356
- # Step 4: Generate blog content
1357
- content = generate_content(analysis, url)
1358
  title = content["title"]
1359
- p1 = content["paragraph1"]
1360
- p2 = content["paragraph2"]
1361
 
1362
- # Step 5: Create featured image (16:9 - 3:1 Collage)
1363
- featured_path = create_featured_image(title, frames, safe_name, categories)
 
 
 
1364
 
1365
- # Step 6: Create flyer (1080x1080)
1366
- flyer_path = create_flyer(title, frames, safe_name, categories)
1367
-
1368
- # Step 7: Upload media to WordPress
1369
- featured_media = upload_media_to_wp(
1370
- featured_path, f"featured-{safe_name}.jpg"
1371
- )
1372
- flyer_media = upload_media_to_wp(
1373
- flyer_path, f"flyer-{safe_name}.jpg"
1374
- )
1375
-
1376
- # Determine if it's a video based on file extension instead of frame count
1377
- is_video_file = video_path.suffix.lower() in [".mp4", ".webm", ".avi", ".mov"]
1378
 
1379
- # Step 8: Publish post
1380
  post = publish_post(
1381
  title=title,
1382
- paragraph1=p1,
1383
- paragraph2=p2,
1384
  featured_media_id=featured_media["id"],
1385
  flyer_id=flyer_media["id"],
1386
  flyer_url=flyer_media["url"],
1387
- source_url=url,
1388
- is_video=is_video_file
1389
  )
1390
 
1391
- result = {
1392
- "source_url": url,
 
1393
  "title": title,
1394
  "post_url": post["url"],
1395
- "post_id": post["id"],
1396
- "featured_image_id": featured_media["id"],
1397
- "flyer_url": flyer_media["url"],
1398
  }
1399
 
1400
- print(f"\n[DONE] SUCCESS! Post published: {post['url']}")
1401
- return result
1402
-
1403
 
1404
  # ──────────────────────────────────────────────
1405
  # 7b. Uploaded Video Pipeline (no download step)
 
1237
  flyer_url: str,
1238
  source_url: str,
1239
  is_video: bool = True,
1240
+ extra_media: list[dict] = None
1241
  ) -> dict:
1242
  """
1243
  Create and publish a WordPress post via REST API.
1244
+ extra_media: list of {"url": str, "id": int, "type": "image"|"video"}
1245
  """
1246
  print("[JOB] Publishing post to WordPress...")
1247
 
1248
+ # Media section (Gutenberg blocks)
1249
+ media_html = ""
1250
+ if extra_media:
1251
+ for item in extra_media:
1252
+ m_url = item["url"]
1253
+ m_id = item["id"]
1254
+ if item.get("type") == "video":
1255
+ media_html += f"""
1256
+ <!-- wp:video {{"id":{m_id},"sizeSlug":"full"}} -->
1257
+ <figure class="wp-block-video"><video controls src="{m_url}"></video></figure>
1258
+ <!-- /wp:video -->
1259
+ """
1260
+ else:
1261
+ media_html += f"""
1262
+ <!-- wp:image {{"id":{m_id},"sizeSlug":"full","linkDestination":"none"}} -->
1263
+ <figure class="wp-block-image size-full"><img src="{m_url}" alt="" class="wp-image-{m_id}"/></figure>
1264
+ <!-- /wp:image -->
1265
+ """
1266
 
1267
+ # Construct post body
1268
  post_body = textwrap.dedent(f"""\
1269
  <!-- wp:paragraph -->
1270
  <p>{paragraph1}</p>
 
1278
  <figure class="wp-block-image size-full"><img src="{flyer_url}" alt="{title}" class="wp-image-{flyer_id}"/></figure>
1279
  <!-- /wp:image -->
1280
 
1281
+ {media_html}
1282
 
1283
  <!-- wp:buttons {{"layout":{{"type":"flex","justifyContent":"center"}}}} -->
1284
  <div class="wp-block-buttons">
1285
  <!-- wp:button -->
1286
+ <div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="{source_url}" target="_blank" rel="noreferrer noopener">Source Link</a></div>
1287
  <!-- /wp:button -->
1288
  </div>
1289
  <!-- /wp:buttons -->
 
1302
  }
1303
 
1304
  url = f"{WP_URL}/wp-json/wp/v2/posts"
1305
+ response = requests.post(url, json=post_data, auth=_wp_auth(), timeout=30)
 
 
 
 
 
 
1306
  if response.status_code not in (200, 201):
1307
+ raise Exception(f"WP post failed: {response.text[:300]}")
 
 
 
1308
 
1309
  data = response.json()
1310
+ return {"id": data["id"], "url": data.get("link", "")}
 
 
 
 
 
1311
 
1312
 
1313
  # ──────────────────────────────────────────────
1314
  # 7. Full Pipeline
1315
  # ──────────────────────────────────────────────
1316
 
1317
+ def process_video(url_input: any) -> dict:
1318
  """
1319
+ Pipeline for video URL(s). Handles single URL string or list of URLs.
 
1320
  """
1321
+ urls = [url_input] if isinstance(url_input, str) else url_input
1322
+ primary_url = urls[0]
1323
+
1324
  print(f"\n{'='*60}")
1325
+ print(f"[JOB] Processing {len(urls)} media URL(s)...")
1326
  print(f"{'='*60}")
1327
 
1328
+ _ensure_dirs()
1329
+
1330
  import hashlib
1331
+ primary_hash = hashlib.md5(primary_url.encode()).hexdigest()[:8]
1332
  safe_name = _sanitize_filename(
1333
+ re.search(r'/(?:reel|p|video|shorts)/([A-Za-z0-9_-]+)', primary_url).group(1)
1334
+ if re.search(r'/(?:reel|p|video|shorts)/([A-Za-z0-9_-]+)', primary_url)
1335
+ else primary_hash
1336
  )
1337
 
1338
+ all_frames = []
1339
+ wp_extra_media = []
 
 
 
1340
 
1341
+ for i, url in enumerate(urls):
1342
+ print(f"[{i+1}/{len(urls)}] Downloading: {url}")
1343
+ video_path = download_video(url)
1344
+ ext = video_path.suffix.lower()
1345
+ is_video = ext in [".mp4", ".webm", ".avi", ".mov"]
1346
+
1347
+ # Extract frames
1348
+ frames = extract_frames(video_path, count=5)
1349
+ all_frames.extend(frames)
1350
+
1351
+ # Upload original to WP
1352
+ wp_item = upload_media_to_wp(video_path, f"grouped-{i}-{video_path.name}")
1353
+ wp_extra_media.append({
1354
+ "id": wp_item["id"],
1355
+ "url": wp_item["url"],
1356
+ "type": "video" if is_video else "image"
1357
+ })
1358
+
1359
+ # Step: Analyze Primary (first one)
1360
+ primary_video = download_video(primary_url) # Redownload or use cache
1361
+ analysis = analyze_video(primary_video)
1362
  categories = analysis.get("frame_categories", {}) if isinstance(analysis, dict) else {}
1363
 
1364
+ # Step: Generate Content
1365
+ content = generate_content(analysis, primary_url)
1366
  title = content["title"]
 
 
1367
 
1368
+ # Step: Create Collages
1369
+ # Use first few frames for the featured image
1370
+ processing_frames = all_frames[:10]
1371
+ featured_path = create_featured_image(title, processing_frames, safe_name, categories)
1372
+ flyer_path = create_flyer(title, processing_frames, safe_name, categories)
1373
 
1374
+ # Step: Upload Generated
1375
+ featured_media = upload_media_to_wp(featured_path, f"featured-{safe_name}.jpg")
1376
+ flyer_media = upload_media_to_wp(flyer_path, f"flyer-{safe_name}.jpg")
 
 
 
 
 
 
 
 
 
 
1377
 
1378
+ # Step: Publish
1379
  post = publish_post(
1380
  title=title,
1381
+ paragraph1=content["paragraph1"],
1382
+ paragraph2=content["paragraph2"],
1383
  featured_media_id=featured_media["id"],
1384
  flyer_id=flyer_media["id"],
1385
  flyer_url=flyer_media["url"],
1386
+ source_url=primary_url,
1387
+ extra_media=wp_extra_media
1388
  )
1389
 
1390
+ print(f"\n[DONE] Grouped post published: {post['url']}")
1391
+ return {
1392
+ "source_url": primary_url,
1393
  "title": title,
1394
  "post_url": post["url"],
1395
+ "post_id": post["id"]
 
 
1396
  }
1397
 
 
 
 
1398
 
1399
  # ──────────────────────────────────────────────
1400
  # 7b. Uploaded Video Pipeline (no download step)
static/dashboard.js CHANGED
@@ -99,11 +99,13 @@ async function submitJobs() {
99
 
100
  const urls = urlsText.split("\n").filter(u => u.trim() && !u.trim().startsWith("#"));
101
 
 
 
102
  try {
103
  const resp = await fetch("/api/jobs", {
104
  method: "POST",
105
  headers: { "Content-Type": "application/json" },
106
- body: JSON.stringify({ urls, mode })
107
  });
108
  const data = await resp.json();
109
 
@@ -219,10 +221,12 @@ async function submitUpload() {
219
  return;
220
  }
221
 
 
222
  const formData = new FormData();
223
  for (let i = 0; i < fileInput.files.length; i++) {
224
  formData.append("video", fileInput.files[i]);
225
  }
 
226
 
227
  const progressBar = document.getElementById("uploadProgressBar");
228
  const progressWrap = document.getElementById("uploadProgress");
 
99
 
100
  const urls = urlsText.split("\n").filter(u => u.trim() && !u.trim().startsWith("#"));
101
 
102
+ const groupMedia = document.getElementById("groupMedia").checked;
103
+
104
  try {
105
  const resp = await fetch("/api/jobs", {
106
  method: "POST",
107
  headers: { "Content-Type": "application/json" },
108
+ body: JSON.stringify({ urls, mode, group_media: groupMedia })
109
  });
110
  const data = await resp.json();
111
 
 
221
  return;
222
  }
223
 
224
+ const groupMedia = document.getElementById("groupMedia").checked;
225
  const formData = new FormData();
226
  for (let i = 0; i < fileInput.files.length; i++) {
227
  formData.append("video", fileInput.files[i]);
228
  }
229
+ formData.append("group_media", groupMedia);
230
 
231
  const progressBar = document.getElementById("uploadProgressBar");
232
  const progressWrap = document.getElementById("uploadProgress");