| import os |
| import time |
| from datetime import datetime, timezone |
| from urllib.parse import urlencode |
|
|
| from flask import Flask, request, jsonify, render_template_string, redirect |
| import requests |
|
|
| app = Flask(__name__) |
|
|
| SUPABASE_URL = os.getenv("SUPABASE_URL", "").rstrip("/") |
| SUPABASE_ANON_KEY = os.getenv("SUPABASE_ANON_KEY", "") |
| APP_BASE_URL = os.getenv("APP_BASE_URL", "").rstrip("/") |
|
|
| NEXT_AGENT = { |
| "trend_hunter": "offer_scout", |
| "offer_scout": "content_strategist", |
| "content_strategist": "seo_writer", |
| "seo_writer": "creative_generator", |
| "creative_generator": "publisher", |
| "publisher": "tracker", |
| "tracker": "analytics", |
| "analytics": "cro_optimizer", |
| "cro_optimizer": "compliance_guard", |
| } |
|
|
|
|
| |
| def now_iso(): |
| return datetime.now(timezone.utc).isoformat() |
|
|
|
|
| def provider_config(provider: str): |
| p = provider.lower() |
| if p == "facebook": |
| return { |
| "client_id": os.getenv("FB_CLIENT_ID", ""), |
| "client_secret": os.getenv("FB_CLIENT_SECRET", ""), |
| "auth_url": "https://www.facebook.com/v19.0/dialog/oauth", |
| "token_url": "https://graph.facebook.com/v19.0/oauth/access_token", |
| "scope": os.getenv("FB_SCOPE", "pages_manage_posts,pages_read_engagement"), |
| } |
| if p == "x": |
| return { |
| "client_id": os.getenv("X_CLIENT_ID", ""), |
| "client_secret": os.getenv("X_CLIENT_SECRET", ""), |
| "auth_url": "https://twitter.com/i/oauth2/authorize", |
| "token_url": "https://api.twitter.com/2/oauth2/token", |
| "scope": os.getenv("X_SCOPE", "tweet.read tweet.write users.read offline.access"), |
| } |
| if p == "reddit": |
| return { |
| "client_id": os.getenv("REDDIT_CLIENT_ID", ""), |
| "client_secret": os.getenv("REDDIT_CLIENT_SECRET", ""), |
| "auth_url": "https://www.reddit.com/api/v1/authorize", |
| "token_url": "https://www.reddit.com/api/v1/access_token", |
| "scope": os.getenv("REDDIT_SCOPE", "identity submit read"), |
| } |
| raise RuntimeError(f"Unsupported provider: {provider}") |
|
|
|
|
| def sb(path, method="GET", params=None, body=None): |
| if not SUPABASE_URL or not SUPABASE_ANON_KEY: |
| raise RuntimeError("Missing SUPABASE_URL / SUPABASE_ANON_KEY") |
|
|
| url = f"{SUPABASE_URL}/rest/v1/{path}" |
| headers = { |
| "apikey": SUPABASE_ANON_KEY, |
| "Authorization": f"Bearer {SUPABASE_ANON_KEY}", |
| "Content-Type": "application/json", |
| } |
| if method in ("POST", "PATCH", "DELETE"): |
| headers["Prefer"] = "return=representation" |
|
|
| r = requests.request(method, url, headers=headers, params=params, json=body, timeout=25) |
| if r.status_code >= 400: |
| raise RuntimeError(f"Supabase {r.status_code}: {r.text[:250]}") |
| if not r.text: |
| return None |
| return r.json() |
|
|
|
|
| |
| def upsert_connection(provider: str, row: dict): |
| existing = sb("social_connections", params={"provider": f"eq.{provider}", "limit": 1}) or [] |
| if existing: |
| sb(f"social_connections?provider=eq.{provider}", method="PATCH", body=row) |
| return {"provider": provider, "status": "updated"} |
| sb("social_connections", method="POST", body=row) |
| return {"provider": provider, "status": "inserted"} |
|
|
|
|
| def save_connection(provider: str, token_payload: dict): |
| expires_at = None |
| if token_payload.get("expires_in"): |
| try: |
| expires_at = datetime.fromtimestamp( |
| time.time() + int(token_payload.get("expires_in")), tz=timezone.utc |
| ).isoformat() |
| except Exception: |
| expires_at = None |
|
|
| row = { |
| "provider": provider, |
| "access_token": token_payload.get("access_token"), |
| "refresh_token": token_payload.get("refresh_token"), |
| "scope": token_payload.get("scope"), |
| "token_type": token_payload.get("token_type"), |
| "expires_at": expires_at, |
| "raw": token_payload, |
| "updated_at": now_iso(), |
| } |
| return upsert_connection(provider, row) |
|
|
|
|
| def save_make_webhook(webhook_url: str): |
| row = { |
| "provider": "make", |
| "access_token": None, |
| "refresh_token": None, |
| "scope": "webhook", |
| "token_type": "webhook", |
| "expires_at": None, |
| "raw": {"webhook_url": webhook_url}, |
| "updated_at": now_iso(), |
| } |
| return upsert_connection("make", row) |
|
|
|
|
| |
| def run_agent(agent_code, input_data): |
| prev = (input_data or {}).get("prev_output", {}) |
|
|
| if agent_code == "trend_hunter": |
| niche = (input_data or {}).get("niche", "affiliate marketing") |
| base = ["best", "review", "promo", "diskon", "vs"] |
| return { |
| "niche": niche, |
| "keywords": [f"{niche} {b}" for b in base], |
| "search_intent": "commercial+transactional", |
| } |
|
|
| if agent_code == "offer_scout": |
| kws = prev.get("keywords", []) |
| return { |
| "offers": [ |
| {"keyword": k, "offer_id": f"offer_{i+1}", "score": 80 - i * 7} |
| for i, k in enumerate(kws[:3]) |
| ] |
| } |
|
|
| if agent_code == "content_strategist": |
| offers = prev.get("offers", []) |
| return { |
| "content_plan": [ |
| { |
| "stage": "BOFU" if i == 0 else ("MOFU" if i == 1 else "TOFU"), |
| "title_angle": f"Solusi terbaik untuk {o.get('keyword', 'offer')}", |
| } |
| for i, o in enumerate(offers) |
| ], |
| "affiliate_link": (input_data or {}).get("affiliate_link"), |
| "short_url": (input_data or {}).get("short_url"), |
| } |
|
|
| if agent_code == "seo_writer": |
| title = prev.get("content_plan", [{}])[0].get("title_angle", "Panduan affiliate") |
| slug = "-".join([x for x in "".join([c.lower() if c.isalnum() else " " for c in title]).split() if x]) |
| return {"title": title, "slug": slug, "article_html": f"<h1>{title}</h1><p>Draft siap publish.</p>"} |
|
|
| if agent_code == "creative_generator": |
| title = prev.get("title", "Promo affiliate") |
| return { |
| "creatives": [ |
| {"channel": "facebook", "hook": f"{title} - cek sekarang"}, |
| {"channel": "instagram", "hook": f"🔥 {title}"}, |
| ] |
| } |
|
|
| if agent_code == "publisher": |
| return { |
| "publish_refs": [{"channel": "blog", "ref": f"post_{int(time.time())}"}], |
| "short_url": (input_data or {}).get("short_url"), |
| } |
|
|
| if agent_code == "tracker": |
| return { |
| "tracking_map": { |
| "utm_source": "hf-space", |
| "utm_medium": "affiliate", |
| "utm_campaign": f"campaign_{int(time.time())}", |
| } |
| } |
|
|
| if agent_code == "analytics": |
| return {"kpi_snapshot": {"ctr": 0.07, "cvr": 0.02, "epc": 0.4}} |
|
|
| if agent_code == "cro_optimizer": |
| return {"experiments": [{"name": "headline-variant-a", "priority": "high"}]} |
|
|
| if agent_code == "compliance_guard": |
| return {"compliance_status": "pass-with-notes", "fixes": ["Tambahkan disclosure affiliate"]} |
|
|
| raise RuntimeError(f"No runner for {agent_code}") |
|
|
|
|
| def run_once(): |
| task_rows = sb("aff_tasks", params={"status": "eq.queued", "order": "scheduled_at.asc", "limit": 1}) or [] |
| if not task_rows: |
| return {"ok": True, "message": "No queued tasks"} |
|
|
| task = task_rows[0] |
| task_id = task["id"] |
| attempts = int(task.get("attempts") or 0) + 1 |
|
|
| sb( |
| f"aff_tasks?id=eq.{task_id}", |
| method="PATCH", |
| body={"status": "running", "started_at": now_iso(), "attempts": attempts}, |
| ) |
|
|
| try: |
| output = run_agent(task["agent_code"], task.get("input") or {}) |
| sb( |
| f"aff_tasks?id=eq.{task_id}", |
| method="PATCH", |
| body={"status": "done", "output": output, "finished_at": now_iso()}, |
| ) |
|
|
| next_agent = NEXT_AGENT.get(task["agent_code"]) |
| if next_agent: |
| sb( |
| "aff_tasks", |
| method="POST", |
| body={ |
| "agent_code": next_agent, |
| "status": "queued", |
| "input": {"prev_agent": task["agent_code"], "prev_output": output}, |
| }, |
| ) |
|
|
| return {"ok": True, "task_id": task_id, "agent": task["agent_code"], "status": "done"} |
|
|
| except Exception as e: |
| sb( |
| f"aff_tasks?id=eq.{task_id}", |
| method="PATCH", |
| body={"status": "error", "error": str(e), "finished_at": now_iso()}, |
| ) |
| return {"ok": False, "task_id": task_id, "agent": task["agent_code"], "error": str(e)} |
|
|
|
|
| |
| def stats_today(): |
| start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0).isoformat() |
| rows = ( |
| sb( |
| "aff_tasks", |
| params={"select": "status,agent_code,created_at,output", "created_at": f"gte.{start}", "limit": 2000}, |
| ) |
| or [] |
| ) |
|
|
| agg = {"queued": 0, "running": 0, "done": 0, "error": 0} |
| by_agent = {} |
|
|
| publish_count = 0 |
| latest_kpi = {"ctr": 0.0, "cvr": 0.0, "epc": 0.0} |
|
|
| for row in rows: |
| st = row.get("status", "queued") |
| agg[st] = agg.get(st, 0) + 1 |
|
|
| code = row.get("agent_code", "unknown") |
| by_agent[code] = by_agent.get(code, 0) + 1 |
|
|
| out = row.get("output") or {} |
| if code == "publisher": |
| refs = out.get("publish_refs") or [] |
| publish_count += len(refs) |
| if code == "analytics" and out.get("kpi_snapshot"): |
| k = out.get("kpi_snapshot") |
| latest_kpi = { |
| "ctr": float(k.get("ctr") or 0.0), |
| "cvr": float(k.get("cvr") or 0.0), |
| "epc": float(k.get("epc") or 0.0), |
| } |
|
|
| est_clicks = int(publish_count * 55) |
| est_leads = int(est_clicks * max(latest_kpi["cvr"], 0.01)) |
|
|
| return { |
| "total": len(rows), |
| "agg": agg, |
| "by_agent": by_agent, |
| "traffic": { |
| "published_items": publish_count, |
| "estimated_clicks": est_clicks, |
| "estimated_leads": est_leads, |
| "ctr": latest_kpi["ctr"], |
| "cvr": latest_kpi["cvr"], |
| "epc": latest_kpi["epc"], |
| "note": "Estimated from pipeline outputs (publisher + latest analytics snapshot).", |
| }, |
| } |
|
|
|
|
| def latest_tasks(limit=12): |
| rows = ( |
| sb( |
| "aff_tasks", |
| params={ |
| "select": "id,agent_code,status,created_at,finished_at,error", |
| "order": "created_at.desc", |
| "limit": limit, |
| }, |
| ) |
| or [] |
| ) |
| return rows |
|
|
|
|
| def connection_overview(): |
| rows = ( |
| sb( |
| "social_connections", |
| params={"select": "provider,scope,token_type,expires_at,updated_at", "order": "updated_at.desc", "limit": 30}, |
| ) |
| or [] |
| ) |
| return rows |
|
|
|
|
| PAGE = """ |
| <!doctype html> |
| <html> |
| <head> |
| <meta charset='utf-8'> |
| <meta name='viewport' content='width=device-width,initial-scale=1'> |
| <title>OpenClaw Growth Console</title> |
| <style> |
| :root{--bg:#0a1022;--panel:#121a33;--panel2:#1a2342;--txt:#eaf0ff;--muted:#9fb0e6;--ok:#3ddc97;--warn:#ffca57;--bad:#ff6b6b;--blue:#4ea1ff} |
| *{box-sizing:border-box} |
| body{margin:0;background:linear-gradient(180deg,#070c1a,#0a1022);color:var(--txt);font-family:Inter,Arial,sans-serif} |
| .wrap{max-width:1140px;margin:24px auto;padding:0 14px} |
| .hero{background:linear-gradient(135deg,#18264a,#121a33);border:1px solid #2a3a66;border-radius:16px;padding:18px} |
| .hero h1{margin:0 0 8px;font-size:24px} |
| .muted{color:var(--muted);font-size:13px} |
| .grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:12px} |
| .card{background:var(--panel);border:1px solid #2a3a66;border-radius:14px;padding:12px} |
| .k{font-size:12px;color:var(--muted)} |
| .v{font-size:24px;font-weight:700;margin-top:4px} |
| .v.ok{color:var(--ok)} .v.warn{color:var(--warn)} .v.bad{color:var(--bad)} |
| .section{background:var(--panel);border:1px solid #2a3a66;border-radius:14px;padding:14px;margin-top:12px} |
| .actions a,.actions button{margin:4px} |
| button{background:#28407a;color:white;border:none;border-radius:10px;padding:8px 12px;cursor:pointer} |
| button:hover{filter:brightness(1.08)} |
| input{width:100%;background:var(--panel2);border:1px solid #38508a;color:white;border-radius:10px;padding:9px} |
| .two{display:grid;grid-template-columns:1fr 1fr;gap:12px} |
| table{width:100%;border-collapse:collapse;font-size:13px} |
| th,td{padding:8px;border-bottom:1px solid #26365f;text-align:left} |
| th{color:#baceff} |
| .pill{display:inline-block;padding:3px 8px;border-radius:999px;font-size:11px} |
| .p-ok{background:#103d2a;color:#7ef0b9}.p-run{background:#3a2c00;color:#ffd86b}.p-err{background:#4a1e1e;color:#ff9b9b}.p-q{background:#1a2d4f;color:#9fc2ff} |
| @media (max-width:960px){.grid{grid-template-columns:repeat(2,1fr)}.two{grid-template-columns:1fr}} |
| </style> |
| </head> |
| <body> |
| <div class='wrap'> |
| <div class='hero'> |
| <h1>⚡ OpenClaw Growth Console</h1> |
| <div class='muted'>Status: {{supabase_ok}} · Last refresh: {{refreshed_at}}</div> |
| <div class='grid'> |
| <div class='card'><div class='k'>Total Tasks Today</div><div class='v'>{{stats.total}}</div></div> |
| <div class='card'><div class='k'>Done</div><div class='v ok'>{{stats.agg.done}}</div></div> |
| <div class='card'><div class='k'>Running + Queued</div><div class='v warn'>{{stats.agg.running + stats.agg.queued}}</div></div> |
| <div class='card'><div class='k'>Errors</div><div class='v bad'>{{stats.agg.error}}</div></div> |
| </div> |
| <div class='grid'> |
| <div class='card'><div class='k'>Published Items</div><div class='v'>{{stats.traffic.published_items}}</div></div> |
| <div class='card'><div class='k'>Estimated Clicks</div><div class='v'>{{stats.traffic.estimated_clicks}}</div></div> |
| <div class='card'><div class='k'>Estimated Leads</div><div class='v'>{{stats.traffic.estimated_leads}}</div></div> |
| <div class='card'><div class='k'>CTR / CVR / EPC</div><div class='v'>{{'%.2f' % (stats.traffic.ctr*100)}}% · {{'%.2f' % (stats.traffic.cvr*100)}}% · {{'%.2f' % stats.traffic.epc}}</div></div> |
| </div> |
| <div class='muted'>{{stats.traffic.note}}</div> |
| </div> |
| |
| <div class='section actions'> |
| <a href='/oauth/start/facebook'><button>Connect Facebook</button></a> |
| <a href='/oauth/start/x'><button>Connect X</button></a> |
| <a href='/oauth/start/reddit'><button>Connect Reddit</button></a> |
| <a href='/connections'><button>View Connections JSON</button></a> |
| <a href='/run-once'><button>Run Once</button></a> |
| <a href='/run-batch?count=5'><button>Run Batch x5</button></a> |
| <a href='/dashboard-data'><button>Dashboard API</button></a> |
| </div> |
| |
| <div class='two'> |
| <div class='section'> |
| <h3>Connect Make.com</h3> |
| <form method='post' action='/make/connect'> |
| <label class='muted'>Webhook URL</label> |
| <input name='webhook_url' placeholder='https://hook.eu2.make.com/xxxx'> |
| <p><button type='submit'>Save Webhook</button> <a href='/make/test'><button type='button'>Test Webhook</button></a></p> |
| </form> |
| </div> |
| |
| <div class='section'> |
| <h3>Enqueue Campaign</h3> |
| <form method='post' action='/enqueue'> |
| <label class='muted'>Affiliate Link</label> |
| <input name='affiliate_link' required> |
| <label class='muted'>Short URL (optional)</label> |
| <input name='short_url'> |
| <p><button type='submit'>Enqueue</button></p> |
| </form> |
| </div> |
| </div> |
| |
| <div class='section'> |
| <h3>Connected Platforms</h3> |
| <table> |
| <thead><tr><th>Provider</th><th>Scope</th><th>Type</th><th>Expires</th><th>Updated</th></tr></thead> |
| <tbody> |
| {% for c in connections %} |
| <tr> |
| <td>{{c.provider}}</td> |
| <td>{{c.scope or '-'}}</td> |
| <td>{{c.token_type or '-'}}</td> |
| <td>{{c.expires_at or '-'}}</td> |
| <td>{{c.updated_at}}</td> |
| </tr> |
| {% endfor %} |
| {% if not connections %} |
| <tr><td colspan='5' class='muted'>Belum ada koneksi. Connect dulu via tombol di atas.</td></tr> |
| {% endif %} |
| </tbody> |
| </table> |
| </div> |
| |
| <div class='section'> |
| <h3>Latest Tasks</h3> |
| <table> |
| <thead><tr><th>Created</th><th>Agent</th><th>Status</th><th>Task ID</th><th>Error</th></tr></thead> |
| <tbody> |
| {% for t in latest %} |
| <tr> |
| <td>{{t.created_at}}</td> |
| <td>{{t.agent_code}}</td> |
| <td> |
| {% if t.status == 'done' %}<span class='pill p-ok'>done</span> |
| {% elif t.status == 'running' %}<span class='pill p-run'>running</span> |
| {% elif t.status == 'error' %}<span class='pill p-err'>error</span> |
| {% else %}<span class='pill p-q'>queued</span>{% endif %} |
| </td> |
| <td>{{t.id}}</td> |
| <td>{{(t.error or '')[:80]}}</td> |
| </tr> |
| {% endfor %} |
| </tbody> |
| </table> |
| </div> |
| </div> |
| </body> |
| </html> |
| """ |
|
|
|
|
| |
| @app.get("/") |
| def home(): |
| try: |
| stats = stats_today() |
| conns = connection_overview() |
| latest = latest_tasks(15) |
| return render_template_string( |
| PAGE, |
| supabase_ok="connected", |
| refreshed_at=now_iso(), |
| stats=stats, |
| connections=conns, |
| latest=latest, |
| ) |
| except Exception as e: |
| return render_template_string( |
| PAGE, |
| supabase_ok=f"error: {e}", |
| refreshed_at=now_iso(), |
| stats={"total": 0, "agg": {"done": 0, "running": 0, "queued": 0, "error": 0}, "traffic": {"published_items": 0, "estimated_clicks": 0, "estimated_leads": 0, "ctr": 0, "cvr": 0, "epc": 0, "note": "No data"}}, |
| connections=[], |
| latest=[], |
| ) |
|
|
|
|
| @app.get("/healthz") |
| def healthz(): |
| return jsonify({"ok": True, "time": now_iso()}) |
|
|
|
|
| @app.get("/dashboard-data") |
| def dashboard_data(): |
| return jsonify({ |
| "ok": True, |
| "time": now_iso(), |
| "stats": stats_today(), |
| "connections": connection_overview(), |
| "latest": latest_tasks(15), |
| }) |
|
|
|
|
| @app.get("/oauth/start/<provider>") |
| def oauth_start(provider): |
| try: |
| conf = provider_config(provider) |
| if not APP_BASE_URL: |
| return jsonify({"ok": False, "error": "Missing APP_BASE_URL secret"}), 500 |
| if not conf["client_id"] or not conf["client_secret"]: |
| return jsonify({"ok": False, "error": f"Missing client credentials for {provider}"}), 400 |
|
|
| redirect_uri = f"{APP_BASE_URL}/oauth/callback/{provider}" |
| state = f"{provider}-{int(time.time())}" |
| params = { |
| "client_id": conf["client_id"], |
| "redirect_uri": redirect_uri, |
| "response_type": "code", |
| "scope": conf["scope"], |
| "state": state, |
| } |
| auth_url = f"{conf['auth_url']}?{urlencode(params)}" |
| return redirect(auth_url) |
| except Exception as e: |
| return jsonify({"ok": False, "error": str(e)}), 500 |
|
|
|
|
| @app.get("/oauth/callback/<provider>") |
| def oauth_callback(provider): |
| code = request.args.get("code") |
| if not code: |
| return jsonify({"ok": False, "error": "Missing code"}), 400 |
|
|
| conf = provider_config(provider) |
| redirect_uri = f"{APP_BASE_URL}/oauth/callback/{provider}" |
| data = { |
| "grant_type": "authorization_code", |
| "code": code, |
| "redirect_uri": redirect_uri, |
| "client_id": conf["client_id"], |
| "client_secret": conf["client_secret"], |
| } |
|
|
| try: |
| token_res = requests.post(conf["token_url"], data=data, timeout=25) |
| payload = token_res.json() |
| if token_res.status_code >= 400: |
| return jsonify({"ok": False, "error": payload}), 400 |
|
|
| saved = save_connection(provider, payload) |
| return jsonify({"ok": True, "provider": provider, "saved": saved}) |
| except Exception as e: |
| return jsonify({"ok": False, "error": str(e)}), 500 |
|
|
|
|
| @app.get("/connections") |
| def connections(): |
| rows = connection_overview() |
| return jsonify({"ok": True, "connections": rows}) |
|
|
|
|
| @app.post("/make/connect") |
| def make_connect(): |
| webhook_url = request.form.get("webhook_url") or (request.json or {}).get("webhook_url") |
| if not webhook_url: |
| return jsonify({"ok": False, "error": "webhook_url required"}), 400 |
| if not webhook_url.startswith("https://hook."): |
| return jsonify({"ok": False, "error": "invalid make webhook url"}), 400 |
|
|
| saved = save_make_webhook(webhook_url) |
| return jsonify({"ok": True, "saved": saved}) |
|
|
|
|
| @app.get("/make/test") |
| def make_test(): |
| rows = sb("social_connections", params={"provider": "eq.make", "limit": 1}) or [] |
| if not rows: |
| return jsonify({"ok": False, "error": "make webhook not connected"}), 400 |
|
|
| webhook_url = ((rows[0] or {}).get("raw") or {}).get("webhook_url") |
| if not webhook_url: |
| return jsonify({"ok": False, "error": "stored webhook missing"}), 400 |
|
|
| payload = { |
| "event": "openclaw_make_test", |
| "timestamp": now_iso(), |
| "source": "hf-space", |
| "message": "Make webhook test OK", |
| } |
|
|
| try: |
| r = requests.post(webhook_url, json=payload, timeout=20) |
| return jsonify({"ok": r.status_code < 400, "status": r.status_code, "text": r.text[:180]}) |
| except Exception as e: |
| return jsonify({"ok": False, "error": str(e)}), 500 |
|
|
|
|
| @app.post("/enqueue") |
| def enqueue(): |
| link = request.form.get("affiliate_link") or (request.json or {}).get("affiliate_link") |
| short = request.form.get("short_url") or (request.json or {}).get("short_url") |
| if not link: |
| return jsonify({"ok": False, "error": "affiliate_link required"}), 400 |
|
|
| row = sb( |
| "aff_tasks", |
| method="POST", |
| body={ |
| "agent_code": "content_strategist", |
| "status": "queued", |
| "input": { |
| "niche": "web hosting", |
| "offer": "Hostinger", |
| "affiliate_link": link, |
| "short_url": short, |
| "source": "hf-space", |
| }, |
| }, |
| ) |
| return jsonify({"ok": True, "task": row[0] if isinstance(row, list) and row else row}) |
|
|
|
|
| @app.get("/run-once") |
| def run_once_route(): |
| return jsonify(run_once()) |
|
|
|
|
| @app.get("/run-batch") |
| def run_batch(): |
| count = int(request.args.get("count", 5)) |
| out = [] |
| for _ in range(max(1, min(count, 20))): |
| result = run_once() |
| out.append(result) |
| if result.get("message") == "No queued tasks": |
| break |
| return jsonify({"ok": True, "results": out}) |
|
|
|
|
| @app.get("/stats") |
| def stats_route(): |
| return jsonify(stats_today()) |
|
|
|
|
| if __name__ == "__main__": |
| port = int(os.getenv("PORT", "7860")) |
| app.run(host="0.0.0.0", port=port) |
|
|