@danhachuel/thunderbolt 0.2.66 → 0.2.68

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.
@@ -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.66",
3
+ "version": "0.2.68",
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",
@@ -0,0 +1,179 @@
1
+ # Setting up Instagram for automations with n8n
2
+
3
+ ## [📚 Join our Skool community for support, premium content and more!](https://www.skool.com/ai-agents-az/about?gw8)
4
+
5
+ ### Be part of a growing community and help us create more content like this
6
+
7
+ ## Convert the Instagram account to a professional account
8
+
9
+ > [!IMPORTANT]
10
+ > Converting the Instagram account to a professional (business or creator) account will make the account public
11
+
12
+ In the Instagram application, follow these steps to convert your account to a professional account.
13
+
14
+ <table>
15
+ <tr>
16
+ <td>
17
+ 1. Open the settings, and select `Account type and tools`
18
+ <img src="https://github.com/user-attachments/assets/adffda31-f8ce-4a49-a971-40e5cd5f5395" alt="" />
19
+ </td>
20
+ <td>
21
+ 2. Click on `Switch to professinal account`
22
+ <img src="https://github.com/user-attachments/assets/5ea13aeb-b6ac-40b4-906f-2fc563625c52" alt="" />
23
+ </td>
24
+ <td>
25
+ 3. Click on `Next`
26
+ <img src="https://github.com/user-attachments/assets/10942cc8-6045-4ca1-a14a-241a2d94591c" />
27
+ </td>
28
+ </tr>
29
+ <tr>
30
+ <td>
31
+ 4. Select the type of account your want to create
32
+ <img src="https://github.com/user-attachments/assets/c9e19bf8-b48d-49e7-b560-e8edf423233d" alt="" />
33
+ </td>
34
+ <td>
35
+ 5. Select the right category, that describe your account
36
+ <img src="https://github.com/user-attachments/assets/eb4680b2-1d38-476c-8fb0-c428d1e12db9" alt="" />
37
+ </td>
38
+ <td>
39
+ 6. Confirm the choice
40
+ <img src="https://github.com/user-attachments/assets/22ee13cf-f751-4cea-9fc0-be466c885b02" alt="" />
41
+ </td>
42
+ </tr>
43
+ </table>
44
+
45
+ ## Create credentials to use with n8n
46
+
47
+ ### 1. Create a Facebook page
48
+
49
+ Navigate to [https://www.facebook.com/pages/create](https://www.facebook.com/pages/create) to create a new Facebook page.
50
+ Fill out the required parameters (name, category) and create the page.
51
+
52
+ <img width="1554" alt="Screenshot 2025-05-05 at 10 02 00 AM" src="https://github.com/user-attachments/assets/f0e321a2-7b85-4b23-b85b-f8e63281b6b9" />
53
+
54
+ Now, you need to be acting on behalf of the page - if it's not active for some reason, select it from the menu.
55
+
56
+ <img width="372" alt="image" src="https://github.com/user-attachments/assets/7aaaccc8-4b0a-4f72-a8c9-f2ecc453584a" />
57
+
58
+ ### 2. Connect Instagram account to the Facebook page
59
+
60
+ Click on your page's name on the left side.
61
+
62
+ <img width="379" alt="image" src="https://github.com/user-attachments/assets/fd1ed1d8-8890-4636-a4ae-0496db252283" />
63
+
64
+ Select `Settings` from the menu.
65
+
66
+ <img width="368" alt="image" src="https://github.com/user-attachments/assets/641904ec-764e-4d56-bede-618da33db7fe" />
67
+
68
+ Scroll down to `Permissions` and select `Linked accounts`.
69
+
70
+ <img width="369" alt="image" src="https://github.com/user-attachments/assets/a024ad7e-8358-4329-a864-c7b621810405" />
71
+
72
+ Select `Instagram`.
73
+
74
+ <img width="740" alt="image" src="https://github.com/user-attachments/assets/141164a0-c88e-46a0-a335-466f81297bc6" />
75
+
76
+ Click on `Connect account` and sign in with your Instagram account.
77
+
78
+ <img width="708" alt="image" src="https://github.com/user-attachments/assets/5324b945-621b-4f2b-b513-9b51e5b5c071" />
79
+
80
+ Click on `Connect`.
81
+
82
+ <img width="573" alt="image" src="https://github.com/user-attachments/assets/85cad094-a815-441a-a0e5-84081fd0ee2d" />
83
+
84
+ Click on `Confirm`.
85
+
86
+ <img width="570" alt="image" src="https://github.com/user-attachments/assets/df5bb377-22a8-41de-b99d-0f80052cb372" />
87
+
88
+ Click on `Continue`.
89
+
90
+ <img width="561" alt="image" src="https://github.com/user-attachments/assets/81fd3143-f00b-4e33-9521-12c0c9ce623d" />
91
+
92
+ You are done.
93
+
94
+ <img width="713" alt="image" src="https://github.com/user-attachments/assets/ce4c0bbe-beed-4c6c-8731-23216efbccb5" />
95
+
96
+ ### 3. Create a Facebook application
97
+
98
+ Head to [https://developers.facebook.com/apps](https://developers.facebook.com/apps) and click on `Create app`
99
+
100
+ <img width="1027" alt="image" src="https://github.com/user-attachments/assets/ca5c79b9-974a-4c55-8f88-6def8c1b98d1" />
101
+
102
+ Select the `Other` use case and hit `Next`.
103
+
104
+ <img width="1033" alt="image" src="https://github.com/user-attachments/assets/31f296bd-3b47-432b-9037-7974e821a311" />
105
+
106
+ Select `Business` app type.
107
+
108
+ <img width="818" alt="image" src="https://github.com/user-attachments/assets/2ab06dd1-33ba-4d87-8365-3637c8327877" />
109
+
110
+ Review, and click on `Create app`
111
+
112
+ <img width="815" alt="image" src="https://github.com/user-attachments/assets/c8cc8e1d-c006-428a-9bfa-8cc78a2932c9" />
113
+
114
+ Add the Instagram product to your app.
115
+
116
+ <img width="1008" alt="image" src="https://github.com/user-attachments/assets/4186f90b-b619-4c1c-9ea8-bfc5294eaeb4" />
117
+
118
+ You don't need to configure it, just leave it as it is.
119
+
120
+ ### 4. Create an access token
121
+
122
+ From the tools menu, select the `Graph API Explorer`
123
+
124
+ <img width="877" alt="image" src="https://github.com/user-attachments/assets/006a40a7-894a-4bd0-9483-0d495b77206f" />
125
+
126
+ You'll see this interface below.
127
+
128
+ <img width="541" alt="image" src="https://github.com/user-attachments/assets/414ece47-2f1d-4d08-9998-ada85ffb065f" />
129
+
130
+ 1. Make sure the Meta app is the one you just created
131
+ 2. In the permissions panel, add the following permissions
132
+
133
+ - `pages_show_list`
134
+ - `business_management`
135
+ - `instagram_basic`
136
+ - `instagram_content_publish`
137
+ - `pages_read_engagement`
138
+
139
+ 3. Click on `Generate Access Token`
140
+
141
+ 4. You'll be prompted to select the Facebook page you give access to, the business account and the Instagram account. Select all of them. During the process, take a not of the **Instagram account id** you are connecting, you'll need that later.
142
+
143
+ <table>
144
+ <tr>
145
+ <td>
146
+ <img width="564" alt="Screenshot 2025-05-09 at 11 07 22 AM" src="https://github.com/user-attachments/assets/6b77415e-1ca2-47f0-b046-974fd391c90a" />
147
+ </td>
148
+ <td>
149
+ <img width="562" alt="Screenshot 2025-05-09 at 11 07 27 AM" src="https://github.com/user-attachments/assets/353b9973-426b-4034-b5fc-e77840ca0d18" />
150
+ </td>
151
+ <td>
152
+ <img width="562" alt="Screenshot 2025-05-09 at 11 08 35 AM" src="https://github.com/user-attachments/assets/58132694-1376-4c40-9d20-61cb8fdb9df5" />
153
+ </td>
154
+ <td>
155
+ <img width="562" alt="Screenshot 2025-05-09 at 11 08 58 AM" src="https://github.com/user-attachments/assets/c2676755-f12f-49d6-9a92-49ea688fc832" />
156
+ </td>
157
+ </tr>
158
+ </table>
159
+
160
+ 5. Copy the access token, and head to the [Facebook token debugger tool](https://developers.facebook.com/tools/debug/accesstoken) and paste in the access token, and click `debug`. Scroll to the very bottom of the page, and click on `Extend Access Token`. Copy the long-lived access token.
161
+
162
+ 6. Go back to the `Graph API Explorer` and paste the access token to the access token field and add `me/accounts` to the graph path and click `Submit`. In the results, you'll find field `access_token` that contains the long-lived page access token that will never expire. Copy that.
163
+
164
+ <img width="693" alt="image" src="https://github.com/user-attachments/assets/0e1bd821-5c27-4c9a-b14e-eeed9c17850f" />
165
+
166
+ 7. (optional) you can verify the expiration of the token, if you paste it to the [Facebook token debugger tool](https://developers.facebook.com/tools/debug/accesstoken).
167
+
168
+ 8. (optional) you can also get the Instagram Business Account's id, if you click on the id of the page in the Graph API explorer, and add `?fields=instagram_business_account` after the id, then click `Submit`
169
+
170
+ <img width="766" alt="image" src="https://github.com/user-attachments/assets/ac0cb186-849a-4c55-8f88-6def8c1b98d1" />
171
+
172
+ **If you did everything above, you'll have two things**
173
+
174
+ 1. A page token, that won't expire to manage your Instagram account. You will use the page token to setup the Facebook Graph node in n8n.
175
+ 2. The Instagram account id which you'll need to set in the `Configure` node in the n8n workflow
176
+
177
+ ---
178
+
179
+ > **Source:** [ai_agents_az/episode_8/guide-instagram.md](https://github.com/gyoridavid/ai_agents_az/blob/main/episode_8/guide-instagram.md)