@praeko/cli 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,311 @@
1
+ import { createRequire as __cr } from 'node:module'; const require = __cr(import.meta.url);
2
+ import "./chunk-LCFK6X55.mjs";
3
+
4
+ // ../core/src/provedores/tiktok.mjs
5
+ import { closeSync, openSync, readSync, statSync } from "node:fs";
6
+ import { createHash, randomBytes } from "node:crypto";
7
+ import { spawnSync } from "node:child_process";
8
+ var nome = "tiktok";
9
+ var rede = "tiktok";
10
+ var imediato = false;
11
+ var API = "https://open.tiktokapis.com";
12
+ var AUTORIZAR = "https://www.tiktok.com/v2/auth/authorize/";
13
+ var ESCOPOS = ["user.info.basic", "video.upload", "video.publish"];
14
+ var MB = 1024 * 1024;
15
+ var LIMITES = { chunkMin: 5 * MB, chunkMax: 64 * MB, ultimoMax: 128 * MB, chunksMax: 1e3, videoMax: 4 * 1024 * MB, titulo: 2200 };
16
+ var REF_CLIENTE = "tiktok/cliente";
17
+ var REF_TOKEN = "tiktok/token";
18
+ var ERROS = {
19
+ unaudited_client_can_only_post_to_private_accounts: 'o app do TikTok ainda n\xE3o foi auditado: ele s\xF3 pode postar em conta PRIVADA e como SELF_ONLY. Use o modo "rascunho" (padr\xE3o) ou torne a conta privada at\xE9 a auditoria (developers.tiktok.com/application/content-posting-api).',
20
+ spam_risk_too_many_pending_share: "o TikTok aceita no m\xE1ximo 5 envios pendentes na caixa de entrada por 24 h; publique ou descarte algum rascunho no app.",
21
+ spam_risk_too_many_posts: "limite di\xE1rio de posts do TikTok atingido para esta conta (cerca de 15 por dia).",
22
+ spam_risk_user_banned_from_posting: "esta conta est\xE1 impedida de postar pelo TikTok.",
23
+ reached_active_user_cap: "o app atingiu a cota di\xE1ria de usu\xE1rios ativos de publica\xE7\xE3o.",
24
+ url_ownership_unverified: "o TikTok exige dom\xEDnio verificado para PULL_FROM_URL (o Praeko usa FILE_UPLOAD; isto n\xE3o deveria ocorrer).",
25
+ privacy_level_option_mismatch: "a privacidade pedida n\xE3o est\xE1 entre as op\xE7\xF5es da conta (privacy_level_options).",
26
+ access_token_invalid: "token do TikTok inv\xE1lido ou expirado: rode `praeko conta tiktok conectar` de novo.",
27
+ scope_not_authorized: "falta autorizar o escopo necess\xE1rio (video.upload para rascunho, video.publish para direto): rode `praeko conta tiktok conectar` e aceite todos.",
28
+ rate_limit_exceeded: "limite de requisi\xE7\xF5es do TikTok; tente de novo em um minuto."
29
+ };
30
+ var mensagemErro = (cod, msg) => ERROS[cod] ? `${ERROS[cod]} [${cod}]` : `TikTok recusou: ${cod}${msg ? ` (${msg})` : ""}`;
31
+ function planoChunks(tamanho, chunkPreferido = 10 * MB) {
32
+ if (!(tamanho > 0)) throw new Error("v\xEDdeo vazio");
33
+ if (tamanho > LIMITES.videoMax) throw new Error(`v\xEDdeo de ${(tamanho / MB).toFixed(0)} MB passa de 4 GB`);
34
+ let chunk = Math.min(LIMITES.chunkMax, Math.max(LIMITES.chunkMin, chunkPreferido));
35
+ if (tamanho < LIMITES.chunkMin || tamanho <= chunk) chunk = tamanho;
36
+ const total = Math.floor(tamanho / chunk);
37
+ if (total < 1 || total > LIMITES.chunksMax) throw new Error(`plano de partes inv\xE1lido (${total} partes)`);
38
+ const partes = [];
39
+ for (let i = 0; i < total; i++) {
40
+ const a = i * chunk, b = i === total - 1 ? tamanho - 1 : a + chunk - 1;
41
+ partes.push({ a, b, tamanho: b - a + 1 });
42
+ }
43
+ if (partes.at(-1).tamanho > LIMITES.ultimoMax) throw new Error("a \xFAltima parte passaria de 128 MB; aumente o tamanho da parte");
44
+ return { chunk, total, partes };
45
+ }
46
+ function truncar(texto, max = LIMITES.titulo) {
47
+ const s = String(texto ?? "");
48
+ if (s.length <= max) return s;
49
+ let corte = max - 1;
50
+ const c = s.charCodeAt(corte - 1);
51
+ if (c >= 55296 && c <= 56319) corte--;
52
+ return s.slice(0, corte) + "\u2026";
53
+ }
54
+ function privacidadeEfetiva({ pedida, auditado, opcoesCriador }) {
55
+ const quer = pedida || "SELF_ONLY";
56
+ const nivel = auditado ? quer : "SELF_ONLY";
57
+ const aviso = nivel !== quer ? `privacidade ${quer} \u2192 SELF_ONLY (app sem auditoria)` : null;
58
+ if (opcoesCriador && !opcoesCriador.includes(nivel)) throw new Error(`privacidade ${nivel} fora das op\xE7\xF5es da conta (${opcoesCriador.join(", ")})`);
59
+ return { nivel, aviso };
60
+ }
61
+ var codigoPkce = () => {
62
+ const verificador = randomBytes(48).toString("base64url");
63
+ return { verificador, desafio: createHash("sha256").update(verificador).digest("base64url") };
64
+ };
65
+ function urlAutorizacao({ key, redirect, escopos = ESCOPOS, estado, pkce }) {
66
+ const q = new URLSearchParams({ client_key: key, scope: escopos.join(","), response_type: "code", redirect_uri: redirect, state: estado });
67
+ if (pkce) {
68
+ q.set("code_challenge", pkce.desafio);
69
+ q.set("code_challenge_method", "S256");
70
+ }
71
+ return `${AUTORIZAR}?${q}`;
72
+ }
73
+ function extrairCodigo(texto) {
74
+ const t = String(texto).trim();
75
+ if (/^https?:\/\//.test(t) || t.includes("code=")) {
76
+ const q = new URL(t.startsWith("http") ? t : "http://x/?" + t.replace(/^\?/, "")).searchParams;
77
+ if (q.get("error")) throw new Error(`o TikTok devolveu erro: ${q.get("error")} ${q.get("error_description") || ""}`);
78
+ if (!q.get("code")) throw new Error("n\xE3o achei `code=` na URL de retorno");
79
+ return { code: q.get("code"), estado: q.get("state") };
80
+ }
81
+ return { code: decodeURIComponent(t), estado: null };
82
+ }
83
+ var dormirPadrao = (ms) => new Promise((r) => setTimeout(r, ms));
84
+ async function json(ctx, url, opts) {
85
+ const r = await ctx.fetchFn(url, opts);
86
+ const txt = await r.text();
87
+ let j;
88
+ try {
89
+ j = JSON.parse(txt);
90
+ } catch {
91
+ throw new Error(`TikTok respondeu ${r.status}: ${txt.slice(0, 200)}`);
92
+ }
93
+ return { status: r.status, ok: r.ok, j };
94
+ }
95
+ function lancarSeErro(res) {
96
+ const e = res.j?.error;
97
+ if (e && e.code && e.code !== "ok") throw new Error(mensagemErro(e.code, e.message));
98
+ if (res.j?.error && typeof res.j.error === "string") throw new Error(`TikTok: ${res.j.error}${res.j.error_description ? ` (${res.j.error_description})` : ""}`);
99
+ if (!res.ok) throw new Error(`TikTok respondeu ${res.status}`);
100
+ return res.j;
101
+ }
102
+ var lerJson = (ctx, ref) => {
103
+ const v = ctx.segredos.obter(ref);
104
+ if (!v) return null;
105
+ try {
106
+ return JSON.parse(v);
107
+ } catch {
108
+ return null;
109
+ }
110
+ };
111
+ var agora = (ctx) => ctx.agora ? ctx.agora() : /* @__PURE__ */ new Date();
112
+ function guardarToken(ctx, r, anterior = {}) {
113
+ const t = agora(ctx).getTime();
114
+ const tok = { access_token: r.access_token, refresh_token: r.refresh_token || anterior.refresh_token, open_id: r.open_id || anterior.open_id, scope: r.scope || anterior.scope, expira_em: t + (r.expires_in || 7200) * 1e3, refresh_expira_em: t + (r.refresh_expires_in || 180 * 86400) * 1e3 };
115
+ ctx.segredos.guardar(REF_TOKEN, JSON.stringify(tok));
116
+ return tok;
117
+ }
118
+ async function pedirToken(ctx, cliente, corpo) {
119
+ const res = await json(ctx, `${API}/v2/oauth/token/`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", "cache-control": "no-cache" }, body: new URLSearchParams({ client_key: cliente.key, client_secret: cliente.segredo, ...corpo }).toString() });
120
+ if (res.j.error && typeof res.j.error === "string") throw new Error(`TikTok: ${res.j.error}${res.j.error_description ? ` (${res.j.error_description})` : ""}`);
121
+ if (!res.j.access_token) throw new Error(`TikTok n\xE3o devolveu token (${res.status})`);
122
+ return res.j;
123
+ }
124
+ async function tokenValido(ctx) {
125
+ const cliente = lerJson(ctx, REF_CLIENTE), tok = lerJson(ctx, REF_TOKEN);
126
+ if (!cliente || !tok) throw new Error("TikTok n\xE3o conectado: rode `praeko conta tiktok conectar`");
127
+ if (tok.expira_em - 5 * 6e4 > agora(ctx).getTime()) return tok.access_token;
128
+ if (tok.refresh_expira_em <= agora(ctx).getTime()) throw new Error("a autoriza\xE7\xE3o do TikTok venceu (refresh token de 180 dias): rode `praeko conta tiktok conectar` de novo");
129
+ const novo = await pedirToken(ctx, cliente, { grant_type: "refresh_token", refresh_token: tok.refresh_token });
130
+ return guardarToken(ctx, novo, tok).access_token;
131
+ }
132
+ var auth = (token) => ({ authorization: `Bearer ${token}`, "content-type": "application/json; charset=UTF-8" });
133
+ async function conectar({ ctx, args = [] }) {
134
+ const f = (k) => {
135
+ const i = args.indexOf("--" + k);
136
+ return i >= 0 ? args[i + 1] : void 0;
137
+ };
138
+ let cliente = lerJson(ctx, REF_CLIENTE);
139
+ if (f("cliente-key") || f("cliente-segredo") || f("redirect")) {
140
+ cliente = { ...cliente || {}, ...f("cliente-key") ? { key: f("cliente-key") } : {}, ...f("cliente-segredo") ? { segredo: f("cliente-segredo") } : {}, ...f("redirect") ? { redirect: f("redirect") } : {} };
141
+ ctx.segredos.guardar(REF_CLIENTE, JSON.stringify(cliente));
142
+ }
143
+ if (!cliente?.key || !cliente?.segredo || !cliente?.redirect) throw new Error("uso: praeko conta tiktok conectar --cliente-key K --cliente-segredo S --redirect https://sua-pagina/retorno (veja docs/tiktok.md)");
144
+ const log = ctx.log || console.log;
145
+ const codigo = f("url") || f("code");
146
+ if (!codigo) {
147
+ const estado2 = randomBytes(8).toString("hex"), pkce = args.includes("--pkce") ? codigoPkce() : null;
148
+ ctx.segredos.guardar("tiktok/pendente", JSON.stringify({ estado: estado2, verificador: pkce?.verificador || null }));
149
+ log("1) Abra no navegador (logado na conta do canal) e autorize:\n");
150
+ log(urlAutorizacao({ key: cliente.key, redirect: cliente.redirect, estado: estado2, pkce }));
151
+ log(`
152
+ 2) O TikTok vai redirecionar para ${cliente.redirect}?code=\u2026 (a p\xE1gina pode dar 404, n\xE3o importa).
153
+ Copie a URL inteira da barra de endere\xE7o e rode:
154
+ praeko conta tiktok conectar --url "<URL copiada>"`);
155
+ return 0;
156
+ }
157
+ const { code, estado } = extrairCodigo(codigo);
158
+ const pend = lerJson(ctx, "tiktok/pendente");
159
+ if (estado && pend?.estado && estado !== pend.estado) throw new Error("o `state` da URL n\xE3o confere com o do pedido (poss\xEDvel link de outra tentativa); rode conectar de novo sem --url");
160
+ const r = await pedirToken(ctx, cliente, { grant_type: "authorization_code", code, redirect_uri: cliente.redirect, ...pend?.verificador ? { code_verifier: pend.verificador } : {} });
161
+ const tok = guardarToken(ctx, r);
162
+ log(`\u2713 TikTok conectado (open_id ${tok.open_id}); escopos: ${tok.scope}`);
163
+ if (!String(tok.scope).includes("video.upload")) log("\u26A0 sem o escopo video.upload: o modo rascunho n\xE3o vai funcionar");
164
+ if (!String(tok.scope).includes("video.publish")) log("\u26A0 sem video.publish (s\xF3 vem depois de o app ser aprovado para Direct Post): o modo direto n\xE3o vai funcionar");
165
+ return 0;
166
+ }
167
+ async function status({ ctx }) {
168
+ const tok = lerJson(ctx, REF_TOKEN);
169
+ if (!tok || !lerJson(ctx, REF_CLIENTE)) return { conectado: false, detalhe: "rode `praeko conta tiktok conectar`" };
170
+ try {
171
+ const t = await tokenValido(ctx);
172
+ const res = await json(ctx, `${API}/v2/user/info/?fields=open_id,display_name`, { method: "GET", headers: { authorization: `Bearer ${t}` } });
173
+ const j = lancarSeErro(res);
174
+ const dias = Math.floor((lerJson(ctx, REF_TOKEN).refresh_expira_em - agora(ctx).getTime()) / 864e5);
175
+ return { conectado: true, detalhe: `${j.data?.user?.display_name || tok.open_id}; escopos ${tok.scope}; autoriza\xE7\xE3o vale mais ${dias} dias` };
176
+ } catch (e) {
177
+ return { conectado: false, detalhe: e.message };
178
+ }
179
+ }
180
+ var modoDe = (item) => item.opcoes?.modo === "direto" ? "direto" : "rascunho";
181
+ function duracaoDe(ctx, arquivo) {
182
+ if (ctx.duracao) return ctx.duracao(arquivo);
183
+ try {
184
+ const r = spawnSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", arquivo], { encoding: "utf8" });
185
+ const d = parseFloat(r.stdout);
186
+ return Number.isFinite(d) ? d : null;
187
+ } catch {
188
+ return null;
189
+ }
190
+ }
191
+ async function planejar({ item }) {
192
+ const modo = modoDe(item), o = item.opcoes || {};
193
+ let tam;
194
+ try {
195
+ tam = statSync(item.video).size;
196
+ } catch {
197
+ tam = null;
198
+ }
199
+ const linhas = [`modo: ${modo}${modo === "rascunho" ? " (vai para a caixa de entrada do app; capa e legenda voc\xEA p\xF5e no app)" : ""}`];
200
+ if (modo === "direto") {
201
+ const p = privacidadeEfetiva({ pedida: o.privacidade, auditado: o.auditado });
202
+ linhas.push(`privacidade: ${p.nivel}${p.aviso ? ` (${p.aviso})` : ""}`);
203
+ linhas.push(`capa: quadro de ${o.capaMs ?? 0} ms`);
204
+ } else linhas.push("legenda n\xE3o vai pela API do rascunho: cole no app (fica em .praeko/ no resultado)");
205
+ if (tam) {
206
+ const pl = planoChunks(tam, (o.chunkMB || 10) * MB);
207
+ linhas.push(`v\xEDdeo ${(tam / MB).toFixed(1)} MB em ${pl.total} parte(s) de ${(pl.chunk / MB).toFixed(1)} MB`);
208
+ }
209
+ return linhas;
210
+ }
211
+ async function enviarPartes(ctx, uploadUrl, arquivo, plano, tamanho) {
212
+ const fd = openSync(arquivo, "r");
213
+ try {
214
+ for (const [i, p] of plano.partes.entries()) {
215
+ const buf = Buffer.alloc(p.tamanho);
216
+ readSync(fd, buf, 0, p.tamanho, p.a);
217
+ let ultimo;
218
+ for (let t = 1; t <= 3; t++) {
219
+ const r = await ctx.fetchFn(uploadUrl, { method: "PUT", headers: { "content-type": "video/mp4", "content-length": String(p.tamanho), "content-range": `bytes ${p.a}-${p.b}/${tamanho}` }, body: buf });
220
+ if (r.status >= 500) {
221
+ ultimo = r.status;
222
+ await (ctx.dormir || dormirPadrao)(1e3 * t);
223
+ continue;
224
+ }
225
+ const ultimaParte = i === plano.partes.length - 1;
226
+ if (ultimaParte && r.status === 201 || !ultimaParte && r.status === 206 || r.status === 200) {
227
+ ultimo = null;
228
+ break;
229
+ }
230
+ if (r.status === 403) throw new Error("o upload_url expirou (vale 1 h); o item ser\xE1 refeito na pr\xF3xima tentativa");
231
+ throw new Error(`upload da parte ${i + 1}/${plano.total} recusado (${r.status})`);
232
+ }
233
+ if (ultimo) throw new Error(`upload da parte ${i + 1}/${plano.total} falhou (${ultimo}) depois de 3 tentativas`);
234
+ }
235
+ } finally {
236
+ closeSync(fd);
237
+ }
238
+ }
239
+ async function aguardar(ctx, token, publicId, alvo) {
240
+ const pollMs = ctx.tiktok?.pollMs ?? 5e3, max = ctx.tiktok?.maxPolls ?? 72, dormir = ctx.dormir || dormirPadrao;
241
+ let ultimo;
242
+ for (let i = 0; i < max; i++) {
243
+ const j = lancarSeErro(await json(ctx, `${API}/v2/post/publish/status/fetch/`, { method: "POST", headers: auth(token), body: JSON.stringify({ publish_id: publicId }) }));
244
+ ultimo = j.data || {};
245
+ if (ultimo.status === "FAILED") throw new Error(`o TikTok falhou ao processar: ${ultimo.fail_reason || "motivo n\xE3o informado"}`);
246
+ if (alvo.includes(ultimo.status)) return ultimo;
247
+ await dormir(pollMs);
248
+ }
249
+ throw new Error(`o TikTok ainda n\xE3o terminou (\xFAltimo status ${ultimo?.status}); confira o app e a fila`);
250
+ }
251
+ async function publicar({ item, ctx }) {
252
+ const o = item.opcoes || {}, modo = modoDe(item), log = ctx.log || console.log;
253
+ const tamanho = statSync(item.video).size, plano = planoChunks(tamanho, (o.chunkMB || 10) * MB);
254
+ const token = await tokenValido(ctx);
255
+ if (item.prep?.publish_id) {
256
+ try {
257
+ const j = lancarSeErro(await json(ctx, `${API}/v2/post/publish/status/fetch/`, { method: "POST", headers: auth(token), body: JSON.stringify({ publish_id: item.prep.publish_id }) }));
258
+ if (["SEND_TO_USER_INBOX", "PUBLISH_COMPLETE"].includes(j.data?.status)) return { id: item.prep.publish_id, modo, status: j.data.status, retomado: true };
259
+ } catch {
260
+ }
261
+ }
262
+ let corpo, caminho, creator = null;
263
+ if (modo === "direto") {
264
+ const ci = lancarSeErro(await json(ctx, `${API}/v2/post/publish/creator_info/query/`, { method: "POST", headers: auth(token), body: "{}" })).data || {};
265
+ creator = ci;
266
+ const dur = duracaoDe(ctx, item.video);
267
+ if (dur != null && ci.max_video_post_duration_sec && dur > ci.max_video_post_duration_sec) throw new Error(`o v\xEDdeo tem ${dur.toFixed(0)} s e esta conta s\xF3 posta at\xE9 ${ci.max_video_post_duration_sec} s no TikTok`);
268
+ const pv = privacidadeEfetiva({ pedida: o.privacidade, auditado: o.auditado, opcoesCriador: ci.privacy_level_options });
269
+ if (pv.aviso) log(`\u26A0 TikTok: ${pv.aviso}`);
270
+ caminho = "/v2/post/publish/video/init/";
271
+ corpo = { post_info: { title: truncar(item.textos?.texto), privacy_level: pv.nivel, disable_duet: !!(o.desabilitarDueto || ci.duet_disabled), disable_stitch: !!(o.desabilitarStitch || ci.stitch_disabled), disable_comment: !!(o.desabilitarComentarios || ci.comment_disabled), video_cover_timestamp_ms: o.capaMs ?? 0, brand_content_toggle: false, brand_organic_toggle: false, is_aigc: o.ia === true }, source_info: { source: "FILE_UPLOAD", video_size: tamanho, chunk_size: plano.chunk, total_chunk_count: plano.total } };
272
+ } else {
273
+ caminho = "/v2/post/publish/inbox/video/init/";
274
+ corpo = { source_info: { source: "FILE_UPLOAD", video_size: tamanho, chunk_size: plano.chunk, total_chunk_count: plano.total } };
275
+ }
276
+ const init = lancarSeErro(await json(ctx, API + caminho, { method: "POST", headers: auth(token), body: JSON.stringify(corpo) })).data || {};
277
+ if (!init.publish_id || !init.upload_url) throw new Error("o TikTok n\xE3o devolveu publish_id/upload_url");
278
+ item.prep = { publish_id: init.publish_id, modo };
279
+ await enviarPartes(ctx, init.upload_url, item.video, plano, tamanho);
280
+ const fim = await aguardar(ctx, token, init.publish_id, modo === "direto" ? ["PUBLISH_COMPLETE"] : ["SEND_TO_USER_INBOX", "PUBLISH_COMPLETE"]);
281
+ const postId = fim.publicaly_available_post_id?.[0];
282
+ const res = { id: init.publish_id, modo, status: fim.status, ...postId && creator?.creator_username ? { url: `https://www.tiktok.com/@${creator.creator_username}/video/${postId}` } : {} };
283
+ if (modo === "rascunho") res.legenda = item.textos?.texto;
284
+ if (modo === "rascunho") log(`\u2713 TikTok: v\xEDdeo na caixa de entrada do app. Abra o app, escolha a capa, cole a legenda e publique.
285
+ --- legenda ---
286
+ ${item.textos?.texto || ""}
287
+ ---------------`);
288
+ return res;
289
+ }
290
+ export {
291
+ API,
292
+ AUTORIZAR,
293
+ ESCOPOS,
294
+ LIMITES,
295
+ codigoPkce,
296
+ conectar,
297
+ extrairCodigo,
298
+ imediato,
299
+ mensagemErro,
300
+ modoDe,
301
+ nome,
302
+ planejar,
303
+ planoChunks,
304
+ privacidadeEfetiva,
305
+ publicar,
306
+ rede,
307
+ status,
308
+ tokenValido,
309
+ truncar,
310
+ urlAutorizacao
311
+ };
@@ -0,0 +1,309 @@
1
+ import { createRequire as __cr } from 'node:module'; const require = __cr(import.meta.url);
2
+ import "./chunk-LCFK6X55.mjs";
3
+
4
+ // ../core/src/provedores/tiktok-api.mjs
5
+ var nome = "tiktok";
6
+ var rede = "tiktok";
7
+ var imediato = true;
8
+ var REF_CLIENTE = "tiktok/cliente";
9
+ var REF_TOKEN = "tiktok/token";
10
+ var ESCOPOS = ["user.info.basic", "user.info.stats", "video.list", "video.upload", "video.publish"];
11
+ var HOST = "https://open.tiktokapis.com";
12
+ var AUTH = "https://www.tiktok.com/v2/auth/authorize/";
13
+ var MB = 1024 * 1024;
14
+ var LIMITES = { chunkMin: 5 * MB, chunkMax: 64 * MB, ultimoMax: 128 * MB, chunksMax: 1e3, videoMax: 4 * 1024 * MB, titulo: 2200 };
15
+ var PRIVACIDADES = ["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"];
16
+ var MODOS = ["rascunho", "direto"];
17
+ var MARGEM_RENOVAR_MS = 5 * 6e4;
18
+ var CAMPOS_PERFIL = ["open_id", "avatar_url", "display_name", "follower_count", "following_count", "likes_count", "video_count"];
19
+ var CAMPOS_VIDEO = "id,title,video_description,create_time,cover_image_url,share_url,duration,view_count,like_count,comment_count,share_count";
20
+ var b64url = (bytes) => {
21
+ const u = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
22
+ let s = "";
23
+ for (const b of u) s += String.fromCharCode(b);
24
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
25
+ };
26
+ async function desafioPkceHex(verificador) {
27
+ const h = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verificador)));
28
+ return [...h].map((b) => b.toString(16).padStart(2, "0")).join("");
29
+ }
30
+ var novoVerificador = (aleatorio = (n) => crypto.getRandomValues(new Uint8Array(n))) => b64url(aleatorio(48));
31
+ function truncar(texto, max = LIMITES.titulo) {
32
+ const s = String(texto ?? "");
33
+ if (s.length <= max) return s;
34
+ let corte = max - 1;
35
+ const c = s.charCodeAt(corte - 1);
36
+ if (c >= 55296 && c <= 56319) corte--;
37
+ return s.slice(0, corte) + "\u2026";
38
+ }
39
+ function planoChunks(tamanho, chunkPreferido = 10 * MB) {
40
+ if (!(tamanho > 0)) throw new Error("v\xEDdeo vazio");
41
+ if (tamanho > LIMITES.videoMax) throw new Error(`v\xEDdeo de ${(tamanho / MB).toFixed(0)} MB passa de 4 GB`);
42
+ let chunk = Math.min(LIMITES.chunkMax, Math.max(LIMITES.chunkMin, chunkPreferido));
43
+ if (tamanho < LIMITES.chunkMin || tamanho <= chunk) chunk = tamanho;
44
+ const total = Math.floor(tamanho / chunk);
45
+ if (total === 1) chunk = tamanho;
46
+ if (total < 1 || total > LIMITES.chunksMax) throw new Error(`plano de partes inv\xE1lido (${total} partes)`);
47
+ const partes = [];
48
+ for (let i = 0; i < total; i++) {
49
+ const a = i * chunk, b = i === total - 1 ? tamanho - 1 : a + chunk - 1;
50
+ partes.push({ a, b, tamanho: b - a + 1 });
51
+ }
52
+ if (partes.at(-1).tamanho > LIMITES.ultimoMax) throw new Error("a \xFAltima parte passaria de 128 MB; aumente o tamanho da parte");
53
+ return { chunk, total, partes };
54
+ }
55
+ var ERROS = {
56
+ unaudited_client_can_only_post_to_private_accounts: 'o app do TikTok ainda n\xE3o foi auditado: ele s\xF3 posta em conta PRIVADA e como SELF_ONLY. Use o modo "rascunho" ou torne a conta privada at\xE9 a auditoria (developers.tiktok.com/application/content-posting-api).',
57
+ spam_risk_too_many_pending_share: "o TikTok aceita no m\xE1ximo 5 envios pendentes na caixa de entrada por 24 h; publique ou descarte algum rascunho no app.",
58
+ spam_risk_too_many_posts: "limite di\xE1rio de posts do TikTok atingido para esta conta (cerca de 15 por dia).",
59
+ spam_risk_user_banned_from_posting: "esta conta est\xE1 impedida de postar pelo TikTok.",
60
+ spam_risk_text: "o TikTok recusou o texto da legenda como spam.",
61
+ spam_risk: "o TikTok bloqueou o envio por risco de spam.",
62
+ reached_active_user_cap: "o app atingiu a cota di\xE1ria de usu\xE1rios ativos de publica\xE7\xE3o do TikTok.",
63
+ url_ownership_unverified: "o dom\xEDnio do v\xEDdeo n\xE3o est\xE1 verificado no TikTok (PULL_FROM_URL).",
64
+ privacy_level_option_mismatch: "a privacidade pedida n\xE3o est\xE1 entre as op\xE7\xF5es da conta (privacy_level_options).",
65
+ access_token_invalid: "token do TikTok inv\xE1lido ou expirado: reconecte o canal (praeko conta tiktok conectar).",
66
+ scope_not_authorized: "falta autorizar um escopo no TikTok (video.upload, video.publish, video.list\u2026): reconecte o canal e aceite todos.",
67
+ scope_permission_missed: "falta autorizar um escopo no TikTok: reconecte o canal e aceite todos.",
68
+ rate_limit_exceeded: "limite de requisi\xE7\xF5es do TikTok; tente de novo em um minuto.",
69
+ file_format_check_failed: "o TikTok n\xE3o aceitou o formato do arquivo (use MP4/MOV com H.264 e AAC).",
70
+ duration_check_failed: "a dura\xE7\xE3o do v\xEDdeo passa do que esta conta pode postar no TikTok.",
71
+ frame_rate_check_failed: "a taxa de quadros do v\xEDdeo n\xE3o \xE9 aceita pelo TikTok (use de 23 a 60 fps).",
72
+ picture_size_check_failed: "a resolu\xE7\xE3o do v\xEDdeo n\xE3o \xE9 aceita pelo TikTok.",
73
+ video_pull_failed: "o TikTok n\xE3o conseguiu baixar o v\xEDdeo pela URL p\xFAblica.",
74
+ publish_cancelled: "a publica\xE7\xE3o foi cancelada no TikTok.",
75
+ auth_removed: "o acesso do Praeko \xE0 conta foi removido no TikTok: reconecte o canal.",
76
+ invalid_publish_id: "o TikTok n\xE3o reconhece este envio (publish_id inv\xE1lido ou vencido).",
77
+ invalid_file_upload: "o TikTok recusou o envio do arquivo em partes."
78
+ };
79
+ var mensagemErro = (cod, msg) => ERROS[cod] ? `${ERROS[cod]} [${cod}]` : `TikTok recusou: ${cod || "erro"}${msg ? ` (${String(msg).slice(0, 160)})` : ""}`;
80
+ var CODIGOS_TRANSITORIOS = ["internal_error", "rate_limit_exceeded", "service_unavailable", "timeout"];
81
+ function falha(status2, codigo, msg) {
82
+ const e = new Error(mensagemErro(codigo, msg));
83
+ return Object.assign(e, { status: status2 || 502, codigo: codigo || null, transitorio: !status2 || status2 >= 500 || status2 === 429 || CODIGOS_TRANSITORIOS.includes(codigo) });
84
+ }
85
+ var transitorio = (e) => !e?.status || !!e.transitorio;
86
+ async function cliente(ctx) {
87
+ const salvo = await ctx.segredos.obter(REF_CLIENTE);
88
+ if (salvo) {
89
+ const j = typeof salvo === "string" ? JSON.parse(salvo) : salvo;
90
+ if (j.client_key && j.client_secret) return j;
91
+ }
92
+ throw Object.assign(new Error("sem credencial do TikTok: defina os secrets TIKTOK_CLIENT_KEY e TIKTOK_CLIENT_SECRET do Worker (docs/tiktok.md)"), { status: 409, codigo: "sem_credencial" });
93
+ }
94
+ function urlAutorizacao({ clientKey, redirect, desafio, estado }) {
95
+ const q = new URLSearchParams({ client_key: clientKey, scope: ESCOPOS.join(","), response_type: "code", redirect_uri: redirect, state: estado, code_challenge: desafio, code_challenge_method: "S256" });
96
+ return `${AUTH}?${q}`;
97
+ }
98
+ async function pedirToken(ctx, corpo) {
99
+ const r = await ctx.fetchFn(`${HOST}/v2/oauth/token/`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", "cache-control": "no-cache" }, body: new URLSearchParams(corpo).toString() });
100
+ const txt = await r.text();
101
+ let j = {};
102
+ try {
103
+ j = txt ? JSON.parse(txt) : {};
104
+ } catch {
105
+ }
106
+ if (!r.ok || j.error || !j.access_token) {
107
+ const e = falha(r.status, j.error, j.error_description);
108
+ e.message = `${j.error === "invalid_grant" ? "o TikTok recusou o c\xF3digo/refresh token (vencido ou j\xE1 usado)" : "TikTok recusou o token"}: ${j.error_description || j.error || `HTTP ${r.status}`}`;
109
+ throw e;
110
+ }
111
+ return j;
112
+ }
113
+ async function trocarCodigo(ctx, cl, { codigo, verificador, redirect }) {
114
+ return pedirToken(ctx, { client_key: cl.client_key, client_secret: cl.client_secret, code: codigo, grant_type: "authorization_code", redirect_uri: redirect, code_verifier: verificador });
115
+ }
116
+ function registroToken(j, agora) {
117
+ return JSON.stringify({ access_token: j.access_token, refresh_token: j.refresh_token, expiry: agora + (j.expires_in || 86400) * 1e3, refreshExpiry: agora + (j.refresh_expires_in || 365 * 86400) * 1e3, openId: j.open_id || null, escopos: String(j.scope || ESCOPOS.join(",")).split(",") });
118
+ }
119
+ async function tokenValido(ctx) {
120
+ const agora = (ctx.deps?.agora || Date.now)(), bruto = await ctx.segredos.obter(REF_TOKEN);
121
+ if (!bruto) throw Object.assign(new Error("TikTok n\xE3o conectado: praeko conta tiktok conectar"), { status: 409, codigo: "tiktok_nao_conectado" });
122
+ const t = typeof bruto === "string" ? JSON.parse(bruto) : bruto;
123
+ if (t.access_token && t.expiry - MARGEM_RENOVAR_MS > agora) return t.access_token;
124
+ if (!t.refresh_token || t.refreshExpiry && t.refreshExpiry < agora) throw Object.assign(new Error("o refresh token do TikTok venceu (vale ~365 dias): reconecte o canal"), { status: 409, codigo: "tiktok_nao_conectado" });
125
+ const cl = await cliente(ctx);
126
+ const j = await pedirToken(ctx, { client_key: cl.client_key, client_secret: cl.client_secret, grant_type: "refresh_token", refresh_token: t.refresh_token });
127
+ const novo = JSON.parse(registroToken({ ...j, refresh_token: j.refresh_token || t.refresh_token, open_id: j.open_id || t.openId }, agora));
128
+ await ctx.segredos.guardar(REF_TOKEN, JSON.stringify({ ...t, ...novo }));
129
+ return novo.access_token;
130
+ }
131
+ async function revogar(ctx) {
132
+ try {
133
+ const bruto = await ctx.segredos.obter(REF_TOKEN), cl = await cliente(ctx);
134
+ if (!bruto) return false;
135
+ const t = JSON.parse(bruto), r = await ctx.fetchFn(`${HOST}/v2/oauth/revoke/`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_key: cl.client_key, client_secret: cl.client_secret, token: t.access_token }).toString() });
136
+ return r.ok;
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+ async function chamar(ctx, caminho, { metodo = "GET", query, corpo } = {}) {
142
+ const token = await tokenValido(ctx), q = query ? `?${new URLSearchParams(query)}` : "";
143
+ let r;
144
+ try {
145
+ r = await ctx.fetchFn(`${HOST}${caminho}${q}`, { method: metodo, headers: { authorization: `Bearer ${token}`, ...corpo !== void 0 ? { "content-type": "application/json; charset=UTF-8" } : {} }, ...corpo !== void 0 ? { body: JSON.stringify(corpo) } : {} });
146
+ } catch (e) {
147
+ throw Object.assign(new Error(`falha de rede com o TikTok: ${e.message}`), { transitorio: true });
148
+ }
149
+ const txt = await r.text();
150
+ let j = {};
151
+ try {
152
+ j = txt ? JSON.parse(txt) : {};
153
+ } catch {
154
+ }
155
+ const cod = j.error?.code;
156
+ if (!r.ok || cod && cod !== "ok") throw falha(r.status, cod && cod !== "ok" ? cod : null, j.error?.message || txt.slice(0, 160));
157
+ return j.data ?? {};
158
+ }
159
+ var num = (x) => x != null && x !== "" && !Number.isNaN(+x) ? +x : null;
160
+ async function perfil({ ctx }) {
161
+ const j = await chamar(ctx, "/v2/user/info/", { query: { fields: CAMPOS_PERFIL.join(",") } });
162
+ const u = j.user || {};
163
+ return { id: u.open_id || null, nome: u.display_name || null, usuario: u.username || null, avatar: u.avatar_url || null, seguidores: num(u.follower_count), seguindo: num(u.following_count), curtidas: num(u.likes_count), videos: num(u.video_count) };
164
+ }
165
+ async function status({ ctx }) {
166
+ if (!await ctx.segredos.obter(REF_TOKEN)) return { conectado: false, detalhe: "rode: praeko conta tiktok conectar" };
167
+ try {
168
+ const p = await perfil({ ctx });
169
+ return { conectado: true, detalhe: p.nome || p.usuario || "TikTok", ...p.id ? { canalId: p.id } : {} };
170
+ } catch (e) {
171
+ return { conectado: false, detalhe: String(e.message).slice(0, 200) };
172
+ }
173
+ }
174
+ var primeiraLinha = (t, max = 120) => {
175
+ const l = String(t || "").split("\n").map((x) => x.trim()).find(Boolean) || "";
176
+ return l.length > max ? `${l.slice(0, max - 1)}\u2026` : l;
177
+ };
178
+ var videoDe = (v) => ({
179
+ id: String(v.id),
180
+ rede: "tiktok",
181
+ titulo: primeiraLinha(v.title || v.video_description),
182
+ legenda: v.video_description || v.title || "",
183
+ publicadoEm: v.create_time ? new Date(Number(v.create_time) * 1e3).toISOString() : null,
184
+ thumbnail: v.cover_image_url || null,
185
+ permalink: v.share_url || null,
186
+ duracao: num(v.duration),
187
+ views: num(v.view_count),
188
+ curtidas: num(v.like_count),
189
+ comentarios: num(v.comment_count),
190
+ compartilhamentos: num(v.share_count)
191
+ });
192
+ async function listarVideos({ ctx, max = 20, cursor }) {
193
+ const j = await chamar(ctx, "/v2/video/list/", { metodo: "POST", query: { fields: CAMPOS_VIDEO }, corpo: { max_count: Math.max(1, Math.min(20, Math.floor(+max) || 20)), ...cursor != null ? { cursor: Number(cursor) } : {} } });
194
+ return { videos: (j.videos || []).map(videoDe), proxima: j.has_more && j.cursor != null ? String(j.cursor) : null };
195
+ }
196
+ async function consultarVideos({ ctx, ids }) {
197
+ const mapa = /* @__PURE__ */ new Map();
198
+ for (let i = 0; i < ids.length; i += 20) {
199
+ const j = await chamar(ctx, "/v2/video/query/", { metodo: "POST", query: { fields: CAMPOS_VIDEO }, corpo: { filters: { video_ids: ids.slice(i, i + 20) } } });
200
+ for (const v of j.videos || []) mapa.set(String(v.id), videoDe(v));
201
+ }
202
+ return mapa;
203
+ }
204
+ async function infoCriador({ ctx }) {
205
+ const j = await chamar(ctx, "/v2/post/publish/creator_info/query/", { metodo: "POST", corpo: {} });
206
+ return { privacidades: j.privacy_level_options || [], duracaoMaxSeg: num(j.max_video_post_duration_sec), comentarioDesabilitado: !!j.comment_disabled, duetoDesabilitado: !!j.duet_disabled, stitchDesabilitado: !!j.stitch_disabled, usuario: j.creator_username || null, nome: j.creator_nickname || null };
207
+ }
208
+ function modoDe(item) {
209
+ const m = item.opcoes?.modo || "rascunho";
210
+ if (!MODOS.includes(m)) throw new Error(`modo do TikTok inv\xE1lido: ${m} (use rascunho ou direto)`);
211
+ return m;
212
+ }
213
+ function privacidadeDe(item) {
214
+ const p = item.opcoes?.privacidade || "SELF_ONLY";
215
+ if (!PRIVACIDADES.includes(p)) throw new Error(`privacidade do TikTok inv\xE1lida: ${p} (use ${PRIVACIDADES.join(", ")})`);
216
+ return p;
217
+ }
218
+ function montarPostInfo(item, info) {
219
+ const o = item.opcoes || {}, privacy_level = privacidadeDe(item);
220
+ if (info && !info.privacidades.includes(privacy_level)) throw Object.assign(new Error(`a privacidade ${privacy_level} n\xE3o est\xE1 entre as op\xE7\xF5es desta conta (${info.privacidades.join(", ") || "nenhuma"}): ${mensagemErro("privacy_level_option_mismatch")}`), { status: 422, codigo: "privacy_level_option_mismatch" });
221
+ const titulo = truncar(item.textos?.texto || item.titulo || "");
222
+ return {
223
+ title: titulo,
224
+ privacy_level,
225
+ disable_duet: !!(o.desabilitarDueto || info?.duetoDesabilitado),
226
+ disable_comment: !!(o.desabilitarComentarios || info?.comentarioDesabilitado),
227
+ disable_stitch: !!(o.desabilitarStitch || info?.stitchDesabilitado),
228
+ ...Number.isFinite(+o.miniaturaMs) && o.miniaturaMs != null ? { video_cover_timestamp_ms: Math.max(0, Math.floor(+o.miniaturaMs)) } : {},
229
+ ...o.conteudoIA != null ? { is_aigc: !!o.conteudoIA } : {},
230
+ ...postInfoComercial(o, privacy_level)
231
+ };
232
+ }
233
+ function postInfoComercial(o = {}, privacidade) {
234
+ const ligado = o.conteudoComercial === true;
235
+ if (ligado && !o.marcaPropria && !o.marcaTerceiros) throw Object.assign(new Error("conte\xFAdo comercial: escolha \u201CSua marca\u201D ou \u201CMarca de terceiros\u201D (ou desligue a divulga\xE7\xE3o)"), { status: 422, codigo: "marca_nao_escolhida" });
236
+ if (ligado && o.marcaTerceiros && privacidade === "SELF_ONLY") throw Object.assign(new Error("o TikTok n\xE3o aceita conte\xFAdo de marca de terceiros como privado (S\xF3 eu): escolha outra visibilidade"), { status: 422, codigo: "marca_nao_pode_ser_privada" });
237
+ return { brand_content_toggle: ligado && !!o.marcaTerceiros, brand_organic_toggle: ligado && !!o.marcaPropria };
238
+ }
239
+ async function iniciarPublicacao({ ctx, modo, postInfo, fonte }) {
240
+ const source_info = fonte.url ? { source: "PULL_FROM_URL", video_url: fonte.url } : { source: "FILE_UPLOAD", video_size: fonte.tamanho, chunk_size: fonte.chunk, total_chunk_count: fonte.total };
241
+ const corpo = modo === "direto" ? { post_info: postInfo, source_info } : { source_info };
242
+ const j = await chamar(ctx, modo === "direto" ? "/v2/post/publish/video/init/" : "/v2/post/publish/inbox/video/init/", { metodo: "POST", corpo });
243
+ if (!j.publish_id) throw new Error("o TikTok n\xE3o devolveu o publish_id");
244
+ return { publishId: String(j.publish_id), uploadUrl: j.upload_url || null };
245
+ }
246
+ async function enviarParte({ ctx, uploadUrl, bytes, parte, total, mime = "video/mp4" }) {
247
+ let r;
248
+ try {
249
+ r = await ctx.fetchFn(uploadUrl, { method: "PUT", headers: { "content-type": mime, "content-length": String(bytes.length), "content-range": `bytes ${parte.a}-${parte.b}/${total}` }, body: bytes });
250
+ } catch (e) {
251
+ throw Object.assign(new Error(`falha de rede ao enviar a parte ao TikTok: ${e.message}`), { transitorio: true });
252
+ }
253
+ if (r.status < 200 || r.status > 299) {
254
+ const txt = await r.text().catch(() => "");
255
+ throw falha(r.status, r.status >= 500 ? null : "invalid_file_upload", txt.slice(0, 160));
256
+ }
257
+ return { status: r.status };
258
+ }
259
+ async function statusPublicacao({ ctx, publishId }) {
260
+ const j = await chamar(ctx, "/v2/post/publish/status/fetch/", { metodo: "POST", corpo: { publish_id: publishId } });
261
+ const ids = j.publicaly_available_post_id || j.publicly_available_post_id || [];
262
+ return { codigo: String(j.status || "").toUpperCase(), motivo: j.fail_reason || null, videoIds: ids.map(String), enviadoBytes: num(j.uploaded_bytes) };
263
+ }
264
+ async function publicar() {
265
+ throw new Error("o TikTok publica em passos pelo Workflow do servidor (tiktok-publicar.js)");
266
+ }
267
+ export {
268
+ AUTH,
269
+ ESCOPOS,
270
+ HOST,
271
+ LIMITES,
272
+ MB,
273
+ MODOS,
274
+ PRIVACIDADES,
275
+ REF_CLIENTE,
276
+ REF_TOKEN,
277
+ b64url,
278
+ chamar,
279
+ cliente,
280
+ consultarVideos,
281
+ desafioPkceHex,
282
+ enviarParte,
283
+ falha,
284
+ imediato,
285
+ infoCriador,
286
+ iniciarPublicacao,
287
+ listarVideos,
288
+ mensagemErro,
289
+ modoDe,
290
+ montarPostInfo,
291
+ nome,
292
+ novoVerificador,
293
+ perfil,
294
+ planoChunks,
295
+ postInfoComercial,
296
+ privacidadeDe,
297
+ publicar,
298
+ rede,
299
+ registroToken,
300
+ revogar,
301
+ status,
302
+ statusPublicacao,
303
+ tokenValido,
304
+ transitorio,
305
+ trocarCodigo,
306
+ truncar,
307
+ urlAutorizacao,
308
+ videoDe
309
+ };