Skip to content

Commit 4b9ccb2

Browse files
Elpatron943claude
andauthored
TikTok : les carrousels partent en mode photo, plus en vidéo diaporama (#15)
Le carrousel des 7 types de vente était arrivé en vidéo. Son premier envoi est gardé dans tiktok-video-retire.json pour qu'il reparte en photos. Claude-Session: https://claude.ai/code/session_01Pg6Ym6gZMPRpL2JZZCaPQU Co-authored-by: Claude <noreply@anthropic.com>
1 parent bbad63f commit 4b9ccb2

4 files changed

Lines changed: 45 additions & 22 deletions

File tree

‎.github/workflows/publish-tiktok.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: Publier les réels et carrousels sur TikTok
22

3-
# Reprend sur TikTok les réels et les carrousels (en diaporama) déjà publiés sur Instagram,
3+
# Reprend sur TikTok les réels et les carrousels (en mode photo) déjà publiés sur Instagram,
44
# en brouillon dans l'app TikTok par défaut. Voir scripts/publish-tiktok.mjs.
55

66
on:
@@ -30,7 +30,7 @@ jobs:
3030
- uses: actions/setup-node@v4
3131
with:
3232
node-version: 22
33-
- name: Installer ffmpeg (diaporama des carrousels)
33+
- name: Installer ffmpeg (durée des réels)
3434
run: command -v ffmpeg >/dev/null || (sudo apt-get update -qq && sudo apt-get install -y -qq ffmpeg)
3535
- name: Publier le contenu prêt pour TikTok
3636
env:

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Dépôt public des carrousels et réels Instagram de Daily Business Review (comp
2525
- Mot-clé : toutes les 10 minutes (`reply-keyword.yml`), option « Essai à blanc » en lancement manuel. Voir le README pour le déroulé.
2626
- Statistiques : chaque jour à 05:17 UTC, dans `stats/`. Prolongation du jeton : le 1er et le 15 de chaque mois. Vérification de la connexion : manuelle ou par push sur `.github/check-trigger`.
2727
- Story du jour : `story-instagram.yml`, chaque jour à 16 h 40 UTC (rattrapages à 17 h 40, 18 h 40, 19 h 40 et 20 h 40 UTC si GitHub saute le passage, sans doublon), republie en story un contenu déjà publié (réel tel quel, carrousel converti en image 9:16 par `tools/story/frame.py`), rotation du moins récemment utilisé, une par jour. Choix tracé dans `stories/<date>/story.json`, publication dans `published.json`. Annuler un jour : `stories/<date>/skipped.json` ; tout arrêter : fichier `stories/PAUSE` ; exclure un contenu : `"no_story": true` dans son `post.json`. Lancement manuel en essai à blanc (par défaut).
28-
- TikTok (`publish-tiktok.yml`, environnement GitHub « TikTok », 30 oct. 2026) : à 7 et 37 de chaque heure, reprend sur TikTok un réel ou un carrousel déjà publié sur Instagram (depuis le 31 oct. ; carrousel envoyé en diaporama 9:16, 4 s par image, fabriqué par ffmpeg, ou `tiktok_video` du dossier) en brouillon dans l'app TikTok (le fondateur le relit et le publie lui-même ; en mode `direct`, `"controle_qualite": "valide_fondateur"` est exigé), au moins 2 h après Instagram et au plus 7 jours, un par jour, entre 7 h et 21 h. Légende `tiktok_caption`, sinon celle d'Instagram sans le paragraphe « Commente ». Écrit `tiktok.json`. Le jeton TikTok reste sur le site (`/api/tiktok-publish` du dépôt websitedbr). Exclure un réel : `"no_tiktok": true` ; tout arrêter : fichier `tiktok/PAUSE`. Lancement manuel en essai à blanc (par défaut). Secret `TIKTOK_PUBLISH_SECRET`, variables optionnelles `TIKTOK_MODE` (`inbox` par défaut : brouillon dans l'app TikTok, légende à coller depuis `tiktok.json` ; `direct` quand `video.publish` sera accordé), `TIKTOK_API_URL`.
28+
- TikTok (`publish-tiktok.yml`, environnement GitHub « TikTok », 30 oct. 2026) : à 7 et 37 de chaque heure, reprend sur TikTok un réel ou un carrousel déjà publié sur Instagram (depuis le 31 oct. ; carrousel envoyé en mode photo depuis le 1er nov. : TikTok télécharge les images `01.jpg` à `35.jpg` via `https://daily-business-review.com/tiktok-media/<dossier>/<NN>.jpg`, préfixe à vérifier dans le portail développeur TikTok ; ou `tiktok_video` du dossier pour envoyer une vidéo) en brouillon dans l'app TikTok (le fondateur le relit et le publie lui-même ; en mode `direct`, `"controle_qualite": "valide_fondateur"` est exigé), au moins 2 h après Instagram et au plus 7 jours, un par jour, entre 7 h et 21 h. Légende `tiktok_caption`, sinon celle d'Instagram sans le paragraphe « Commente ». Écrit `tiktok.json`. Le jeton TikTok reste sur le site (`/api/tiktok-publish` du dépôt websitedbr). Exclure un réel : `"no_tiktok": true` ; tout arrêter : fichier `tiktok/PAUSE`. Lancement manuel en essai à blanc (par défaut). Secret `TIKTOK_PUBLISH_SECRET`, variables optionnelles `TIKTOK_MODE` (`inbox` par défaut : brouillon dans l'app TikTok, légende à coller depuis `tiktok.json` ; `direct` quand `video.publish` sera accordé), `TIKTOK_API_URL`.
2929
- Secrets : `IG_ACCESS_TOKEN`, `IG_USER_ID`. Netlify : `IG_APP_SECRET`. Variable optionnelle : `SUPABASE_PUBLISHABLE_KEY`.
3030

3131
## Annuler ou suspendre

instagram/carrousel-2026-10-31-types-de-vente/tiktok.json renamed to instagram/carrousel-2026-10-31-types-de-vente/tiktok-video-retire.json

File renamed without changes.

‎scripts/publish-tiktok.mjs‎

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@
1010
// Légende : "tiktok_caption" de post.json, sinon celle d'Instagram sans le paragraphe « Commente <MOT> »
1111
// (l'envoi automatique du guide n'existe que sur Instagram).
1212
// Carrousels (31 oct. 2026) : un contenu DBR part sur tous les réseaux, TikTok compris. Un carrousel publié sur Instagram
13-
// part donc aussi, en vidéo diaporama (4 s par image, format 9:16) fabriquée ici avec ffmpeg, ou "tiktok_video" si le
14-
// dossier en fournit une. Mêmes garde-fous que les réels.
13+
// part donc aussi, en mode photo (1er nov. 2026 : carrousel natif TikTok, on fait défiler les images ; plus de vidéo
14+
// diaporama), ou en vidéo si le dossier fournit "tiktok_video". TikTok télécharge lui-même les images depuis
15+
// https://daily-business-review.com/tiktok-media/<dossier>/<NN>.jpg (route du site, préfixe vérifié dans le portail
16+
// développeur TikTok). Images nommées 01.jpg à 35.jpg. Mêmes garde-fous que les réels.
1517
// Le jeton TikTok n'est jamais ici : la route /api/tiktok-publish du site le garde et renvoie l'adresse d'envoi.
1618
import fs from "node:fs";
1719
import path from "node:path";
18-
import os from "node:os";
1920
import { execFileSync } from "node:child_process";
2021

2122
const API = process.env.TIKTOK_API_URL || "https://daily-business-review.com/api/tiktok-publish";
@@ -82,24 +83,14 @@ for (const dir of dirs) {
8283
const video = path.join(dir, String((isReel ? m.video : m.tiktok_video) || ""));
8384
const hasVideo = /\.mp4$/i.test(video) && fs.existsSync(video);
8485
if (isReel && !hasVideo) { console.log(`::error::${dir} : vidéo manquante`); continue; }
85-
if (!isReel && !hasVideo && !m.images.every((f) => fs.existsSync(path.join(dir, f)))) { console.log(`::error::${dir} : image du carrousel manquante`); continue; }
86+
if (!isReel && !hasVideo && !m.images.every((f) => /^[0-9]{2}\.jpe?g$/i.test(f) && fs.existsSync(path.join(dir, f)))) {
87+
console.log(`::error::${dir} : image du carrousel manquante ou mal nommée (01.jpg à 35.jpg attendus)`);
88+
continue;
89+
}
8690
due.push({ dir, m, at, caption, video: hasVideo ? video : null });
8791
}
8892
due.sort((a, b) => a.at - b.at);
8993

90-
// Diaporama 9:16 d'un carrousel : chaque image 1080 x 1350 centrée sur le fond DBR, 4 s par image.
91-
export function slideshow(dir, images) {
92-
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tiktok-"));
93-
const list = path.join(tmp, "list.txt");
94-
const abs = images.map((f) => path.resolve(dir, f).replace(/'/g, "'\\''"));
95-
fs.writeFileSync(list, abs.map((f) => `file '${f}'\nduration 4`).join("\n") + `\nfile '${abs[abs.length - 1]}'\n`);
96-
const out = path.join(tmp, "diaporama.mp4");
97-
execFileSync("ffmpeg", ["-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", list,
98-
"-vf", "scale=1080:1350:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:color=0x0A0A0D,fps=30,format=yuv420p",
99-
"-c:v", "libx264", "-preset", "medium", "-crf", "20", "-movflags", "+faststart", out], { stdio: "inherit" });
100-
return out;
101-
}
102-
10394
async function call(body) {
10495
const r = await fetch(API, { method: "POST", headers: { Authorization: `Bearer ${SECRET}`, "Content-Type": "application/json" }, body: JSON.stringify(body) });
10596
const j = await r.json().catch(() => ({}));
@@ -113,16 +104,48 @@ if (!SECRET && (DRY_RUN || due.length)) { console.error("::error::Secret TIKTOK_
113104
if (DRY_RUN) {
114105
const c = await call({ action: "check" });
115106
console.log(`Essai à blanc. Compte TikTok : ${c.account}, droits : ${c.scope}, publication directe : ${c.direct_post ? "oui" : "non"}, confidentialité visée : ${c.privacy_level} (possibles : ${c.privacy_level_options.join(", ") || "aucune"}).`);
116-
console.log(due.length ? `Serait publié : ${due[0].dir}${due[0].video ? "" : " (carrousel, en diaporama)"}\n--- légende ---\n${due[0].caption}` : "Aucun réel à publier maintenant.");
107+
console.log(due.length ? `Serait publié : ${due[0].dir}${due[0].video ? "" : " (carrousel, en mode photo)"}\n--- légende ---\n${due[0].caption}` : "Aucun réel à publier maintenant.");
117108
process.exit(0);
118109
}
119110
if (!due.length) { console.log("Aucun réel à publier sur TikTok maintenant."); process.exit(0); }
120111
if (today.hour < OPEN_HOUR || today.hour >= CLOSE_HOUR) { console.log(`Il est ${today.hour} h à Paris : pas de publication TikTok avant ${OPEN_HOUR} h ni après ${CLOSE_HOUR} h.`); process.exit(0); }
121112
if (sentToday) { console.log(`Déjà un réel TikTok aujourd'hui : ${due.length} réel(s) attendent demain.`); process.exit(0); }
122113

123114
const { dir, m, caption } = due[0]; // une seule publication par passage
115+
116+
// Carrousel en mode photo : TikTok télécharge les images, rien à pousser d'ici.
117+
async function sendPhotos() {
118+
const images = m.images.map((f) => `${path.basename(dir)}/${f}`);
119+
const title = caption.split("\n")[0].trim();
120+
const init = await call({ action: "init_photo", mode: MODE, images, title, description: caption });
121+
const record = { mode: init.mode, media: "photo", publish_id: init.publish_id, status: "PROCESSING_DOWNLOAD", post_id: null, approval_id: m.approval_id || null, at: new Date().toISOString() };
122+
if (init.mode === "inbox") record.caption = caption;
123+
try {
124+
const final = init.mode === "inbox" ? ["SEND_TO_USER_INBOX", "PUBLISH_COMPLETE"] : ["PUBLISH_COMPLETE"];
125+
for (let i = 0; i < 60; i++) {
126+
await sleep(5000);
127+
const s = await call({ action: "status", publish_id: init.publish_id });
128+
record.status = s.status;
129+
if (s.publicaly_available_post_id?.length) record.post_id = String(s.publicaly_available_post_id[0]);
130+
if (s.status === "FAILED") throw new Error(`TikTok a refusé les images : ${s.fail_reason || "raison inconnue"}`);
131+
if (final.includes(s.status) && (init.mode === "inbox" || record.post_id || i > 10)) break;
132+
}
133+
} catch (e) {
134+
record.error = e.message;
135+
throw e;
136+
} finally {
137+
fs.writeFileSync(path.join(dir, "tiktok.json"), JSON.stringify(record, null, 1));
138+
}
139+
console.log(`${dir} : carrousel ${init.mode === "inbox" ? "envoyé en brouillon TikTok" : "publié sur TikTok"} en mode photo (statut ${record.status})`);
140+
}
141+
142+
if (!due[0].video) {
143+
try { await sendPhotos(); } catch (e) { console.error(`::error::${dir} : ÉCHEC TikTok (photos), ${e.message}`); process.exit(1); }
144+
process.exit(0);
145+
}
146+
124147
try {
125-
const video = due[0].video || slideshow(dir, m.images);
148+
const video = due[0].video;
126149
const buf = fs.readFileSync(video);
127150
let duration;
128151
try { duration = Math.ceil(Number(execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", video], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim())); } catch {}

0 commit comments

Comments
 (0)