@danhachuel/thunderbolt 0.6.38 → 0.6.40
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
|
@@ -94,6 +94,7 @@ from hermes_ui.update_manager import check_version, restart_current_process, upd
|
|
|
94
94
|
|
|
95
95
|
from hermes_ui.script_documents import list_script_documents, read_script_document, save_script_document, script_storage_path
|
|
96
96
|
from hermes_ui.script_generation import generate_script_document
|
|
97
|
+
from hermes_ui.video_length import channel_video_length, length_generation_settings, words_from_channel_time
|
|
97
98
|
from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
|
|
98
99
|
from hermes_ui.elevenlabs_voices import ElevenLabsVoicesError, cached_personal_voices, fetch_personal_voices, personal_voice_options
|
|
99
100
|
from hermes_ui.thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image
|
|
@@ -130,6 +131,7 @@ from integrations.session_info_health import check_account_session_info_health
|
|
|
130
131
|
from integrations.youtube_session_manager import renew_account_session
|
|
131
132
|
from integrations.youtube_batch import account_key as youtube_batch_account_key, account_status as youtube_batch_account_status, authorize_account as authorize_youtube_batch_account, delete_account_token as delete_youtube_batch_token, list_my_channels as list_youtube_batch_channels, loopback_redirect_uri
|
|
132
133
|
from integrations.youtube_upload import authorize_youtube_agent
|
|
134
|
+
from integrations.tiktok_public import fetch_public_tiktok_profile
|
|
133
135
|
from integrations.youtube_growth_api import find_account_for_channel as find_youtube_growth_account
|
|
134
136
|
from integrations.local_runtime import MoneyPrinterRuntime
|
|
135
137
|
from integrations.moneyprinter_config import sync_moneyprinter_config
|
|
@@ -731,6 +733,10 @@ def generate_video_content_for_ui(
|
|
|
731
733
|
subject, topic_result = resolve_video_subject_for_generation(settings, channel, subject, blueprint)
|
|
732
734
|
|
|
733
735
|
blueprint = blueprint if isinstance(blueprint, dict) else blueprint_for_channel(channel)
|
|
736
|
+
generation_settings = {
|
|
737
|
+
**(generation_settings or {}),
|
|
738
|
+
**length_generation_settings(channel, blueprint),
|
|
739
|
+
}
|
|
734
740
|
script_result = generate_script_document(
|
|
735
741
|
settings,
|
|
736
742
|
document_type="Roteiro de vídeo",
|
|
@@ -740,7 +746,7 @@ def generate_video_content_for_ui(
|
|
|
740
746
|
channel=channel,
|
|
741
747
|
blueprint=blueprint,
|
|
742
748
|
structure_notes=str((generation_settings or {}).get("script_structure_notes") or ""),
|
|
743
|
-
generation_settings=generation_settings
|
|
749
|
+
generation_settings=generation_settings,
|
|
744
750
|
)
|
|
745
751
|
script = str(script_result.get("content") or "").strip()
|
|
746
752
|
keywords = generate_video_keywords(
|
|
@@ -2343,6 +2349,12 @@ def render_tiktok_channels():
|
|
|
2343
2349
|
with card_cols[4]:
|
|
2344
2350
|
st.metric("Vídeos", format_metric_number(channel.get("video_count")))
|
|
2345
2351
|
with card_cols[5]:
|
|
2352
|
+
if st.button("↻", key=f"refresh_tiktok_metrics_{channel_id}", help="Actualizar Seguidores, Vídeos e Curtidas", use_container_width=True):
|
|
2353
|
+
with st.spinner("A actualizar métricas TikTok…"):
|
|
2354
|
+
refreshed, message = _refresh_tiktok_channel_metrics(channel)
|
|
2355
|
+
(st.success if refreshed else st.warning)(message)
|
|
2356
|
+
if refreshed:
|
|
2357
|
+
st.rerun()
|
|
2346
2358
|
active = st.toggle("Activo", value=bool(channel.get("active", True)), key=f"tiktok_import_card_active_{channel_id}")
|
|
2347
2359
|
if active != bool(channel.get("active", True)):
|
|
2348
2360
|
update_channel(channel_id, {"active": active})
|
|
@@ -2503,6 +2515,42 @@ def _format_channel_count(value: Any) -> str:
|
|
|
2503
2515
|
return str(value)
|
|
2504
2516
|
|
|
2505
2517
|
|
|
2518
|
+
def _channel_average_video_time(channel: dict[str, Any], *, prompt_master: str = "") -> tuple[str, str, int]:
|
|
2519
|
+
blueprint = blueprint_for_channel(channel) if is_youtube_channel_record(channel) else {}
|
|
2520
|
+
words, duration, source = channel_video_length(channel, blueprint, prompt_master)
|
|
2521
|
+
return duration, source, words
|
|
2522
|
+
|
|
2523
|
+
|
|
2524
|
+
def _refresh_youtube_channel_metrics(channel: dict[str, Any], youtube: YouTubeAdapter) -> tuple[bool, str]:
|
|
2525
|
+
result = youtube.fetch_channel_public(str(channel.get("url") or channel.get("handle") or ""))
|
|
2526
|
+
if not result.ok or not isinstance(result.data, dict):
|
|
2527
|
+
return False, result.message
|
|
2528
|
+
data = result.data
|
|
2529
|
+
update_channel(str(channel["id"]), {
|
|
2530
|
+
"subscriber_count": data.get("subscriber_count"),
|
|
2531
|
+
"video_count": data.get("video_count"),
|
|
2532
|
+
"view_count": data.get("view_count"),
|
|
2533
|
+
"metrics_source": data.get("metrics_source", "youtube_public_page"),
|
|
2534
|
+
"last_public_lookup_at": data.get("last_public_lookup_at", now()),
|
|
2535
|
+
})
|
|
2536
|
+
return True, "Métricas YouTube actualizadas."
|
|
2537
|
+
|
|
2538
|
+
|
|
2539
|
+
def _refresh_tiktok_channel_metrics(channel: dict[str, Any]) -> tuple[bool, str]:
|
|
2540
|
+
result = fetch_public_tiktok_profile(str(channel.get("url") or channel.get("handle") or ""))
|
|
2541
|
+
if not result.ok or not isinstance(result.data, dict):
|
|
2542
|
+
return False, result.message
|
|
2543
|
+
data = result.data
|
|
2544
|
+
update_channel(str(channel["id"]), {
|
|
2545
|
+
"subscriber_count": data.get("subscriber_count"),
|
|
2546
|
+
"likes_count": data.get("likes_count"),
|
|
2547
|
+
"video_count": data.get("video_count"),
|
|
2548
|
+
"metrics_source": data.get("metrics_source", "tiktok_public_page"),
|
|
2549
|
+
"last_public_lookup_at": data.get("last_public_lookup_at", now()),
|
|
2550
|
+
})
|
|
2551
|
+
return True, "Métricas TikTok actualizadas."
|
|
2552
|
+
|
|
2553
|
+
|
|
2506
2554
|
def render_channels():
|
|
2507
2555
|
st.title("Canais Youtube")
|
|
2508
2556
|
st.caption("Escolha entre importar dados públicos do YouTube ou preencher o canal manualmente.")
|
|
@@ -2990,6 +3038,12 @@ def render_channels():
|
|
|
2990
3038
|
with header_cols[4]:
|
|
2991
3039
|
st.metric("Visualizações", _format_channel_count(channel.get("view_count")))
|
|
2992
3040
|
with header_cols[5]:
|
|
3041
|
+
if st.button("↻", key=f"refresh_youtube_metrics_{channel_id}", help="Actualizar Inscritos, Vídeos e Visualizações", use_container_width=True):
|
|
3042
|
+
with st.spinner("A actualizar métricas YouTube…"):
|
|
3043
|
+
refreshed, message = _refresh_youtube_channel_metrics(channel, youtube)
|
|
3044
|
+
(st.success if refreshed else st.warning)(message)
|
|
3045
|
+
if refreshed:
|
|
3046
|
+
st.rerun()
|
|
2993
3047
|
active = st.toggle("Activo", value=channel.get("active", True), key=f"active_{channel_id}")
|
|
2994
3048
|
if active != channel.get("active"):
|
|
2995
3049
|
update_channel(channel_id, {"active": active})
|
|
@@ -5349,7 +5403,7 @@ def render_tiktok_automation():
|
|
|
5349
5403
|
for channel in channels:
|
|
5350
5404
|
channel_id = str(channel["id"])
|
|
5351
5405
|
with st.container(border=True):
|
|
5352
|
-
header_cols = st.columns([0.55, 2.
|
|
5406
|
+
header_cols = st.columns([0.55, 2.15, 1.25, 1.35, 1.55, 1.2])
|
|
5353
5407
|
with header_cols[0]:
|
|
5354
5408
|
profile_image = _tiktok_avatar_url(channel)
|
|
5355
5409
|
if profile_image:
|
|
@@ -5363,6 +5417,11 @@ def render_tiktok_automation():
|
|
|
5363
5417
|
enabled = st.toggle("Automação ligada", value=bool(channel.get("automation_on", False)), key=f"tiktok_automation_on_{channel_id}")
|
|
5364
5418
|
with header_cols[3]:
|
|
5365
5419
|
schedule_time = st.text_input("Horário (HH:MM)", value=channel.get("automation_time", "00:00"), key=f"tiktok_automation_time_{channel_id}")
|
|
5420
|
+
with header_cols[4]:
|
|
5421
|
+
prompt_file = TIKTOK_PROMPT_MASTERS / str(channel.get("default_prompt_master") or channel.get("prompt_master") or "")
|
|
5422
|
+
prompt_content = load_prompt_master_file(prompt_file) if prompt_file.is_file() else ""
|
|
5423
|
+
calculated_time, calculated_source, calculated_words = _channel_average_video_time(channel, prompt_master=prompt_content)
|
|
5424
|
+
average_video_time = st.text_input("Tempo Medio de Video (HH:MM)", value=str(channel.get("average_video_time") or calculated_time), key=f"tiktok_average_video_time_{channel_id}", help=f"Referência média: {calculated_words or 0} palavras · fonte: {calculated_source}. Use 00:00 para voltar ao Prompt Master.")
|
|
5366
5425
|
default_cols = st.columns([1.0, 1.0, 1.35, 1.45, 1.55, 1.0], gap="small")
|
|
5367
5426
|
with default_cols[0]:
|
|
5368
5427
|
st.markdown("**Idioma do roteiro**")
|
|
@@ -5385,9 +5444,11 @@ def render_tiktok_automation():
|
|
|
5385
5444
|
if st.button("Guardar", key=f"tiktok_automation_save_{channel_id}", use_container_width=True, type="primary"):
|
|
5386
5445
|
if not valid_hhmm(schedule_time):
|
|
5387
5446
|
st.error("Use o formato HH:MM, por exemplo 08:30.")
|
|
5447
|
+
elif not valid_hhmm(average_video_time):
|
|
5448
|
+
st.error("O Tempo Medio de Video deve estar no formato HH:MM, por exemplo 00:02.")
|
|
5388
5449
|
else:
|
|
5389
5450
|
avatar_url = _tiktok_avatar_url(channel)
|
|
5390
|
-
update_channel(channel_id, {"automation_on": bool(enabled), "automation_time": schedule_time.strip(), "default_prompt_master": prompt, "prompt_master": prompt, "platform": "tiktok", "format": automation_format, "video_aspect_ratio": "Portrait 9:16", "style_wide": "portrait", "avatar_url": avatar_url, "thumbnail_url": avatar_url})
|
|
5451
|
+
update_channel(channel_id, {"automation_on": bool(enabled), "automation_time": schedule_time.strip(), "average_video_time": average_video_time.strip() or "00:00", "average_video_word_count": words_from_channel_time(average_video_time), "default_prompt_master": prompt, "prompt_master": prompt, "platform": "tiktok", "format": automation_format, "video_aspect_ratio": "Portrait 9:16", "style_wide": "portrait", "avatar_url": avatar_url, "thumbnail_url": avatar_url})
|
|
5391
5452
|
st.success("Automação TikTok guardada.")
|
|
5392
5453
|
st.rerun()
|
|
5393
5454
|
_render_tiktok_automation_cards()
|
|
@@ -5521,7 +5582,7 @@ def render_automation():
|
|
|
5521
5582
|
automation_voice = current_voice
|
|
5522
5583
|
paired_thumbnail = thumbnail_blueprint_for_blueprint(automation_blueprint)
|
|
5523
5584
|
automation_format = str(channel.get("format") or "wide")
|
|
5524
|
-
header_cols = st.columns([0.
|
|
5585
|
+
header_cols = st.columns([0.58, 1.95, 1.4, 1.15, 1.15, 1.25, 1.25, 1.45], gap="small")
|
|
5525
5586
|
with header_cols[0]:
|
|
5526
5587
|
if channel.get("thumbnail_url"):
|
|
5527
5588
|
st.image(channel["thumbnail_url"], width=48)
|
|
@@ -5546,6 +5607,9 @@ def render_automation():
|
|
|
5546
5607
|
st.caption(str(channel.get("video_aspect_ratio") or "Landscape 16:9"))
|
|
5547
5608
|
with header_cols[6]:
|
|
5548
5609
|
schedule_time = st.text_input("Horário (HH:MM)", value=channel.get("automation_time", "00:00"), key=f"automation_time_{channel_id}")
|
|
5610
|
+
with header_cols[7]:
|
|
5611
|
+
calculated_time, calculated_source, calculated_words = _channel_average_video_time(channel)
|
|
5612
|
+
average_video_time = st.text_input("Tempo Medio de Video (HH:MM)", value=str(channel.get("average_video_time") or calculated_time), key=f"average_video_time_{channel_id}", help=f"Referência média: {calculated_words or 0} palavras · fonte: {calculated_source}. Use 00:00 para voltar ao Blueprint.")
|
|
5549
5613
|
|
|
5550
5614
|
control_cols = st.columns([1.8, 1.8, 1.35, 1.2], gap="small")
|
|
5551
5615
|
with control_cols[0]:
|
|
@@ -5571,10 +5635,14 @@ def render_automation():
|
|
|
5571
5635
|
if st.button("Guardar", key=f"automation_save_{channel_id}", use_container_width=True, type="primary"):
|
|
5572
5636
|
if not valid_hhmm(schedule_time):
|
|
5573
5637
|
st.error("Use o formato HH:MM, por exemplo 08:30.")
|
|
5638
|
+
elif not valid_hhmm(average_video_time):
|
|
5639
|
+
st.error("O Tempo Medio de Video deve estar no formato HH:MM, por exemplo 00:02.")
|
|
5574
5640
|
else:
|
|
5575
5641
|
update_channel(channel_id, {
|
|
5576
5642
|
"automation_on": bool(enabled),
|
|
5577
5643
|
"automation_time": schedule_time.strip(),
|
|
5644
|
+
"average_video_time": average_video_time.strip() or "00:00",
|
|
5645
|
+
"average_video_word_count": words_from_channel_time(average_video_time),
|
|
5578
5646
|
"format": automation_format,
|
|
5579
5647
|
})
|
|
5580
5648
|
set_channel_defaults(channel_id, automation_blueprint, automation_voice)
|
|
@@ -22,6 +22,7 @@ from .domain import create_batch, create_tasks_for_batch
|
|
|
22
22
|
from .languages import language_code
|
|
23
23
|
from .material_sources import selected_material_source
|
|
24
24
|
from .notifications import record_notification
|
|
25
|
+
from .video_length import length_generation_settings
|
|
25
26
|
from integrations.session_info_health import check_all_accounts_session_info_health, emit_session_info_health_alerts
|
|
26
27
|
|
|
27
28
|
WORKER_STATE_FILE = "automation_worker.json"
|
|
@@ -165,6 +166,19 @@ def _blueprint_for_channel(channel: dict[str, Any]) -> dict[str, Any]:
|
|
|
165
166
|
return {}
|
|
166
167
|
|
|
167
168
|
|
|
169
|
+
def _length_settings(channel: dict[str, Any], blueprint: dict[str, Any]) -> dict[str, Any]:
|
|
170
|
+
prompt_content = ""
|
|
171
|
+
prompt_id = str(channel.get("default_prompt_master") or channel.get("prompt_master") or "").strip()
|
|
172
|
+
if prompt_id:
|
|
173
|
+
prompt_path = storage.TIKTOK_PROMPT_MASTERS / prompt_id
|
|
174
|
+
if prompt_path.is_file():
|
|
175
|
+
try:
|
|
176
|
+
prompt_content = storage.load_prompt_master_file(prompt_path)
|
|
177
|
+
except (OSError, ValueError):
|
|
178
|
+
prompt_content = ""
|
|
179
|
+
return length_generation_settings(channel, blueprint, prompt_content)
|
|
180
|
+
|
|
181
|
+
|
|
168
182
|
def _creative_payload(channel: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
|
169
183
|
settings = storage.read_json("settings.json", {})
|
|
170
184
|
blueprint = _blueprint_for_channel(channel)
|
|
@@ -194,7 +208,7 @@ def _creative_payload(channel: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
|
|
194
208
|
"thumbnail_blueprint_id": str(channel.get("default_thumbnail_blueprint_id") or channel.get("thumbnail_blueprint_id") or ""),
|
|
195
209
|
"voice": str(channel.get("default_voice") or channel.get("voice") or ""),
|
|
196
210
|
"material_source": material_source,
|
|
197
|
-
"generation_settings": {"video_keywords": editorial.get("keywords", []), "material_source": material_source},
|
|
211
|
+
"generation_settings": {"video_keywords": editorial.get("keywords", []), "material_source": material_source, **_length_settings(channel, blueprint)},
|
|
198
212
|
"ai_generation": {"topic": topic_package, "editorial": editorial},
|
|
199
213
|
}
|
|
200
214
|
return topic_package["topic"], payload
|
|
@@ -241,7 +255,7 @@ def _pending_payload(channel: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
|
|
241
255
|
"thumbnail_blueprint_id": str(channel.get("default_thumbnail_blueprint_id") or channel.get("thumbnail_blueprint_id") or ""),
|
|
242
256
|
"voice": str(channel.get("default_voice") or channel.get("voice") or ""),
|
|
243
257
|
"material_source": material_source,
|
|
244
|
-
"generation_settings": {"material_source": material_source},
|
|
258
|
+
"generation_settings": {"material_source": material_source, **_length_settings(channel, blueprint)},
|
|
245
259
|
"ai_generation": {},
|
|
246
260
|
}
|
|
247
261
|
return "", payload
|
|
@@ -43,6 +43,7 @@ def generate_script_document(
|
|
|
43
43
|
blueprint_payload = blueprint or {}
|
|
44
44
|
generation_settings_payload = generation_settings or {}
|
|
45
45
|
if normalized_type == "video_script":
|
|
46
|
+
target_word_count = generation_settings_payload.get("target_word_count")
|
|
46
47
|
output_requirements = {
|
|
47
48
|
"content": "roteiro completo em Markdown, com título, gancho, cenas, narração e indicações visuais/sonoras",
|
|
48
49
|
"summary": "resumo editorial em uma frase",
|
|
@@ -52,7 +53,8 @@ def generate_script_document(
|
|
|
52
53
|
f"escrevendo título, resumo e conteúdo integralmente em {language_name} ({language_code}). "
|
|
53
54
|
"alinhado exclusivamente ao nicho e às regras do Blueprint fornecido. Não inventes factos sensíveis, "
|
|
54
55
|
"não uses introduções genéricas, não escrevas comentários sobre IA e não incluas um CTA vazio. "
|
|
55
|
-
"
|
|
56
|
+
+ (f"Escreve aproximadamente {int(target_word_count)} palavras; trata este valor como uma referência média e não como um limite rígido. " if target_word_count else "")
|
|
57
|
+
+ "Responde apenas com JSON válido nas chaves title, summary e content."
|
|
56
58
|
)
|
|
57
59
|
else:
|
|
58
60
|
output_requirements = {
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Shared video-length estimation and channel override helpers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
WORDS_PER_MINUTE = 150
|
|
9
|
+
AVERAGE_VIDEO_TIME_KEY = "average_video_time"
|
|
10
|
+
AVERAGE_VIDEO_WORD_COUNT_KEY = "average_video_word_count"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def valid_hhmm(value: Any) -> bool:
|
|
14
|
+
match = re.fullmatch(r"\s*(\d{1,3}):(\d{2})\s*", str(value or ""))
|
|
15
|
+
if not match:
|
|
16
|
+
return False
|
|
17
|
+
hours, minutes = int(match.group(1)), int(match.group(2))
|
|
18
|
+
return hours >= 0 and 0 <= minutes <= 59
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def minutes_from_hhmm(value: Any) -> int:
|
|
22
|
+
if not valid_hhmm(value):
|
|
23
|
+
return 0
|
|
24
|
+
hours, minutes = (int(part) for part in str(value).strip().split(":"))
|
|
25
|
+
return hours * 60 + minutes
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def hhmm_from_minutes(value: Any) -> str:
|
|
29
|
+
try:
|
|
30
|
+
total = max(0, int(value))
|
|
31
|
+
except (TypeError, ValueError):
|
|
32
|
+
total = 0
|
|
33
|
+
return f"{total // 60:02d}:{total % 60:02d}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _numbers(value: Any) -> list[int]:
|
|
37
|
+
if isinstance(value, bool):
|
|
38
|
+
return []
|
|
39
|
+
if isinstance(value, (int, float)):
|
|
40
|
+
return [int(value)] if value > 0 else []
|
|
41
|
+
return [int(item.replace(",", "")) for item in re.findall(r"(?<![\w])\d[\d,.]*(?![\w])", str(value or "")) if int(item.replace(",", "").replace(".", "")) > 0]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _configured_word_count(value: Any, key: str = "") -> int:
|
|
45
|
+
"""Find an explicit word target, or convert an explicit character target."""
|
|
46
|
+
if isinstance(value, dict):
|
|
47
|
+
for child_key, child in value.items():
|
|
48
|
+
normalized = str(child_key).casefold().replace("_", " ").replace("-", " ")
|
|
49
|
+
if any(token in normalized for token in ("word", "words", "palavra", "palavras")):
|
|
50
|
+
numbers = _numbers(child)
|
|
51
|
+
if numbers:
|
|
52
|
+
return max(numbers) if len(numbers) > 1 else numbers[0]
|
|
53
|
+
result = _configured_word_count(child, str(child_key))
|
|
54
|
+
if result:
|
|
55
|
+
return result
|
|
56
|
+
return 0
|
|
57
|
+
if isinstance(value, list):
|
|
58
|
+
normalized_key = key.casefold().replace("_", " ").replace("-", " ")
|
|
59
|
+
if any(token in normalized_key for token in ("character", "caracter", "char length", "length requirement")):
|
|
60
|
+
numbers = [number for child in value for number in _numbers(child)]
|
|
61
|
+
if numbers:
|
|
62
|
+
return max(1, round((sum(numbers) / len(numbers)) / 6))
|
|
63
|
+
for child in value:
|
|
64
|
+
result = _configured_word_count(child, key)
|
|
65
|
+
if result:
|
|
66
|
+
return result
|
|
67
|
+
return 0
|
|
68
|
+
text = str(value or "")
|
|
69
|
+
normalized_key = key.casefold().replace("_", " ").replace("-", " ")
|
|
70
|
+
if any(token in normalized_key for token in ("word", "words", "palavra", "palavras")):
|
|
71
|
+
numbers = _numbers(text)
|
|
72
|
+
if numbers:
|
|
73
|
+
return max(numbers) if len(numbers) > 1 else numbers[0]
|
|
74
|
+
if any(token in normalized_key for token in ("character", "caracter", "char length", "length requirement")):
|
|
75
|
+
numbers = _numbers(text)
|
|
76
|
+
if numbers:
|
|
77
|
+
return max(1, round((sum(numbers) / len(numbers)) / 6))
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def explicit_word_count(blueprint: Any = None, prompt_master: str = "") -> int:
|
|
82
|
+
"""Return only an explicit target; never count the instructions themselves."""
|
|
83
|
+
result = _configured_word_count(blueprint)
|
|
84
|
+
if result:
|
|
85
|
+
return result
|
|
86
|
+
matches = re.findall(r"(?:at least|minimum of|around|approximately|exactly|entre|mínimo de|cerca de|aproximadamente)\s+(\d[\d,.]*)\s+(?:words|word|palavras|palavra)", prompt_master, flags=re.IGNORECASE)
|
|
87
|
+
if matches:
|
|
88
|
+
return max(1, int(matches[-1].replace(",", "").replace(".", "")))
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def channel_video_length(channel: dict[str, Any], blueprint: Any = None, prompt_master: str = "") -> tuple[int, str, str]:
|
|
93
|
+
"""Return (word_count, hh:mm, source), preferring channel override over defaults."""
|
|
94
|
+
configured_time = minutes_from_hhmm(channel.get(AVERAGE_VIDEO_TIME_KEY))
|
|
95
|
+
configured_words = int(channel.get(AVERAGE_VIDEO_WORD_COUNT_KEY) or 0) if str(channel.get(AVERAGE_VIDEO_WORD_COUNT_KEY) or "0").isdigit() else 0
|
|
96
|
+
if configured_time:
|
|
97
|
+
return configured_time * WORDS_PER_MINUTE, hhmm_from_minutes(configured_time), "canal"
|
|
98
|
+
if configured_words:
|
|
99
|
+
return configured_words, hhmm_from_minutes(round(configured_words / WORDS_PER_MINUTE)), "canal"
|
|
100
|
+
words = explicit_word_count(blueprint, prompt_master)
|
|
101
|
+
if words:
|
|
102
|
+
return words, hhmm_from_minutes(round(words / WORDS_PER_MINUTE)), "Blueprint/Prompt Master"
|
|
103
|
+
return 0, "00:00", "sem referência"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def words_from_channel_time(value: Any) -> int:
|
|
107
|
+
minutes = minutes_from_hhmm(value)
|
|
108
|
+
return minutes * WORDS_PER_MINUTE if minutes else 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def length_generation_settings(channel: dict[str, Any], blueprint: Any = None, prompt_master: str = "") -> dict[str, Any]:
|
|
112
|
+
words, duration, source = channel_video_length(channel, blueprint, prompt_master)
|
|
113
|
+
if not words:
|
|
114
|
+
return {}
|
|
115
|
+
return {
|
|
116
|
+
"target_word_count": words,
|
|
117
|
+
"target_video_duration": duration,
|
|
118
|
+
"target_word_count_source": source,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
__all__ = [
|
|
122
|
+
"AVERAGE_VIDEO_TIME_KEY",
|
|
123
|
+
"AVERAGE_VIDEO_WORD_COUNT_KEY",
|
|
124
|
+
"WORDS_PER_MINUTE",
|
|
125
|
+
"channel_video_length",
|
|
126
|
+
"explicit_word_count",
|
|
127
|
+
"hhmm_from_minutes",
|
|
128
|
+
"length_generation_settings",
|
|
129
|
+
"minutes_from_hhmm",
|
|
130
|
+
"valid_hhmm",
|
|
131
|
+
"words_from_channel_time",
|
|
132
|
+
]
|
|
@@ -152,6 +152,17 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
|
|
|
152
152
|
merged[key] = value
|
|
153
153
|
return merged
|
|
154
154
|
|
|
155
|
+
def extract_user(payload: Any) -> Mapping[str, Any] | None:
|
|
156
|
+
if not isinstance(payload, Mapping):
|
|
157
|
+
return None
|
|
158
|
+
candidates = [
|
|
159
|
+
payload.get('user'),
|
|
160
|
+
(payload.get('data') or {}).get('user') if isinstance(payload.get('data'), Mapping) else None,
|
|
161
|
+
(payload.get('graphql') or {}).get('user') if isinstance(payload.get('graphql'), Mapping) else None,
|
|
162
|
+
(payload.get('data') or {}).get('profile') if isinstance(payload.get('data'), Mapping) else None,
|
|
163
|
+
]
|
|
164
|
+
return next((candidate for candidate in candidates if isinstance(candidate, Mapping)), None)
|
|
165
|
+
|
|
155
166
|
request_headers = [
|
|
156
167
|
headers,
|
|
157
168
|
{**headers, 'User-Agent': 'Instagram 390.0.0.0.50 Android', 'Accept': 'application/json, text/plain, */*'},
|
|
@@ -169,7 +180,7 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
|
|
|
169
180
|
if best_user:
|
|
170
181
|
return best_user
|
|
171
182
|
continue
|
|
172
|
-
user = (
|
|
183
|
+
user = extract_user(payload)
|
|
173
184
|
if isinstance(user, dict):
|
|
174
185
|
best_user = merge_profile(best_user, user)
|
|
175
186
|
if profile_quality(best_user) >= 4:
|
|
@@ -189,7 +200,7 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
|
|
|
189
200
|
check=False,
|
|
190
201
|
)
|
|
191
202
|
payload = json.loads(completed.stdout) if completed.returncode == 0 and completed.stdout else None
|
|
192
|
-
user = (
|
|
203
|
+
user = extract_user(payload)
|
|
193
204
|
if isinstance(user, dict):
|
|
194
205
|
best_user = merge_profile(best_user, user)
|
|
195
206
|
if profile_quality(best_user) >= 4:
|
|
@@ -215,7 +226,7 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
|
|
|
215
226
|
if response is None or response.status >= 400:
|
|
216
227
|
continue
|
|
217
228
|
payload = json.loads(page.locator('body').inner_text(timeout=5000))
|
|
218
|
-
user = (
|
|
229
|
+
user = extract_user(payload)
|
|
219
230
|
if isinstance(user, dict):
|
|
220
231
|
best_user = merge_profile(best_user, user)
|
|
221
232
|
if profile_quality(best_user) >= 4:
|
package/package.json
CHANGED