@danhachuel/thunderbolt 0.2.65 → 0.2.67

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
@@ -40,6 +40,7 @@ from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesiz
40
40
  from hermes_ui.thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image
41
41
  from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_topic_for_channel
42
42
  from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
43
+ from integrations.tiktok_public import fetch_public_tiktok_profile, normalize_tiktok_reference
43
44
  from integrations.postiz import PostizAdapter
44
45
  from integrations.upload_routing import OFFICIAL_DAILY_LIMIT, official_upload_count, upload_with_default_route
45
46
  from integrations.youtube_direct_upload import YouTubeDirectUploader
@@ -754,6 +755,159 @@ def render_blueprints():
754
755
  st.error(str(exc))
755
756
 
756
757
 
758
+ def _tiktok_accounts_from_settings(settings: dict[str, Any]) -> list[dict[str, Any]]:
759
+ raw_accounts = settings.get("tiktok_accounts")
760
+ if not isinstance(raw_accounts, list) or not raw_accounts:
761
+ raw_accounts = settings.get("tiktok_profiles", [])
762
+ if not isinstance(raw_accounts, list):
763
+ return []
764
+ accounts: list[dict[str, Any]] = []
765
+ seen: set[str] = set()
766
+ for raw in raw_accounts:
767
+ if not isinstance(raw, dict):
768
+ continue
769
+ try:
770
+ reference = normalize_tiktok_reference(str(raw.get("url") or raw.get("handle") or raw.get("username") or raw.get("id") or ""))
771
+ except ValueError:
772
+ continue
773
+ account = {**raw, **reference}
774
+ account["name"] = str(raw.get("name") or raw.get("label") or reference["username"]).strip()
775
+ account["bio"] = str(raw.get("bio") or raw.get("description") or "").strip()
776
+ account["source"] = str(raw.get("source") or raw.get("origin") or "manual").strip()
777
+ key = account["id"]
778
+ if key in seen:
779
+ continue
780
+ seen.add(key)
781
+ accounts.append(account)
782
+ return accounts
783
+
784
+
785
+ def _save_tiktok_accounts(accounts: list[dict[str, Any]]) -> None:
786
+ settings = read_json("settings.json", {})
787
+ settings["tiktok_accounts"] = accounts
788
+ write_json("settings.json", settings)
789
+
790
+
791
+ def _upsert_tiktok_account(account: dict[str, Any]) -> tuple[list[dict[str, Any]], bool]:
792
+ settings = read_json("settings.json", {})
793
+ accounts = _tiktok_accounts_from_settings(settings)
794
+ normalized = normalize_tiktok_reference(str(account.get("url") or account.get("handle") or account.get("username") or ""))
795
+ merged = {**account, **normalized}
796
+ merged["name"] = str(account.get("name") or normalized["username"]).strip() or normalized["username"]
797
+ existing_index = next((index for index, item in enumerate(accounts) if item.get("id") == normalized["id"] or item.get("username") == normalized["username"]), None)
798
+ created = existing_index is None
799
+ if existing_index is None:
800
+ accounts.append(merged)
801
+ else:
802
+ accounts[existing_index] = {**accounts[existing_index], **merged}
803
+ _save_tiktok_accounts(accounts)
804
+ return accounts, created
805
+
806
+
807
+ def _tiktok_account_metric(account: dict[str, Any], key: str, label: str) -> str:
808
+ value = account.get(key)
809
+ return f"{label}: {value:,}" if isinstance(value, int) else f"{label}: —"
810
+
811
+
812
+ def render_tiktok_accounts():
813
+ st.title("Contas TikTok")
814
+ st.caption("Pesquisa pública e cadastro manual de contas TikTok para alimentar o selector de destino no Upload. Esta área não usa OAuth, API de publicação nem lote.")
815
+ settings = read_json("settings.json", {})
816
+ accounts = _tiktok_accounts_from_settings(settings)
817
+
818
+ search_tab, manual_tab, library_tab = st.tabs(["Pesquisa pública", "Cadastro manual", "Contas cadastradas"])
819
+ with search_tab:
820
+ st.subheader("Pesquisar perfil público")
821
+ st.info("A pesquisa consulta apenas a página pública do perfil e pode devolver dados incompletos. Se o TikTok bloquear a consulta, utilize o cadastro manual.")
822
+ with st.form("tiktok_public_lookup_form"):
823
+ lookup_source = st.text_input("URL pública ou @handle", placeholder="https://www.tiktok.com/@conta ou @conta", key="tiktok_public_lookup_source")
824
+ lookup_submitted = st.form_submit_button("Pesquisar perfil público", type="primary", use_container_width=True)
825
+ if lookup_submitted:
826
+ result = fetch_public_tiktok_profile(lookup_source)
827
+ st.session_state["tiktok_public_lookup"] = {"ok": result.ok, "message": result.message, "data": result.data}
828
+ (st.success if result.ok else st.warning)(result.message)
829
+ lookup = st.session_state.get("tiktok_public_lookup", {})
830
+ lookup_data = lookup.get("data") if isinstance(lookup, dict) else None
831
+ if isinstance(lookup_data, dict) and lookup_data.get("url"):
832
+ with st.container(border=True):
833
+ st.subheader(str(lookup_data.get("name") or lookup_data.get("handle") or "Perfil TikTok"))
834
+ st.caption(f"{lookup_data.get('handle', '')} · {lookup_data.get('public_url') or lookup_data.get('url')}")
835
+ if lookup_data.get("bio"):
836
+ st.write(lookup_data["bio"])
837
+ metric_cols = st.columns(4)
838
+ with metric_cols[0]: st.metric("Seguidores", lookup_data.get("subscriber_count") if lookup_data.get("subscriber_count") is not None else "—")
839
+ with metric_cols[1]: st.metric("Seguindo", lookup_data.get("following_count") if lookup_data.get("following_count") is not None else "—")
840
+ with metric_cols[2]: st.metric("Gostos", lookup_data.get("likes_count") if lookup_data.get("likes_count") is not None else "—")
841
+ with metric_cols[3]: st.metric("Vídeos", lookup_data.get("video_count") if lookup_data.get("video_count") is not None else "—")
842
+ display_name = st.text_input("Nome da conta", value=str(lookup_data.get("name") or lookup_data.get("username") or ""), key="tiktok_lookup_display_name")
843
+ notes = st.text_area("Observações internas", value=str(lookup_data.get("notes") or ""), key="tiktok_lookup_notes", height=80)
844
+ if st.button("Cadastrar conta TikTok", type="primary", use_container_width=True, key="tiktok_register_public_account"):
845
+ try:
846
+ stored = {**lookup_data, "name": display_name.strip() or lookup_data.get("username", ""), "notes": notes.strip(), "source": "public_lookup"}
847
+ _upsert_tiktok_account(stored)
848
+ st.session_state.pop("tiktok_public_lookup", None)
849
+ st.success("Conta TikTok cadastrada e disponível no selector de Upload.")
850
+ st.rerun()
851
+ except ValueError as exc:
852
+ st.error(str(exc))
853
+
854
+ with manual_tab:
855
+ st.subheader("Cadastrar conta manualmente")
856
+ with st.form("tiktok_manual_account_form"):
857
+ manual_source = st.text_input("@handle ou URL pública", placeholder="@minhaconta", key="tiktok_manual_source")
858
+ manual_name = st.text_input("Nome da conta", placeholder="Nome de apresentação", key="tiktok_manual_name")
859
+ manual_notes = st.text_area("Observações internas", height=90, key="tiktok_manual_notes")
860
+ manual_submitted = st.form_submit_button("Guardar cadastro manual", type="primary", use_container_width=True)
861
+ if manual_submitted:
862
+ try:
863
+ reference = normalize_tiktok_reference(manual_source)
864
+ _upsert_tiktok_account({**reference, "name": manual_name.strip() or reference["username"], "notes": manual_notes.strip(), "source": "manual"})
865
+ st.success("Conta TikTok cadastrada manualmente.")
866
+ st.rerun()
867
+ except ValueError as exc:
868
+ st.error(str(exc))
869
+
870
+ with library_tab:
871
+ st.subheader(f"Contas TikTok cadastradas ({len(accounts)})")
872
+ if not accounts:
873
+ st.info("Ainda não existem contas TikTok. Use Pesquisa pública ou Cadastro manual para adicionar a primeira conta.")
874
+ for account in accounts:
875
+ account_id = str(account.get("id") or account.get("username"))
876
+ with st.container(border=True):
877
+ card_cols = st.columns([0.18, 2.7, 1.1, 1.1, 0.8])
878
+ with card_cols[0]:
879
+ if account.get("avatar_url"):
880
+ st.image(account["avatar_url"], width=52)
881
+ else:
882
+ st.markdown("### TT")
883
+ with card_cols[1]:
884
+ st.write(f"**{account.get('name') or account.get('username')}**")
885
+ st.caption(f"{account.get('handle') or '@' + str(account.get('username', ''))} · {account.get('public_url') or account.get('url')}")
886
+ st.caption(str(account.get("source") or "manual").replace("_", " ").title())
887
+ with card_cols[2]: st.caption(_tiktok_account_metric(account, "subscriber_count", "Seguidores"))
888
+ with card_cols[3]: st.caption(_tiktok_account_metric(account, "video_count", "Vídeos"))
889
+ with card_cols[4]:
890
+ if st.button("Apagar", key=f"delete_tiktok_account_{account_id}"):
891
+ _save_tiktok_accounts([item for item in accounts if str(item.get("id")) != account_id])
892
+ st.success("Conta TikTok removida.")
893
+ st.rerun()
894
+ with st.expander("Editar conta", expanded=False):
895
+ with st.form(f"edit_tiktok_account_{account_id}"):
896
+ edited_name = st.text_input("Nome da conta", value=str(account.get("name") or account.get("username") or ""), key=f"edit_tiktok_name_{account_id}")
897
+ edited_source = st.text_input("@handle ou URL pública", value=str(account.get("public_url") or account.get("url") or account.get("handle") or ""), key=f"edit_tiktok_source_{account_id}")
898
+ edited_notes = st.text_area("Observações internas", value=str(account.get("notes") or ""), key=f"edit_tiktok_notes_{account_id}", height=80)
899
+ edit_submitted = st.form_submit_button("Guardar conta", type="primary", use_container_width=True)
900
+ if edit_submitted:
901
+ try:
902
+ reference = normalize_tiktok_reference(edited_source)
903
+ updated = {**account, **reference, "name": edited_name.strip() or reference["username"], "notes": edited_notes.strip(), "source": account.get("source") or "manual"}
904
+ _upsert_tiktok_account(updated)
905
+ st.success("Conta TikTok actualizada.")
906
+ st.rerun()
907
+ except ValueError as exc:
908
+ st.error(str(exc))
909
+
910
+
757
911
  def render_tiktok_prompt_masters():
