#!/usr/bin/env python3
"""StudioSoft Healthcare — run cron hc50 (29 Sep 2026 02:00 WIB / 28 Sep 19:00 UTC).

Topik Threads : "Tips SEO Digital Marketing Surabaya: Optimasi Local untuk Klinik dan Bengkel
                Lokal" (https://studiosoft.id/tips-seo-digital-marketing-surabaya-optimasi-local-
                untuk-klinik-dan-bengkel-lokal/ — audit 29 Sep 2026: jejak Threads-nya hanya 1x
                TEXT 13 Sep 2026 07:01Z, BELUM PERNAH jadi carousel Threads, dan jejaknya paling
                tua di antara artikel HC yang masih hidup; slide BARU ss_hc50_seolokal_*).
LinkedIn      : SKIP — semua 11 artikel HC yang masih HIDUP sudah ada di antrean LI pending
                (5–11 Okt 2026). Rule: jangan menyusun caption LI untuk artikel yang sudah ada
                di antrean. (Temuan terpisah: 6 jadwal LI pending 9–10 Okt masih berlink artikel
                yang sudah 404 — dilaporkan ke Bos, TIDAK diubah otomatis.)
Facebook      : TEXT-only tanpa media, replies=[], artikel `cara-klinik-kecantikan-di-surabaya-
                bisa-ditemukan-lebih-cepat-di-google` (terakhir tayang di FB SS 8 Sep 2026 =>
                jejak FB paling tua di antara artikel HC hidup setelah 7 artikel 404 dikecualikan;
                tidak bentrok dengan topik Threads hari ini).

TEMUAN: stok artikel healthcare BARU masih HABIS (WP REST 29 Sep: 337 post, terbaru 28 Sep
`tools-ai-untuk-bisnis-pilih-sesuai-kebutuhan` = non-healthcare) dan 7 artikel HC seri ini
MASIH 404: strategi-digital-marketing-rumah-sakit, website-rumah-sakit-gagal-pasien,
iklan-facebook-rumah-sakit, klinik-kecil-butuh-digital-marketing, konten-edukasi-kesehatan-
kepercayaan-pasien, social-media-rumah-sakit-efektif, data-analytics-rumah-sakit-kebutuhan-
pasien. Semua link artikel run ini DIVERIFIKASI HTTP 200.

ATURAN (FIXED 5 & 15 Sep 2026):
- HANYA THREADS SS yang boleh carousel/image. FB & LinkedIn SS: TEXT saja.
- IG SS tidak disentuh (punya desain sendiri).
- Carousel = 09:00 WIB. Threads <=500 char, hashtag huruf kecil, 0 mention, pancingan diskusi.
- Standar desain 16 Sep 2026: engine ss_carousel_lib.py, bg #122B63, rata kiri, blok tengah
  vertikal, tanpa separator, bullet dot, Poppins Bold 85 / Regular 36 / sub 30, auto-fit.
"""
import json
import os
import re
import subprocess
import sys
from datetime import datetime, timedelta, timezone

WS = "/home/mantapman/.hermes/profiles/atun/workspace"
sys.path.insert(0, WS)
from PIL import Image
from repliz_client import ReplizClient, ACC, IMG_DIR, IMG_BASE_URL
from diskusi_engine import tambahkan_diskusi

DRY = os.environ.get("DRY", "") == "1"
_DOCS = None
c = ReplizClient()

TITLE = "Tips SEO Digital Marketing Surabaya: Optimasi Local untuk Klinik dan Bengkel Lokal"
ARTICLE_URL = ("https://studiosoft.id/tips-seo-digital-marketing-surabaya-optimasi-local-"
               "untuk-klinik-dan-bengkel-lokal/")
FB_TITLE = "Cara Klinik Kecantikan di Surabaya Bisa Ditemukan Lebih Cepat di Google"
FB_URL = ("https://studiosoft.id/cara-klinik-kecantikan-di-surabaya-bisa-ditemukan-lebih-"
          "cepat-di-google/")
