@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 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
- st.write(f"**{channel.get('name', 'Sem nome')}**")
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", "Facebook Pages": "/canais-perfis-videos/facebook-pages",
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", "Contas Instagram": "/ai-influencers/redes-sociais",
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": lambda: render_edit_placeholder("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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.7.29",
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",