@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 or {},
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.35, 1.35, 1.5, 1.35])
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.62, 2.15, 1.55, 1.25, 1.25, 1.45, 1.3], gap="small")
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
- "Responde apenas com JSON válido nas chaves title, summary e content."
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 = ((payload.get('data') or {}).get('user') if isinstance(payload, dict) else None)
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 = ((payload.get('data') or {}).get('user') if isinstance(payload, dict) else None)
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 = ((payload.get('data') or {}).get('user') if isinstance(payload, dict) else None)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.6.38",
3
+ "version": "0.6.40",
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",