HASHTAG_LINE = "#studiosoft #seolokal #klinik #bengkel #digitalmarketingsurabaya"

MEDIA_FILES = [
    "ss_hc50_seolokal_01_cover.jpg",
    "ss_hc50_seolokal_02_penting.jpg",
    "ss_hc50_seolokal_03_gmb.jpg",
    "ss_hc50_seolokal_04_konten.jpg",
    "ss_hc50_seolokal_05_cta.jpg",
]
SRC_PNG = os.path.join(WS, "studiosoft_hc50_carousel")
MEDIA_URLS = [IMG_BASE_URL.rstrip("/") + "/openclaw-img/" + f for f in MEDIA_FILES]
INGRESS = ["103.84.155.153", "103.84.155.217"]

THREADS_BASE = """Klinik dan bengkel di Surabaya jarang kalah kualitas, tapi sering kalah di kolom pencarian Google.

Local SEO menutup celah itu: profil Google rapi, ulasan dikelola, kata kunci long-tail, dan konten yang menyebut area sekitar.

""" + ARTICLE_URL

FB_BASE = """Klinik kecantikan di Surabaya bersaing ketat. Setiap hari ratusan orang mencari layanan facial, laser, atau anti-aging lewat Google, dan hampir tidak ada yang scroll melewati lima hasil teratas.

Yang membuat klinik muncul lebih cepat biasanya bukan iklan besar, tapi hal sederhana: Google Business Profile yang lengkap dengan foto, jam buka, dan alamat yang akurat; ulasan pasien yang aktif dibalas; serta halaman layanan yang menyebut kata kunci lokal.

Banyak pemilik klinik sibuk mengejar pasien baru lewat promo, padahal pasien lama yang puas dan mau menulis ulasan adalah aset visibilitas paling murah. Satu ulasan jujur sering lebih kuat daripada sepuluh banner.

Dari pengalaman mendampingi klinik di Jawa Timur, yang berubah lebih dulu biasanya bukan websitenya, melainkan kebiasaan merawat profil Google dan menjawab pertanyaan pasien dengan cepat.

Bacaan lengkapnya: """ + FB_URL

CTA_RE = re.compile(r"komentar|pendapat|diskusi|setuju|share|pengalaman|menurut|bagaimana|apa yang|komen|tanggapan|pandangan", re.I)
BAN_ANY = re.compile(r"sembuh total|dijamin sembuh", re.I)
CURATED_T = {
    "netral": "Menurut Anda, dari langkah local SEO itu mana yang paling sering dilewatkan klinik dan bengkel? Yuk diskusi di komentar.",
    "opini": "Bold statement: profil Google yang rapi lebih cepat mendatangkan pelanggan lokal daripada iklan berbayar. Setuju atau tidak?",
    "sanjungan": "Buat klinik dan bengkel yang halamannya sudah rapi di mata Google, Anda selangkah lebih maju! \U0001f44f",
}
CURATED_FB = {
    "netral": "Menurut Anda, apa bagian tersulit membuat klinik muncul di halaman pertama Google? Yuk diskusi di komentar.",
    "opini": "Bold statement: ulasan pasien lebih menentukan klinik ditemukan daripada besarnya anggaran iklan. Setuju atau tidak?",
    "sanjungan": "Buat klinik yang profil Google-nya sudah rapi dan aktif, Anda selangkah lebih maju! \U0001f44f",
}


def build(base, platform, industri, tones, limit=None, ban=None, max_try=120):
    for tone in tones:
        for _ in range(max_try):
            txt = tambahkan_diskusi(base, platform=platform, industri=industri, tone=tone, force=True)
            if not CTA_RE.search(txt[-200:]):
                continue
            if limit is not None and len(txt) > limit:
                continue
            if ban is not None and ban.search(txt):
                continue
            if "..." in txt:
                continue
            return txt
    return None


