@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 +163 -5
- package/hermes_ui/storage.py +1 -0
- package/integrations/tiktok_public.py +229 -0
- package/package.json +1 -1
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": "
|
|
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,
|
package/hermes_ui/storage.py
CHANGED
|
@@ -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