Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/publish-tiktok.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: Publier les réels et carrousels sur TikTok

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

on:
Expand Down Expand Up @@ -30,7 +30,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Installer ffmpeg (diaporama des carrousels)
- name: Installer ffmpeg (durée des réels)
run: command -v ffmpeg >/dev/null || (sudo apt-get update -qq && sudo apt-get install -y -qq ffmpeg)
- name: Publier le contenu prêt pour TikTok
env:
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Dépôt public des carrousels et réels Instagram de Daily Business Review (comp
- Mot-clé : toutes les 10 minutes (`reply-keyword.yml`), option « Essai à blanc » en lancement manuel. Voir le README pour le déroulé.
- 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`.
- 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).
- 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`.
- 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`.
- Secrets : `IG_ACCESS_TOKEN`, `IG_USER_ID`. Netlify : `IG_APP_SECRET`. Variable optionnelle : `SUPABASE_PUBLISHABLE_KEY`.

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

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

// Diaporama 9:16 d'un carrousel : chaque image 1080 x 1350 centrée sur le fond DBR, 4 s par image.
export function slideshow(dir, images) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tiktok-"));
const list = path.join(tmp, "list.txt");
const abs = images.map((f) => path.resolve(dir, f).replace(/'/g, "'\\''"));
fs.writeFileSync(list, abs.map((f) => `file '${f}'\nduration 4`).join("\n") + `\nfile '${abs[abs.length - 1]}'\n`);
const out = path.join(tmp, "diaporama.mp4");
execFileSync("ffmpeg", ["-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", list,
"-vf", "scale=1080:1350:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:color=0x0A0A0D,fps=30,format=yuv420p",
"-c:v", "libx264", "-preset", "medium", "-crf", "20", "-movflags", "+faststart", out], { stdio: "inherit" });
return out;
}

async function call(body) {
const r = await fetch(API, { method: "POST", headers: { Authorization: `Bearer ${SECRET}`, "Content-Type": "application/json" }, body: JSON.stringify(body) });
const j = await r.json().catch(() => ({}));
Expand All @@ -113,16 +104,48 @@ if (!SECRET && (DRY_RUN || due.length)) { console.error("::error::Secret TIKTOK_
if (DRY_RUN) {
const c = await call({ action: "check" });
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"}).`);
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.");
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.");
process.exit(0);
}
if (!due.length) { console.log("Aucun réel à publier sur TikTok maintenant."); process.exit(0); }
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); }
if (sentToday) { console.log(`Déjà un réel TikTok aujourd'hui : ${due.length} réel(s) attendent demain.`); process.exit(0); }

const { dir, m, caption } = due[0]; // une seule publication par passage

// Carrousel en mode photo : TikTok télécharge les images, rien à pousser d'ici.
async function sendPhotos() {
const images = m.images.map((f) => `${path.basename(dir)}/${f}`);
const title = caption.split("\n")[0].trim();
const init = await call({ action: "init_photo", mode: MODE, images, title, description: caption });
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() };
if (init.mode === "inbox") record.caption = caption;
try {
const final = init.mode === "inbox" ? ["SEND_TO_USER_INBOX", "PUBLISH_COMPLETE"] : ["PUBLISH_COMPLETE"];
for (let i = 0; i < 60; i++) {
await sleep(5000);
const s = await call({ action: "status", publish_id: init.publish_id });
record.status = s.status;
if (s.publicaly_available_post_id?.length) record.post_id = String(s.publicaly_available_post_id[0]);
if (s.status === "FAILED") throw new Error(`TikTok a refusé les images : ${s.fail_reason || "raison inconnue"}`);
if (final.includes(s.status) && (init.mode === "inbox" || record.post_id || i > 10)) break;
}
} catch (e) {
record.error = e.message;
throw e;
} finally {
fs.writeFileSync(path.join(dir, "tiktok.json"), JSON.stringify(record, null, 1));
}
console.log(`${dir} : carrousel ${init.mode === "inbox" ? "envoyé en brouillon TikTok" : "publié sur TikTok"} en mode photo (statut ${record.status})`);
}

if (!due[0].video) {
try { await sendPhotos(); } catch (e) { console.error(`::error::${dir} : ÉCHEC TikTok (photos), ${e.message}`); process.exit(1); }
process.exit(0);
}

try {
const video = due[0].video || slideshow(dir, m.images);
const video = due[0].video;
const buf = fs.readFileSync(video);
let duration;
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 {}
Expand Down