@danhachuel/thunderbolt 0.7.29 → 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 +253 -10
- package/hermes_ui/facebook_automation.py +220 -0
- package/package.json +1 -1
package/app/main.py
CHANGED
|
@@ -111,6 +111,7 @@ from hermes_ui.notifications import clear_notifications, list_notifications, mar
|
|
|
111
111
|
from hermes_ui.influencers import BACKEND_OPTIONS, DOCUMENT_EXTENSIONS, IMAGE_EXTENSIONS, backend_name, backend_status, get_repository, test_backend
|
|
112
112
|
from hermes_ui.logs import list_logs, logs_to_rows
|
|
113
113
|
from hermes_ui.languages import LANGUAGE_CODES, VIDEO_LANGUAGE_CODES, LANGUAGE_FLAG_DATA_URIS, language_code, language_label, ui_language_menu_label, ui_text, video_language_label, video_language_options
|
|
114
|
+
from hermes_ui.countries import COUNTRY_OPTIONS
|
|
114
115
|
from hermes_ui.api_key_tests import test_apify_credentials, test_influencer_database, test_innertube_api_key, test_kaggle_credentials, test_material_source_credentials, test_media_provider_card, test_nano_banana_credentials, test_postiz_credentials, test_telegram_credentials, test_tiktok_credentials, test_upload_post_credentials, test_voice_provider
|
|
115
116
|
from hermes_ui.tutorials import tutorial_body, tutorial_caption, tutorial_title
|
|
116
117
|
from hermes_ui.update_manager import check_version, restart_current_process, update_to_latest
|
|
@@ -138,6 +139,7 @@ from hermes_ui.growth_tiktok import render_growth_tiktok
|
|
|
138
139
|
from hermes_ui.growth_instagram import render_growth_instagram
|
|
139
140
|
from hermes_ui.growth_facebook_pages import render_growth_facebook_pages
|
|
140
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
|
|
141
143
|
from hermes_ui.canva_auth import authorization_url, create_pkce_pair, create_state, exchange_code
|
|
142
144
|
from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
|
|
143
145
|
from integrations.tiktok_public import fetch_public_tiktok_profile, normalize_tiktok_reference
|
|
@@ -1558,6 +1560,9 @@ def render_channel_edit_form(channel: dict, youtube_account_ids: list[str], yout
|
|
|
1558
1560
|
edited_name = st.text_input("Nome do canal", value=str(channel.get("name") or ""))
|
|
1559
1561
|
edited_url = st.text_input("URL", value=str(channel.get("url") or ""))
|
|
1560
1562
|
edited_handle = st.text_input("Handle", value=str(channel.get("handle") or ""))
|
|
1563
|
+
country_options = [""] + list(COUNTRY_OPTIONS)
|
|
1564
|
+
current_country = str(channel.get("country") or "")
|
|
1565
|
+
edited_country = st.selectbox("País", country_options, index=country_options.index(current_country) if current_country in country_options else 0)
|
|
1561
1566
|
language_options = VIDEO_LANGUAGE_SELECTION_OPTIONS
|
|
1562
1567
|
edited_language = st.selectbox("Idioma do roteiro", language_options, index=language_options.index(normalize_video_language(channel.get("language") or "pt")) if normalize_video_language(channel.get("language") or "pt") in language_options else 0, format_func=video_language_label)
|
|
1563
1568
|
edited_style = st.selectbox("Fonte do vídeo", WIDE_STYLE_OPTIONS, index=WIDE_STYLE_OPTIONS.index(channel_video_source_value(channel.get("style_wide"))) if channel_video_source_value(channel.get("style_wide")) in WIDE_STYLE_OPTIONS else 0)
|
|
@@ -1588,7 +1593,7 @@ def render_channel_edit_form(channel: dict, youtube_account_ids: list[str], yout
|
|
|
1588
1593
|
update_channel(channel_id, {
|
|
1589
1594
|
"name": edited_name.strip(), "url": edited_url.strip(), "handle": edited_handle.strip(), "language": edited_language,
|
|
1590
1595
|
"style_wide": channel_video_source_storage(edited_style), "video_aspect_ratio": edited_aspect,
|
|
1591
|
-
"niche": edited_niche.strip(), "reference_channels": [item.strip() for item in re.split(r"[,|]", edited_niche) if item.strip()],
|
|
1596
|
+
"niche": edited_niche.strip(), "reference_channels": [item.strip() for item in re.split(r"[,|]", edited_niche) if item.strip()], "country": edited_country.strip(),
|
|
1592
1597
|
"default_blueprint_id": edited_blueprint.strip(), "blueprint_id": edited_blueprint.strip(),
|
|
1593
1598
|
"default_voice": edited_voice.strip(), "voice": edited_voice.strip(),
|
|
1594
1599
|
"google_account_id": edited_account.strip(), "google_account_email": str(youtube_accounts_by_id.get(edited_account, {}).get("email", "")),
|
|
@@ -2381,6 +2386,8 @@ def classify_channel_platform(channel: Any) -> str:
|
|
|
2381
2386
|
return "tiktok"
|
|
2382
2387
|
if value in {"instagram", "ig"}:
|
|
2383
2388
|
return "instagram"
|
|
2389
|
+
if value in {"facebook", "facebook_page", "facebook_pages", "fb"}:
|
|
2390
|
+
return "facebook"
|
|
2384
2391
|
def has_tiktok_marker(item: Any) -> bool:
|
|
2385
2392
|
if isinstance(item, dict):
|
|
2386
2393
|
return any(has_tiktok_marker(key) or has_tiktok_marker(value) for key, value in item.items())
|
|
@@ -2778,6 +2785,103 @@ def _tiktok_channel_url(channel: dict[str, Any]) -> str:
|
|
|
2778
2785
|
return source if source.startswith(("http://", "https://")) else ""
|
|
2779
2786
|
|
|
2780
2787
|
|
|
2788
|
+
def render_facebook_pages():
|
|
2789
|
+
st.title("Facebook Pages")
|
|
2790
|
+
st.caption("Cadastre páginas públicas do Facebook por URL. Esta área aceita apenas páginas Facebook e não perfis pessoais.")
|
|
2791
|
+
with st.form("facebook_page_form"):
|
|
2792
|
+
form_cols = st.columns(2)
|
|
2793
|
+
with form_cols[0]:
|
|
2794
|
+
name = st.text_input("Nome da página", key="facebook_page_name")
|
|
2795
|
+
url = st.text_input("URL pública da página", placeholder="https://www.facebook.com/suapagina", key="facebook_page_url")
|
|
2796
|
+
country_options = [""] + list(COUNTRY_OPTIONS)
|
|
2797
|
+
country = st.selectbox("País", country_options, key="facebook_page_country")
|
|
2798
|
+
with form_cols[1]:
|
|
2799
|
+
description = st.text_area("Descrição", key="facebook_page_description")
|
|
2800
|
+
niche = st.text_input("Nicho", key="facebook_page_niche")
|
|
2801
|
+
active = st.toggle("Activo", value=True, key="facebook_page_active")
|
|
2802
|
+
submitted = st.form_submit_button("Guardar página Facebook", type="primary", width="stretch")
|
|
2803
|
+
if submitted:
|
|
2804
|
+
normalized_url = url.strip()
|
|
2805
|
+
if not name.strip():
|
|
2806
|
+
st.error("Informe o nome da página.")
|
|
2807
|
+
elif not normalized_url.startswith(("https://www.facebook.com/", "https://facebook.com/", "http://www.facebook.com/", "http://facebook.com/")):
|
|
2808
|
+
st.error("Informe uma URL pública válida de uma página Facebook.")
|
|
2809
|
+
else:
|
|
2810
|
+
existing = [item for item in read_json("channels.json", []) if isinstance(item, dict)]
|
|
2811
|
+
duplicate = next((item for item in existing if classify_channel_platform(item) == "facebook" and str(item.get("url") or "").strip().casefold() == normalized_url.casefold()), None)
|
|
2812
|
+
if duplicate:
|
|
2813
|
+
st.warning("Esta página Facebook já está cadastrada.")
|
|
2814
|
+
else:
|
|
2815
|
+
page = create_channel(name.strip(), normalized_url, {
|
|
2816
|
+
"platform": "facebook",
|
|
2817
|
+
"social_network": "Facebook Pages",
|
|
2818
|
+
"country": country.strip(),
|
|
2819
|
+
"description": description.strip(),
|
|
2820
|
+
"niche": niche.strip(),
|
|
2821
|
+
"active": bool(active),
|
|
2822
|
+
"metrics_source": "public_url",
|
|
2823
|
+
})
|
|
2824
|
+
update_channel(page["id"], {"platform": "facebook", "social_network": "Facebook Pages"})
|
|
2825
|
+
st.success(f"Página {page['name']} guardada.")
|
|
2826
|
+
st.rerun()
|
|
2827
|
+
|
|
2828
|
+
pages = [item for item in read_json("channels.json", []) if isinstance(item, dict) and classify_channel_platform(item) == "facebook"]
|
|
2829
|
+
st.divider()
|
|
2830
|
+
st.subheader(f"Facebook Pages cadastradas ({len(pages)})")
|
|
2831
|
+
if not pages:
|
|
2832
|
+
st.info("Nenhuma página Facebook cadastrada.")
|
|
2833
|
+
return
|
|
2834
|
+
for page in pages:
|
|
2835
|
+
page_id = str(page.get("id") or "")
|
|
2836
|
+
with st.container(border=True):
|
|
2837
|
+
header_cols = st.columns([0.7, 2.75, 1.15, 1.1, 1.0, 1.35, 1.35])
|
|
2838
|
+
with header_cols[0]:
|
|
2839
|
+
st.markdown("### FB")
|
|
2840
|
+
with header_cols[1]:
|
|
2841
|
+
page_country = str(page.get("country") or "").strip()
|
|
2842
|
+
st.write(f"**{page.get('name', 'Sem nome')}**{f' · {page_country}' if page_country else ''}")
|
|
2843
|
+
st.caption(page.get("niche") or "SEM NICHO CONFIGURADO")
|
|
2844
|
+
st.caption(page.get("url") or "sem URL")
|
|
2845
|
+
with header_cols[2]:
|
|
2846
|
+
if page.get("url"):
|
|
2847
|
+
st.link_button("Abrir página", str(page["url"]), type="primary", width="content")
|
|
2848
|
+
with header_cols[3]:
|
|
2849
|
+
st.metric("País", page_country or "—")
|
|
2850
|
+
with header_cols[4]:
|
|
2851
|
+
st.metric("Estado", "Activo" if page.get("active", True) else "Inactivo")
|
|
2852
|
+
with header_cols[5]:
|
|
2853
|
+
st.caption("Facebook Page")
|
|
2854
|
+
with header_cols[6]:
|
|
2855
|
+
active_value = st.toggle("Activo", value=bool(page.get("active", True)), key=f"facebook_active_{page_id}")
|
|
2856
|
+
if active_value != bool(page.get("active", True)):
|
|
2857
|
+
update_channel(page_id, {"active": active_value})
|
|
2858
|
+
st.rerun()
|
|
2859
|
+
with st.expander("Detalhes e configuração da página", expanded=False):
|
|
2860
|
+
with st.form(f"facebook_page_edit_{page_id}"):
|
|
2861
|
+
edited_name = st.text_input("Nome da página", value=str(page.get("name") or ""))
|
|
2862
|
+
edited_url = st.text_input("URL pública", value=str(page.get("url") or ""))
|
|
2863
|
+
edit_country_options = [""] + list(COUNTRY_OPTIONS)
|
|
2864
|
+
current_page_country = str(page.get("country") or "")
|
|
2865
|
+
edited_country = st.selectbox("País", edit_country_options, index=edit_country_options.index(current_page_country) if current_page_country in edit_country_options else 0)
|
|
2866
|
+
edited_description = st.text_area("Descrição", value=str(page.get("description") or ""))
|
|
2867
|
+
edited_niche = st.text_input("Nicho", value=str(page.get("niche") or ""))
|
|
2868
|
+
save_page = st.form_submit_button("Guardar alterações", type="primary")
|
|
2869
|
+
action_cols = st.columns(2)
|
|
2870
|
+
with action_cols[0]:
|
|
2871
|
+
if save_page:
|
|
2872
|
+
if not edited_name.strip() or not edited_url.strip().startswith(("https://www.facebook.com/", "https://facebook.com/", "http://www.facebook.com/", "http://facebook.com/")):
|
|
2873
|
+
st.error("Informe o nome e uma URL pública válida de uma página Facebook.")
|
|
2874
|
+
else:
|
|
2875
|
+
update_channel(page_id, {"name": edited_name.strip(), "url": edited_url.strip(), "country": edited_country.strip(), "description": edited_description.strip(), "niche": edited_niche.strip()})
|
|
2876
|
+
st.success("Página Facebook actualizada.")
|
|
2877
|
+
st.rerun()
|
|
2878
|
+
with action_cols[1]:
|
|
2879
|
+
if st.button("Apagar", key=f"facebook_delete_{page_id}"):
|
|
2880
|
+
delete_channel(page_id)
|
|
2881
|
+
st.success("Página Facebook apagada.")
|
|
2882
|
+
st.rerun()
|
|
2883
|
+
|
|
2884
|
+
|
|
2781
2885
|
def render_channels():
|
|
2782
2886
|
st.title("Canais Youtube")
|
|
2783
2887
|
st.caption("Escolha entre importar dados públicos do YouTube ou preencher o canal manualmente.")
|
|
@@ -2849,6 +2953,8 @@ def render_channels():
|
|
|
2849
2953
|
url = st.text_input("URL", value=imported.get("url", source if source.startswith("http") else ""), key="yt_import_url")
|
|
2850
2954
|
handle = st.text_input("Handle", value=imported.get("handle", ""), key="yt_import_handle")
|
|
2851
2955
|
language = st.selectbox("Idioma do roteiro", VIDEO_LANGUAGE_SELECTION_OPTIONS, index=VIDEO_LANGUAGE_SELECTION_OPTIONS.index(normalize_video_language(imported.get("language") or "pt")) if normalize_video_language(imported.get("language") or "pt") in VIDEO_LANGUAGE_SELECTION_OPTIONS else 0, format_func=video_language_label, key="yt_import_language")
|
|
2956
|
+
import_country_options = [""] + list(COUNTRY_OPTIONS)
|
|
2957
|
+
import_country = st.selectbox("País", import_country_options, index=import_country_options.index(str(imported.get("country") or "")) if str(imported.get("country") or "") in import_country_options else 0, key="yt_import_country")
|
|
2852
2958
|
style = st.selectbox("Fonte do vídeo", WIDE_STYLE_OPTIONS, index=0, key="yt_import_style")
|
|
2853
2959
|
video_aspect_ratio = st.selectbox("Proporção do vídeo", CHANNEL_ASPECT_RATIO_OPTIONS, key="yt_import_aspect_ratio")
|
|
2854
2960
|
blueprint = st.selectbox("Blueprint Padrão", blueprint_ids, index=blueprint_ids.index(imported_blueprint) if imported_blueprint in blueprint_ids else 0, format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"), key="yt_import_blueprint")
|
|
@@ -2879,7 +2985,7 @@ def render_channels():
|
|
|
2879
2985
|
"description": description.strip(),
|
|
2880
2986
|
"niche": niche.strip(),
|
|
2881
2987
|
"reference_channels": [item.strip() for item in re.split(r"[,|]", niche) if item.strip()],
|
|
2882
|
-
"language": language,
|
|
2988
|
+
"language": language, "country": import_country.strip(),
|
|
2883
2989
|
"style_wide": channel_video_source_storage(style), "video_aspect_ratio": video_aspect_ratio, "format": "wide",
|
|
2884
2990
|
"blueprint_id": blueprint.strip(),
|
|
2885
2991
|
"default_blueprint_id": blueprint.strip(),
|
|
@@ -3138,6 +3244,8 @@ def render_channels():
|
|
|
3138
3244
|
description = st.text_area("Descrição", key="manual_channel_description")
|
|
3139
3245
|
niche = st.text_input("Nicho", placeholder="Ex.: História militar, mistérios, ciência", key="manual_channel_niche")
|
|
3140
3246
|
language = st.selectbox("Idioma", list(LANGUAGE_CODES), index=list(LANGUAGE_CODES).index("pt"), format_func=language_label, key="manual_channel_language")
|
|
3247
|
+
manual_country_options = [""] + list(COUNTRY_OPTIONS)
|
|
3248
|
+
manual_country = st.selectbox("País", manual_country_options, key="manual_channel_country")
|
|
3141
3249
|
style = st.selectbox("Estilo wide", ["Pexels/Pixabay", "full_ia", "Apenas Música"], index=0, key="manual_channel_style")
|
|
3142
3250
|
manual_blueprint_items = blueprint_catalog()
|
|
3143
3251
|
manual_blueprint_ids = [item[0] for item in manual_blueprint_items]
|
|
@@ -3166,7 +3274,7 @@ def render_channels():
|
|
|
3166
3274
|
"description": description.strip(),
|
|
3167
3275
|
"niche": niche.strip(),
|
|
3168
3276
|
"reference_channels": [item.strip() for item in re.split(r"[,|]", niche) if item.strip()],
|
|
3169
|
-
"language": language,
|
|
3277
|
+
"language": language, "country": manual_country.strip(),
|
|
3170
3278
|
"style_wide": {"Pexels/Pixabay": "pexels", "full_ia": "full_ia", "Apenas Música": "music"}.get(style, style),
|
|
3171
3279
|
"blueprint_id": blueprint.strip(),
|
|
3172
3280
|
"default_blueprint_id": blueprint.strip(),
|
|
@@ -3228,7 +3336,8 @@ def render_channels():
|
|
|
3228
3336
|
else:
|
|
3229
3337
|
st.markdown("### YT")
|
|
3230
3338
|
with header_cols[1]:
|
|
3231
|
-
|
|
3339
|
+
country_label = str(channel.get("country") or "").strip()
|
|
3340
|
+
st.write(f"**{channel.get('name', 'Sem nome')}**{f' · {country_label}' if country_label else ''}")
|
|
3232
3341
|
st.caption(channel_niche_label(channel))
|
|
3233
3342
|
st.caption(f"{channel.get('handle') or channel.get('url') or 'sem URL'} · {channel.get('metrics_source', 'manual')}")
|
|
3234
3343
|
with header_cols[2]:
|
|
@@ -6175,6 +6284,130 @@ def _render_youtube_automation_cards():
|
|
|
6175
6284
|
st.rerun()
|
|
6176
6285
|
|
|
6177
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
|
+
|
|
6178
6411
|
def render_automation():
|
|
6179
6412
|
st.title("Automação Youtube")
|
|
6180
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.")
|
|
@@ -9552,12 +9785,13 @@ def main():
|
|
|
9552
9785
|
channel_profile_items = [
|
|
9553
9786
|
("Canais YouTube", ":material/ondemand_video:", "Canais YouTube"),
|
|
9554
9787
|
("Canais Tiktok", ":material/music_video:", "Canais Tiktok"),
|
|
9788
|
+
("Contas Instagram", ":material/share:", "Contas Instagram"),
|
|
9789
|
+
("Facebook Pages", ":material/public:", "Facebook Pages"),
|
|
9555
9790
|
("Blueprints Youtube", ":material/library_books:", "Blueprints Youtube"),
|
|
9556
9791
|
("Thumbnail Blueprints", ":material/image:", "Thumbnail Blueprints"),
|
|
9557
9792
|
("Brandings Youtube", ":material/brush:", "Brandings Youtube"),
|
|
9558
9793
|
("Contas TikTok", ":material/account_circle:", "Contas TikTok"),
|
|
9559
9794
|
("Prompt Masters", ":material/auto_awesome:", "Prompt Masters"),
|
|
9560
|
-
("Facebook Pages", ":material/public:", "Facebook Pages"),
|
|
9561
9795
|
]
|
|
9562
9796
|
music_items = [
|
|
9563
9797
|
("Criação de Músicas", ":material/music_note:", "Criação de Músicas"),
|
|
@@ -9570,7 +9804,6 @@ def main():
|
|
|
9570
9804
|
("Geração de Conteúdo IA", ":material/auto_awesome:", "Geração de Conteúdo IA"),
|
|
9571
9805
|
("Motion Control", ":material/motion_photos_on:", "Motion Control"),
|
|
9572
9806
|
("UGC Products", ":material/shopping_bag:", "UGC Products"),
|
|
9573
|
-
("Contas Instagram", ":material/share:", "Contas Instagram"),
|
|
9574
9807
|
]
|
|
9575
9808
|
growth_items = [
|
|
9576
9809
|
("Analista Growth Youtube", ":material/analytics:", "Analista Growth Youtube"),
|
|
@@ -9601,6 +9834,11 @@ def main():
|
|
|
9601
9834
|
automation_items = [
|
|
9602
9835
|
("Automação Youtube", ":material/schedule:", "Automação Youtube"),
|
|
9603
9836
|
("Automação Tiktok", ":material/schedule:", "Automação Tiktok"),
|
|
9837
|
+
("Automação Facebook", ":material/schedule:", "Automação Facebook"),
|
|
9838
|
+
("Automação Musicas", ":material/schedule:", "Automação Musicas"),
|
|
9839
|
+
("Automação UGC", ":material/schedule:", "Automação UGC"),
|
|
9840
|
+
("Automação Influencer Content", ":material/schedule:", "Automação Influencer Content"),
|
|
9841
|
+
("Automação Bilibili", ":material/schedule:", "Automação Bilibili"),
|
|
9604
9842
|
]
|
|
9605
9843
|
edition_items = [
|
|
9606
9844
|
("Limpador de Metadados", ":material/edit_note:", "Limpador de Metadados"),
|
|
@@ -9634,12 +9872,12 @@ def main():
|
|
|
9634
9872
|
"Configurações": settings_items,
|
|
9635
9873
|
}
|
|
9636
9874
|
nav_paths = {
|
|
9637
|
-
"Início": "/inicio", "Automação": "/automacao", "Automação Youtube": "/automacao/youtube", "Automação Tiktok": "/automacao/tiktok",
|
|
9875
|
+
"Início": "/inicio", "Automação": "/automacao", "Automação Youtube": "/automacao/youtube", "Automação Tiktok": "/automacao/tiktok", "Automação Facebook": "/automacao/facebook", "Automação Musicas": "/automacao/musicas", "Automação UGC": "/automacao/ugc", "Automação Influencer Content": "/automacao/influencer-content", "Automação Bilibili": "/automacao/bilibili",
|
|
9638
9876
|
"Niche Finder": "/niche-finder", "Niche Finder Kaggle": "/niche-finder/kaggle", "Niche Finder Apify": "/niche-finder/apify",
|
|
9639
9877
|
"Pipeline Vídeos": "/pipeline-videos", "Criação de Vídeos": "/pipeline-videos/criacao", "Criação de Shorts": "/pipeline-videos/shorts", "Backlog Vídeos": "/pipeline-videos/backlog", "Roteiros": "/pipeline-videos/roteiros", "Thumbnails": "/pipeline-videos/thumbnails", "Upload": "/pipeline-videos/upload", "Update Youtube Vídeos": "/pipeline-videos/update-youtube",
|
|
9640
9878
|
"Pipeline Música": "/pipeline-musica", "Criação de Músicas": "/pipeline-musica/criacao", "Music Backlog": "/pipeline-musica/backlog", "Vozes Personalizadas": "/pipeline-musica/vozes-personalizadas", "Upload Música": "/pipeline-musica/upload",
|
|
9641
|
-
"Canais/Perfis (Vídeos)": "/canais-perfis-videos", "Canais YouTube": "/canais-perfis-videos/canais-youtube", "Canais Tiktok": "/canais-perfis-videos/canais-tiktok", "Blueprints Youtube": "/canais-perfis-videos/blueprints-youtube", "Thumbnail Blueprints": "/canais-perfis-videos/thumbnail-blueprints", "Brandings Youtube": "/canais-perfis-videos/brandings-youtube", "Contas TikTok": "/canais-perfis-videos/contas-tiktok", "Prompt Masters": "/canais-perfis-videos/prompt-masters",
|
|
9642
|
-
"AI Influencers": "/ai-influencers", "Personagens": "/ai-influencers/personagens", "Geração de Conteúdo IA": "/ai-influencers/geracao-conteudo", "Motion Control": "/ai-influencers/motion-control", "UGC Products": "/ai-influencers/ugc-products",
|
|
9879
|
+
"Canais/Perfis (Vídeos)": "/canais-perfis-videos", "Canais YouTube": "/canais-perfis-videos/canais-youtube", "Canais Tiktok": "/canais-perfis-videos/canais-tiktok", "Contas Instagram": "/canais-perfis-videos/contas-instagram", "Facebook Pages": "/canais-perfis-videos/facebook-pages", "Blueprints Youtube": "/canais-perfis-videos/blueprints-youtube", "Thumbnail Blueprints": "/canais-perfis-videos/thumbnail-blueprints", "Brandings Youtube": "/canais-perfis-videos/brandings-youtube", "Contas TikTok": "/canais-perfis-videos/contas-tiktok", "Prompt Masters": "/canais-perfis-videos/prompt-masters",
|
|
9880
|
+
"AI Influencers": "/ai-influencers", "Personagens": "/ai-influencers/personagens", "Geração de Conteúdo IA": "/ai-influencers/geracao-conteudo", "Motion Control": "/ai-influencers/motion-control", "UGC Products": "/ai-influencers/ugc-products",
|
|
9643
9881
|
"Edição": "/edicao", "Limpador de Metadados": "/edicao/limpador-metadados", "Cortes": "/edicao/cortes", "Editor Python": "/edicao/editor-python", "Download Mídia": "/edicao/download-midia",
|
|
9644
9882
|
"Growth": "/growth", "Analista Growth Youtube": "/growth/youtube", "Analista Growth Tiktok": "/growth/tiktok", "Analista Growth Instagram": "/growth/instagram", "Analista Facebook Pages": "/growth/facebook-pages", "Analista Bilibili": "/growth/bilibili",
|
|
9645
9883
|
"Documentação": "/documentacao", "Tutorial Meta": "/documentacao/meta", "Tutorial Supabase": "/documentacao/supabase", "Tutorial Kaggle": "/documentacao/kaggle", "Tutorial Apify": "/documentacao/apify", "Tutorial YouTube Video-Upload Frontend": "/documentacao/youtube-video-upload-frontend", "Tutorial OAuth do Google": "/documentacao/oauth-google", "Tutorial YouTube Data API Key (Public Data)": "/documentacao/youtube-data-api-key",
|
|
@@ -9731,9 +9969,14 @@ def main():
|
|
|
9731
9969
|
"Canais YouTube": render_channels,
|
|
9732
9970
|
"Canais Tiktok": render_tiktok_channels,
|
|
9733
9971
|
"Contas TikTok": render_tiktok_accounts,
|
|
9734
|
-
"Facebook Pages":
|
|
9972
|
+
"Facebook Pages": render_facebook_pages,
|
|
9735
9973
|
"Automação Youtube": render_automation,
|
|
9736
9974
|
"Automação Tiktok": render_tiktok_automation,
|
|
9975
|
+
"Automação Facebook": render_facebook_automation,
|
|
9976
|
+
"Automação Musicas": lambda: None,
|
|
9977
|
+
"Automação UGC": lambda: None,
|
|
9978
|
+
"Automação Influencer Content": lambda: None,
|
|
9979
|
+
"Automação Bilibili": lambda: None,
|
|
9737
9980
|
"Niche Finder Kaggle": render_niche_finder,
|
|
9738
9981
|
"Tutorial Kaggle": lambda: render_niche_tutorial("kaggle"),
|
|
9739
9982
|
"Niche Finder Apify": render_niche_finder_apify,
|
|
@@ -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