@danhachuel/thunderbolt 0.7.30 → 0.7.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app/main.py CHANGED
@@ -139,6 +139,7 @@ from hermes_ui.growth_tiktok import render_growth_tiktok
139
139
  from hermes_ui.growth_instagram import render_growth_instagram
140
140
  from hermes_ui.growth_facebook_pages import render_growth_facebook_pages
141
141
  from hermes_ui.growth_bilibili import render_growth_bilibili
142
+ from hermes_ui.facebook_automation import caption_images, collect_images, create_post, generate_article, generate_theme, list_posts, publish_to_facebook, save_post
142
143
  from hermes_ui.canva_auth import authorization_url, create_pkce_pair, create_state, exchange_code
143
144
  from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
144
145
  from integrations.tiktok_public import fetch_public_tiktok_profile, normalize_tiktok_reference
@@ -6283,6 +6284,130 @@ def _render_youtube_automation_cards():
6283
6284
  st.rerun()
6284
6285
 
6285
6286
 
6287
+ def _facebook_pages_for_automation() -> list[dict[str, Any]]:
6288
+ return [channel for channel in read_json("channels.json", []) if isinstance(channel, dict) and classify_channel_platform(channel) == "facebook"]
6289
+
6290
+
6291
+ @st.fragment(run_every=5.0)
6292
+ def _render_facebook_automation_cards() -> None:
6293
+ st.divider()
6294
+ st.subheader("Posts Facebook em produção")
6295
+ posts = list_posts()
6296
+ if not posts:
6297
+ st.info("Ainda não existem posts Facebook em produção.")
6298
+ return
6299
+ for post in posts:
6300
+ post_id = str(post.get("id") or "")
6301
+ with st.container(border=True):
6302
+ cols = st.columns([2.5, 1.35, 1.15, 2.0], gap="small")
6303
+ with cols[0]:
6304
+ st.write(f"**{post.get('title') or post.get('theme') or 'Post sem tema'}**")
6305
+ st.caption(f"{post.get('page_name') or 'Facebook Page'} · {post_id}")
6306
+ st.caption(f"Etapa: {post.get('status', 'tema_pendente')}")
6307
+ if post.get("article_text"):
6308
+ with st.expander("Ver artigo", expanded=False):
6309
+ st.text_area("Artigo", value=str(post["article_text"]), height=220, key=f"facebook_article_{post_id}", disabled=True)
6310
+ image_records = post.get("images") if isinstance(post.get("images"), list) else []
6311
+ if image_records:
6312
+ image_cols = st.columns(min(5, len(image_records)))
6313
+ for image_col, image in zip(image_cols, image_records):
6314
+ image_path = Path(str(image.get("captioned_path") or image.get("path") or ""))
6315
+ with image_col:
6316
+ if image_path.is_file():
6317
+ st.image(str(image_path), use_container_width=True)
6318
+ st.caption(str(image.get("status") or "pendente"))
6319
+ with cols[1]:
6320
+ st.caption("Estado")
6321
+ st.write(str(post.get("status") or "tema_pendente").replace("_", " ").capitalize())
6322
+ st.progress({"tema_pendente": 0, "artigo_pendente": 20, "imagens_pendentes": 45, "legendas_pendentes": 70, "pronto_upload": 90, "publicado": 100}.get(str(post.get("status")), 0), text=f"{len(post.get('images') or [])}/5 imagens")
6323
+ with cols[2]:
6324
+ st.caption("Imagens")
6325
+ st.write(f"{len(post.get('images') or [])} previstas")
6326
+ st.caption("Google Imagens ou IA")
6327
+ with cols[3]:
6328
+ settings = read_json("settings.json", {})
6329
+ pages = _facebook_pages_for_automation()
6330
+ page = next((item for item in pages if str(item.get("id")) == str(post.get("page_id"))), {})
6331
+ if st.button("Gerar tema", key=f"facebook_generate_theme_{post_id}", disabled=str(post.get("status")) not in {"tema_pendente", "erro"}, width="stretch"):
6332
+ try:
6333
+ generate_theme(settings, post, page)
6334
+ st.success("Tema criado pelo LLM.")
6335
+ st.rerun()
6336
+ except Exception as exc:
6337
+ st.error(str(exc))
6338
+ if st.button("Criar artigo", key=f"facebook_generate_article_{post_id}", disabled=str(post.get("status")) != "artigo_pendente", width="stretch"):
6339
+ try:
6340
+ generate_article(settings, post, page)
6341
+ st.success("Artigo e prompts de imagens criados pelo LLM.")
6342
+ st.rerun()
6343
+ except Exception as exc:
6344
+ st.error(str(exc))
6345
+ source = st.selectbox("Fonte das imagens", ["Google Imagens", "Gerar com IA"], key=f"facebook_image_source_{post_id}")
6346
+ if st.button("Buscar/Gerar imagens", key=f"facebook_collect_images_{post_id}", disabled=str(post.get("status")) != "imagens_pendentes", width="stretch"):
6347
+ try:
6348
+ collect_images(settings, post, source="google" if source == "Google Imagens" else "ai")
6349
+ st.success("Imagens processadas.")
6350
+ st.rerun()
6351
+ except Exception as exc:
6352
+ st.error(str(exc))
6353
+ if st.button("Legendar imagens", key=f"facebook_caption_images_{post_id}", disabled=str(post.get("status")) != "legendas_pendentes", width="stretch"):
6354
+ try:
6355
+ caption_images(post)
6356
+ st.success("Textos aplicados nas imagens com Pillow.")
6357
+ st.rerun()
6358
+ except Exception as exc:
6359
+ st.error(str(exc))
6360
+ if st.button("Upload para Facebook", key=f"facebook_publish_{post_id}", type="primary", disabled=str(post.get("status")) != "pronto_upload", width="stretch"):
6361
+ try:
6362
+ publish_to_facebook(post, page)
6363
+ st.success("Post publicado na Facebook Page.")
6364
+ st.rerun()
6365
+ except Exception as exc:
6366
+ st.error(str(exc))
6367
+
6368
+
6369
+ def render_facebook_automation() -> None:
6370
+ st.title("Automação Facebook")
6371
+ st.caption("Fluxo local de posts: Ideia/Tema → LLM → Artigo → Google Imagens ou IA → textos e legendas → Upload pela Meta Graph API.")
6372
+ st.info("Esta página não usa planilhas nem Google Drive. Os artigos, imagens, legendas e estados ficam no armazenamento local do Thunderbolt.")
6373
+ pages = _facebook_pages_for_automation()
6374
+ settings = read_json("settings.json", {})
6375
+ if not pages:
6376
+ st.warning("Cadastre primeiro uma página em Canais e Perfis de Vídeos > Facebook Pages.")
6377
+ return
6378
+ with st.expander("Configurar API Facebook da página", expanded=False):
6379
+ for page in pages:
6380
+ page_id = str(page.get("id") or "")
6381
+ with st.form(f"facebook_page_api_{page_id}"):
6382
+ st.write(f"**{page.get('name') or 'Facebook Page'}**")
6383
+ meta_page_id = st.text_input("Page ID da Meta", value=str(page.get("page_id") or page.get("facebook_page_id") or ""), key=f"facebook_meta_page_id_{page_id}")
6384
+ access_token = st.text_input("Access Token da Page", value=str(page.get("access_token") or page.get("facebook_access_token") or ""), type="password", key=f"facebook_meta_token_{page_id}")
6385
+ if st.form_submit_button("Guardar credenciais Facebook", type="primary"):
6386
+ update_channel(page_id, {"page_id": meta_page_id.strip(), "facebook_page_id": meta_page_id.strip(), "access_token": access_token.strip(), "facebook_access_token": access_token.strip()})
6387
+ st.success("Credenciais da página guardadas localmente.")
6388
+ st.rerun()
6389
+ with st.form("facebook_automation_new_post"):
6390
+ page_options = [str(page.get("id")) for page in pages]
6391
+ page_labels = {str(page.get("id")): str(page.get("name") or "Facebook Page") for page in pages}
6392
+ selected_page = st.selectbox("Facebook Page", page_options, format_func=lambda value: page_labels.get(value, value))
6393
+ form_cols = st.columns([2.2, 1, 1.3])
6394
+ with form_cols[0]:
6395
+ theme = st.text_input("Tema/ideia opcional", placeholder="Deixe vazio para o LLM sugerir um tema")
6396
+ with form_cols[1]:
6397
+ image_count = st.number_input("Imagens", min_value=1, max_value=5, value=3, step=1)
6398
+ with form_cols[2]:
6399
+ start_stage = st.selectbox("Iniciar por", ["Ideia / Tema", "Tema já definido"])
6400
+ create_clicked = st.form_submit_button("Criar post na fila", type="primary", width="stretch")
6401
+ if create_clicked:
6402
+ page = next(page for page in pages if str(page.get("id")) == selected_page)
6403
+ post = create_post(page, image_count=int(image_count), theme=theme)
6404
+ if start_stage == "Tema já definido" and theme.strip():
6405
+ save_post({**post, "status": "artigo_pendente"})
6406
+ st.success("Post criado na fila local. Execute cada etapa pelos botões do card.")
6407
+ st.rerun()
6408
+ _render_facebook_automation_cards()
6409
+
6410
+
6286
6411
  def render_automation():