758
912
  st.title("Prompts Master")
759
913
  st.caption(f"Biblioteca exclusiva para vídeos TikTok. Os ficheiros ficam em `{TIKTOK_PROMPT_MASTERS}` e nunca entram na pasta de Blueprints YouTube.")
@@ -2509,7 +2663,7 @@ def render_upload_postiz():
2509
2663
 
2510
2664
 
2511
2665
  UPLOAD_DESTINATION_TARGET_KEYS = {
2512
- "TikTok": "tiktok_profiles",
2666
+ "TikTok": "tiktok_accounts",
2513
2667
  "Instagram": "instagram_profiles",
2514
2668
  "Facebook Pages": "facebook_pages",
2515
2669
  }
@@ -2539,6 +2693,8 @@ def upload_targets_for_destination(destination: str, channels: list[dict[str, An
2539
2693
  if not setting_key:
2540
2694
  return []
2541
2695
  configured_targets = settings.get(setting_key, [])
2696
+ if destination == "TikTok" and (not isinstance(configured_targets, list) or not configured_targets):
2697
+ configured_targets = settings.get("tiktok_profiles", [])
2542
2698
  if not isinstance(configured_targets, list):
2543
2699
  return []
2544
2700
  targets: list[Any] = []
@@ -2553,12 +2709,14 @@ def upload_targets_for_destination(destination: str, channels: list[dict[str, An
2553
2709
  def render_upload_destination_target(destination: str, channels: list[dict[str, Any]], settings: dict[str, Any]) -> Any | None:
2554
2710
  options = upload_targets_for_destination(destination, channels, settings)
2555
2711
  destination_key = re.sub(r"[^a-z0-9]+", "_", destination.lower()).strip("_")
2556
- select_label = "Canal" if destination == "YouTube" else "Perfil / página"
2557
- empty_label = "Nenhum canal YouTube cadastrado" if destination == "YouTube" else f"Nenhum {destination} configurado"
2712
+ select_label = "Canal" if destination == "YouTube" else ("Conta TikTok" if destination == "TikTok" else "Perfil / página")
2713
+ empty_label = "Nenhum canal YouTube cadastrado" if destination == "YouTube" else ("Nenhuma conta TikTok cadastrada" if destination == "TikTok" else f"Nenhum {destination} configurado")
2558
2714
  if not options:
2559
2715
  st.selectbox(select_label, [empty_label], disabled=True, key=f"upload_target_{destination_key}")
2560
2716
  if destination == "YouTube":
2561
2717
  st.caption("Cadastre ou liste pelo menos um canal YouTube antes de escolher o destino de envio.")
2718
+ elif destination == "TikTok":
2719
+ st.caption("Cadastre uma conta em Pipeline TikTok > Contas TikTok antes de escolher o destino de envio.")
2562
2720
  else:
2563
2721
  st.caption(f"A lista de {destination} será ligada numa etapa própria de credenciais/API.")
2564
2722
  return None
@@ -2735,8 +2893,6 @@ def render_settings():
2735
2893
  missing_document_parts = list(direct_status.get("missing_cookies", []))
2736
2894
  if not direct_status.get("has_session_info"):
2737
2895
  missing_document_parts.append("sessionInfo")
2738
- if not direct_status.get("has_innertube_api_key"):
2739
- missing_document_parts.append("INNERTUBE_API_KEY")
2740
2896
  if missing_document_parts:
2741
2897
  youtube_accounts_missing_document.append(account_email_snapshot)
2742
2898
 
@@ -3449,6 +3605,7 @@ def main():
3449
3605
  ]
3450
3606
  pipeline_tiktok_items = [
3451
3607
  ("Prompts Master", ":material/auto_awesome:", "Prompts Master"),
3608
+ ("Contas TikTok", ":material/account_circle:", "Contas TikTok"),
3452
3609
  ]
3453
3610
  edition_items = [
3454
3611
  ("Limpador de Metadados", ":material/edit_note:", "Limpador de Metadados"),
@@ -3547,6 +3704,7 @@ def main():
3547
3704
  "Criação de Músicas": render_music_creation,
3548
3705
  "Roteiros": render_scripts,
3549
3706
  "Prompts Master": render_tiktok_prompt_masters,
3707
+ "Contas TikTok": render_tiktok_accounts,
3550
3708
  "Automação Youtube": render_automation,
3551
3709
  "Niche Finder Kaggle": render_niche_finder,
3552
3710
  "Niche Finder Apify": render_niche_finder_apify,
@@ -208,6 +208,7 @@ DEFAULTS: dict[str, Any] = {
208
208
  "postiz_auto_publish": False,
209
209
  "tiktok_client_key": "",
210
210
  "tiktok_client_secret": "",
211
+ "tiktok_accounts": [],
211
212
  "tiktok_redirect_uri": "http://localhost:3030/oauth/tiktok/callback",
212
213
  "tiktok_scopes": "user.info.basic,video.publish,video.upload",
213
214
  "tiktok_access_token": "",
@@ -0,0 +1,229 @@
1
+ import json
2
+ import re
3
+ from datetime import datetime, timezone
4
+ from html import unescape
5
+ from hashlib import sha256
6
+ from typing import Any
7
+ from urllib.parse import urlparse
8
+
9
+ import requests
10
+
11
+ from integrations.platforms import IntegrationResult
12
+
13
+
14
+ TIKTOK_PUBLIC_HOSTS = {"tiktok.com", "www.tiktok.com", "m.tiktok.com"}
15
+ TIKTOK_PUBLIC_URL = "https://www.tiktok.com"
16
+ PUBLIC_USER_AGENT = "Thunderbolt/0.2 TikTok public profile lookup; manual user initiated request"
17
+
18
+
19
+ def _now_iso() -> str:
20
+ return datetime.now(timezone.utc).isoformat()
21
+
22
+
23
+ def _text(value: Any) -> str:
24
+ if isinstance(value, str):
25
+ return value.strip()
26
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
27
+ return str(value)
28
+ if isinstance(value, dict):
29
+ for key in ("text", "content", "simpleText", "name", "title", "value"):
30
+ text = _text(value.get(key))
31
+ if text:
32
+ return text
33
+ return ""
34
+
35
+
36
+ def _first(value: Any, keys: set[str]) -> Any:
37
+ if isinstance(value, dict):
38
+ for key, child in value.items():
39
+ if key in keys and child not in (None, "", [], {}):
40
+ return child
41
+ for child in value.values():
42
+ found = _first(child, keys)
43
+ if found is not None:
44
+ return found
45
+ elif isinstance(value, list):
46
+ for child in value:
47
+ found = _first(child, keys)
48
+ if found is not None:
49
+ return found
50
+ return None
51
+
52
+
53
+ def _number(value: Any) -> int | None:
54
+ if isinstance(value, bool) or value is None:
55
+ return None
56
+ if isinstance(value, (int, float)):
57
+ return int(value)
58
+ text = _text(value).lower().replace(" ", "")
59
+ match = re.search(r"([0-9][0-9.,]*)(k|m|b|mil|milhões|mi|bi)?", text)
60
+ if not match:
61
+ return None
62
+ raw = match.group(1)
63
+ suffix = match.group(2) or ""
64
+ try:
65
+ if suffix in {"k", "mil"}:
66
+ return int(float(raw.replace(",", ".")) * 1_000)
67
+ if suffix in {"m", "mi", "milhões"}:
68
+ return int(float(raw.replace(",", ".")) * 1_000_000)
69
+ if suffix in {"b", "bi"}:
70
+ return int(float(raw.replace(",", ".")) * 1_000_000_000)
71
+ return int(re.sub(r"[^0-9]", "", raw))
72
+ except ValueError:
73
+ return None
74
+
75
+
76
+ def _meta(document: str, *names: str) -> str:
77
+ for name in names:
78
+ patterns = (
79
+ rf'<meta[^>]+(?:name|property)=["\']{re.escape(name)}["\'][^>]+content=["\']([^"\']*)["\']',
80
+ rf'<meta[^>]+content=["\']([^"\']*)["\'][^>]+(?:name|property)=["\']{re.escape(name)}["\']',
81
+ )
82
+ for pattern in patterns:
83
+ match = re.search(pattern, document, flags=re.IGNORECASE)
84
+ if match:
85
+ return unescape(match.group(1)).strip()
86
+ return ""
87
+
88
+
89
+ def _json_scripts(document: str) -> list[Any]:
90
+ values: list[Any] = []
91
+ for match in re.finditer(r"<script[^>]*>(.*?)</script>", document, flags=re.IGNORECASE | re.DOTALL):
92
+ body = match.group(1).strip()
93
+ if not body or not (body.startswith("{") or body.startswith("[")):
94
+ continue
95
+ try:
96
+ values.append(json.loads(unescape(body)))
97
+ except (TypeError, ValueError, json.JSONDecodeError):
98
+ continue
99
+ return values
100
+
101
+
102
+ def _username_from_reference(source: str) -> str:
103
+ value = str(source or "").strip()
104
+ if not value:
105
+ return ""
106
+ if not value.startswith(("http://", "https://")):
107
+ value = value if value.startswith("@") else f"@{value}"
108
+ return value[1:].strip("/ ")
109
+ parsed = urlparse(value)
110
+ for part in parsed.path.split("/"):
111
+ if part.startswith("@") and len(part) > 1:
112
+ return part[1:].strip()
113
+ return ""
114
+
115
+
116
+ def normalize_tiktok_reference(source: str) -> dict[str, str]:
117
+ value = str(source or "").strip()
118
+ if not value:
119
+ raise ValueError("Informe um @handle ou URL pública do TikTok.")
120
+ if value.startswith("@"):
121
+ username = value[1:].strip()
122
+ elif value.startswith(("http://", "https://")):
123
+ parsed = urlparse(value)
124
+ if parsed.netloc.lower().split(":", 1)[0] not in TIKTOK_PUBLIC_HOSTS:
125
+ raise ValueError("Use uma URL pública do TikTok, por exemplo https://www.tiktok.com/@conta.")
126
+ username = _username_from_reference(value)
127
+ else:
128
+ username = value
129
+ username = username.strip().lstrip("@").split("/", 1)[0]
130
+ if not re.fullmatch(r"[A-Za-z0-9._-]{2,64}", username):
131
+ raise ValueError("O @handle TikTok deve conter apenas letras, números, ponto, sublinhado ou hífen.")
132
+ handle = f"@{username}"
133
+ url = f"{TIKTOK_PUBLIC_URL}/{handle}"
134
+ return {
135
+ "id": f"tiktok_{sha256(url.encode('utf-8')).hexdigest()[:20]}",
136
+ "username": username,
137
+ "handle": handle,
138
+ "url": url,
139
+ }
140
+
141
+
142
+ def _canonical_profile_data(source: str, document: str) -> dict[str, Any]:
143
+ reference = normalize_tiktok_reference(source)
144
+ title = _meta(document, "og:title", "twitter:title")
145
+ description = _meta(document, "og:description", "description", "twitter:description")
146
+ avatar_url = _meta(document, "og:image", "twitter:image")
147
+ canonical_url = _meta(document, "og:url") or reference["url"]
148
+ scripts = _json_scripts(document)
149
+ for payload in scripts:
150
+ candidate = payload
151
+ if isinstance(payload, dict) and payload.get("@type") in {"Person", "ProfilePage"}:
152
+ display_name = _text(payload.get("name"))
153
+ description = description or _text(payload.get("description"))
154
+ avatar_url = avatar_url or _text(payload.get("image"))
155
+ canonical_url = _text(payload.get("url")) or canonical_url
156
+ if display_name:
157
+ title = display_name
158
+ username = _first(candidate, {"uniqueId", "unique_id", "username"})
159
+ display_name = _first(candidate, {"nickname", "displayName", "display_name"})
160
+ bio = _first(candidate, {"signature", "bio", "bioDescription", "bio_description"})
161
+ avatar = _first(candidate, {"avatarLarger", "avatarMedium", "avatar_url", "avatarUrl"})
162
+ if username:
163
+ reference["username"] = _text(username).lstrip("@").strip() or reference["username"]
164
+ reference["handle"] = f"@{reference['username']}"
165
+ reference["url"] = f"{TIKTOK_PUBLIC_URL}/{reference['handle']}"
166
+ if display_name and not title:
167
+ title = _text(display_name)
168
+ if bio and not description:
169
+ description = _text(bio)
170
+ if avatar and not avatar_url:
171
+ avatar_url = _text(avatar)
172
+
173
+ title = re.sub(r"\s*[|·—-]\s*TikTok\s*$", "", title, flags=re.IGNORECASE).strip()
174
+ if title and "(" in title:
175
+ title = title.split("(", 1)[0].strip()
176
+ if title.startswith("@"):
177
+ title = ""
178
+ follower_count = None
179
+ following_count = None
180
+ likes_count = None
181
+ video_count = None
182
+ for payload in scripts:
183
+ follower_count = follower_count or _number(_first(payload, {"followerCount", "followers", "follower_count"}))
184
+ following_count = following_count or _number(_first(payload, {"followingCount", "following", "following_count"}))
185
+ likes_count = likes_count or _number(_first(payload, {"heartCount", "likes", "likeCount", "likes_count"}))
186
+ video_count = video_count or _number(_first(payload, {"videoCount", "video_count"}))
187
+
188
+ return {
189
+ **reference,
190
+ "name": title or reference["username"],
191
+ "bio": description,
192
+ "avatar_url": avatar_url,
193
+ "subscriber_count": follower_count,
194
+ "following_count": following_count,
195
+ "likes_count": likes_count,
196
+ "video_count": video_count,
197
+ "public_url": canonical_url if "tiktok.com" in canonical_url else reference["url"],
198
+ "public_lookup": True,
199
+ "metrics_source": "tiktok_public_page",
200
+ "last_public_lookup_at": _now_iso(),
201
+ }
202
+
203
+
204
+ def fetch_public_tiktok_profile(source: str) -> IntegrationResult:
205
+ try:
206
+ reference = normalize_tiktok_reference(source)
207
+ except ValueError as exc:
208
+ return IntegrationResult(False, str(exc), {})
209
+ headers = {
210
+ "User-Agent": PUBLIC_USER_AGENT,
211
+ "Accept-Language": "pt-BR,pt;q=0.9,en;q=0.8",
212
+ "Accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8",
213
+ }
214
+ try:
215
+ response = requests.get(reference["url"], headers=headers, timeout=12, allow_redirects=True)
216
+ except requests.RequestException as exc:
217
+ return IntegrationResult(False, f"Não foi possível consultar o perfil público do TikTok: {exc}", reference)
218
+ if response.status_code in {401, 403, 429}:
219
+ return IntegrationResult(False, "O TikTok bloqueou ou limitou a pesquisa pública. Use o cadastro manual com o @handle e a URL.", reference | {"status_code": response.status_code})
220
+ if response.status_code >= 400:
221
+ return IntegrationResult(False, f"O perfil público do TikTok devolveu HTTP {response.status_code}. Confirme o @handle ou use o cadastro manual.", reference | {"status_code": response.status_code})
222
+ data = _canonical_profile_data(source, response.text)
223
+ recognized = bool(data.get("name") or data.get("bio") or data.get("avatar_url") or any(data.get(key) is not None for key in ("subscriber_count", "video_count", "likes_count")))
224
+ if not recognized:
225
+ return IntegrationResult(False, "A página pública não expôs dados estruturados reconhecíveis. Pode cadastrar a conta manualmente.", data)
226
+ return IntegrationResult(True, "Perfil TikTok encontrado publicamente. Reveja os dados antes de cadastrar.", data)
227
+
228
+
229
+ __all__ = ["PUBLIC_USER_AGENT", "fetch_public_tiktok_profile", "normalize_tiktok_reference"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.2.65",
3
+ "version": "0.2.67",
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",