@danhachuel/thunderbolt 0.4.33 → 0.4.34
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 +174 -2
- package/hermes_ui/channel_import.py +429 -0
- package/hermes_ui/domain.py +3 -0
- package/package.json +1 -1
- package/requirements.txt +2 -0
package/app/main.py
CHANGED
|
@@ -35,6 +35,7 @@ def display_version(version: str) -> str:
|
|
|
35
35
|
APP_VERSION_LABEL = display_version(APP_VERSION)
|
|
36
36
|
|
|
37
37
|
from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, delete_channel, delete_task, pipeline_summary, retry_task_with_current_settings, set_channel_defaults, stop_task_by_user, transition_task, update_channel, update_channel_video
|
|
38
|
+
from hermes_ui.channel_import import build_channel_template_xlsx, channel_is_duplicate, find_duplicate_channel, parse_channel_workbook, resolve_blueprint, resolve_google_account, resolve_voice
|
|
38
39
|
from hermes_ui.drafts import list_drafts, save_draft
|
|
39
40
|
from hermes_ui.automation_worker import load_worker_status
|
|
40
41
|
from hermes_ui.pipeline_worker import load_pipeline_worker_status, recover_stale_tasks, STALE_TASK_SECONDS, WORKER_HEARTBEAT_TIMEOUT_SECONDS
|
|
@@ -1849,7 +1850,7 @@ def render_channels():
|
|
|
1849
1850
|
youtube_account_labels = {"": "Sem conta Google associada"}
|
|
1850
1851
|
youtube_account_labels.update({str(account["id"]): f"{account.get('label', 'Canais YouTube')} — {account.get('email', 'sem e-mail')}" for account in youtube_accounts})
|
|
1851
1852
|
youtube_accounts_by_id = {str(account["id"]): account for account in youtube_accounts}
|
|
1852
|
-
import_tab, batch_tab, manual_tab = render_localized_tabs(["Importar do YouTube", "Canais em lote gmail", "Cadastro manual"])
|
|
1853
|
+
import_tab, spreadsheet_tab, batch_tab, manual_tab = render_localized_tabs(["Importar do YouTube", "Canais em lote Planilha", "Canais em lote gmail", "Cadastro manual"])
|
|
1853
1854
|
|
|
1854
1855
|
with import_tab:
|
|
1855
1856
|
st.caption("A pesquisa pública funciona sem API Key. A Data API é opcional e fica disponível numa opção separada para métricas oficiais.")
|
|
@@ -1943,6 +1944,142 @@ def render_channels():
|
|
|
1943
1944
|
else:
|
|
1944
1945
|
st.info("Introduza um URL, handle ou ID e clique em Buscar no YouTube. Não é necessária API Key na opção pública.")
|
|
1945
1946
|
|
|
1947
|
+
with spreadsheet_tab:
|
|
1948
|
+
st.caption("Carregue uma planilha Excel (.xlsx ou .xls). Os cabeçalhos e valores são interpretados, normalizados e associados aos cadastros existentes antes da gravação.")
|
|
1949
|
+
download_col, help_col = st.columns([1.35, 2.65])
|
|
1950
|
+
with download_col:
|
|
1951
|
+
st.download_button(
|
|
1952
|
+
"Baixar planilha modelo",
|
|
1953
|
+
data=build_channel_template_xlsx(),
|
|
1954
|
+
file_name="modelo_canais_youtube.xlsx",
|
|
1955
|
+
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
1956
|
+
use_container_width=True,
|
|
1957
|
+
key="download_channel_spreadsheet_template",
|
|
1958
|
+
)
|
|
1959
|
+
with help_col:
|
|
1960
|
+
st.caption("O modelo inclui as 15 colunas aceitas. Deixe campos não utilizados vazios; a Descrição será pesquisada publicamente pelo handle, URL ou nome quando estiver vazia.")
|
|
1961
|
+
|
|
1962
|
+
uploaded_sheet = st.file_uploader(
|
|
1963
|
+
"Upload da planilha de canais",
|
|
1964
|
+
type=["xlsx", "xls"],
|
|
1965
|
+
key="channel_spreadsheet_upload",
|
|
1966
|
+
help="Apenas a primeira aba do arquivo será importada. Linhas sem URL, nome e handle são ignoradas.",
|
|
1967
|
+
)
|
|
1968
|
+
if st.button("Ler e preparar planilha", type="primary", use_container_width=True, key="read_channel_spreadsheet"):
|
|
1969
|
+
if uploaded_sheet is None:
|
|
1970
|
+
st.error("Selecione um arquivo Excel antes de continuar.")
|
|
1971
|
+
else:
|
|
1972
|
+
try:
|
|
1973
|
+
spreadsheet_rows, spreadsheet_warnings = parse_channel_workbook(uploaded_sheet.getvalue(), uploaded_sheet.name)
|
|
1974
|
+
st.session_state["channel_spreadsheet_rows"] = spreadsheet_rows
|
|
1975
|
+
st.session_state["channel_spreadsheet_warnings"] = spreadsheet_warnings
|
|
1976
|
+
st.session_state.pop("channel_spreadsheet_result", None)
|
|
1977
|
+
if spreadsheet_rows:
|
|
1978
|
+
st.success(f"{len(spreadsheet_rows)} linha(s) de canal preparada(s) para revisão.")
|
|
1979
|
+
else:
|
|
1980
|
+
st.warning("A planilha não contém linhas com URL, nome ou handle de canal.")
|
|
1981
|
+
except ValueError as exc:
|
|
1982
|
+
st.session_state.pop("channel_spreadsheet_rows", None)
|
|
1983
|
+
st.error(str(exc))
|
|
1984
|
+
|
|
1985
|
+
spreadsheet_warnings = st.session_state.get("channel_spreadsheet_warnings", [])
|
|
1986
|
+
for spreadsheet_warning in spreadsheet_warnings:
|
|
1987
|
+
st.warning(spreadsheet_warning)
|
|
1988
|
+
spreadsheet_result = st.session_state.get("channel_spreadsheet_result")
|
|
1989
|
+
if isinstance(spreadsheet_result, dict):
|
|
1990
|
+
if spreadsheet_result.get("created"):
|
|
1991
|
+
st.success(f"Canais cadastrados: {', '.join(spreadsheet_result['created'])}")
|
|
1992
|
+
if spreadsheet_result.get("skipped"):
|
|
1993
|
+
st.info(f"Já cadastrados e não duplicados: {', '.join(spreadsheet_result['skipped'])}")
|
|
1994
|
+
for spreadsheet_error in spreadsheet_result.get("errors", []):
|
|
1995
|
+
st.warning(spreadsheet_error)
|
|
1996
|
+
|
|
1997
|
+
spreadsheet_rows = st.session_state.get("channel_spreadsheet_rows", [])
|
|
1998
|
+
if spreadsheet_rows:
|
|
1999
|
+
blueprint_items = blueprint_catalog()
|
|
2000
|
+
voice_options = voice_catalog()
|
|
2001
|
+
spreadsheet_accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
|
|
2002
|
+
existing_spreadsheet_channels = [channel for channel in read_json("channels.json", []) if isinstance(channel, dict)]
|
|
2003
|
+
preview_rows = []
|
|
2004
|
+
for row in spreadsheet_rows:
|
|
2005
|
+
resolved_blueprint = resolve_blueprint(row.get("blueprint"), blueprint_items)
|
|
2006
|
+
resolved_voice = resolve_voice(row.get("voice"), voice_options)
|
|
2007
|
+
resolved_account, _ = resolve_google_account(row.get("google_account"), spreadsheet_accounts)
|
|
2008
|
+
preview_rows.append({
|
|
2009
|
+
"Linha": row.get("_source_row", "—"),
|
|
2010
|
+
"Nome": row.get("name") or "—",
|
|
2011
|
+
"Handle": row.get("handle") or "—",
|
|
2012
|
+
"Blueprint interpretado": resolved_blueprint or "—",
|
|
2013
|
+
"Voz interpretada": resolved_voice or "—",
|
|
2014
|
+
"Idioma": language_code(row.get("language")) if row.get("language") else "—",
|
|
2015
|
+
"Descrição": "Preencher via YouTube" if not row.get("description") else "Da planilha",
|
|
2016
|
+
"Estado": "Já cadastrado" if find_duplicate_channel({"name": row.get("name"), "handle": row.get("handle"), "url": row.get("url")}, existing_spreadsheet_channels) else "Novo",
|
|
2017
|
+
})
|
|
2018
|
+
st.dataframe(preview_rows, use_container_width=True, hide_index=True, height=min(420, 86 + 38 * len(preview_rows)))
|
|
2019
|
+
st.caption("Blueprints e vozes são resolvidos pelos catálogos atuais. Por exemplo, `finanças`, `blueprint_finanças` e `Blueprint Canal Finanças` apontam para o mesmo Blueprint quando ele existe.")
|
|
2020
|
+
if st.button("Cadastrar canais da planilha", type="primary", use_container_width=True, key="import_channel_spreadsheet"):
|
|
2021
|
+
created_names: list[str] = []
|
|
2022
|
+
skipped_names: list[str] = []
|
|
2023
|
+
spreadsheet_errors: list[str] = []
|
|
2024
|
+
current_channels = list(existing_spreadsheet_channels)
|
|
2025
|
+
for row in spreadsheet_rows:
|
|
2026
|
+
row_label = str(row.get("name") or row.get("handle") or row.get("url") or f"Linha {row.get('_source_row', '?')}")
|
|
2027
|
+
candidate = {"name": row.get("name", ""), "handle": row.get("handle", ""), "url": row.get("url", "")}
|
|
2028
|
+
duplicate = find_duplicate_channel(candidate, current_channels)
|
|
2029
|
+
if duplicate:
|
|
2030
|
+
skipped_names.append(f"{row_label} (linha {row.get('_source_row', '?')})")
|
|
2031
|
+
continue
|
|
2032
|
+
automation_time = str(row.get("automation_time") or "").strip()
|
|
2033
|
+
if automation_time and not valid_hhmm(automation_time):
|
|
2034
|
+
spreadsheet_errors.append(f"{row_label} (linha {row.get('_source_row', '?')}): horário inválido; a linha não foi cadastrada.")
|
|
2035
|
+
continue
|
|
2036
|
+
try:
|
|
2037
|
+
resolved_blueprint = resolve_blueprint(row.get("blueprint"), blueprint_items)
|
|
2038
|
+
resolved_voice = resolve_voice(row.get("voice"), voice_options)
|
|
2039
|
+
google_account_id, google_account_email = resolve_google_account(row.get("google_account"), spreadsheet_accounts)
|
|
2040
|
+
description = str(row.get("description") or "").strip()
|
|
2041
|
+
description_status = "da planilha"
|
|
2042
|
+
if not description:
|
|
2043
|
+
lookup_source = str(row.get("handle") or row.get("url") or row.get("name") or "").strip()
|
|
2044
|
+
if lookup_source:
|
|
2045
|
+
description_result = youtube.fetch_channel_public(lookup_source)
|
|
2046
|
+
description = str(description_result.data.get("description") or "").strip()
|
|
2047
|
+
if not description and youtube.api_key:
|
|
2048
|
+
api_description_result = youtube.fetch_channel(lookup_source)
|
|
2049
|
+
description = str(api_description_result.data.get("description") or "").strip()
|
|
2050
|
+
description_status = "buscada no YouTube" if description else "não encontrada"
|
|
2051
|
+
style_value = str(row.get("style_wide") or "").strip()
|
|
2052
|
+
metadata = {
|
|
2053
|
+
"handle": str(row.get("handle") or "").strip(),
|
|
2054
|
+
"description": description,
|
|
2055
|
+
"niche": str(row.get("niche") or "").strip(),
|
|
2056
|
+
"reference_channels": [item.strip() for item in re.split(r"[,|]", str(row.get("niche") or "")) if item.strip()],
|
|
2057
|
+
"language": language_code(row.get("language")) if row.get("language") else "",
|
|
2058
|
+
"style_wide": style_value,
|
|
2059
|
+
"blueprint_id": resolved_blueprint,
|
|
2060
|
+
"default_blueprint_id": resolved_blueprint,
|
|
2061
|
+
"default_voice": resolved_voice,
|
|
2062
|
+
"voice": resolved_voice,
|
|
2063
|
+
"google_account_id": google_account_id,
|
|
2064
|
+
"google_account_email": google_account_email,
|
|
2065
|
+
"automation_on": bool(row.get("automation_on")) if row.get("automation_on") is not None else False,
|
|
2066
|
+
"automation_time": automation_time,
|
|
2067
|
+
"delegated_session_id": str(row.get("delegated_session_id") or "").strip(),
|
|
2068
|
+
"active": bool(row.get("active")) if row.get("active") is not None else True,
|
|
2069
|
+
"default_video_duration_minutes": row.get("duration_minutes"),
|
|
2070
|
+
"metrics_source": "spreadsheet",
|
|
2071
|
+
"import_source": "spreadsheet",
|
|
2072
|
+
"description_source": description_status,
|
|
2073
|
+
}
|
|
2074
|
+
created = create_channel(str(row.get("name") or "").strip(), str(row.get("url") or "").strip(), metadata)
|
|
2075
|
+
current_channels.append(created)
|
|
2076
|
+
created_names.append(row_label)
|
|
2077
|
+
except Exception as exc:
|
|
2078
|
+
spreadsheet_errors.append(f"{row_label} (linha {row.get('_source_row', '?')}): {exc}")
|
|
2079
|
+
st.session_state["channel_spreadsheet_result"] = {"created": created_names, "skipped": skipped_names, "errors": spreadsheet_errors}
|
|
2080
|
+
st.session_state.pop("channel_spreadsheet_rows", None)
|
|
2081
|
+
st.rerun()
|
|
2082
|
+
|
|
1946
2083
|
with batch_tab:
|
|
1947
2084
|
st.caption("Esta subaba usa a conta Google/YouTube seleccionada para listar os canais que ela gere. Não lê a caixa Gmail e não usa e-mails como pesquisa pública.")
|
|
1948
2085
|
accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
|
|
@@ -2094,6 +2231,34 @@ def render_channels():
|
|
|
2094
2231
|
if not channels:
|
|
2095
2232
|
st.info("Nenhum canal cadastrado.")
|
|
2096
2233
|
return
|
|
2234
|
+
channel_rows = [
|
|
2235
|
+
{
|
|
2236
|
+
"Nome": str(channel.get("name") or ""),
|
|
2237
|
+
"Handle": str(channel.get("handle") or ""),
|
|
2238
|
+
"URL": str(channel.get("url") or ""),
|
|
2239
|
+
"Nicho": channel_niche_label(channel),
|
|
2240
|
+
"Descrição": str(channel.get("description") or ""),
|
|
2241
|
+
"Origem": str(channel.get("import_source") or channel.get("metrics_source") or "manual"),
|
|
2242
|
+
"Activo": "Sim" if channel.get("active", True) else "Não",
|
|
2243
|
+
}
|
|
2244
|
+
for channel in channels
|
|
2245
|
+
]
|
|
2246
|
+
st.dataframe(
|
|
2247
|
+
channel_rows,
|
|
2248
|
+
use_container_width=True,
|
|
2249
|
+
hide_index=True,
|
|
2250
|
+
height=min(360, 76 + 38 * len(channel_rows)),
|
|
2251
|
+
column_config={
|
|
2252
|
+
"Nome": st.column_config.TextColumn("Nome", width=180),
|
|
2253
|
+
"Handle": st.column_config.TextColumn("Handle", width=150),
|
|
2254
|
+
"URL": st.column_config.LinkColumn("URL", width=220),
|
|
2255
|
+
"Nicho": st.column_config.TextColumn("Nicho", width=160),
|
|
2256
|
+
"Descrição": st.column_config.TextColumn("Descrição", width=360),
|
|
2257
|
+
"Origem": st.column_config.TextColumn("Origem", width=130),
|
|
2258
|
+
"Activo": st.column_config.TextColumn("Activo", width=80),
|
|
2259
|
+
},
|
|
2260
|
+
)
|
|
2261
|
+
st.caption("Tabela de canais cadastrados. Os cartões abaixo mantêm as ações de edição, credenciais e vídeos publicados.")
|
|
2097
2262
|
for channel in channels:
|
|
2098
2263
|
channel_id = str(channel["id"])
|
|
2099
2264
|
edit_key = f"edit_channel_{channel_id}"
|
|
@@ -7335,10 +7500,16 @@ def main():
|
|
|
7335
7500
|
}
|
|
7336
7501
|
all_children = [item for items in groups.values() for item in items]
|
|
7337
7502
|
valid_targets = {item[0] for item in top_pages + all_children}
|
|
7338
|
-
|
|
7503
|
+
# Session state is reset by a browser refresh or an application update. Keep
|
|
7504
|
+
# the canonical page in the URL so the open section can be restored.
|
|
7505
|
+
query_page = str(st.query_params.get("page") or "").strip()
|
|
7506
|
+
stored_page = query_page or str(st.session_state.get("page", "Início"))
|
|
7507
|
+
current_page = aliases.get(stored_page, stored_page)
|
|
7339
7508
|
if current_page not in valid_targets:
|
|
7340
7509
|
current_page = "Início"
|
|
7341
7510
|
st.session_state["page"] = current_page
|
|
7511
|
+
if str(st.query_params.get("page") or "") != current_page:
|
|
7512
|
+
st.query_params["page"] = current_page
|
|
7342
7513
|
ui_language = current_ui_language()
|
|
7343
7514
|
current_path = nav_paths.get(current_page, "/inicio")
|
|
7344
7515
|
|
|
@@ -7352,6 +7523,7 @@ def main():
|
|
|
7352
7523
|
|
|
7353
7524
|
def navigate(target: str):
|
|
7354
7525
|
st.session_state["page"] = target
|
|
7526
|
+
st.query_params["page"] = target
|
|
7355
7527
|
st.rerun()
|
|
7356
7528
|
|
|
7357
7529
|
def render_nav_button(target: str, icon: str, label: str, scope: str):
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""Helpers for importing YouTube channel registrations from spreadsheets.
|
|
2
|
+
|
|
3
|
+
The spreadsheet is intentionally treated as a human-authored document rather
|
|
4
|
+
than a database export: headers and values are normalised semantically, common
|
|
5
|
+
Portuguese/English variants are accepted, and catalog-backed values are
|
|
6
|
+
resolved to the application's canonical identifiers before persistence.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import date, datetime, time
|
|
12
|
+
from difflib import SequenceMatcher
|
|
13
|
+
from io import BytesIO
|
|
14
|
+
import math
|
|
15
|
+
import re
|
|
16
|
+
import unicodedata
|
|
17
|
+
from copy import copy
|
|
18
|
+
from typing import Any, Iterable, Mapping, Sequence
|
|
19
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
20
|
+
|
|
21
|
+
import pandas as pd
|
|
22
|
+
|
|
23
|
+
from .languages import language_code
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
CHANNEL_TEMPLATE_COLUMNS: tuple[str, ...] = (
|
|
27
|
+
"URL canal",
|
|
28
|
+
"Nome canal",
|
|
29
|
+
"Handle canal",
|
|
30
|
+
"Narrador/ voz padrão",
|
|
31
|
+
"Idioma",
|
|
32
|
+
"Nicho",
|
|
33
|
+
"Blueprint Padrão",
|
|
34
|
+
"Estilo Wide",
|
|
35
|
+
"Activo ",
|
|
36
|
+
"Descrição",
|
|
37
|
+
"Conta Google do Documento deste Canal",
|
|
38
|
+
"Automação Ligada ",
|
|
39
|
+
"Horário diário (HH:MM)",
|
|
40
|
+
"DELEGATED_SESSION_ID",
|
|
41
|
+
"Duração Padrão Vídeos (Min)",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_FIELD_ALIASES: dict[str, tuple[str, ...]] = {
|
|
46
|
+
"url": ("url canal", "url", "link canal", "link", "youtube url", "youtube link", "channel url"),
|
|
47
|
+
"name": ("nome canal", "nome", "nome do canal", "channel name", "name"),
|
|
48
|
+
"handle": ("handle canal", "handle", "identificador canal", "youtube handle", "channel handle"),
|
|
49
|
+
"voice": ("narrador voz padrão", "narrador voz padrao", "narrador", "voz padrão", "voz padrao", "voz", "voice"),
|
|
50
|
+
"language": ("idioma", "lingua", "language", "lang"),
|
|
51
|
+
"niche": ("nicho", "niche", "tema", "categoria", "category"),
|
|
52
|
+
"blueprint": ("blueprint padrão", "blueprint padrao", "blueprint", "modelo blueprint", "blueprint name"),
|
|
53
|
+
"style_wide": ("estilo wide", "estilo", "wide style", "video style", "style"),
|
|
54
|
+
"active": ("activo", "ativo", "activa", "ativa", "active", "enabled"),
|
|
55
|
+
"description": ("descrição", "descricao", "description", "sobre o canal", "channel description"),
|
|
56
|
+
"google_account": (
|
|
57
|
+
"conta google do documento deste canal",
|
|
58
|
+
"conta google",
|
|
59
|
+
"google account",
|
|
60
|
+
"gmail",
|
|
61
|
+
"email google",
|
|
62
|
+
"e mail google",
|
|
63
|
+
),
|
|
64
|
+
"automation_on": ("automação ligada", "automacao ligada", "automação", "automacao", "automation", "automation on"),
|
|
65
|
+
"automation_time": ("horário diário hh mm", "horario diario hh mm", "horário diário", "horario diario", "automation time", "schedule"),
|
|
66
|
+
"delegated_session_id": ("delegated session id", "delegated_session_id", "delegated session", "session id"),
|
|
67
|
+
"duration_minutes": (
|
|
68
|
+
"duração padrão vídeos min",
|
|
69
|
+
"duracao padrao videos min",
|
|
70
|
+
"duração padrão vídeos",
|
|
71
|
+
"duracao padrao videos",
|
|
72
|
+
"duração vídeos min",
|
|
73
|
+
"video duration minutes",
|
|
74
|
+
"duration minutes",
|
|
75
|
+
),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _strip_accents(value: Any) -> str:
|
|
80
|
+
text = str(value or "")
|
|
81
|
+
return "".join(char for char in unicodedata.normalize("NFKD", text) if not unicodedata.combining(char))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _text(value: Any) -> str:
|
|
85
|
+
if value is None:
|
|
86
|
+
return ""
|
|
87
|
+
try:
|
|
88
|
+
if bool(pd.isna(value)):
|
|
89
|
+
return ""
|
|
90
|
+
except (TypeError, ValueError):
|
|
91
|
+
pass
|
|
92
|
+
if isinstance(value, float) and math.isnan(value):
|
|
93
|
+
return ""
|
|
94
|
+
return str(value).strip()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _key(value: Any) -> str:
|
|
98
|
+
return re.sub(r"[^a-z0-9]+", "", _strip_accents(value).casefold())
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _semantic_key(value: Any) -> str:
|
|
102
|
+
"""Return a comparison key that ignores catalog naming conventions.
|
|
103
|
+
|
|
104
|
+
For example, ``finanças``, ``blueprint_finanças`` and
|
|
105
|
+
``Blueprint Canal Finanças`` all become ``financas``.
|
|
106
|
+
"""
|
|
107
|
+
compact = _key(value)
|
|
108
|
+
for generic in ("blueprints", "blueprint", "canal", "channel", "padrao", "default", "modelo"):
|
|
109
|
+
compact = compact.replace(generic, "")
|
|
110
|
+
return compact
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _header_match(header: Any) -> str | None:
|
|
114
|
+
normalized = _key(header)
|
|
115
|
+
if not normalized:
|
|
116
|
+
return None
|
|
117
|
+
aliases: dict[str, str] = {}
|
|
118
|
+
for field, values in _FIELD_ALIASES.items():
|
|
119
|
+
for alias in values:
|
|
120
|
+
aliases[_key(alias)] = field
|
|
121
|
+
if normalized in aliases:
|
|
122
|
+
return aliases[normalized]
|
|
123
|
+
# Spreadsheet authors often add a unit or a harmless prefix/suffix.
|
|
124
|
+
ranked: list[tuple[float, str]] = []
|
|
125
|
+
for alias, field in aliases.items():
|
|
126
|
+
if alias and (alias in normalized or normalized in alias):
|
|
127
|
+
ranked.append((min(len(alias), len(normalized)) / max(len(alias), len(normalized)), field))
|
|
128
|
+
if ranked:
|
|
129
|
+
return max(ranked)[1]
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def parse_channel_workbook(file_content: bytes, filename: str = "channels.xlsx") -> tuple[list[dict[str, Any]], list[str]]:
|
|
134
|
+
"""Read the first worksheet and return normalised source rows.
|
|
135
|
+
|
|
136
|
+
Blank rows are discarded. Unknown columns are ignored and reported so a
|
|
137
|
+
user can still import a workbook containing extra operational columns.
|
|
138
|
+
"""
|
|
139
|
+
suffix = str(filename or "").lower().rsplit(".", 1)[-1] if "." in str(filename) else "xlsx"
|
|
140
|
+
engine = "xlrd" if suffix == "xls" else "openpyxl"
|
|
141
|
+
try:
|
|
142
|
+
frame = pd.read_excel(BytesIO(file_content), sheet_name=0, dtype=object, engine=engine)
|
|
143
|
+
except ImportError as exc:
|
|
144
|
+
raise ValueError("Para ficheiros .xls, instale o suporte xlrd; para .xlsx, use o formato Excel moderno (.xlsx).") from exc
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
raise ValueError(f"Não foi possível ler a planilha Excel: {exc}") from exc
|
|
147
|
+
|
|
148
|
+
warnings: list[str] = []
|
|
149
|
+
field_by_column: dict[Any, str] = {}
|
|
150
|
+
for column in frame.columns:
|
|
151
|
+
field = _header_match(column)
|
|
152
|
+
if field is None:
|
|
153
|
+
warnings.append(f"Coluna ignorada: {str(column).strip() or '(sem nome)'}")
|
|
154
|
+
continue
|
|
155
|
+
if field in field_by_column.values():
|
|
156
|
+
warnings.append(f"Coluna duplicada ignorada para {field}: {str(column).strip()}")
|
|
157
|
+
continue
|
|
158
|
+
field_by_column[column] = field
|
|
159
|
+
|
|
160
|
+
if not any(field in field_by_column.values() for field in ("url", "name", "handle")):
|
|
161
|
+
expected = ", ".join(("URL canal", "Nome canal", "Handle canal"))
|
|
162
|
+
raise ValueError(f"A planilha precisa de pelo menos uma coluna de identificação: {expected}.")
|
|
163
|
+
|
|
164
|
+
rows: list[dict[str, Any]] = []
|
|
165
|
+
for source_index, (_, record) in enumerate(frame.iterrows(), start=2):
|
|
166
|
+
if all(not _text(value) for value in record.tolist()):
|
|
167
|
+
continue
|
|
168
|
+
row: dict[str, Any] = {field: record[column] for column, field in field_by_column.items()}
|
|
169
|
+
row["_source_row"] = source_index
|
|
170
|
+
normalized = normalize_channel_row(row)
|
|
171
|
+
# The public template contains pre-filled defaults such as False and
|
|
172
|
+
# 10 minutes. They do not make a row a channel registration.
|
|
173
|
+
if not any(normalized.get(field) for field in ("url", "name", "handle")):
|
|
174
|
+
continue
|
|
175
|
+
rows.append(normalized)
|
|
176
|
+
return rows, warnings
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def normalize_handle(value: Any) -> str:
|
|
180
|
+
raw = _text(value)
|
|
181
|
+
if not raw:
|
|
182
|
+
return ""
|
|
183
|
+
match = re.search(r"(?:youtube\.com/)?@([A-Za-z0-9_.-]+)", raw, flags=re.IGNORECASE)
|
|
184
|
+
if match:
|
|
185
|
+
return f"@{match.group(1)}"
|
|
186
|
+
return raw if raw.startswith("@") else f"@{raw}"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _normalise_boolean(value: Any) -> bool | None:
|
|
190
|
+
if isinstance(value, bool):
|
|
191
|
+
return value
|
|
192
|
+
raw = _strip_accents(_text(value)).casefold()
|
|
193
|
+
if not raw:
|
|
194
|
+
return None
|
|
195
|
+
if raw in {"1", "true", "t", "yes", "y", "sim", "s", "ativo", "activa", "ativa", "ligado", "ligada", "on", "x"}:
|
|
196
|
+
return True
|
|
197
|
+
if raw in {"0", "false", "f", "no", "n", "nao", "inativo", "inactiva", "inativa", "desligado", "desligada", "off"}:
|
|
198
|
+
return False
|
|
199
|
+
if raw.endswith(".0") and raw[:-2] in {"0", "1"}:
|
|
200
|
+
return raw[:-2] == "1"
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _normalise_time(value: Any) -> str:
|
|
205
|
+
if value is None:
|
|
206
|
+
return ""
|
|
207
|
+
if isinstance(value, datetime):
|
|
208
|
+
return value.strftime("%H:%M")
|
|
209
|
+
if isinstance(value, time):
|
|
210
|
+
return value.strftime("%H:%M")
|
|
211
|
+
raw = _text(value)
|
|
212
|
+
if not raw:
|
|
213
|
+
return ""
|
|
214
|
+
match = re.search(r"\b([01]?\d|2[0-3])\s*[:hH]\s*([0-5]\d)\b", raw)
|
|
215
|
+
if match:
|
|
216
|
+
return f"{int(match.group(1)):02d}:{int(match.group(2)):02d}"
|
|
217
|
+
return raw
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _normalise_duration(value: Any) -> float | None:
|
|
221
|
+
if value is None:
|
|
222
|
+
return None
|
|
223
|
+
if isinstance(value, datetime):
|
|
224
|
+
return float(value.hour * 60 + value.minute) or None
|
|
225
|
+
if isinstance(value, time):
|
|
226
|
+
return float(value.hour * 60 + value.minute) or None
|
|
227
|
+
raw = _text(value)
|
|
228
|
+
if not raw:
|
|
229
|
+
return None
|
|
230
|
+
match = re.search(r"(\d+(?:[.,]\d+)?)\s*(?:min|m|minutes?)?", raw, flags=re.IGNORECASE)
|
|
231
|
+
if match:
|
|
232
|
+
try:
|
|
233
|
+
parsed = float(match.group(1).replace(",", "."))
|
|
234
|
+
return parsed if parsed > 0 else None
|
|
235
|
+
except ValueError:
|
|
236
|
+
return None
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _normalise_style(value: Any) -> str:
|
|
241
|
+
raw = _strip_accents(_text(value)).casefold()
|
|
242
|
+
if not raw:
|
|
243
|
+
return ""
|
|
244
|
+
if any(token in raw for token in ("musica", "music", "audio only", "so audio")):
|
|
245
|
+
return "music"
|
|
246
|
+
if any(token in raw for token in ("full", "ia", "ai", "gerado", "artificial")):
|
|
247
|
+
return "full_ia"
|
|
248
|
+
if any(token in raw for token in ("pexels", "pixabay", "stock", "banco", "material")):
|
|
249
|
+
return "pexels"
|
|
250
|
+
return _text(value)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def normalize_channel_row(row: Mapping[str, Any]) -> dict[str, Any]:
|
|
254
|
+
"""Convert raw spreadsheet values into the application's field vocabulary."""
|
|
255
|
+
normalized: dict[str, Any] = {
|
|
256
|
+
"url": _text(row.get("url")),
|
|
257
|
+
"name": _text(row.get("name")),
|
|
258
|
+
"handle": normalize_handle(row.get("handle")),
|
|
259
|
+
"voice": _text(row.get("voice")),
|
|
260
|
+
"language": _text(row.get("language")),
|
|
261
|
+
"niche": _text(row.get("niche")),
|
|
262
|
+
"blueprint": _text(row.get("blueprint")),
|
|
263
|
+
"style_wide": _normalise_style(row.get("style_wide")),
|
|
264
|
+
"active": _normalise_boolean(row.get("active")),
|
|
265
|
+
"description": _text(row.get("description")),
|
|
266
|
+
"google_account": _text(row.get("google_account")),
|
|
267
|
+
"automation_on": _normalise_boolean(row.get("automation_on")),
|
|
268
|
+
"automation_time": _normalise_time(row.get("automation_time")),
|
|
269
|
+
"delegated_session_id": _text(row.get("delegated_session_id")),
|
|
270
|
+
"duration_minutes": _normalise_duration(row.get("duration_minutes")),
|
|
271
|
+
}
|
|
272
|
+
if "_source_row" in row:
|
|
273
|
+
normalized["_source_row"] = row["_source_row"]
|
|
274
|
+
return normalized
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def resolve_blueprint(value: Any, catalog: Sequence[tuple[str, str]]) -> str:
|
|
278
|
+
"""Resolve a human label or filename-like value to a Blueprint id."""
|
|
279
|
+
raw = _text(value)
|
|
280
|
+
if not raw:
|
|
281
|
+
return ""
|
|
282
|
+
raw_key = _key(raw)
|
|
283
|
+
raw_semantic = _semantic_key(raw)
|
|
284
|
+
candidates = [(str(identifier), str(label)) for identifier, label in catalog if str(identifier).strip()]
|
|
285
|
+
for identifier, label in candidates:
|
|
286
|
+
if raw_key in {_key(identifier), _key(label)}:
|
|
287
|
+
return identifier
|
|
288
|
+
if raw_semantic:
|
|
289
|
+
semantic_matches = [(identifier, label) for identifier, label in candidates if raw_semantic in {_semantic_key(identifier), _semantic_key(label)}]
|
|
290
|
+
if len(semantic_matches) == 1:
|
|
291
|
+
return semantic_matches[0][0]
|
|
292
|
+
if semantic_matches:
|
|
293
|
+
return max(semantic_matches, key=lambda item: SequenceMatcher(None, raw_semantic, _semantic_key(item[1])).ratio())[0]
|
|
294
|
+
scored: list[tuple[float, str]] = []
|
|
295
|
+
for identifier, label in candidates:
|
|
296
|
+
score = max(SequenceMatcher(None, raw_key, _key(identifier)).ratio(), SequenceMatcher(None, raw_key, _key(label)).ratio())
|
|
297
|
+
if score >= 0.72:
|
|
298
|
+
scored.append((score, identifier))
|
|
299
|
+
if scored:
|
|
300
|
+
return max(scored)[1]
|
|
301
|
+
return raw
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def resolve_voice(value: Any, catalog: Iterable[str]) -> str:
|
|
305
|
+
raw = _text(value)
|
|
306
|
+
if not raw:
|
|
307
|
+
return ""
|
|
308
|
+
options = [str(item) for item in catalog if str(item).strip()]
|
|
309
|
+
raw_key = _key(raw)
|
|
310
|
+
for option in options:
|
|
311
|
+
option_key = _key(option)
|
|
312
|
+
if raw_key == option_key or raw_key in option_key or option_key in raw_key:
|
|
313
|
+
return option
|
|
314
|
+
scored = [(SequenceMatcher(None, raw_key, _key(option)).ratio(), option) for option in options]
|
|
315
|
+
best = max(scored, default=(0.0, ""))
|
|
316
|
+
return best[1] if best[0] >= 0.72 else raw
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def resolve_google_account(value: Any, accounts: Iterable[Mapping[str, Any]]) -> tuple[str, str]:
|
|
320
|
+
"""Resolve an email, id or display label to ``(id, email)``."""
|
|
321
|
+
raw = _text(value)
|
|
322
|
+
if not raw:
|
|
323
|
+
return "", ""
|
|
324
|
+
raw_key = _key(raw)
|
|
325
|
+
candidates: list[tuple[str, str, str]] = []
|
|
326
|
+
for account in accounts:
|
|
327
|
+
identifier = _text(account.get("id"))
|
|
328
|
+
if not identifier:
|
|
329
|
+
continue
|
|
330
|
+
email = _text(account.get("email"))
|
|
331
|
+
label = _text(account.get("label"))
|
|
332
|
+
candidates.append((identifier, email, label))
|
|
333
|
+
if raw_key in {_key(identifier), _key(email), _key(label)}:
|
|
334
|
+
return identifier, email
|
|
335
|
+
scored = []
|
|
336
|
+
for identifier, email, label in candidates:
|
|
337
|
+
score = max(SequenceMatcher(None, raw_key, _key(email)).ratio(), SequenceMatcher(None, raw_key, _key(label)).ratio())
|
|
338
|
+
scored.append((score, identifier, email))
|
|
339
|
+
best = max(scored, default=(0.0, "", ""))
|
|
340
|
+
return (best[1], best[2]) if best[0] >= 0.78 else ("", raw)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _canonical_url(value: Any) -> str:
|
|
344
|
+
raw = _text(value)
|
|
345
|
+
if not raw:
|
|
346
|
+
return ""
|
|
347
|
+
if not re.match(r"^https?://", raw, flags=re.IGNORECASE):
|
|
348
|
+
raw = f"https://{raw}"
|
|
349
|
+
try:
|
|
350
|
+
parsed = urlsplit(raw)
|
|
351
|
+
query = [(key, val) for key, val in parse_qsl(parsed.query, keep_blank_values=True) if key.casefold() not in {"utm_source", "utm_medium", "utm_campaign", "si"}]
|
|
352
|
+
return urlunsplit((parsed.scheme.casefold(), parsed.netloc.casefold(), parsed.path.rstrip("/").casefold(), urlencode(query), ""))
|
|
353
|
+
except ValueError:
|
|
354
|
+
return _key(raw)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _identity_text(value: Any) -> str:
|
|
358
|
+
return _key(value)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def channel_is_duplicate(candidate: Mapping[str, Any], existing: Mapping[str, Any]) -> bool:
|
|
362
|
+
"""Return true when two records identify the same channel.
|
|
363
|
+
|
|
364
|
+
Handle, canonical URL and YouTube id are strong identifiers. A name is a
|
|
365
|
+
fallback only when one side has no handle, avoiding false positives for
|
|
366
|
+
two channels that happen to share a common name.
|
|
367
|
+
"""
|
|
368
|
+
candidate_id = _key(candidate.get("youtube_channel_id") or candidate.get("youtube_id"))
|
|
369
|
+
existing_id = _key(existing.get("youtube_channel_id") or existing.get("youtube_id"))
|
|
370
|
+
if candidate_id and existing_id and candidate_id == existing_id:
|
|
371
|
+
return True
|
|
372
|
+
candidate_handle = _identity_text(candidate.get("handle"))
|
|
373
|
+
existing_handle = _identity_text(existing.get("handle"))
|
|
374
|
+
if candidate_handle and existing_handle and candidate_handle == existing_handle:
|
|
375
|
+
return True
|
|
376
|
+
candidate_url = _canonical_url(candidate.get("url"))
|
|
377
|
+
existing_url = _canonical_url(existing.get("url"))
|
|
378
|
+
if candidate_url and existing_url and candidate_url == existing_url:
|
|
379
|
+
return True
|
|
380
|
+
candidate_name = _identity_text(candidate.get("name"))
|
|
381
|
+
existing_name = _identity_text(existing.get("name"))
|
|
382
|
+
return bool(candidate_name and existing_name and candidate_name == existing_name and not (candidate_handle and existing_handle and candidate_handle != existing_handle))
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def find_duplicate_channel(candidate: Mapping[str, Any], channels: Iterable[Mapping[str, Any]]) -> Mapping[str, Any] | None:
|
|
386
|
+
return next((channel for channel in channels if isinstance(channel, Mapping) and channel_is_duplicate(candidate, channel)), None)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def build_channel_template_xlsx() -> bytes:
|
|
390
|
+
"""Build a fresh, formatted workbook using the public template columns."""
|
|
391
|
+
frame = pd.DataFrame([{column: "" for column in CHANNEL_TEMPLATE_COLUMNS} for _ in range(20)])
|
|
392
|
+
frame["Automação Ligada "] = False
|
|
393
|
+
frame["Activo "] = True
|
|
394
|
+
frame["Horário diário (HH:MM)"] = "00:00"
|
|
395
|
+
frame["Duração Padrão Vídeos (Min)"] = 10
|
|
396
|
+
output = BytesIO()
|
|
397
|
+
with pd.ExcelWriter(output, engine="openpyxl") as writer:
|
|
398
|
+
frame.to_excel(writer, index=False, sheet_name="Canais YouTube")
|
|
399
|
+
worksheet = writer.book["Canais YouTube"]
|
|
400
|
+
worksheet.freeze_panes = "A2"
|
|
401
|
+
worksheet.auto_filter.ref = worksheet.dimensions
|
|
402
|
+
for cell in worksheet[1]:
|
|
403
|
+
font = copy(cell.font)
|
|
404
|
+
font.bold = True
|
|
405
|
+
font.color = "FFFFFF"
|
|
406
|
+
cell.font = font
|
|
407
|
+
fill = copy(cell.fill)
|
|
408
|
+
fill.fill_type = "solid"
|
|
409
|
+
fill.fgColor = "1F4E78"
|
|
410
|
+
cell.fill = fill
|
|
411
|
+
widths = {column: max(18, min(42, len(column) + 4)) for column in CHANNEL_TEMPLATE_COLUMNS}
|
|
412
|
+
widths["Descrição"] = 48
|
|
413
|
+
widths["URL canal"] = 38
|
|
414
|
+
for index, column in enumerate(CHANNEL_TEMPLATE_COLUMNS, start=1):
|
|
415
|
+
worksheet.column_dimensions[chr(64 + index) if index <= 26 else worksheet.cell(1, index).column_letter].width = widths[column]
|
|
416
|
+
return output.getvalue()
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
__all__ = [
|
|
420
|
+
"CHANNEL_TEMPLATE_COLUMNS",
|
|
421
|
+
"build_channel_template_xlsx",
|
|
422
|
+
"channel_is_duplicate",
|
|
423
|
+
"find_duplicate_channel",
|
|
424
|
+
"normalize_channel_row",
|
|
425
|
+
"parse_channel_workbook",
|
|
426
|
+
"resolve_blueprint",
|
|
427
|
+
"resolve_google_account",
|
|
428
|
+
"resolve_voice",
|
|
429
|
+
]
|
package/hermes_ui/domain.py
CHANGED
|
@@ -35,6 +35,7 @@ def create_channel(name: str, url: str = "", metadata: dict[str, Any] | None = N
|
|
|
35
35
|
"url": url.strip(),
|
|
36
36
|
"handle": "",
|
|
37
37
|
"description": "",
|
|
38
|
+
"description_source": "",
|
|
38
39
|
"niche": "",
|
|
39
40
|
"reference_channels": [],
|
|
40
41
|
"thumbnail_url": "",
|
|
@@ -56,6 +57,8 @@ def create_channel(name: str, url: str = "", metadata: dict[str, Any] | None = N
|
|
|
56
57
|
"automation_time": "00:00",
|
|
57
58
|
"active": True,
|
|
58
59
|
"daily_limit": 1,
|
|
60
|
+
"default_video_duration_minutes": None,
|
|
61
|
+
"import_source": "manual",
|
|
59
62
|
"backlog_total": 0,
|
|
60
63
|
"created_at": now(),
|
|
61
64
|
"updated_at": now(),
|
package/package.json
CHANGED