6287
6412
  st.title("Automação Youtube")
6288
6413
  st.caption("Agendamento diário da geração por canal. O worker verifica o relógio local do computador e coloca os lotes agendados na fila.")
@@ -9847,7 +9972,7 @@ def main():
9847
9972
  "Facebook Pages": render_facebook_pages,
9848
9973
  "Automação Youtube": render_automation,
9849
9974
  "Automação Tiktok": render_tiktok_automation,
9850
- "Automação Facebook": lambda: None,
9975
+ "Automação Facebook": render_facebook_automation,
9851
9976
  "Automação Musicas": lambda: None,
9852
9977
  "Automação UGC": lambda: None,
9853
9978
  "Automação Influencer Content": lambda: None,
@@ -0,0 +1,220 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import textwrap
6
+ import uuid
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any, Mapping
10
+
11
+ import requests
12
+ from PIL import Image, ImageDraw, ImageFont
13
+
14
+ from .creative_generation import CreativeGenerationError, _chat_json
15
+ from .media_generation import generate_image_for_card
16
+ from .storage import STORAGE, read_json, write_json
17
+
18
+ POSTS_FILE = "facebook_automation_posts.json"
19
+ POSTS_DIR = STORAGE / "facebook_automation"
20
+
21
+
22
+ def _now() -> str:
23
+ return datetime.now(timezone.utc).isoformat()
24
+
25
+
26
+ def _posts() -> list[dict[str, Any]]:
27
+ value = read_json(POSTS_FILE, [])
28
+ return [dict(item) for item in value if isinstance(item, Mapping)] if isinstance(value, list) else []
29
+
30
+
31
+ def list_posts() -> list[dict[str, Any]]:
32
+ return sorted(_posts(), key=lambda item: str(item.get("created_at") or ""), reverse=True)
33
+
34
+
35
+ def save_post(post: Mapping[str, Any]) -> dict[str, Any]:
36
+ value = dict(post)
37
+ value.setdefault("id", f"fbpost_{uuid.uuid4().hex[:12]}")
38
+ value.setdefault("created_at", _now())
39
+ entries = _posts()
40
+ for index, existing in enumerate(entries):
41
+ if str(existing.get("id")) == str(value["id"]):
42
+ entries[index] = value
43
+ break
44
+ else:
45
+ entries.append(value)
46
+ write_json(POSTS_FILE, entries)
47
+ return value
48
+
49
+
50
+ def create_post(page: Mapping[str, Any], *, image_count: int = 3, theme: str = "") -> dict[str, Any]:
51
+ post_id = f"fbpost_{uuid.uuid4().hex[:12]}"
52
+ folder = POSTS_DIR / post_id / "images"
53
+ folder.mkdir(parents=True, exist_ok=True)
54
+ return save_post({
55
+ "id": post_id,
56
+ "page_id": str(page.get("id") or ""),
57
+ "page_name": str(page.get("name") or "Facebook Page"),
58
+ "page_url": str(page.get("url") or ""),
59
+ "theme": theme.strip(),
60
+ "tone": "Motivacional/Superação",
61
+ "image_count": max(1, min(5, int(image_count))),
62
+ "status": "tema_pendente",
63
+ "created_at": _now(),
64
+ "folder": str(folder),
65
+ "images": [],
66
+ })
67
+
68
+
69
+ def generate_theme(settings: dict[str, Any], post: Mapping[str, Any], page: Mapping[str, Any], context: str = "") -> dict[str, Any]:
70
+ system = (
71
+ "És um curador de temas para posts virais de storytelling em português para uma Facebook Page. "
72
+ "Escolhe um tema específico, novo e visualmente forte, com contexto, obstáculo e reviravolta. "
73
+ "Não inventes factos sobre pessoas reais; quando não houver pessoa indicada, cria uma história genérica plausível. "
74
+ "Responde apenas JSON válido com as chaves tema e tom."
75
+ )
76
+ result = _chat_json(settings, system, json.dumps({
77
+ "page": {"name": page.get("name"), "description": page.get("description"), "niche": page.get("niche"), "country": page.get("country")},
78
+ "context": context or post.get("theme") or "",
79
+ "tone_options": ["Motivacional/Superação", "Curiosidade Histórica", "Biografia de Empresário"],
80
+ }, ensure_ascii=False))
81
+ theme = str(result.get("tema") or result.get("theme") or "").strip()
82
+ tone = str(result.get("tom") or result.get("tone") or "Motivacional/Superação").strip()
83
+ if not theme:
84
+ raise CreativeGenerationError("O LLM não devolveu um tema válido para a página Facebook.")
85
+ return save_post({**post, "theme": theme, "tone": tone, "status": "artigo_pendente"})
86
+
87
+
88
+ def generate_article(settings: dict[str, Any], post: Mapping[str, Any], page: Mapping[str, Any]) -> dict[str, Any]:
89
+ image_count = max(1, min(5, int(post.get("image_count") or 3)))
90
+ system = (
91
+ "És um roteirista de posts virais de storytelling para Facebook, em português. "
92
+ "Gera um artigo completo com aproximadamente 650 a 900 palavras, semelhante ao formato de storytelling do exemplo: "
93
+ "frases curtas, uma ideia por linha, quebras frequentes, contexto de ano/lugar, factos e números concretos, "
94
+ "obstáculos, reviravoltas e uma frase final de efeito. Não inventes factos sobre pessoas reais. "
95
+ f"Depois do artigo, gera exatamente {image_count} cards em ordem cronológica. Cada card deve conter "
96
+ "index, search_query e overlay_text com no máximo 25 palavras. Responde apenas JSON válido com title, article_text e images."
97
+ )
98
+ result = _chat_json(settings, system, json.dumps({
99
+ "page": {"name": page.get("name"), "description": page.get("description"), "niche": page.get("niche"), "country": page.get("country")},
100
+ "theme": post.get("theme"), "tone": post.get("tone"), "image_count": image_count,
101
+ "requirements": {"approximate_words": "650-900", "language": "pt-BR", "format": "one idea per line"},
102
+ }, ensure_ascii=False))
103
+ article = str(result.get("article_text") or result.get("article") or "").strip()
104
+ title = str(result.get("title") or post.get("theme") or "Post Facebook").strip()
105
+ images = result.get("images") if isinstance(result.get("images"), list) else []
106
+ normalized_images = []
107
+ for index, item in enumerate(images[:image_count], start=1):
108
+ if isinstance(item, Mapping):
109
+ normalized_images.append({"index": index, "search_query": str(item.get("search_query") or "").strip(), "overlay_text": str(item.get("overlay_text") or "").strip()})
110
+ if not article or not normalized_images:
111
+ raise CreativeGenerationError("O LLM devolveu um artigo ou cards de imagem incompletos.")
112
+ return save_post({**post, "title": title, "article_text": article, "images": normalized_images, "status": "imagens_pendentes"})
113
+
114
+
115
+ def _download_image(url: str, destination: Path) -> Path:
116
+ response = requests.get(url, timeout=45, headers={"User-Agent": "Thunderbolt Facebook Automation/1.0"})
117
+ response.raise_for_status()
118
+ destination.parent.mkdir(parents=True, exist_ok=True)
119
+ destination.write_bytes(response.content)
120
+ return destination
121
+
122
+
123
+ def collect_images(settings: dict[str, Any], post: Mapping[str, Any], *, source: str = "google") -> dict[str, Any]:
124
+ folder = Path(str(post.get("folder") or POSTS_DIR / str(post.get("id"))) / "images")
125
+ images = []
126
+ custom_key = str(settings.get("google_custom_search_api_key") or settings.get("google_images_api_key") or "").strip()
127
+ custom_cx = str(settings.get("google_custom_search_cx") or settings.get("google_images_cx") or "").strip()
128
+ for index, item in enumerate(post.get("images") or [], start=1):
129
+ query = str(item.get("search_query") or post.get("theme") or "").strip()
130
+ image_record = dict(item)
131
+ try:
132
+ destination = folder / f"image-{index}.jpg"
133
+ if source == "google" and custom_key and custom_cx:
134
+ payload = requests.get("https://www.googleapis.com/customsearch/v1", params={"key": custom_key, "cx": custom_cx, "q": query, "searchType": "image", "num": 1, "safe": "active"}, timeout=30).json()
135
+ link = str(((payload.get("items") or [{}])[0]).get("link") or "")
136
+ if not link:
137
+ raise ValueError("A pesquisa Google não devolveu uma imagem.")
138
+ _download_image(link, destination)
139
+ image_record["source"] = "google_images"
140
+ else:
141
+ cards = settings.get("media_provider_cards") if isinstance(settings.get("media_provider_cards"), list) else []
142
+ card = next((c for c in cards if isinstance(c, Mapping) and bool(c.get("enabled", True)) and str(c.get("kind") or c.get("type") or "image").lower() in {"image", "images", ""}), None)
143
+ if not card:
144
+ raise ValueError("Configure um provider de imagens IA em Configuração API.")
145
+ generated = generate_image_for_card(settings, card, query, topic=str(post.get("title") or post.get("theme") or "Facebook"), variant_index=index - 1)
146
+ destination = Path(generated)
147
+ image_record["source"] = "ai"
148
+ image_record["path"] = str(destination)
149
+ image_record["status"] = "imagem_baixada"
150
+ except Exception as exc:
151
+ image_record["status"] = "erro"
152
+ image_record["error"] = str(exc)[:300]
153
+ images.append(image_record)
154
+ return save_post({**post, "images": images, "status": "legendas_pendentes" if any(item.get("path") for item in images) else "imagens_pendentes"})
155
+
156
+
157
+ def _font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
158
+ candidates = ["/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf"]
159
+ for candidate in candidates:
160
+ if Path(candidate).is_file():
161
+ return ImageFont.truetype(candidate, size=size)
162
+ return ImageFont.load_default()
163
+
164
+
165
+ def caption_images(post: Mapping[str, Any]) -> dict[str, Any]:
166
+ updated = []
167
+ for index, item in enumerate(post.get("images") or [], start=1):
168
+ record = dict(item)
169
+ raw_path = str(record.get("path") or "")
170
+ if raw_path and Path(raw_path).is_file():
171
+ source = Path(raw_path)
172
+ output = source.with_name(f"captioned-{source.stem}.jpg")
173
+ with Image.open(source).convert("RGB") as image:
174
+ image.thumbnail((1600, 1600))
175
+ canvas = Image.new("RGB", image.size, "black")
176
+ canvas.paste(image, ((canvas.width - image.width) // 2, (canvas.height - image.height) // 2))
177
+ draw = ImageDraw.Draw(canvas)
178
+ text = str(record.get("overlay_text") or "").strip()
179
+ font = _font(max(28, min(64, canvas.width // 22)))
180
+ lines = textwrap.wrap(text, width=max(18, canvas.width // 32))
181
+ line_height = int(font.size * 1.2) if hasattr(font, "size") else 40
182
+ box_height = line_height * len(lines) + 48
183
+ y = canvas.height - box_height - 24
184
+ draw.rounded_rectangle((24, y, canvas.width - 24, canvas.height - 24), radius=18, fill=(0, 0, 0, 190))
185
+ for line in lines:
186
+ bbox = draw.textbbox((0, 0), line, font=font)
187
+ x = (canvas.width - (bbox[2] - bbox[0])) // 2
188
+ draw.text((x + 2, y + 16), line, font=font, fill="black")
189
+ draw.text((x, y + 14), line, font=font, fill="white")
190
+ y += line_height
191
+ canvas.save(output, "JPEG", quality=92)
192
+ record["captioned_path"] = str(output)
193
+ record["status"] = "legendada"
194
+ updated.append(record)
195
+ return save_post({**post, "images": updated, "status": "pronto_upload" if any(item.get("captioned_path") for item in updated) else "legendas_pendentes"})
196
+
197
+
198
+ def publish_to_facebook(post: Mapping[str, Any], page: Mapping[str, Any]) -> dict[str, Any]:
199
+ page_id = str(page.get("page_id") or page.get("facebook_page_id") or "").strip()
200
+ token = str(page.get("access_token") or page.get("facebook_access_token") or "").strip()
201
+ if not page_id or not token:
202
+ raise ValueError("Configure o Page ID e o Access Token da Meta nesta página Facebook antes de publicar.")
203
+ message = f"{post.get('title') or post.get('theme') or ''}\n\n{post.get('article_text') or ''}".strip()
204
+ published = []
205
+ for item in post.get("images") or []:
206
+ path = Path(str(item.get("captioned_path") or item.get("path") or ""))
207
+ if not path.is_file():
208
+ continue
209
+ with path.open("rb") as handle:
210
+ response = requests.post(f"https://graph.facebook.com/v20.0/{page_id}/photos", data={"caption": message, "published": "false", "access_token": token}, files={"source": (path.name, handle, "image/jpeg")}, timeout=90)
211
+ payload = response.json() if response.content else {}
212
+ if response.status_code >= 400 or payload.get("error"):
213
+ raise ValueError(str(payload.get("error", {}).get("message") or f"Meta Graph API HTTP {response.status_code}"))
214
+ published.append(payload)
215
+ if not published:
216
+ raise ValueError("Não existe nenhuma imagem legendada para publicar.")
217
+ return save_post({**post, "status": "publicado", "published_at": _now(), "facebook_media_ids": published})
218
+
219
+
220
+ __all__ = ["POSTS_DIR", "caption_images", "collect_images", "create_post", "generate_article", "generate_theme", "list_posts", "publish_to_facebook", "save_post"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.7.30",
3
+ "version": "0.7.31",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",