simbiot-harness/video/daily/daily.py
Simbiot Harness a08cd482f5 ежедневный ролик: вертикаль 1080×1920, 5 стилей по кругу, субтитры, приватность
- вертикальный формат (Shorts/Reels): YouTube через Buffer принимает только вертикаль;
  безопасные зоны под интерфейс приложений, сегментный прогресс-бар как у сторис;
- 5 тем (gold, terminal, paper, violet, poster): палитра, шрифты, фон-мотив,
  манера появления/ухода и своя музыка (MusicGen); тема дня — наименее давно использованная;
- новые сцены chat и terminal (команды сверяются с источниками), ≥4 разных типа,
  не повторять структуру вчерашнего ролика;
- озвучка по предложениям → точные субтитры и появление элементов на фразах;
- приватность: без цитат реальных переписок и имён людей (проверка в валидаторе).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 08:46:10 +00:00

559 lines
30 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Ежедневный ролик про Simbiot Harness: сценарий → озвучка → hyperframes → соцсети.
Запуск (cron, 06:25 UTC; публикация ровно в 07:00 UTC = 10:00 Минск):
venv/bin/python video/daily/daily.py --publish-at 07:00
Флаги: --dry-run (без публикации), --date YYYY-MM-DD, --spec file.json (свой
сценарий, без LLM), --platforms x,telegram,linkedin,facebook,instagram,youtube.
Предохранители (публикация без ревью человеком — решение Alex 2026-10-17):
- факты только из README/SOUL/свежих коммитов; любое число в тексте должно
встречаться в источниках, иначе сценарий отклоняется и переписывается;
- темы не повторяются (state.json хранит историю);
- hyperframes check с ошибками или вылезающим текстом → без публикации;
- любой сбой → без публикации + уведомление владельцу в TG;
- повторный запуск за ту же дату не постит дважды (результаты по площадкам в state).
"""
import argparse
import datetime as dt
import fcntl
import json
import math
import os
import re
import shutil
import subprocess
import sys
import time
import traceback
import urllib.request
REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
sys.path.insert(0, REPO)
sys.path.insert(0, os.path.dirname(__file__))
import template # noqa: E402
HOME = os.environ.get("SIMBIOT_HOME", "/var/lib/simbiot-harness")
os.environ.setdefault("SIMBIOT_HOME", HOME)
BASE = os.path.join(HOME, "daily-video")
STATE = os.path.join(BASE, "state.json")
DAILY = os.path.dirname(os.path.abspath(__file__))
ASSETS = os.path.join(REPO, "video", "simbiot-enterprise-en") # gsap, hyperframes.json
FONTS = os.path.join(DAILY, "fonts")
MUSIC = os.path.join(DAILY, "music") # <тема>.wav; нет файла — gold.wav
HF = ["npx", "--yes", "hyperframes@0.8.168"]
KOKORO_PY = "/opt/kokoro-venv/bin/python"
VOICE, SPEED = "am_michael", "1.1"
PUBLIC_DIR, PUBLIC_URL = "/srv/raw.cil.by/simbiot/daily", "https://raw.cil.by/simbiot/daily"
SOCIAL = "/root/coop/social"
# (провайдер, модель): 9router первым; при его сбое — OpenRouter (ключ в env харнеса)
MODELS = [("router", "ocz/claude-sonnet-5"), ("openrouter", "anthropic/claude-sonnet-5.5"),
("openrouter", "anthropic/claude-opus-5.5"), ("router", "auto")]
ALL_PLATFORMS = ["x", "telegram", "linkedin", "facebook", "instagram", "youtube"]
BUFFER_CHANNELS = {"facebook": "facebook", "instagram": "instagram", "youtube": "youtube"}
LAYOUT_BAD = ("overflow", "out_of_canvas", "clipped", "escaped")
def log(*a):
print(f"[{dt.datetime.now(dt.timezone.utc):%H:%M:%S}]", *a, flush=True)
def sh(cmd, timeout=1800, env=None, cwd=None):
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, cwd=cwd,
env={**os.environ, **(env or {})}, stdin=subprocess.DEVNULL)
if r.returncode:
raise RuntimeError(f"{' '.join(map(str, cmd[:4]))}… rc={r.returncode}: {(r.stderr or r.stdout)[-800:]}")
return r.stdout
def load_state():
try:
return json.load(open(STATE))
except (OSError, ValueError):
return {"days": {}}
def save_state(st):
os.makedirs(BASE, exist_ok=True)
tmp = STATE + ".tmp"
json.dump(st, open(tmp, "w"), ensure_ascii=False, indent=2)
os.replace(tmp, STATE)
# ───────────────────────── источники и сценарий ─────────────────────────
def sources():
readme = open(os.path.join(REPO, "README.md")).read()
soul = open(os.path.join(REPO, "docs", "SOUL.md")).read()
commits = sh(["git", "-C", REPO, "log", "--since=4 days ago", "--date=short",
"--pretty=format:### %ad %s%n%b"], timeout=30)[:14000]
return {"readme": readme, "soul": soul, "commits": commits}
SCHEMA_HELP = """
The video is VERTICAL (1080×1920, YouTube Shorts / Reels), ~60–75 seconds. Return ONLY a JSON object:
{
"topic": "short English topic label (≤60 chars), must differ from past topics",
"series": "short uppercase tag for the HUD, e.g. 'DAILY · SECURITY' (≤20 chars)",
"title": "video title (≤80 chars)",
"youtube_title": "≤90 chars, ends with #Shorts",
"x_text": "post for X, ≤260 chars incl. hashtags and 'cil.by'",
"post_text": "post for LinkedIn/Telegram/Facebook/Instagram, 450–950 chars, short paragraphs, a few emoji ok, ends with a call to action mentioning cil.by and 3–5 hashtags",
"scenes": [ ...5 to 7 scenes... ]
}
Scene types (every scene also has "narration": what the voice says during the scene):
{"type":"hook","line1":"≤44 chars","strike":"optional: one word from line1 that the video REJECTS (e.g. chatbot in Not another chatbot); crossing it out must not invert the meaning — omit if unsure","line2":"≤44 chars, may contain *accent*","narration":"…"}
{"type":"points","kicker":"NN · Label","headline":"≤40 chars, may contain *accent*","items":[{"title":"≤26 chars","desc":"≤60 chars"}] (2–4),"narration":"…"}
{"type":"flow","kicker":"…","headline":"≤40 chars","steps":[{"title":"≤24 chars","desc":"≤50 chars"}] (3–4),"narration":"…"}
{"type":"stat","kicker":"…","headline":"≤40 chars","value":"≤6 chars, a number that literally appears in the sources","caption":"≤80 chars","narration":"…"}
{"type":"compare","kicker":"…","headline":"≤40 chars","left_title":"≤22 chars (typical AI bot / status quo)","left":["≤32 chars", 2–3],"right_title":"Simbiot Harness","right":["≤32 chars", same count],"narration":"…"}
{"type":"statement","kicker":"…","text":"≤60 chars, may contain *accent*","sub":"≤100 chars","narration":"…"}
{"type":"chat","kicker":"…","headline":"≤40 chars","messages":[{"from":"user|agent|system","text":"≤90 chars"}] (2–4) — an illustrative exchange that shows REAL documented behaviour,"narration":"…"}
{"type":"terminal","kicker":"…","headline":"≤40 chars","lines":["$ a real command from the sources (≤44 chars)","output line (≤44 chars)"] (2–5),"narration":"…"}
{"type":"outro","line1":"≤34 chars","line2":"≤34 chars, may contain *accent*","cta":"e.g. Let’s talk about a pilot · *cil.by*","narration":"…"}
Structure: first "hook", last "outro", 3–5 middle scenes. Use at least 4 DIFFERENT scene types overall and do not copy
yesterday's sequence of types. Kickers numbered "01 · Label".
Narration: English, punchy spoken style, short sentences, 8–30 words per scene, 110–150 words total. Each sentence of
a scene introduces the next on-screen element in order (elements appear on sentence starts); captions show each sentence.
"""
RULES = """You write a daily ~80-second explainer video + social posts about Simbiot Harness (an AI agent built by
Cooperative 42, cil.by), for business decision-makers, in English.
Hard rules:
- Everything must be about Simbiot Harness / Cooperative 42. Use ONLY facts present in the SOURCES. No invented
customers, metrics, prices, benchmarks, partners, dates or promises. Every number you write must appear in the sources.
- Pick the topic that is most relevant NOW: prefer what changed in the RECENT COMMITS, explained as a business benefit;
otherwise pick an important capability not covered by PAST TOPICS. Never repeat a past topic.
- Translate internal Russian terms into plain business English. No internal task ids (t-…, e-…), no file paths.
- Tone: confident, concrete, no hype words like "revolutionary".
- PRIVACY: never quote, paraphrase or reconstruct real private chat messages or internal conversations from the
sources, and never name individual people (owner, board members, members, partners, candidates). Refer to roles only:
"the owner", "a board member", "a client". Chat scenes must be generic illustrations, not real exchanges.
- In narration write the domain as "cil dot by" (for the voice); in on-screen text and posts write "cil.by".
"""
def llm(messages):
from harness import runtime
prov = {"router": (runtime._router_base_url(), runtime._router_key()),
"openrouter": (runtime._env_or_file("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"),
runtime._env_or_file("OPENROUTER_KEY"))}
last = None
for name, model in MODELS:
base, key = prov[name]
if not key:
continue
try:
body = {"model": model, "messages": messages, "temperature": 0.7, "max_tokens": 6000, "stream": False}
req = urllib.request.Request(base.rstrip("/") + "/chat/completions", data=json.dumps(body).encode(),
headers={"Authorization": "Bearer " + key, "Content-Type": "application/json"})
out = json.loads(urllib.request.urlopen(req, timeout=240).read())
txt = out["choices"][0]["message"]["content"]
log("llm ok:", name, model, out.get("usage", {}))
return txt
except Exception as e: # следующий по списку
last = e
log("llm fail:", name, model, e)
raise RuntimeError(f"all models failed: {last}")
def parse_json(txt):
m = re.search(r"\{.*\}", txt, re.S)
if not m:
raise ValueError("no JSON in LLM answer")
return json.loads(m.group(0))
# люди из README/SOUL/коммитов — в публичный ролик их имена и переписки не попадают
PRIVATE_NAMES = ["Alex", "Alexander", "Shkor", "Vlad", "Vladislav", "Yura", "Yuri", "Gennady", "Nadezhda", "Yulia",
"Stas", "Bugaev", "Алекс", "Влад", "Юра", "Геннадий"]
LIMITS = {"line1": 44, "line2": 44, "headline": 40, "value": 6, "caption": 80, "text": 60, "sub": 100,
"left_title": 22, "right_title": 22, "cta": 50, "kicker": 24}
OUTRO_LIMIT = 34
def plain_(x):
return str(x).replace("*", "")
def validate(spec, src_text, past):
errs = []
sc = spec.get("scenes") or []
if not 5 <= len(sc) <= 7:
errs.append(f"need 5–7 scenes, got {len(sc)}")
if sc and sc[0].get("type") != "hook":
errs.append("first scene must be hook")
if sc and sc[-1].get("type") != "outro":
errs.append("last scene must be outro")
words = 0
for i, s in enumerate(sc):
n = len(str(s.get("narration", "")).split())
words += n
if not 6 <= n <= 34:
errs.append(f"scene {i + 1}: narration {n} words (need 8–30)")
if s.get("type") == "outro" and max(len(plain_(s.get("line1", ""))), len(plain_(s.get("line2", "")))) > OUTRO_LIMIT:
errs.append(f"scene {i + 1}: outro lines must be ≤{OUTRO_LIMIT} chars")
for k, lim in LIMITS.items():
v = s.get(k)
if isinstance(v, str) and len(v.replace("*", "")) > lim:
errs.append(f"scene {i + 1}: {k} too long ({len(v)}>{lim}): {v!r}")
t = s.get("type")
need = {"hook": ["line1", "line2"], "points": ["headline", "items"], "flow": ["headline", "steps"],
"stat": ["headline", "value", "caption"], "compare": ["headline", "left", "right", "left_title", "right_title"],
"statement": ["text"], "outro": ["line1", "line2"], "chat": ["headline", "messages"],
"terminal": ["headline", "lines"]}.get(t)
if need is None:
errs.append(f"scene {i + 1}: unknown type {t}")
continue
for k in need:
if not s.get(k):
errs.append(f"scene {i + 1}: missing {k}")
for key, lo, hi, tl, dl in (("items", 2, 4, 26, 60), ("steps", 3, 4, 24, 50)):
if t in ("points", "flow") and key in s:
if not lo <= len(s[key]) <= hi:
errs.append(f"scene {i + 1}: {key} count {len(s[key])} (need {lo}–{hi})")
for it in s[key]:
if len(it.get("title", "")) > tl or len(it.get("desc", "")) > dl:
errs.append(f"scene {i + 1}: {key} entry too long: {it}")
if t == "compare" and (len(s.get("left", [])) != len(s.get("right", [])) or not 2 <= len(s.get("left", [])) <= 3):
errs.append(f"scene {i + 1}: compare needs 2–3 rows on each side, equal counts")
for row in s.get("left", []) + s.get("right", []):
if len(row) > 32:
errs.append(f"scene {i + 1}: compare row too long: {row!r}")
if t == "chat":
ms = s.get("messages") or []
if not 2 <= len(ms) <= 4 or any(m.get("from") not in ("user", "agent", "system") or len(m.get("text", "")) > 90 for m in ms):
errs.append(f"scene {i + 1}: chat needs 2–4 messages, from user|agent|system, text ≤90")
if t == "terminal":
ls = s.get("lines") or []
if not 2 <= len(ls) <= 5 or any(len(x) > 46 for x in ls):
errs.append(f"scene {i + 1}: terminal needs 2–5 lines ≤44 chars")
for x in ls:
if x.startswith("$ "):
cmd = " ".join(x[2:].split()[:2])
if cmd.lower() not in src_text.lower():
errs.append(f"scene {i + 1}: command {cmd!r} not found in sources — use a real one")
if not 100 <= words <= 165:
errs.append(f"total narration {words} words (need 110–150)")
if len({x.get("type") for x in sc}) < 4:
errs.append("use at least 4 different scene types")
if len(spec.get("x_text", "")) > 270:
errs.append("x_text over 270 chars")
if not 300 <= len(spec.get("post_text", "")) <= 1000:
errs.append(f"post_text length {len(spec.get('post_text', ''))} (need 450–950)")
if "cil.by" not in spec.get("post_text", "") or "cil.by" not in spec.get("x_text", ""):
errs.append("posts must mention cil.by")
# числа — только из источников
blob = json.dumps(spec, ensure_ascii=False)
norm_src = src_text.replace(" ", "").replace(",", "").lower()
for num in set(re.findall(r"\d[\d.,:/]*\d|\d", blob)):
n = num.strip(".,:/").replace(",", "")
if n in ("1", "2", "3", "4", "5", "6", "7", "01", "02", "03", "04", "05", "06", "42", "24", "7"):
continue # номера глав/пунктов, «42» — имя кооператива
if n.lower() not in norm_src:
errs.append(f"number {num!r} not found in sources — remove it or use a sourced one")
for name in PRIVATE_NAMES:
if re.search(rf"\b{name}\b", blob, re.I):
errs.append(f"private person name {name!r} found — refer to roles, never quote real chats")
if spec.get("topic", "").strip().lower() in {p.lower() for p in past}:
errs.append("topic repeats a past topic")
return errs
def write_spec(src, past, date, yesterday_types=None):
src_text = src["readme"] + src["soul"] + src["commits"]
user = (f"TODAY: {date}\n\nPAST TOPICS (do not repeat):\n- " + "\n- ".join(past[-40:] or ["(none)"]) +
f"\n\nYESTERDAY'S SCENE TYPES (vary from this): {yesterday_types or 'none'}"
f"\n\nRECENT COMMITS (last 4 days):\n{src['commits']}\n\nSOUL:\n{src['soul']}\n\nREADME:\n{src['readme']}\n\n{SCHEMA_HELP}")
msgs = [{"role": "system", "content": RULES}, {"role": "user", "content": user}]
for attempt in range(4):
txt = llm(msgs)
try:
spec = parse_json(txt)
errs = validate(spec, src_text, past)
except Exception as e:
spec, errs = None, [f"invalid JSON: {e}"]
if not errs:
return spec
log(f"spec attempt {attempt + 1} rejected:", errs[:8])
msgs += [{"role": "assistant", "content": txt},
{"role": "user", "content": "Fix these problems and return the full corrected JSON only:\n- " + "\n- ".join(errs)}]
raise RuntimeError(f"spec still invalid after retries: {errs[:6]}")
# ───────────────────────── озвучка и сборка ─────────────────────────
def _tts_one(text, out_wav, workdir):
raw = os.path.join(workdir, "raw.wav")
sh(HF + ["tts", text, "-v", VOICE, "-s", SPEED, "-o", raw], timeout=600, env={"HYPERFRAMES_PYTHON": KOKORO_PY})
sh(["ffmpeg", "-nostdin", "-y", "-loglevel", "error", "-i", raw, "-af",
"silenceremove=start_periods=1:start_threshold=-45dB,areverse,silenceremove=start_periods=1:start_threshold=-45dB,areverse",
"-ar", "48000", "-ac", "1", out_wav])
os.remove(raw)
return float(sh(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", out_wav]).strip())
def sentences(text):
parts = [p.strip() for p in re.split(r"(?<=[.!?])\s+", text.strip()) if p.strip()]
return parts or [text.strip()]
def tts(text, out_wav, workdir, gap=0.22):
"""Озвучка по предложениям: точные начала фраз для появления элементов и субтитров."""
sents, files, t = [], [], 0.0
for k, sent in enumerate(sentences(text)):
f = os.path.join(workdir, f"sent{k}.wav")
d = _tts_one(sent, f, workdir)
sents.append({"t0": round(t, 2), "t1": round(t + d, 2), "text": sent})
files.append(f)
t += d + gap
sil = os.path.join(workdir, "gap.wav")
sh(["ffmpeg", "-nostdin", "-y", "-loglevel", "error", "-f", "lavfi", "-i", "anullsrc=r=48000:cl=mono", "-t", str(gap), sil])
lst = os.path.join(workdir, "concat.txt")
with open(lst, "w") as fh:
for k, f in enumerate(files):
fh.write(f"file '{f}'\n" + (f"file '{sil}'\n" if k < len(files) - 1 else ""))
sh(["ffmpeg", "-nostdin", "-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", lst,
"-af", "loudnorm=I=-16:TP=-1.5", "-ar", "48000", out_wav])
for f in files + [sil, lst]:
os.remove(f)
dur = float(sh(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", out_wav]).strip())
return dur, sents
def bgm_bed(total, out_wav, theme):
src = os.path.join(MUSIC, f"{theme}.wav")
if not os.path.exists(src):
src = os.path.join(MUSIC, "gold.wav")
seg = float(sh(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", src]).strip())
n = math.ceil(total / max(8, seg - 4)) + 1
ins = sum((["-i", src] for _ in range(n)), [])
chain, prev = [], "[0]"
for k in range(1, n):
lab = f"[x{k}]"
chain.append(f"{prev}[{k}]acrossfade=d=4:c1=tri:c2=tri{lab}")
prev = lab
chain.append(f"{prev}atrim=0:{total:.2f},afade=t=in:d=0.6,afade=t=out:st={total - 3:.2f}:d=3,loudnorm=I=-16:TP=-1.5")
sh(["ffmpeg", "-nostdin", "-y", "-loglevel", "error", *ins, "-filter_complex", ";".join(chain), "-ar", "48000", out_wav])
def pick_theme(st, date):
"""Наименее давно использованная тема (ротация 5 стилей)."""
last = {name: "" for name in template.THEMES}
for d, info in sorted(st["days"].items()):
if d != date and info.get("theme") in last:
last[info["theme"]] = d
return min(last, key=lambda name: (last[name], list(template.THEMES).index(name)))
def build_project(spec, work, date, theme):
proj = os.path.join(work, "project")
shutil.rmtree(proj, ignore_errors=True)
os.makedirs(os.path.join(proj, "assets", "vo"))
os.makedirs(os.path.join(proj, "assets", "bgm"))
shutil.copytree(FONTS, os.path.join(proj, "fonts"))
for f in ("gsap.min.js", "hyperframes.json", "meta.json", "package.json"):
shutil.copy(os.path.join(ASSETS, f), proj)
timing, t = [], 0.0
for i, s in enumerate(spec["scenes"]):
wav = os.path.join(proj, "assets", "vo", f"v{i + 1}.wav")
dur, sents = tts(s["narration"], wav, work)
lead, tail = 0.45, (2.4 if i == len(spec["scenes"]) - 1 else 0.6)
vo = t + lead
timing.append({"start": round(t, 2), "dur": round(lead + dur + tail, 2), "vo": round(vo, 2), "vo_dur": round(dur, 2),
"sents": [{"t0": round(vo + x["t0"], 2), "t1": round(vo + x["t1"], 2), "text": x["text"]} for x in sents]})
t += lead + dur + tail
log(f"scene {i + 1} {s['type']}: vo {dur:.1f}s, {len(sents)} sentences")
total = timing[-1]["start"] + timing[-1]["dur"]
bgm_bed(total, os.path.join(proj, "assets", "bgm", "bed.wav"), theme)
spec["date_label"] = dt.date.fromisoformat(date).strftime("%d %b %Y").upper()
open(os.path.join(proj, "index.html"), "w").write(template.build(spec, timing, theme, spec.get("series", "DAILY")))
json.dump(timing, open(os.path.join(work, "timing.json"), "w"), indent=1)
return proj, total
def check(proj):
out = subprocess.run(HF + ["check", "--json"], cwd=proj, capture_output=True, text=True, timeout=900,
stdin=subprocess.DEVNULL).stdout
rep = json.loads(out[out.index("{"):])
problems = []
for sect in ("lint", "runtime", "layout", "motion"):
s = rep.get(sect) or {}
if s.get("errorCount"):
problems.append(f"{sect}: {s['errorCount']} errors")
if sect == "layout":
for f in s.get("findings", []):
code = str(f.get("code") or f.get("rule") or f.get("type") or "")
if any(b in code for b in LAYOUT_BAD) and "bg" not in json.dumps(f)[:300]:
problems.append(f"layout {code}: {str(f.get('message') or f)[:160]}")
return problems
def render(proj, out_mp4):
sh(HF + ["render", "-o", out_mp4], timeout=2400, cwd=proj)
info = sh(["ffprobe", "-v", "error", "-show_entries", "stream=codec_type", "-of", "csv=p=0", out_mp4])
if "video" not in info or "audio" not in info:
raise RuntimeError(f"render missing streams: {info!r}")
# ───────────────────────── публикация ─────────────────────────
def buffer_post(service, text, url, spec):
env = dict(l.split("=", 1) for l in open(os.path.join(SOCIAL, ".env")).read().splitlines() if "=" in l)
key, org = env["BUFFER_ACCESS_TOKEN"].strip(), env.get("BUFFER_ORG_ID", "6151d52eea195d0f412ff493").strip()
def gql(q, v=None):
req = urllib.request.Request("https://api.buffer.com", data=json.dumps({"query": q, "variables": v or {}}).encode(),
headers={"Authorization": "Bearer " + key, "Content-Type": "application/json"})
return json.loads(urllib.request.urlopen(req, timeout=90).read())
chans = gql('query($o: OrganizationId!){ channels(input:{organizationId:$o}){ id service isDisconnected isLocked } }',
{"o": org})["data"]["channels"]
ch = next((c for c in chans if c["service"] == service and not c["isDisconnected"] and not c["isLocked"]), None)
if not ch:
return "skip: channel not connected"
meta = {"facebook": {"facebook": {"type": "post"}},
"instagram": {"instagram": {"type": "reel", "shouldShareToFeed": True}},
"youtube": {"youtube": {"title": spec.get("youtube_title") or spec["title"], "privacy": "public",
"categoryId": "28", "madeForKids": False, "isAiGenerated": True,
"notifySubscribers": True}}}[service]
video = {"url": url, "metadata": {"title": spec["title"][:100]}}
if service == "instagram":
video = {"url": url, "metadata": {"thumbnailOffset": int(spec.get("_thumb_ms", 2500))}}
inp = {"channelId": ch["id"], "text": text, "assets": [{"video": video}], "mode": "shareNow",
"schedulingType": "automatic", "needsApproval": False, "metadata": meta, "source": "simbiot-daily"}
r = gql("mutation($input: CreatePostInput!){ createPost(input:$input){ __typename ... on PostActionSuccess { post { id } } ... on MutationError { message } } }",
{"input": inp})["data"]["createPost"]
if r["__typename"] != "PostActionSuccess":
raise RuntimeError(r.get("message"))
pid = r["post"]["id"]
for _ in range(60):
p = gql("query($id: PostId!){ post(input:{id:$id}){ status error { message } externalLink } }", {"id": pid})["data"]["post"]
if p["status"] == "sent":
return p.get("externalLink") or f"sent {pid}"
if p["status"] in ("error", "failed") or p.get("error"):
raise RuntimeError(f"buffer {service}: {p}")
time.sleep(10)
return f"pending {pid}"
def publish(spec, mp4, date, platforms, results):
os.makedirs(PUBLIC_DIR, exist_ok=True)
pub = os.path.join(PUBLIC_DIR, f"{date}.mp4")
shutil.copy(mp4, pub)
url = f"{PUBLIC_URL}/{date}.mp4"
for p in platforms:
if str(results.get(p, "")).startswith(("ok", "http", "sent")):
continue # уже опубликовано этим днём — не дублируем
try:
if p in ("x", "telegram", "linkedin"):
text = spec["x_text"] if p == "x" else spec["post_text"]
out = sh(["python3", os.path.join(SOCIAL, "publish.py"), "--text", text, "--media", mp4, "--platforms", p],
timeout=1500, env={"LINKEDIN_VIDEO_TITLE": spec["title"][:100]})
res = json.loads(out[out.index("{"):]).get(p, out[-200:])
if str(res).startswith("ERROR"):
raise RuntimeError(res)
results[p] = res
else:
results[p] = buffer_post(BUFFER_CHANNELS[p], spec["post_text"], url, spec)
except Exception as e:
results[p] = f"ERROR: {str(e)[:300]}"
log("publish", p, "→", results[p])
return results
def notify(text, key):
try:
sh([os.path.join(REPO, "venv", "bin", "python"), os.path.join(REPO, "cli.py"), "tg-notify", text, "-k", key], timeout=60)
except Exception as e:
log("notify failed:", e)
def links(results):
tg_ch = "shkorvision"
out = []
for p, r in results.items():
r = str(r)
if p == "x" and r.startswith("ok id="):
r = f"https://x.com/alexshkor/status/{r[6:]}"
elif p == "telegram" and r.startswith("ok id="):
r = f"https://t.me/{tg_ch}/{r[6:]}"
elif p == "linkedin" and "urn:li:" in r:
r = "https://www.linkedin.com/feed/update/" + r[r.index("urn:li:"):]
out.append(f"• {p}: {r}")
return "\n".join(out)
# ───────────────────────── main ─────────────────────────
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--date", default=dt.date.today().isoformat())
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--spec")
ap.add_argument("--publish-at", help="HH:MM UTC — ждать до этого времени перед публикацией")
ap.add_argument("--platforms", default=",".join(ALL_PLATFORMS))
ap.add_argument("--theme", choices=list(template.THEMES))
a = ap.parse_args()
os.makedirs(BASE, exist_ok=True)
lock = open(os.path.join(BASE, ".lock"), "w")
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
log("another run in progress — exit")
return 0
st = load_state()
day = st["days"].setdefault(a.date, {})
work = os.path.join(BASE, a.date)
os.makedirs(work, exist_ok=True)
mp4 = os.path.join(work, "video.mp4")
try:
if day.get("rendered") and os.path.exists(mp4) and os.path.exists(os.path.join(work, "spec.json")):
spec = json.load(open(os.path.join(work, "spec.json")))
log("reuse rendered video for", a.date)
else:
past = [d["topic"] for k, d in sorted(st["days"].items()) if d.get("topic") and k != a.date]
prev = [d for k, d in sorted(st["days"].items()) if k < a.date and d.get("types")]
theme = a.theme or pick_theme(st, a.date)
spec = json.load(open(a.spec)) if a.spec else write_spec(sources(), past, a.date, prev[-1]["types"] if prev else None)
json.dump(spec, open(os.path.join(work, "spec.json"), "w"), ensure_ascii=False, indent=2)
day["topic"] = spec.get("topic", "")
log("topic:", day["topic"])
day.update(theme=theme, types=[x["type"] for x in spec["scenes"]])
log("theme:", theme)
proj, total = build_project(spec, work, a.date, theme)
probs = check(proj)
if probs:
raise RuntimeError("hyperframes check failed: " + "; ".join(probs[:6]))
render(proj, mp4)
day.update(rendered=True, duration=round(total, 1))
save_state(st)
if a.dry_run:
log("dry run — video:", mp4)
return 0
if a.publish_at:
hh, mm = map(int, a.publish_at.split(":"))
now = dt.datetime.now(dt.timezone.utc)
target = now.replace(hour=hh, minute=mm, second=0, microsecond=0)
if target > now:
log("waiting until", target, "UTC")
time.sleep((target - now).total_seconds())
res = publish(spec, mp4, a.date, [p for p in a.platforms.split(",") if p], day.setdefault("results", {}))
save_state(st)
failed = [p for p, r in res.items() if str(r).startswith("ERROR")]
notify(f"🎬 Ежедневный ролик {a.date}: «{spec.get('topic')}»\n{links(res)}"
+ (f"\n⚠️ не вышло: {', '.join(failed)}" if failed else ""), f"daily-video-{a.date}")
return 1 if failed else 0
except Exception as e:
day["error"] = str(e)[:500]
save_state(st)
log("FAILED:", traceback.format_exc())
if not a.dry_run:
notify(f"⚠️ Ежедневный ролик {a.date} не опубликован: {str(e)[:400]}", f"daily-video-fail-{a.date}")
return 2
if __name__ == "__main__":
sys.exit(main())