def build_curated(base, curated, tones, limit=None):
    for tone in tones:
        hook = (curated or {}).get(tone)
        if not hook:
            continue
        cand = base.rstrip() + "\n\n" + hook
        if CTA_RE.search(cand[-200:]) and (limit is None or len(cand) <= limit) and "@" not in cand:
            return cand
    return None


def build_safe(base, platform, industri, tones, limit=None, ban=None, curated=None):
    txt = build_curated(base, curated, tones, limit=limit)
    if txt is not None:
        return txt
    txt = build(base, platform, industri, tones, limit=limit, ban=ban)
    if txt is not None:
        return txt
    for tone in tones:
        hook = (curated or {}).get(tone)
        if not hook:
            continue
        cand = base.rstrip() + "\n\n" + hook
        if CTA_RE.search(cand[-200:]) and (limit is None or len(cand) <= limit):
            return cand
    return None


THREADS = build_safe(THREADS_BASE, "threads", "klinik", ["netral", "opini"], limit=500,
                     ban=BAN_ANY, curated=CURATED_T)
FB = build_safe(FB_BASE, "facebook", "klinik", ["netral", "opini"], ban=BAN_ANY,
                curated=CURATED_FB)

REPLY1 = ("Baca selengkapnya: %s\n\nButuh partner digital marketing untuk klinik atau rumah sakit "
          "Anda?\nwww.studiosoft.id" % ARTICLE_URL)
REPLY2 = HASHTAG_LINE


def check_caption(name, txt, limit=None, require_cta=True):
    ok = True
    if txt is None:
        print("  \u274c %s: gagal disusun" % name)
        return False
    if limit and len(txt) > limit:
        print("  \u274c %s: %d char > %d" % (name, len(txt), limit))
        ok = False
    m = re.findall(r"@\w+", txt)
    if m:
        print("  \u274c %s: ada mention %s" % (name, m))
        ok = False
    caps = [t for t in re.findall(r"#(\w+)", txt) if t != t.lower()]
    if caps:
        print("  \u274c %s: hashtag KAPITAL %s" % (name, caps))
        ok = False
    if "..." in txt:
        print("  \u274c %s: ada elipsis" % name)
        ok = False
    if re.search(r"[\u3000-\u9fff\uff00-\uffef]", txt):
        print("  \u274c %s: ada karakter CJK" % name)
        ok = False
    if require_cta and not CTA_RE.search(txt[-200:]):
        print("  \u274c %s: tidak ada pancingan diskusi di ekor caption" % name)
        ok = False
    print("  %s %s: %d char" % ("\u2705" if ok else "\u26a0\ufe0f", name, len(txt)))
    return ok


def get_retry(ep, tries=6):
    import time
    last = None
    for i in range(tries):
        try:
            return c.get(ep)
        except Exception as e:
            last = e
            time.sleep(3 + 3 * i)
    raise last


def all_docs():
    """Ambil semua jadwal SEKALI (cache)."""
    global _DOCS
    if _DOCS is not None:
        return _DOCS
    docs, page = [], 1
    while True:
        d = get_retry("/public/schedule?page=%d&limit=100" % page)
        docs.extend(d.get("docs", []))
        if page >= (d.get("totalPages") or 1):
            break
        page += 1
    _DOCS = docs
    print("   (dump jadwal: %d docs)" % len(docs))
    return _DOCS


def scan_media(statuses=("pending", "scheduled", "draft")):
    hits = []
    for s in all_docs():
        if s.get("accountId") not in (ACC.STUDIOSOFT_THREADS, ACC.STUDIOSOFT_LINKEDIN,
                                      ACC.STUDIOSOFT_FB, ACC.STUDIOSOFT_IG):
            continue
        if (s.get("status") or "").lower() not in statuses:
            continue
        for m in (s.get("medias") or []):
            base = os.path.basename((m.get("url") or "").split("?")[0]).lower()
            if base in [f.lower() for f in MEDIA_FILES]:
                hits.append((s.get("accountId"), (s.get("scheduleAt") or "")[:16],
                             (s.get("title") or "")[:40], s.get("type"), s.get("status")))
                break
    return hits


