@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 +126 -1
- package/hermes_ui/facebook_automation.py +220 -0
- package/package.json +1 -1
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":
|
|
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