@danhachuel/thunderbolt 0.2.49 → 0.2.51

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.
@@ -8,6 +8,7 @@ from pathlib import Path
8
8
  from typing import Any
9
9
 
10
10
  from . import storage
11
+ from .creative_generation import generate_creative_package, generate_topic_for_channel
11
12
  from .domain import create_batch, create_tasks_for_batch
12
13
 
13
14
  WORKER_STATE_FILE = "automation_worker.json"
@@ -127,9 +128,50 @@ def _daily_quantity(channel: dict[str, Any]) -> int:
127
128
  return 1
128
129
 
129
130
 
130
- def _automation_topic(channel: dict[str, Any]) -> str:
131
- topic = str(channel.get("automation_topic") or "").strip()
132
- return topic or f"Geração automática — {channel.get('name') or 'Canal'}"
131
+ def _blueprint_for_channel(channel: dict[str, Any]) -> dict[str, Any]:
132
+ blueprint_id = str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or "").strip()
133
+ if not blueprint_id:
134
+ return {}
135
+ for path in storage.list_blueprint_files():
136
+ try:
137
+ data = storage.load_blueprint_file(path)
138
+ except (OSError, ValueError):
139
+ continue
140
+ identifiers = {str(data.get("id") or ""), path.stem, str(data.get("name") or "")}
141
+ if blueprint_id in identifiers:
142
+ return data
143
+ return {}
144
+
145
+
146
+ def _creative_payload(channel: dict[str, Any]) -> tuple[str, dict[str, Any]]:
147
+ settings = storage.read_json("settings.json", {})
148
+ blueprint = _blueprint_for_channel(channel)
149
+ user_context = str(channel.get("automation_topic") or "").strip()
150
+ topic_package = generate_topic_for_channel(settings, channel, blueprint, user_context=user_context)
151
+ creative = generate_creative_package(
152
+ settings,
153
+ channel,
154
+ topic_package["topic"],
155
+ blueprint,
156
+ language=str(channel.get("language") or "Português"),
157
+ )
158
+ variant = creative["thumbnail_variant"]
159
+ payload = {
160
+ "topic": topic_package["topic"],
161
+ "topic_source": "llm",
162
+ "title": creative["title"],
163
+ "title_candidates": creative["title_candidates"],
164
+ "thumbnail_variant": variant,
165
+ "thumbnail_variants": creative["thumbnail_variants"],
166
+ "thumbnail_prompt": variant.get("image_prompt", ""),
167
+ "thumbnail_text": variant.get("overlay_text", ""),
168
+ "thumbnail_status": creative.get("thumbnail_status", "prompt_ready"),
169
+ "blueprint_id": str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or ""),
170
+ "blueprint_name": str(blueprint.get("name") or "SEM BLUEPRINT CONFIGURADO"),
171
+ "voice": str(channel.get("default_voice") or channel.get("voice") or ""),
172
+ "ai_generation": {"topic": topic_package, "creative": creative},
173
+ }
174
+ return topic_package["topic"], payload
133
175
 
134
176
 
135
177
  def _batch_for_day(channel_id: str, day: str) -> dict[str, Any] | None:
@@ -155,6 +197,7 @@ def _create_channel_batch(channel: dict[str, Any], when: datetime) -> dict[str,
155
197
  date_key = when.date().isoformat()
156
198
  style_wide = str(channel.get("style_wide") or "pexels")
157
199
  music_mode = style_wide == "music"
200
+ topic, payload = _creative_payload(channel)
158
201
  options = {
159
202
  "language": channel.get("language") or "Português",
160
203
  "format": "wide",
@@ -164,17 +207,13 @@ def _create_channel_batch(channel: dict[str, Any], when: datetime) -> dict[str,
164
207
  "background_mode": "none" if music_mode else ("ai" if style_wide == "full_ia" else "stock"),
165
208
  "music_path": channel.get("music_path") or "",
166
209
  "music_source": channel.get("music_source") or "",
210
+ "topic_source": "llm",
211
+ "channel_payloads": {channel_id: payload},
167
212
  "automation_worker": True,
168
213
  "automation_date": date_key,
169
214
  "automation_scheduled_at": _local_iso(when),
170
215
  }
171
- batch = create_batch(
172
- "single",
173
- [channel_id],
174
- _automation_topic(channel),
175
- _daily_quantity(channel),
176
- options,
177
- )
216
+ batch = create_batch("single", [channel_id], topic, _daily_quantity(channel), options)
178
217
  tasks = create_tasks_for_batch(batch)
179
218
  return {"batch": batch, "tasks": tasks, "channel_id": channel_id}
180
219
 
@@ -0,0 +1,278 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from functools import lru_cache
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import requests
10
+
11
+
12
+ ROOT = Path(__file__).resolve().parents[1]
13
+ REFERENCES_DIR = ROOT / "seed" / "references"
14
+
15
+ REFERENCE_FILES = {
16
+ "title_formulas": "title-formulas.md",
17
+ "viral_titles": "viral-titles.md",
18
+ "thumbnail_checklist": "thumbnail-checklist.md",
19
+ "viral_thumbnails": "viral-thumbnails.md",
20
+ "trend_intelligence": "trend-intelligence.md",
21
+ "humanize": "humanize-integration.md",
22
+ "ai_tells": "ai-tells.md",
23
+ }
24
+
25
+
26
+ class CreativeGenerationError(RuntimeError):
27
+ """Raised when a configured LLM cannot produce a valid creative package."""
28
+
29
+
30
+ def _provider_config(settings: dict[str, Any]) -> tuple[str, str, str, str]:
31
+ provider = str(settings.get("llm_provider") or "openai").strip().lower()
32
+ key = str(settings.get(f"{provider}_api_key") or "").strip()
33
+ base_url = str(settings.get(f"{provider}_base_url") or "").strip()
34
+ model = str(settings.get(f"{provider}_model_name") or "").strip()
35
+ if provider == "openai":
36
+ base_url = base_url or "https://api.openai.com/v1"
37
+ elif provider == "ollama":
38
+ base_url = base_url or "http://127.0.0.1:11434/v1"
39
+ if not base_url:
40
+ raise CreativeGenerationError(
41
+ f"O provider LLM '{provider}' não tem Base URL configurada em Configurações Técnicas > API Keys."
42
+ )
43
+ if not model:
44
+ raise CreativeGenerationError(
45
+ f"O provider LLM '{provider}' não tem modelo configurado em Configurações Técnicas > API Keys."
46
+ )
47
+ if provider not in {"ollama", "litellm"} and not key:
48
+ raise CreativeGenerationError(
49
+ f"Configure a API key do provider LLM '{provider}' em Configurações Técnicas > API Keys."
50
+ )
51
+ return provider, key, base_url.rstrip("/"), model
52
+
53
+
54
+ @lru_cache(maxsize=1)
55
+ def reference_bundle() -> str:
56
+ sections: list[str] = []
57
+ for label, filename in REFERENCE_FILES.items():
58
+ path = REFERENCES_DIR / filename
59
+ try:
60
+ content = path.read_text(encoding="utf-8")
61
+ except OSError:
62
+ continue
63
+ # Keep prompts bounded while preserving the concrete rules from the attachments.
64
+ sections.append(f"[{label}]\n{content[:4200]}")
65
+ return "\n\n".join(sections)
66
+
67
+
68
+ def _json_content(payload: dict[str, Any]) -> dict[str, Any]:
69
+ choices = payload.get("choices") or []
70
+ if not choices:
71
+ raise CreativeGenerationError("O endpoint LLM devolveu uma resposta sem choices.")
72
+ message = (choices[0] or {}).get("message") or {}
73
+ content = message.get("content")
74
+ if isinstance(content, list):
75
+ content = "".join(str(item.get("text", "")) for item in content if isinstance(item, dict))
76
+ text = str(content or "").strip()
77
+ text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.IGNORECASE)
78
+ text = re.sub(r"\s*```$", "", text).strip()
79
+ try:
80
+ parsed = json.loads(text)
81
+ except json.JSONDecodeError as exc:
82
+ raise CreativeGenerationError("O endpoint LLM não devolveu JSON válido.") from exc
83
+ if not isinstance(parsed, dict):
84
+ raise CreativeGenerationError("A resposta do endpoint LLM não é um objecto JSON.")
85
+ return parsed
86
+
87
+
88
+ def _chat_json(settings: dict[str, Any], system_prompt: str, user_prompt: str) -> dict[str, Any]:
89
+ _provider, api_key, base_url, model = _provider_config(settings)
90
+ headers = {"Content-Type": "application/json"}
91
+ if api_key:
92
+ headers["Authorization"] = f"Bearer {api_key}"
93
+ body = {
94
+ "model": model,
95
+ "messages": [
96
+ {"role": "system", "content": system_prompt},
97
+ {"role": "user", "content": user_prompt},
98
+ ],
99
+ "response_format": {"type": "json_object"},
100
+ }
101
+ endpoint = f"{base_url}/chat/completions"
102
+ try:
103
+ response = requests.post(endpoint, headers=headers, json=body, timeout=120)
104
+ except requests.RequestException as exc:
105
+ raise CreativeGenerationError(f"Não foi possível contactar o provider LLM: {exc}") from exc
106
+ if response.status_code >= 400:
107
+ detail = response.text[:500].replace(api_key, "[REDACTED]") if api_key else response.text[:500]
108
+ raise CreativeGenerationError(f"O provider LLM devolveu HTTP {response.status_code}: {detail}")
109
+ try:
110
+ payload = response.json()
111
+ except ValueError as exc:
112
+ raise CreativeGenerationError("O provider LLM devolveu uma resposta que não é JSON.") from exc
113
+ return _json_content(payload)
114
+
115
+
116
+ def channel_context(channel: dict[str, Any], blueprint: dict[str, Any] | None = None) -> dict[str, Any]:
117
+ blueprint = blueprint or {}
118
+ metadata = blueprint.get("metadata") if isinstance(blueprint.get("metadata"), dict) else {}
119
+ return {
120
+ "channel_name": str(channel.get("name") or "Canal sem nome"),
121
+ "handle": str(channel.get("handle") or ""),
122
+ "description": str(channel.get("description") or ""),
123
+ "language": str(channel.get("language") or "Português"),
124
+ "style_wide": str(channel.get("style_wide") or "pexels"),
125
+ "blueprint_id": str(blueprint.get("id") or channel.get("default_blueprint_id") or channel.get("blueprint_id") or ""),
126
+ "blueprint_name": str(blueprint.get("name") or "SEM BLUEPRINT CONFIGURADO"),
127
+ "blueprint_niche": str(blueprint.get("target_niche") or blueprint.get("niche") or metadata.get("target_niche") or metadata.get("niche") or ""),
128
+ "default_voice": str(channel.get("default_voice") or channel.get("voice") or ""),
129
+ }
130
+
131
+
132
+ def generate_topic_for_channel(
133
+ settings: dict[str, Any],
134
+ channel: dict[str, Any],
135
+ blueprint: dict[str, Any] | None = None,
136
+ user_context: str = "",
137
+ ) -> dict[str, Any]:
138
+ context = channel_context(channel, blueprint)
139
+ system = (
140
+ "És o estratega editorial de um canal faceless. Gera um briefing específico para este canal. "
141
+ "Não inventes factos sobre o canal. Se o contexto já tiver um tópico, desenvolve-o sem trocar o nicho. "
142
+ "Escreve de forma natural, sem introduções artificiais, frases de IA, clickbait enganoso ou CTA genérico. "
143
+ "Responde apenas com JSON válido com as chaves topic, angle, hook, niche e rationale."
144
+ )
145
+ user = json.dumps(
146
+ {
147
+ "channel": context,
148
+ "user_context": user_context.strip(),
149
+ "reference_rules": reference_bundle(),
150
+ "requirements": [
151
+ "topic em uma frase clara",
152
+ "angle contrarian ou inesperado quando fizer sentido",
153
+ "hook para os primeiros segundos",
154
+ "niche coerente com o Blueprint e descrição",
155
+ "rationale curta e prática",
156
+ ],
157
+ },
158
+ ensure_ascii=False,
159
+ )
160
+ result = _chat_json(settings, system, user)
161
+ required = ("topic", "angle", "hook", "niche", "rationale")
162
+ if any(not str(result.get(key) or "").strip() for key in required):
163
+ raise CreativeGenerationError("O briefing gerado veio incompleto; tente novamente.")
164
+ result["topic_source"] = "llm"
165
+ result["channel_id"] = str(channel.get("id") or "")
166
+ return result
167
+
168
+
169
+ def _score(value: Any) -> int:
170
+ try:
171
+ return max(0, min(3, int(value)))
172
+ except (TypeError, ValueError):
173
+ return 0
174
+
175
+
176
+ def _short_overlay(value: Any) -> str:
177
+ words = re.findall(r"\S+", str(value or "").strip())
178
+ return " ".join(words[:4])
179
+
180
+
181
+ def generate_creative_package(
182
+ settings: dict[str, Any],
183
+ channel: dict[str, Any],
184
+ topic: str,
185
+ blueprint: dict[str, Any] | None = None,
186
+ language: str = "",
187
+ ) -> dict[str, Any]:
188
+ if not topic.strip():
189
+ raise CreativeGenerationError("É necessário um tópico ou briefing antes de gerar título e thumbnail.")
190
+ context = channel_context(channel, blueprint)
191
+ system = (
192
+ "És director editorial e de thumbnails para YouTube. Cria um pacote coerente de título e thumbnail "
193
+ "para o tópico fornecido. Gera exactamente pelo menos 20 títulos candidatos e entre 3 e 5 variantes de thumbnail. "
194
+ "O título deve carregar keywords no início, ter curiosidade, especificidade e emoção, sem clickbait falso. "
195
+ "A thumbnail deve ter no máximo três elementos, alto contraste, uma composição clara, texto opcional de até 4 palavras, "
196
+ "safe zones e leitura em 120px. O texto da thumbnail não pode repetir o título integralmente. Remove AI tells. "
197
+ "Responde apenas com JSON válido nas chaves selected_title, title_candidates e thumbnail_variants."
198
+ )
199
+ user = json.dumps(
200
+ {
201
+ "channel": context,
202
+ "language": language or context["language"],
203
+ "topic": topic.strip(),
204
+ "reference_rules": reference_bundle(),
205
+ "title_candidates_schema": {
206
+ "title": "string",
207
+ "formula": "string",
208
+ "curiosity_score": "integer 0-3",
209
+ "specificity_score": "integer 0-3",
210
+ "emotional_score": "integer 0-3",
211
+ },
212
+ "thumbnail_variant_schema": {
213
+ "concept": "string",
214
+ "overlay_text": "string, maximum 4 words",
215
+ "composition": "string",
216
+ "color_palette": "string",
217
+ "subject": "string",
218
+ "image_prompt": "string, no text rendered in the image",
219
+ "title_synergy": "string",
220
+ },
221
+ },
222
+ ensure_ascii=False,
223
+ )
224
+ result = _chat_json(settings, system, user)
225
+ raw_titles = result.get("title_candidates")
226
+ if not isinstance(raw_titles, list) or len(raw_titles) < 20:
227
+ raise CreativeGenerationError("O provider deve devolver pelo menos 20 títulos candidatos.")
228
+ titles: list[dict[str, Any]] = []
229
+ for item in raw_titles[:30]:
230
+ if not isinstance(item, dict) or not str(item.get("title") or "").strip():
231
+ continue
232
+ title = str(item["title"]).strip()
233
+ titles.append(
234
+ {
235
+ "title": title,
236
+ "formula": str(item.get("formula") or "custom").strip(),
237
+ "curiosity_score": _score(item.get("curiosity_score")),
238
+ "specificity_score": _score(item.get("specificity_score")),
239
+ "emotional_score": _score(item.get("emotional_score")),
240
+ }
241
+ )
242
+ if len(titles) < 20:
243
+ raise CreativeGenerationError("Os títulos devolvidos pelo provider não têm conteúdo suficiente.")
244
+ variants_raw = result.get("thumbnail_variants")
245
+ if not isinstance(variants_raw, list) or len(variants_raw) < 3:
246
+ raise CreativeGenerationError("O provider deve devolver pelo menos 3 variantes de thumbnail.")
247
+ variants: list[dict[str, Any]] = []
248
+ for item in variants_raw[:5]:
249
+ if not isinstance(item, dict):
250
+ continue
251
+ if not str(item.get("concept") or "").strip() or not str(item.get("image_prompt") or "").strip():
252
+ continue
253
+ variants.append(
254
+ {
255
+ "concept": str(item.get("concept") or "").strip(),
256
+ "overlay_text": _short_overlay(item.get("overlay_text")),
257
+ "composition": str(item.get("composition") or "").strip(),
258
+ "color_palette": str(item.get("color_palette") or "").strip(),
259
+ "subject": str(item.get("subject") or "").strip(),
260
+ "image_prompt": str(item.get("image_prompt") or "").strip(),
261
+ "title_synergy": str(item.get("title_synergy") or "").strip(),
262
+ "status": "prompt_ready",
263
+ }
264
+ )
265
+ if len(variants) < 3:
266
+ raise CreativeGenerationError("As variantes de thumbnail devolvidas pelo provider estão incompletas.")
267
+ selected_title = str(result.get("selected_title") or titles[0]["title"]).strip()
268
+ if selected_title not in {item["title"] for item in titles}:
269
+ selected_title = titles[0]["title"]
270
+ return {
271
+ "title": selected_title,
272
+ "title_candidates": titles,
273
+ "thumbnail_variant": variants[0],
274
+ "thumbnail_variants": variants,
275
+ "thumbnail_status": "prompt_ready",
276
+ "topic_source": "llm",
277
+ "generated_by": "configured_llm",
278
+ }
@@ -29,6 +29,8 @@ def create_channel(name: str, url: str = "", metadata: dict[str, Any] | None = N
29
29
  "url": url.strip(),
30
30
  "handle": "",
31
31
  "description": "",
32
+ "niche": "",
33
+ "reference_channels": [],
32
34
  "thumbnail_url": "",
33
35
  "subscriber_count": None,
34
36
  "video_count": None,
@@ -108,39 +110,66 @@ def create_batch(mode: str, channel_ids: list[str], topic: str, quantity: int, o
108
110
 
109
111
 
110
112
  def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
113
+ """Expand a batch into tasks, allowing independent payloads for each channel."""
111
114
  tasks = read_json("tasks.json", [])
112
115
  channels = {c["id"]: c for c in read_json("channels.json", [])}
113
- target_channels = batch["channel_ids"]
114
- count = batch["quantity"]
115
- if batch["mode"] == "general":
116
+ options = batch.get("options") or {}
117
+ channel_payloads = options.get("channel_payloads") or {}
118
+ target_channels = batch.get("channel_ids") or []
119
+ count = max(1, int(batch.get("quantity") or 1))
120
+ if batch.get("mode") == "general":
116
121
  count = 1
117
122
  created: list[dict[str, Any]] = []
118
123
  for channel_id in target_channels:
124
+ channel = channels.get(channel_id, {})
125
+ payload = channel_payloads.get(channel_id) if isinstance(channel_payloads, dict) else None
126
+ payload = payload if isinstance(payload, dict) else {}
119
127
  for index in range(count):
120
- channel = channels.get(channel_id, {})
128
+ default_topic = str(batch.get("topic") or "").strip()
129
+ if count == 1:
130
+ topic = str(payload.get("topic") or default_topic).strip()
131
+ else:
132
+ topic = str(payload.get("topic") or f"{default_topic} — variação {index + 1}").strip()
133
+ if not topic:
134
+ topic = f"Vídeo para {channel.get('name', 'Canal')}"
135
+ title = str(payload.get("title") or topic).strip()
136
+ artifacts = dict(payload.get("artifacts") or {})
137
+ thumbnail_path = str(payload.get("thumbnail_path") or "").strip()
138
+ if thumbnail_path:
139
+ artifacts.setdefault("thumbnail", thumbnail_path)
121
140
  task = {
122
141
  "id": make_id("video"),
123
142
  "batch_id": batch["id"],
124
- "creation_mode": batch["mode"],
143
+ "creation_mode": batch.get("mode", "single"),
125
144
  "channel_id": channel_id,
126
145
  "channel_name": channel.get("name", "Canal"),
127
- "topic": batch["topic"] if count == 1 else f"{batch['topic']} — variação {index + 1}",
128
- "language": batch["options"].get("language", channel.get("language", "Português")),
129
- "format": batch["options"].get("format", "wide"),
130
- "style_wide": batch["options"].get("style_wide", channel.get("style_wide", "pexels")),
131
- "style_ia": batch["options"].get("style_ia", ""),
132
- "music_mode": batch["options"].get("music_mode", False),
133
- "music_path": batch["options"].get("music_path", ""),
134
- "music_source": batch["options"].get("music_source", ""),
135
- "background_mode": batch["options"].get("background_mode", "stock"),
136
- "blueprint_id": channel.get("default_blueprint_id") or channel.get("blueprint_id", ""),
137
- "voice": channel.get("default_voice") or channel.get("voice", ""),
146
+ "topic": topic,
147
+ "title": title,
148
+ "topic_source": str(payload.get("topic_source") or options.get("topic_source") or "manual"),
149
+ "language": payload.get("language", options.get("language", channel.get("language", "Português"))),
150
+ "format": payload.get("format", options.get("format", "wide")),
151
+ "style_wide": payload.get("style_wide", options.get("style_wide", channel.get("style_wide", "pexels"))),
152
+ "style_ia": payload.get("style_ia", options.get("style_ia", "")),
153
+ "music_mode": payload.get("music_mode", options.get("music_mode", False)),
154
+ "music_path": payload.get("music_path", options.get("music_path", "")),
155
+ "music_source": payload.get("music_source", options.get("music_source", "")),
156
+ "background_mode": payload.get("background_mode", options.get("background_mode", "stock")),
157
+ "blueprint_id": payload.get("blueprint_id") or channel.get("default_blueprint_id") or channel.get("blueprint_id", ""),
158
+ "blueprint_name": payload.get("blueprint_name", ""),
159
+ "voice": payload.get("voice") or channel.get("default_voice") or channel.get("voice", ""),
138
160
  "automation_on": bool(channel.get("automation_on", False)),
139
161
  "automation_time": channel.get("automation_time", "00:00"),
162
+ "thumbnail_variant": payload.get("thumbnail_variant", {}),
163
+ "thumbnail_variants": payload.get("thumbnail_variants", []),
164
+ "thumbnail_prompt": payload.get("thumbnail_prompt", ""),
165
+ "thumbnail_text": payload.get("thumbnail_text", ""),
166
+ "thumbnail_status": payload.get("thumbnail_status", "not_generated"),
167
+ "title_candidates": payload.get("title_candidates", []),
168
+ "ai_generation": payload.get("ai_generation", {}),
140
169
  "stage": "script",
141
170
  "state": "to_do",
142
171
  "progress": 0,
143
- "artifacts": {},
172
+ "artifacts": artifacts,
144
173
  "error": None,
145
174
  "created_at": now(),
146
175
  "updated_at": now(),
@@ -148,13 +177,27 @@ def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
148
177
  tasks.append(task)
149
178
  created.append(task)
150
179
  write_json("tasks.json", tasks)
151
- for task in created:
152
- queues = read_json("queues.json", {})
153
- queues.setdefault("script", []).append(task["id"])
154
- write_json("queues.json", queues)
180
+ queues = read_json("queues.json", {})
181
+ if not isinstance(queues, dict):
182
+ queues = {}
183
+ queues.setdefault("script", [])
184
+ queues["script"].extend(task["id"] for task in created)
185
+ write_json("queues.json", queues)
155
186
  return created
156
187
 
157
188
 
189
+ def update_channel_video(video_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
190
+ """Actualizar um vídeo remoto sincronizado ou um override local de vídeo do canal."""
191
+ videos = read_json("channel_videos.json", [])
192
+ for video in videos:
193
+ if video.get("id") == video_id:
194
+ video.update(updates)
195
+ video["updated_at"] = now()
196
+ write_json("channel_videos.json", videos)
197
+ return video
198
+ return None
199
+
200
+
158
201
  def update_task(task_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
159
202
  tasks = read_json("tasks.json", [])
160
203
  for task in tasks:
@@ -16,8 +16,9 @@ NICHES_DATA = STORAGE / "data" / "niches"
16
16
  SEED_BLUEPRINTS = ROOT / "seed" / "blueprints"
17
17
 
18
18
  DEFAULTS: dict[str, Any] = {
19
- "channels.json": [],
20
- "tasks.json": [],
19
+ "channels.json": [],
20
+ "channel_videos.json": [],
21
+ "tasks.json": [],
21
22
  "queues.json": {"niche": [], "blueprint": [], "brand": [], "script": [], "title": [], "thumbnail": [], "video": [], "edit": [], "upload": []},
22
23
  "batches.json": [],
23
24
  "uploads.json": [],
@@ -238,6 +238,80 @@ def _public_feed_data(channel_id: str, headers: dict[str, str]) -> dict[str, Any
238
238
  return {}
239
239
 
240
240
 
241
+ def fetch_channel_videos_public(channel_ref: str | dict[str, Any], limit: int = 10) -> "IntegrationResult":
242
+ """Fetch the latest public channel videos from YouTube's Atom feed without an API key."""
243
+ if isinstance(channel_ref, dict):
244
+ source = str(channel_ref.get("youtube_channel_id") or channel_ref.get("url") or "").strip()
245
+ else:
246
+ source = str(channel_ref or "").strip()
247
+ if not source:
248
+ return IntegrationResult(False, "Este canal ainda não tem ID ou URL pública do YouTube.", {"videos": []})
249
+ headers = {
250
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124 Safari/537.36",
251
+ "Accept-Language": "pt-BR,pt;q=0.9,en;q=0.8",
252
+ "Accept": "application/atom+xml,application/xml,text/xml;q=0.9,*/*;q=0.8",
253
+ }
254
+ channel_id = _channel_id_from_source(source)
255
+ if not channel_id and source.startswith(("http://", "https://")):
256
+ for page_url in _public_page_candidates(source):
257
+ try:
258
+ response = requests.get(page_url, headers=headers, timeout=12, allow_redirects=True)
259
+ response.raise_for_status()
260
+ except requests.RequestException:
261
+ continue
262
+ document = response.text or ""
263
+ channel_id = _channel_id_from_document(document, str(getattr(response, "url", "") or page_url))
264
+ if channel_id:
265
+ break
266
+ if not channel_id:
267
+ return IntegrationResult(False, "Não foi possível resolver o ID público do canal para carregar os vídeos.", {"videos": []})
268
+ feed_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
269
+ try:
270
+ response = requests.get(feed_url, headers=headers, timeout=12)
271
+ response.raise_for_status()
272
+ root = ET.fromstring(response.text)
273
+ except (requests.RequestException, ET.ParseError, ValueError) as exc:
274
+ return IntegrationResult(False, f"Não foi possível carregar os vídeos públicos do canal: {exc}", {"channel_id": channel_id, "videos": []})
275
+ namespace = {
276
+ "yt": "http://www.youtube.com/xml/schemas/2015",
277
+ "atom": "http://www.w3.org/2005/Atom",
278
+ "media": "http://search.yahoo.com/mrss/",
279
+ }
280
+ videos: list[dict[str, Any]] = []
281
+ max_items = max(1, min(10, int(limit or 10)))
282
+ for entry in root.findall("atom:entry", namespace)[:max_items]:
283
+ video_id = entry.findtext("yt:videoId", default="", namespaces=namespace).strip()
284
+ title = entry.findtext("atom:title", default="", namespaces=namespace).strip()
285
+ published_at = entry.findtext("atom:published", default="", namespaces=namespace).strip()
286
+ updated_at = entry.findtext("atom:updated", default="", namespaces=namespace).strip()
287
+ link = ""
288
+ for candidate in entry.findall("atom:link", namespace):
289
+ if candidate.attrib.get("rel", "alternate") == "alternate":
290
+ link = candidate.attrib.get("href", "")
291
+ break
292
+ if not link and video_id:
293
+ link = f"https://www.youtube.com/watch?v={video_id}"
294
+ thumbnail = ""
295
+ media_group = entry.find("media:group", namespace)
296
+ if media_group is not None:
297
+ media_thumbnail = media_group.find("media:thumbnail", namespace)
298
+ if media_thumbnail is not None:
299
+ thumbnail = str(media_thumbnail.attrib.get("url", ""))
300
+ videos.append({
301
+ "id": f"youtube_{video_id}" if video_id else f"youtube_{len(videos)}",
302
+ "youtube_video_id": video_id,
303
+ "channel_id": channel_id,
304
+ "title": title or "Vídeo sem título",
305
+ "published_at": published_at,
306
+ "updated_at": updated_at,
307
+ "url": link,
308
+ "thumbnail_url": thumbnail,
309
+ "source": "youtube_public_rss",
310
+ "status": "publicado",
311
+ })
312
+ return IntegrationResult(True, f"{len(videos)} vídeo(s) público(s) carregado(s) sem API Key.", {"channel_id": channel_id, "videos": videos})
313
+
314
+
241
315
  @dataclass
242
316
  class IntegrationResult:
243
317
  ok: bool
@@ -385,6 +459,9 @@ class YouTubeAdapter:
385
459
  data["youtube_id"] = direct_id
386
460
  return IntegrationResult(False, f"Não foi possível obter dados públicos do YouTube sem API Key. {last_error or 'Confirme o URL/handle ou use Cadastro manual.'}".strip(), data)
387
461
 
462
+ def fetch_channel_videos_public(self, channel_ref: str | dict[str, Any], limit: int = 10) -> IntegrationResult:
463
+ return fetch_channel_videos_public(channel_ref, limit=limit)
464
+
388
465
  def fetch_channel(self, value: str) -> IntegrationResult:
389
466
  ref = self.extract_channel_ref(value)
390
467
  if not self.api_key:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.2.49",
3
+ "version": "0.2.51",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "main": "scripts/cli.mjs",
6
6
  "type": "module",
@@ -17,6 +17,7 @@
17
17
  "storage/blueprints/**/*.json",
18
18
  "seed/blueprints/**/*.json",
19
19
  "seed/skills/*.md",
20
+ "seed/references/*.md",
20
21
  "README.md",
21
22
  "MANUAL-INSTALACAO.md",
22
23
  "THIRD-PARTY-NOTICES.md",