def busy_by_account(aid):
    busy = set()
    for s in all_docs():
        if s.get("accountId") == aid and (s.get("status") or "").lower() in ("pending", "scheduled", "draft"):
            busy.add((s.get("scheduleAt") or "")[:19])
    return busy


def next_free_09():
    busy = busy_by_account(ACC.STUDIOSOFT_THREADS)
    now = datetime.now(timezone.utc)
    start = datetime(now.year, now.month, now.day, tzinfo=timezone.utc)
    if now.hour >= 2:
        start += timedelta(days=1)
    for day in range(0, 20):
        cand = (start + timedelta(days=day)).strftime("%Y-%m-%dT02:00:00")
        if cand not in busy:
            lab = (datetime.fromisoformat(cand) + timedelta(hours=7)).strftime("%d %b %Y 09:00 WIB")
            return cand + ".000Z", lab
    return None, None


def next_free_fb():
    busy = busy_by_account(ACC.STUDIOSOFT_FB)
    now = datetime.now(timezone.utc) + timedelta(minutes=30)
    now_naive = now.replace(tzinfo=None)
    today = datetime.now(timezone.utc)
    for day in range(0, 21):
        d0 = datetime(today.year, today.month, today.day, tzinfo=timezone.utc) + timedelta(days=day)
        cand = "%sT12:30:00" % d0.strftime("%Y-%m-%d")
        if datetime.fromisoformat(cand) <= now_naive:
            continue
        if cand not in busy:
            lab = (datetime.fromisoformat(cand) + timedelta(hours=7)).strftime("%d %b %Y 19:30 WIB")
            return cand + ".000Z", lab
    return None, None


def prep_media():
    ok = True
    for png in sorted(os.listdir(SRC_PNG)):
        if not (png.endswith(".png") and png.startswith("ss_hc50_seolokal_")):
            continue
        jpg = png.replace(".png", ".jpg")
        src = Image.open(os.path.join(SRC_PNG, png))
        if src.mode != "RGB":
            src = src.convert("RGB")
        src.save(os.path.join(IMG_DIR, jpg), "JPEG", quality=85, optimize=True)
        print("  \u2705 %s -> %s %s" % (png, jpg, src.size))
    for f in MEDIA_FILES:
        try:
            im = Image.open(os.path.join(IMG_DIR, f))
            im.verify()
            im = Image.open(os.path.join(IMG_DIR, f))
            print("  \u2705 PIL %s %s %s %d bytes" % (f, im.size, im.mode,
                                                      os.path.getsize(os.path.join(IMG_DIR, f))))
        except Exception as e:
            print("  \u274c PIL %s: %s" % (f, e))
            ok = False
    return ok


def verify_urls(tries=4):
    """Ingress 103.84.155.217 kadang lambat (21-30s) -> timeout 60s + 4 percobaan."""
    ok = True
    for u in MEDIA_URLS:
        for ip in INGRESS:
            code = "000"
            for _ in range(tries):
                r = subprocess.run(["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "60",
                                    "--resolve", "mantapserver.tail3947bb.ts.net:443:%s" % ip, u],
                                   capture_output=True, text=True)
                code = r.stdout.strip()
                if code == "200":
                    break
            print("  %s @%s: %s" % (u.split("/")[-1], ip, code))
            if code != "200":
                ok = False
    return ok


def verify_link(url):
    r = subprocess.run(["curl", "-4", "-sIL", "--http1.1", "--max-time", "200",
                        "-A", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", url,
                        "-o", "/dev/null", "-w", "%{http_code}"], capture_output=True, text=True)
    code = (r.stdout or "").strip()
    print("  langsung %s: %s" % (url, code))
    if code != "200":
        r2 = subprocess.run(["curl", "-4", "-s", "--max-time", "120", "-o", "/dev/null", "-w", "%{http_code}",
                             "https://r.jina.ai/" + url], capture_output=True, text=True)
        code = (r2.stdout or "").strip()
        print("  via r.jina.ai: %s" % code)
    return code == "200"


