@danhachuel/thunderbolt 0.5.70 → 0.5.72
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
|
@@ -121,6 +121,7 @@ from integrations.upload_routing import OFFICIAL_DAILY_LIMIT, official_upload_co
|
|
|
121
121
|
from integrations.youtube_direct_upload import YouTubeDirectUploader
|
|
122
122
|
from integrations.youtube_direct_credentials import delete_credentials_document, direct_account_status, document_status, ensure_credentials_document, load_credentials_document, merge_credentials_document, parse_credentials_document, save_credentials_document, update_credentials_document_session_info
|
|
123
123
|
from integrations.session_info_health import check_account_session_info_health
|
|
124
|
+
from integrations.youtube_session_manager import renew_account_session
|
|
124
125
|
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
|
|
125
126
|
from integrations.local_runtime import MoneyPrinterRuntime
|
|
126
127
|
from integrations.moneyprinter_config import sync_moneyprinter_config
|
|
@@ -6570,6 +6571,8 @@ def render_google_accounts(*, include_innertube: bool = True):
|
|
|
6570
6571
|
st.error(health_message, icon="⚠️")
|
|
6571
6572
|
elif session_health.status == "unknown":
|
|
6572
6573
|
st.warning(health_message, icon="⚠️")
|
|
6574
|
+
elif session_health.status in {"blocked_by_google", "invalid_format"}:
|
|
6575
|
+
st.error(health_message, icon="⚠️")
|
|
6573
6576
|
with st.expander("Detalhes da conta Google", expanded=False):
|
|
6574
6577
|
with st.form(f"batch_account_form_{account_id}"):
|
|
6575
6578
|
account_cols = st.columns(2)
|
|
@@ -6687,6 +6690,30 @@ def render_google_accounts(*, include_innertube: bool = True):
|
|
|
6687
6690
|
except ValueError as exc:
|
|
6688
6691
|
st.error(str(exc))
|
|
6689
6692
|
|
|
6693
|
+
st.markdown("**Renovação de sessionInfo e cookies**")
|
|
6694
|
+
st.caption("A renovação é manual ou opt-in antes do upload; não há timer residente nem execução ao seleccionar a conta.")
|
|
6695
|
+
renewal_cols = st.columns([2, 1, 1])
|
|
6696
|
+
auto_renew = renewal_cols[0].checkbox(
|
|
6697
|
+
"Renovar automaticamente antes do upload",
|
|
6698
|
+
value=bool(batch_account.get("auto_renew_before_upload", False)),
|
|
6699
|
+
key=f"auto_renew_before_upload_{account_id}",
|
|
6700
|
+
)
|
|
6701
|
+
if renewal_cols[1].button("Renovar agora", key=f"renew_session_info_{account_id}", type="primary", use_container_width=True):
|
|
6702
|
+
result = renew_account_session(STORAGE, batch_account, settings, force=True, wait_seconds=10)
|
|
6703
|
+
(st.success if result.ok else st.error)(result.message)
|
|
6704
|
+
if result.ok:
|
|
6705
|
+
st.rerun()
|
|
6706
|
+
with renewal_cols[2]:
|
|
6707
|
+
_api_status_badge(
|
|
6708
|
+
"Configured" if session_health.status == "healthy" else ("Missing key" if session_health.status == "missing" else "Missing configuration"),
|
|
6709
|
+
"ready" if session_health.status == "healthy" else "missing",
|
|
6710
|
+
)
|
|
6711
|
+
if auto_renew != bool(batch_account.get("auto_renew_before_upload", False)):
|
|
6712
|
+
batch_account["auto_renew_before_upload"] = bool(auto_renew)
|
|
6713
|
+
settings["youtube_batch_accounts"] = batch_accounts
|
|
6714
|
+
write_json("settings.json", settings)
|
|
6715
|
+
st.success("Preferência de renovação guardada para esta conta.")
|
|
6716
|
+
|
|
6690
6717
|
if youtube_accounts_missing_document:
|
|
6691
6718
|
st.info("Contas que ainda precisam de dados no documento: " + ", ".join(youtube_accounts_missing_document))
|
|
6692
6719
|
|
|
@@ -6799,7 +6826,11 @@ def render_google_accounts(*, include_innertube: bool = True):
|
|
|
6799
6826
|
st.rerun()
|
|
6800
6827
|
|
|
6801
6828
|
st.divider()
|
|
6802
|
-
st.
|
|
6829
|
+
youtube_global_cols = st.columns([3.2, 1.2])
|
|
6830
|
+
with youtube_global_cols[0]:
|
|
6831
|
+
st.subheader("Configuração Global: YouTube Data API v3 (API Key)")
|
|
6832
|
+
with youtube_global_cols[1]:
|
|
6833
|
+
_render_credential_status(settings.get("youtube_api_key"), required=True)
|
|
6803
6834
|
st.caption("1. YouTube Data API v3: Metadados públicos (títulos, views, likes) Via MCP integrado.\n\n2. YouTube Analytics API: Métricas internas (CTR, retenção, RPM, tráfego, demografia) Via OAuth 2.0 (Directamente via OAuth das Contas Google/YouTube cadastradas)")
|
|
6804
6835
|
with st.form("google_global_api_settings_form"):
|
|
6805
6836
|
st.caption("A autorização OAuth 2.0 é gerida por cada Conta Google/YouTube cadastrada e associada ao canal.")
|
|
@@ -7848,6 +7879,9 @@ def render_settings():
|
|
|
7848
7879
|
with st.expander("API Tiktok", expanded=False):
|
|
7849
7880
|
render_tiktok_api_cards(settings)
|
|
7850
7881
|
with st.expander("API Bilibili", expanded=False):
|
|
7882
|
+
bilibili_cards = settings.get("bilibili_api_cards") if isinstance(settings.get("bilibili_api_cards"), list) else []
|
|
7883
|
+
bilibili_ready = any(bool(card.get("active", True) and card.get("sessdata") and card.get("bili_jct") and card.get("buvid3")) for card in bilibili_cards if isinstance(card, dict))
|
|
7884
|
+
_api_status_badge("Configured" if bilibili_ready else "Missing configuration", "ready" if bilibili_ready else "missing")
|
|
7851
7885
|
render_bilibili_api_cards(settings)
|
|
7852
7886
|
with st.expander("Composio", expanded=False):
|
|
7853
7887
|
st.caption("A API key é guardada apenas no storage local. O slug da ferramenta e o provider são descobertos no Upload via Composio; a autenticação da conta é feita pelo Connect Link do Composio.")
|
|
@@ -7887,6 +7921,7 @@ def render_settings():
|
|
|
7887
7921
|
upload_post_user_upload = st.text_input("Upload-Post username", value=str(settings.get("upload_post_username") or ""), key="upload_tab_upload_post_user")
|
|
7888
7922
|
upload_post_platforms_upload = st.text_input("Plataformas Upload-Post", value=str(settings.get("upload_post_platforms") or "youtube,tiktok"), key="upload_tab_upload_post_platforms")
|
|
7889
7923
|
upload_post_auto_upload = st.checkbox("Publicar automaticamente após gerar", bool(settings.get("upload_post_auto_upload", False)), key="upload_tab_upload_post_auto")
|
|
7924
|
+
_render_credential_status(upload_post_key_upload, required=True)
|
|
7890
7925
|
if st.button("Guardar Upload-Post", type="primary", use_container_width=True, key="upload_tab_save_upload_post"):
|
|
7891
7926
|
settings.update({"upload_post_enabled": bool(upload_post_enabled_upload), "upload_post_api_key": upload_post_key_upload.strip(), "upload_post_username": upload_post_user_upload.strip(), "upload_post_platforms": upload_post_platforms_upload.strip(), "upload_post_auto_upload": bool(upload_post_auto_upload)})
|
|
7892
7927
|
write_json("settings.json", settings)
|
|
@@ -7899,6 +7934,7 @@ def render_settings():
|
|
|
7899
7934
|
postiz_mcp_upload = st.text_input("Postiz MCP URL", value=str(settings.get("postiz_mcp_url") or "https://api.postiz.com/mcp"), key="upload_tab_postiz_mcp")
|
|
7900
7935
|
postiz_integration_upload = st.text_input("Postiz integração padrão", value=str(settings.get("postiz_integration_id") or ""), key="upload_tab_postiz_integration")
|
|
7901
7936
|
postiz_auto_upload = st.checkbox("Permitir publicação imediata no Postiz", bool(settings.get("postiz_auto_publish", False)), key="upload_tab_postiz_auto")
|
|
7937
|
+
_render_credential_status(postiz_key_upload, required=True)
|
|
7902
7938
|
if st.button("Guardar Postiz", type="primary", use_container_width=True, key="upload_tab_save_postiz"):
|
|
7903
7939
|
settings.update({"postiz_enabled": bool(postiz_enabled_upload), "postiz_api_key": postiz_key_upload.strip(), "postiz_base_url": postiz_base_upload.strip(), "postiz_mcp_url": postiz_mcp_upload.strip(), "postiz_integration_id": postiz_integration_upload.strip(), "postiz_auto_publish": bool(postiz_auto_upload)})
|
|
7904
7940
|
write_json("settings.json", settings)
|
|
@@ -8,6 +8,7 @@ conta Google/YouTube.
|
|
|
8
8
|
"""
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import json
|
|
11
12
|
from dataclasses import dataclass
|
|
12
13
|
from datetime import datetime, timedelta, timezone
|
|
13
14
|
from pathlib import Path
|
|
@@ -15,10 +16,10 @@ from typing import Any
|
|
|
15
16
|
|
|
16
17
|
from integrations.youtube_direct_credentials import credentials_document_path, load_credentials_document
|
|
17
18
|
|
|
18
|
-
DEFAULT_SESSION_INFO_TTL_HOURS =
|
|
19
|
+
DEFAULT_SESSION_INFO_TTL_HOURS = 19
|
|
19
20
|
DEFAULT_SESSION_INFO_ALERT_HOURS = 6
|
|
20
|
-
MIN_SESSION_INFO_TTL_HOURS =
|
|
21
|
-
MAX_SESSION_INFO_TTL_HOURS =
|
|
21
|
+
MIN_SESSION_INFO_TTL_HOURS = 1
|
|
22
|
+
MAX_SESSION_INFO_TTL_HOURS = 72
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
def utc_now() -> datetime:
|
|
@@ -28,6 +29,11 @@ def utc_now() -> datetime:
|
|
|
28
29
|
|
|
29
30
|
def parse_timestamp(value: Any) -> datetime | None:
|
|
30
31
|
"""Parse ISO-8601 timestamps used in local credential documents."""
|
|
32
|
+
if isinstance(value, (int, float)):
|
|
33
|
+
try:
|
|
34
|
+
return datetime.fromtimestamp(float(value), tz=timezone.utc)
|
|
35
|
+
except (TypeError, ValueError, OSError, OverflowError):
|
|
36
|
+
return None
|
|
31
37
|
text = str(value or "").strip()
|
|
32
38
|
if not text:
|
|
33
39
|
return None
|
|
@@ -118,11 +124,17 @@ def health_check_session_info(
|
|
|
118
124
|
file_text = str(credential_file or "")
|
|
119
125
|
if not session_info:
|
|
120
126
|
return SessionInfoHealth("missing", "Falta sessionInfo nesta conta Google/YouTube.", None, None, None, None, account_id, account_label, file_text, False, True)
|
|
127
|
+
if str(document.get("sessionInfoHealthStatus") or "").strip() == "blocked_by_google":
|
|
128
|
+
return SessionInfoHealth("blocked_by_google", "Sessão bloqueada pelo Google; é necessário login manual.", None, None, None, captured.isoformat() if captured else None, account_id, account_label, file_text, True, True)
|
|
129
|
+
if str(document.get("sessionInfoHealthStatus") or "").strip() == "invalid_format":
|
|
130
|
+
return SessionInfoHealth("invalid_format", "sessionInfo capturado, mas o formato foi rejeitado.", None, None, None, captured.isoformat() if captured else None, account_id, account_label, file_text, True, True)
|
|
121
131
|
if captured is None:
|
|
122
|
-
|
|
132
|
+
# Legacy documents without a capture date remain compatible with the
|
|
133
|
+
# existing uploader, while the UI still asks for a manual renewal.
|
|
134
|
+
return SessionInfoHealth("unknown", "sessionInfo existe, mas a data de captura não é conhecida; renove a sessão para activar o alerta de expiração.", None, None, None, None, account_id, account_label, file_text, True, True)
|
|
123
135
|
age = max(0.0, (current - captured.astimezone(timezone.utc)).total_seconds() / 3600)
|
|
124
|
-
|
|
125
|
-
|
|
136
|
+
expires = parse_timestamp(document.get("expires_at")) or (captured.astimezone(timezone.utc) + timedelta(hours=ttl))
|
|
137
|
+
remaining = (expires - current.astimezone(timezone.utc)).total_seconds() / 3600
|
|
126
138
|
if remaining <= 0:
|
|
127
139
|
return SessionInfoHealth("expired", f"sessionInfo expirou há {abs(remaining):.1f} horas; renove-o manualmente antes de criar ou enviar conteúdos.", age, remaining, expires.isoformat(), captured.isoformat(), account_id, account_label, file_text, True, True)
|
|
128
140
|
if remaining <= alert:
|
|
@@ -160,6 +172,14 @@ def check_account_session_info_health(
|
|
|
160
172
|
"""Load one account document and return its safe SessionInfo health state."""
|
|
161
173
|
settings = settings or {}
|
|
162
174
|
document = load_credentials_document(storage_root, account, settings, create=False)
|
|
175
|
+
state_path = credentials_document_path(storage_root, account).with_name("renewal_state.json")
|
|
176
|
+
try:
|
|
177
|
+
state = json.loads(state_path.read_text(encoding="utf-8")) if state_path.exists() else {}
|
|
178
|
+
except (OSError, json.JSONDecodeError):
|
|
179
|
+
state = {}
|
|
180
|
+
if isinstance(state, dict) and state.get("status") in {"blocked_by_google", "invalid_format"}:
|
|
181
|
+
document = dict(document)
|
|
182
|
+
document["sessionInfoHealthStatus"] = state["status"]
|
|
163
183
|
return session_info_health_from_settings(
|
|
164
184
|
account,
|
|
165
185
|
document,
|
|
@@ -18,6 +18,7 @@ from integrations.postiz import PostizAdapter
|
|
|
18
18
|
from integrations.session_info_health import check_account_session_info_health, emit_session_info_health_alerts
|
|
19
19
|
from integrations.youtube_direct_credentials import document_status
|
|
20
20
|
from integrations.youtube_direct_upload import YouTubeDirectUploader
|
|
21
|
+
from integrations.youtube_session_manager import renew_account_session
|
|
21
22
|
from integrations.composio_upload import ComposioUploadError, execute_upload
|
|
22
23
|
from hermes_ui.storage import read_json, write_json
|
|
23
24
|
from hermes_ui.languages import language_locale
|
|
@@ -157,6 +158,16 @@ def upload_with_default_route(
|
|
|
157
158
|
direct_ready = False
|
|
158
159
|
if account:
|
|
159
160
|
try:
|
|
161
|
+
renewal_warning = ""
|
|
162
|
+
if bool(account.get("auto_renew_before_upload", settings.get("auto_renew_before_upload", False))):
|
|
163
|
+
current_health = check_account_session_info_health(storage_root, account, settings)
|
|
164
|
+
if current_health.status in {"expiring", "expired"}:
|
|
165
|
+
renewal = renew_account_session(storage_root, account, settings, wait_seconds=10)
|
|
166
|
+
if not renewal.ok:
|
|
167
|
+
renewal_warning = f"Renovação automática não concluída ({renewal.status}); o upload pode falhar."
|
|
168
|
+
attempts.append({"route": "Renovação sessionInfo", "status": "failed", "message": renewal_warning, "data": {"status": renewal.status}})
|
|
169
|
+
else:
|
|
170
|
+
attempts.append({"route": "Renovação sessionInfo", "status": "success", "message": renewal.message, "data": {"status": renewal.status}})
|
|
160
171
|
status = document_status(storage_root, account, channel, settings, [channel])
|
|
161
172
|
health = check_account_session_info_health(storage_root, account, settings)
|
|
162
173
|
emit_session_info_health_alerts([health])
|
|
@@ -165,7 +176,7 @@ def upload_with_default_route(
|
|
|
165
176
|
direct_result = IntegrationResult(
|
|
166
177
|
False,
|
|
167
178
|
f"Upload directo indisponível: {health.message}",
|
|
168
|
-
{"missing": ["sessionInfo"], "session_info_health": health.as_dict()},
|
|
179
|
+
{"missing": ["sessionInfo"], "session_info_health": health.as_dict(), "renewal_warning": renewal_warning},
|
|
169
180
|
)
|
|
170
181
|
elif not direct_ready:
|
|
171
182
|
missing = list(status.get("missing_cookies", []))
|
|
@@ -175,7 +186,7 @@ def upload_with_default_route(
|
|
|
175
186
|
missing.append("INNERTUBE_API_KEY")
|
|
176
187
|
if not status.get("has_delegated_session_id"):
|
|
177
188
|
missing.append("DELEGATED_SESSION_ID")
|
|
178
|
-
direct_result = IntegrationResult(False, f"Upload directo indisponível: {', '.join(missing)}.", {"missing": missing})
|
|
189
|
+
direct_result = IntegrationResult(False, f"Upload directo indisponível: {', '.join(missing)}. {renewal_warning}".strip(), {"missing": missing, "renewal_warning": renewal_warning})
|
|
179
190
|
else:
|
|
180
191
|
direct_result = None
|
|
181
192
|
except Exception as exc:
|
|
@@ -185,6 +185,10 @@ def _normalise_document(raw: Any, account: dict[str, Any]) -> dict[str, Any]:
|
|
|
185
185
|
"email": str(raw.get("email") or account.get("email") or "").strip(),
|
|
186
186
|
"sessionInfo": _placeholder_to_empty(raw.get("sessionInfo") or raw.get("session_info") or raw.get("direct_session_info") or _account_session_info(account)),
|
|
187
187
|
"sessionInfoCapturedAt": str(raw.get("sessionInfoCapturedAt") or raw.get("session_info_captured_at") or account.get("sessionInfoCapturedAt") or account.get("session_info_captured_at") or "").strip(),
|
|
188
|
+
"expires_at": raw.get("expires_at") or account.get("expires_at") or "",
|
|
189
|
+
"sessionInfoHealthStatus": str(raw.get("sessionInfoHealthStatus") or "").strip(),
|
|
190
|
+
"renewalLastAttemptAt": str(raw.get("renewalLastAttemptAt") or "").strip(),
|
|
191
|
+
"renewalLastSuccessAt": str(raw.get("renewalLastSuccessAt") or "").strip(),
|
|
188
192
|
"cookies": {key: cookies.get(key, "") for key in COOKIE_KEYS},
|
|
189
193
|
"INNERTUBE_API_KEY": _placeholder_to_empty(raw.get("INNERTUBE_API_KEY") or raw.get("innertube_api_key") or raw.get("direct_innertube_api_key") or ""),
|
|
190
194
|
"chunk_size": _safe_chunk_size(raw.get("chunk_size", raw.get("direct_chunk_size", DEFAULT_CHUNK_SIZE))),
|
|
@@ -229,6 +233,10 @@ def save_credentials_document(storage_root: Path, account: dict[str, Any], docum
|
|
|
229
233
|
"email": normalised["email"],
|
|
230
234
|
"sessionInfo": normalised["sessionInfo"],
|
|
231
235
|
"sessionInfoCapturedAt": normalised.get("sessionInfoCapturedAt", ""),
|
|
236
|
+
"expires_at": normalised.get("expires_at", ""),
|
|
237
|
+
"sessionInfoHealthStatus": normalised.get("sessionInfoHealthStatus", ""),
|
|
238
|
+
"renewalLastAttemptAt": normalised.get("renewalLastAttemptAt", ""),
|
|
239
|
+
"renewalLastSuccessAt": normalised.get("renewalLastSuccessAt", ""),
|
|
232
240
|
"cookies": normalised["cookies"],
|
|
233
241
|
# INNERTUBE_API_KEY não pertence ao documento de cookies/credenciais.
|
|
234
242
|
"chunk_size": normalised["chunk_size"],
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Safe, opt-in renewal of YouTube cookies and sessionInfo per account.
|
|
2
|
+
|
|
3
|
+
The module is deliberately one-shot: it never starts Streamlit, workers, or a
|
|
4
|
+
resident polling loop. Browser automation is best-effort and refuses Google
|
|
5
|
+
verification/CAPTCHA pages instead of attempting to bypass them.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import base64
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import signal
|
|
17
|
+
import time
|
|
18
|
+
from contextlib import contextmanager
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import datetime, timedelta, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Iterator
|
|
23
|
+
|
|
24
|
+
from integrations.session_info_health import check_account_session_info_health
|
|
25
|
+
from integrations.youtube_direct_credentials import (
|
|
26
|
+
COOKIE_KEYS,
|
|
27
|
+
account_directory,
|
|
28
|
+
account_key,
|
|
29
|
+
credentials_document_path,
|
|
30
|
+
load_credentials_document,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
LOCK_TIMEOUT_SECONDS = 300
|
|
35
|
+
LOCK_WAIT_SECONDS = 10
|
|
36
|
+
MAX_CAPTURE_ATTEMPTS = 2
|
|
37
|
+
TITLE_SELECTORS = [
|
|
38
|
+
'input[aria-label="Título"]',
|
|
39
|
+
'input[aria-label="Title"]',
|
|
40
|
+
'input[name="title"]',
|
|
41
|
+
'input[placeholder*="Título"]',
|
|
42
|
+
'input[placeholder*="Title"]',
|
|
43
|
+
'ytcp-text-input#title-text-input',
|
|
44
|
+
]
|
|
45
|
+
DESCRIPTION_SELECTORS = [
|
|
46
|
+
'textarea[aria-label="Descrição"]',
|
|
47
|
+
'textarea[aria-label="Description"]',
|
|
48
|
+
'textarea[name="description"]',
|
|
49
|
+
'textarea[placeholder*="Descrição"]',
|
|
50
|
+
'textarea[placeholder*="Description"]',
|
|
51
|
+
'ytcp-mention-drawer textarea',
|
|
52
|
+
]
|
|
53
|
+
BLOCKED_TEXT = ("verify", "verificação", "verification", "captcha", "confirme que você é humano", "confirm it's you", "confirme que é você")
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
from yt_cm import YouTubeCookieManager # type: ignore
|
|
57
|
+
YT_CM_AVAILABLE = True
|
|
58
|
+
except ImportError: # optional by design
|
|
59
|
+
YouTubeCookieManager = None # type: ignore
|
|
60
|
+
YT_CM_AVAILABLE = False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_yt_cm_available() -> bool:
|
|
64
|
+
"""Return whether optional yt-cm can be imported."""
|
|
65
|
+
return bool(YT_CM_AVAILABLE)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def validate_session_info(token: str | None) -> bool:
|
|
69
|
+
"""Validate the conservative Base64-like shape required by the plan."""
|
|
70
|
+
value = str(token or "").strip()
|
|
71
|
+
return bool(value and len(value) >= 50 and re.fullmatch(r"[A-Za-z0-9+/=]+", value))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _safe_error(exc: BaseException) -> str:
|
|
75
|
+
text = str(exc)
|
|
76
|
+
for secret in ("sessionInfo", "SID", "SSID", "HSID", "APISID", "SAPISID"):
|
|
77
|
+
text = text.replace(secret, "[redacted]")
|
|
78
|
+
return text[:500]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _pid_alive(pid: int) -> bool:
|
|
82
|
+
if pid <= 0:
|
|
83
|
+
return False
|
|
84
|
+
try:
|
|
85
|
+
os.kill(pid, 0)
|
|
86
|
+
except (OSError, ProcessLookupError):
|
|
87
|
+
return False
|
|
88
|
+
except PermissionError:
|
|
89
|
+
return True
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@contextmanager
|
|
94
|
+
def account_lock(account_dir: Path, account_id: str, *, wait_seconds: int = 0) -> Iterator[None]:
|
|
95
|
+
"""Acquire an atomic per-account lock, removing only dead/expired locks."""
|
|
96
|
+
account_dir.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
path = account_dir / f"{account_key({'id': account_id})}.lock"
|
|
98
|
+
deadline = time.monotonic() + max(0, wait_seconds)
|
|
99
|
+
acquired = False
|
|
100
|
+
while not acquired:
|
|
101
|
+
payload = {"pid": os.getpid(), "acquired_at": datetime.now(timezone.utc).isoformat(), "timeout_seconds": LOCK_TIMEOUT_SECONDS}
|
|
102
|
+
try:
|
|
103
|
+
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
104
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
105
|
+
json.dump(payload, handle)
|
|
106
|
+
acquired = True
|
|
107
|
+
except FileExistsError:
|
|
108
|
+
stale = False
|
|
109
|
+
try:
|
|
110
|
+
current = json.loads(path.read_text(encoding="utf-8"))
|
|
111
|
+
pid = int(current.get("pid", 0))
|
|
112
|
+
acquired_at = datetime.fromisoformat(str(current.get("acquired_at", "")).replace("Z", "+00:00"))
|
|
113
|
+
age = (datetime.now(timezone.utc) - (acquired_at if acquired_at.tzinfo else acquired_at.replace(tzinfo=timezone.utc))).total_seconds()
|
|
114
|
+
stale = age > LOCK_TIMEOUT_SECONDS or not _pid_alive(pid)
|
|
115
|
+
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
|
116
|
+
stale = True
|
|
117
|
+
if stale:
|
|
118
|
+
try:
|
|
119
|
+
path.unlink()
|
|
120
|
+
except FileNotFoundError:
|
|
121
|
+
pass
|
|
122
|
+
continue
|
|
123
|
+
if time.monotonic() >= deadline:
|
|
124
|
+
raise TimeoutError("lock de renovação já está activo")
|
|
125
|
+
time.sleep(0.25)
|
|
126
|
+
try:
|
|
127
|
+
yield
|
|
128
|
+
finally:
|
|
129
|
+
try:
|
|
130
|
+
current = json.loads(path.read_text(encoding="utf-8"))
|
|
131
|
+
if int(current.get("pid", -1)) == os.getpid():
|
|
132
|
+
path.unlink(missing_ok=True)
|
|
133
|
+
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _rotate_backups(destination: Path) -> None:
|
|
138
|
+
bak, bak1, bak2 = (destination.with_name(destination.name + suffix) for suffix in (".bak", ".bak1", ".bak2"))
|
|
139
|
+
if bak1.exists():
|
|
140
|
+
os.replace(bak1, bak2)
|
|
141
|
+
if bak.exists():
|
|
142
|
+
os.replace(bak, bak1)
|
|
143
|
+
if destination.exists():
|
|
144
|
+
shutil.copy2(destination, bak)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _write_renewal_state(directory: Path, status: str) -> None:
|
|
148
|
+
"""Persist only non-secret renewal state for the UI/health check."""
|
|
149
|
+
path = directory / "renewal_state.json"
|
|
150
|
+
path.write_text(json.dumps({"status": status, "updated_at": datetime.now(timezone.utc).isoformat()}), encoding="utf-8")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def atomic_save_credentials(destination: Path, document: dict[str, Any]) -> None:
|
|
154
|
+
"""Rotate three backups, atomically replace, and restore on failure."""
|
|
155
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
_rotate_backups(destination)
|
|
157
|
+
temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp")
|
|
158
|
+
try:
|
|
159
|
+
temporary.write_text(json.dumps(document, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
160
|
+
try:
|
|
161
|
+
os.chmod(temporary, 0o600)
|
|
162
|
+
except OSError:
|
|
163
|
+
pass
|
|
164
|
+
os.replace(temporary, destination)
|
|
165
|
+
except Exception:
|
|
166
|
+
temporary.unlink(missing_ok=True)
|
|
167
|
+
latest = destination.with_name(destination.name + ".bak")
|
|
168
|
+
if latest.exists():
|
|
169
|
+
os.replace(latest, destination)
|
|
170
|
+
raise
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _blocked_page(page: Any) -> bool:
|
|
174
|
+
try:
|
|
175
|
+
body = str(page.locator("body").inner_text(timeout=2000)).lower()
|
|
176
|
+
if any(marker in body for marker in BLOCKED_TEXT):
|
|
177
|
+
return True
|
|
178
|
+
if page.locator('input[type="email"], input[type="password"], iframe[src*="captcha"], iframe[title*="captcha" i]').count() > 0:
|
|
179
|
+
return True
|
|
180
|
+
except Exception:
|
|
181
|
+
return False
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _first_selector(page: Any, selectors: list[str]) -> str | None:
|
|
186
|
+
for selector in selectors:
|
|
187
|
+
try:
|
|
188
|
+
page.wait_for_selector(selector, timeout=3000)
|
|
189
|
+
logger.debug("Selector YouTube utilizado: %s", selector)
|
|
190
|
+
return selector
|
|
191
|
+
except Exception:
|
|
192
|
+
continue
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _capture_with_playwright(document: dict[str, Any], *, logs_dir: Path, video_id: str = "") -> tuple[str, str | None]:
|
|
197
|
+
try:
|
|
198
|
+
from playwright.sync_api import sync_playwright # type: ignore
|
|
199
|
+
except ImportError:
|
|
200
|
+
logger.warning("Playwright não instalado; captura sessionInfo indisponível.")
|
|
201
|
+
return "browser_unavailable", None
|
|
202
|
+
captured: list[str] = []
|
|
203
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
cookies = [{"name": key, "value": str(document["cookies"][key]), "domain": ".youtube.com", "path": "/"} for key in COOKIE_KEYS if document.get("cookies", {}).get(key)]
|
|
205
|
+
with sync_playwright() as playwright:
|
|
206
|
+
browser = playwright.chromium.launch(headless=True)
|
|
207
|
+
context = browser.new_context()
|
|
208
|
+
context.add_cookies(cookies)
|
|
209
|
+
page = context.new_page()
|
|
210
|
+
|
|
211
|
+
def on_response(response: Any) -> None:
|
|
212
|
+
try:
|
|
213
|
+
text = response.text()
|
|
214
|
+
for match in re.findall(r"[A-Za-z0-9+/=]{50,}", text):
|
|
215
|
+
if validate_session_info(match):
|
|
216
|
+
captured.append(match)
|
|
217
|
+
break
|
|
218
|
+
except Exception:
|
|
219
|
+
pass
|
|
220
|
+
|
|
221
|
+
page.on("response", on_response)
|
|
222
|
+
try:
|
|
223
|
+
url = "https://studio.youtube.com/"
|
|
224
|
+
if video_id:
|
|
225
|
+
url += f"video/{video_id}/edit"
|
|
226
|
+
page.goto(url, wait_until="domcontentloaded", timeout=30000)
|
|
227
|
+
if _blocked_page(page):
|
|
228
|
+
logger.warning("🔒 Sessão bloqueada pelo Google. Renovação manual necessária.")
|
|
229
|
+
return "blocked_by_google", None
|
|
230
|
+
selector = _first_selector(page, TITLE_SELECTORS)
|
|
231
|
+
if selector is None:
|
|
232
|
+
selector = _first_selector(page, DESCRIPTION_SELECTORS)
|
|
233
|
+
if selector is None:
|
|
234
|
+
screenshot = logs_dir / f"failure_{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}.png"
|
|
235
|
+
page.screenshot(path=str(screenshot), full_page=True)
|
|
236
|
+
logger.error("Nenhum selector de título ou descrição do YouTube foi encontrado; screenshot: %s", screenshot)
|
|
237
|
+
return "selector_failure", None
|
|
238
|
+
try:
|
|
239
|
+
page.locator(selector).focus()
|
|
240
|
+
page.keyboard.press("Escape")
|
|
241
|
+
except Exception:
|
|
242
|
+
pass
|
|
243
|
+
page.wait_for_timeout(1500)
|
|
244
|
+
for token in captured:
|
|
245
|
+
if validate_session_info(token):
|
|
246
|
+
return "captured", token
|
|
247
|
+
return "invalid_format", None
|
|
248
|
+
finally:
|
|
249
|
+
context.close()
|
|
250
|
+
browser.close()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _try_renew_cookies(document: dict[str, Any], account: dict[str, Any]) -> dict[str, Any]:
|
|
254
|
+
if not is_yt_cm_available():
|
|
255
|
+
logger.warning("⚠️ yt-cm não encontrado. A renovação de cookies será pulada. Instale com: pip install yt-cm")
|
|
256
|
+
return document
|
|
257
|
+
manager = YouTubeCookieManager() # type: ignore[operator]
|
|
258
|
+
for method_name in ("renew", "refresh", "validate"):
|
|
259
|
+
method = getattr(manager, method_name, None)
|
|
260
|
+
if callable(method):
|
|
261
|
+
result = method(document.get("cookies", {}), account=account)
|
|
262
|
+
if isinstance(result, dict):
|
|
263
|
+
document["cookies"] = {key: str(result.get(key, document["cookies"].get(key, ""))) for key in COOKIE_KEYS}
|
|
264
|
+
break
|
|
265
|
+
return document
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@dataclass(frozen=True)
|
|
269
|
+
class RenewalResult:
|
|
270
|
+
ok: bool
|
|
271
|
+
status: str
|
|
272
|
+
message: str
|
|
273
|
+
account_id: str
|
|
274
|
+
attempts: int = 0
|
|
275
|
+
|
|
276
|
+
def as_dict(self) -> dict[str, Any]:
|
|
277
|
+
return {"ok": self.ok, "status": self.status, "message": self.message, "account_id": self.account_id, "attempts": self.attempts}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def renew_account_session(storage_root: Path, account: dict[str, Any], settings: dict[str, Any] | None = None, *, force: bool = False, wait_seconds: int = 0, logs_dir: Path | None = None) -> RenewalResult:
|
|
281
|
+
settings = settings or {}
|
|
282
|
+
account_id = str(account.get("id") or "").strip()
|
|
283
|
+
if not account_id:
|
|
284
|
+
return RenewalResult(False, "invalid_account", "Conta Google sem ID.", "")
|
|
285
|
+
health = check_account_session_info_health(storage_root, account, settings)
|
|
286
|
+
if not force and health.status not in {"expiring", "expired", "missing", "invalid_format"}:
|
|
287
|
+
return RenewalResult(True, health.status, "Renovação não necessária.", account_id)
|
|
288
|
+
directory = account_directory(storage_root, account)
|
|
289
|
+
destination = credentials_document_path(storage_root, account)
|
|
290
|
+
logs_dir = logs_dir or directory / "logs"
|
|
291
|
+
try:
|
|
292
|
+
with account_lock(directory, account_id, wait_seconds=wait_seconds):
|
|
293
|
+
document = load_credentials_document(storage_root, account, settings, create=False)
|
|
294
|
+
document = dict(document)
|
|
295
|
+
try:
|
|
296
|
+
document = _try_renew_cookies(document, account)
|
|
297
|
+
except Exception as exc:
|
|
298
|
+
return RenewalResult(False, "cookie_renewal_failed", f"Falha na renovação de cookies: {_safe_error(exc)}", account_id)
|
|
299
|
+
for attempt in range(1, MAX_CAPTURE_ATTEMPTS + 1):
|
|
300
|
+
capture_status, token = _capture_with_playwright(document, logs_dir=logs_dir, video_id=str(account.get("video_id") or ""))
|
|
301
|
+
if capture_status == "blocked_by_google":
|
|
302
|
+
_write_renewal_state(directory, "blocked_by_google")
|
|
303
|
+
return RenewalResult(False, "blocked_by_google", "🔒 Sessão bloqueada pelo Google. Renovação manual necessária.", account_id, attempt)
|
|
304
|
+
if validate_session_info(token):
|
|
305
|
+
document["sessionInfo"] = token
|
|
306
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
307
|
+
document["sessionInfoCapturedAt"] = now
|
|
308
|
+
document["expires_at"] = (datetime.now(timezone.utc) + timedelta(hours=19)).isoformat()
|
|
309
|
+
document["sessionInfoHealthStatus"] = ""
|
|
310
|
+
atomic_save_credentials(destination, document)
|
|
311
|
+
_write_renewal_state(directory, "healthy")
|
|
312
|
+
return RenewalResult(True, "healthy", "sessionInfo renovado com sucesso.", account_id, attempt)
|
|
313
|
+
logger.warning("sessionInfo capturado, mas formato inválido. Descartando.")
|
|
314
|
+
_write_renewal_state(directory, "invalid_format")
|
|
315
|
+
return RenewalResult(False, "invalid_format", "Não foi possível capturar um sessionInfo válido.", account_id, MAX_CAPTURE_ATTEMPTS)
|
|
316
|
+
except TimeoutError as exc:
|
|
317
|
+
return RenewalResult(False, "lock_timeout", str(exc), account_id)
|
|
318
|
+
except Exception as exc:
|
|
319
|
+
return RenewalResult(False, "renewal_failed", f"Renovação falhou: {_safe_error(exc)}", account_id)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _accounts_from_settings(settings: dict[str, Any]) -> list[dict[str, Any]]:
|
|
323
|
+
accounts = settings.get("youtube_batch_accounts", [])
|
|
324
|
+
return [item for item in accounts if isinstance(item, dict) and str(item.get("id") or "").strip()] if isinstance(accounts, list) else []
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def run_all_accounts(storage_root: Path, settings: dict[str, Any]) -> int:
|
|
328
|
+
renewed = failed = ignored = 0
|
|
329
|
+
for account in _accounts_from_settings(settings):
|
|
330
|
+
enabled = bool(account.get("auto_renew_enabled", settings.get("auto_renew_enabled", False)))
|
|
331
|
+
if not enabled:
|
|
332
|
+
ignored += 1
|
|
333
|
+
continue
|
|
334
|
+
health = check_account_session_info_health(storage_root, account, settings)
|
|
335
|
+
if health.status not in {"expiring", "expired"}:
|
|
336
|
+
ignored += 1
|
|
337
|
+
continue
|
|
338
|
+
result = renew_account_session(storage_root, account, settings)
|
|
339
|
+
if result.ok:
|
|
340
|
+
renewed += 1
|
|
341
|
+
else:
|
|
342
|
+
failed += 1
|
|
343
|
+
logger.info("Renovadas: %s, Falhas: %s, Ignoradas: %s", renewed, failed, ignored)
|
|
344
|
+
return 1 if failed else 0
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def main(argv: list[str] | None = None) -> int:
|
|
348
|
+
parser = argparse.ArgumentParser(description="Renovação única de sessões YouTube")
|
|
349
|
+
parser.add_argument("--all-accounts", action="store_true")
|
|
350
|
+
parser.add_argument("--account-id")
|
|
351
|
+
parser.add_argument("--storage-root", default=os.environ.get("THUNDERBOLT_STORAGE_DIR", "storage"))
|
|
352
|
+
args = parser.parse_args(argv)
|
|
353
|
+
root = Path(args.storage_root)
|
|
354
|
+
settings_path = root / "state" / "settings.json"
|
|
355
|
+
try:
|
|
356
|
+
settings = json.loads(settings_path.read_text(encoding="utf-8")) if settings_path.exists() else {}
|
|
357
|
+
except (OSError, json.JSONDecodeError):
|
|
358
|
+
settings = {}
|
|
359
|
+
if args.all_accounts:
|
|
360
|
+
return run_all_accounts(root, settings)
|
|
361
|
+
accounts = [a for a in _accounts_from_settings(settings) if str(a.get("id")) == str(args.account_id)]
|
|
362
|
+
if not accounts:
|
|
363
|
+
return 1
|
|
364
|
+
result = renew_account_session(root, accounts[0], settings, force=True)
|
|
365
|
+
return 0 if result.ok else 1
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
if __name__ == "__main__":
|
|
369
|
+
raise SystemExit(main())
|
|
370
|
+
|
|
371
|
+
__all__ = ["RenewalResult", "TITLE_SELECTORS", "account_lock", "atomic_save_credentials", "is_yt_cm_available", "renew_account_session", "run_all_accounts", "validate_session_info"]
|
package/package.json
CHANGED