def main():
    wib = datetime.now(timezone(timedelta(hours=7)))
    print("Time WIB: %s" % wib.strftime("%Y-%m-%d %H:%M"))

    print("\n0) LINK ARTIKEL (WAJIB 200 — 7 artikel HC sudah dihapus dari website):")
    for u in (ARTICLE_URL, FB_URL):
        if not verify_link(u):
            print("  \u274c link tidak 200 \u2014 batal.")
            return False

    print("\n1) SLOT:")
    SLOT_UTC, SLOT_LABEL = next_free_09()
    if not SLOT_UTC:
        print("  \u274c tidak ada slot 09:00 WIB bebas \u2014 batal.")
        return False
    print("  \u2705 carousel Threads: %s (%s)" % (SLOT_LABEL, SLOT_UTC))
    FB_SLOT_UTC, FB_SLOT_LABEL = next_free_fb()
    print("  \u2705 Facebook text (tanpa media, replies=[]): %s (%s)" % (FB_SLOT_LABEL, FB_SLOT_UTC))
    print("  \u23ed\ufe0f LinkedIn: SKIP \u2014 semua artikel HC hidup sudah ada di antrean LI pending.")
    if not FB_SLOT_UTC:
        print("  \u274c slot FB tidak ditemukan \u2014 batal.")
        return False

    print("\n2) CAPTION:")
    ok = True
    ok &= check_caption("Threads", THREADS, 500)
    ok &= check_caption("Facebook", FB)
    ok &= check_caption("Reply1", REPLY1, require_cta=False)
    ok &= check_caption("Reply2", REPLY2, require_cta=False)
    if not ok:
        print("\n\u274c caption gagal aturan \u2014 batal.")
        return False

    print("\n3) IDEMPOTENSI (media hc50 sudah pernah dijadwalkan?):")
    hits = scan_media(("pending", "scheduled", "draft", "success"))
    if hits:
        print("  \u26a0\ufe0f sudah ada jadwal dgn media hc50 -> SKIP create:")
        for h in hits:
            print("   ", h)
        return True
    print("  \u2705 belum ada")

    print("\n4) MEDIA: PNG standar baru -> JPEG openclaw-img + PIL:")
    if not prep_media():
        print("  \u274c media rusak \u2014 batal.")
        return False

    print("\n5) JALUR INTERNET (2 ingress):")
    if not verify_urls():
        print("  \u274c ada media tidak 200 \u2014 batal.")
        return False

    if DRY:
        print("\n[DRY RUN] semua cek lolos; tidak ada create.")
        return True

    base_dt = datetime.fromisoformat(SLOT_UTC.replace("Z", "+00:00"))
    r2 = (base_dt + timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M:%S.000Z")
    r3 = (base_dt + timedelta(minutes=2)).strftime("%Y-%m-%dT%H:%M:%S.000Z")
    medias = [c.make_media(u, "image", "slide%d" % (i + 1)) for i, u in enumerate(MEDIA_URLS)]

    results = {}
    print("\n6) CREATE \u2014 THREADS (album + 2 replies):")
    try:
        alb = c.schedule_album(ACC.STUDIOSOFT_THREADS, THREADS, SLOT_UTC, medias, title=TITLE)
        pid = alb.get("scheduleId") or alb.get("_id") or alb.get("id")
        print("  album -> %s" % pid)
        c.create_schedule(ACC.STUDIOSOFT_THREADS, REPLY1, r2, type_="text", replies=[pid])
        c.create_schedule(ACC.STUDIOSOFT_THREADS, REPLY2, r3, type_="text", replies=[pid])
        print("  2 replies OK")
        results["threads"] = pid
    except Exception as e:
        print("  GAGAL %s" % e)
        results["threads"] = None

    print("\n   FACEBOOK (TEXT saja, TANPA media, replies=[], %s):" % FB_SLOT_LABEL)
    try:
        res = c.create_schedule(ACC.STUDIOSOFT_FB, FB, FB_SLOT_UTC, title=FB_TITLE,
                                type_="text", medias=[], replies=[])
        print("  text -> %s" % (res.get("scheduleId") or res.get("_id") or res.get("id")))
        results["facebook"] = res.get("scheduleId") or res.get("_id") or res.get("id")
    except Exception as e:
        print("  GAGAL %s" % e)
        results["facebook"] = None

    print("\n" + "=" * 55)
    ok = all(results.values())
    print("SEMUA PLATFORM TERJADWAL" if ok else "GAGAL: %s" % [k for k, v in results.items() if not v])

    if ok:
        print("\n7) VERIFIKASI (fetch ULANG dari API per jadwal):")
        for name, sid in results.items():
            d = get_retry("/public/schedule/%s" % sid)
            doc = d.get("doc") or d.get("data") or d
            print("  %s: status=%s at=%s type=%s medias=%d replies=%d" % (
                name, doc.get("status"), doc.get("scheduleAt"), doc.get("type"),
                len(doc.get("medias") or []), len(doc.get("replies") or [])))
        for h in scan_media(("pending",)):
            print("   ", h)

        st = json.loads(open(WS + "/studiosoft_healthcare_state.json").read())
        st["index"] = st.get("index", 45) + 1
        st["topic"] = TITLE
        st["last_post"] = wib.strftime("%Y-%m-%d %H:%M")
        json.dump(st, open(WS + "/studiosoft_healthcare_state.json", "w"))
        with open(WS + "/studiosoft_healthcare_log.txt", "a") as f:
            f.write("\n[%s WIB] hc50 (RE-AIR/new-topic Threads: SEO lokal klinik & bengkel Surabaya "
                    "\u2014 jejak Threads hanya 1x TEXT 13 Sep 2026 07:01Z (hc31), BELUM PERNAH "
                    "jadi carousel Threads & jejak paling tua di antara artikel HC yang masih "
                    "hidup; slide BARU ss_hc50_seolokal_*) | %s | %s | Threads album %s (%s) + "
                    "2 replies | LinkedIn SKIP (semua 11 artikel HC hidup sudah ada di antrean LI "
                    "pending 5-11 Okt; temuan: 6 jadwal LI pending 9-10 Okt masih berlink artikel "
                    "404) | FB TEXT \"%s\" (tanpa media, replies=[]) %s | catatan: stok artikel "
                    "healthcare baru masih HABIS (WP REST 29 Sep: 337 post, terbaru 28 Sep "
                    "tools-ai-untuk-bisnis = non-healthcare) & 7 artikel HC seri ini MASIH 404 "
                    "(strategi-digital-marketing-rumah-sakit, website-rumah-sakit-gagal-pasien, "
                    "iklan-facebook-rumah-sakit, klinik-kecil-butuh-digital-marketing, konten-"
                    "edukasi-kesehatan-kepercayaan-pasien, social-media-rumah-sakit-efektif, "
                    "data-analytics-rumah-sakit-kebutuhan-pasien) -> hanya artikel terverifikasi 200 "
                    "yang dipakai. FB: cara-klinik-kecantikan (terakhir di FB SS 8 Sep 2026 22:00Z, "
                    "jejak FB paling tua di antara artikel HC hidup). state index -> %s.\n"
                    % (wib.strftime("%Y-%m-%d %H:%M"), TITLE, ARTICLE_URL, SLOT_LABEL,
                       SLOT_UTC[11:16] + "Z", FB_TITLE, FB_SLOT_LABEL, st["index"]))
        print("Log + state ditulis.")
    return ok


if __name__ == "__main__":
    sys.exit(0 if main() else 1)
