TikTok V6
This commit is contained in:
@@ -6,6 +6,10 @@ import type { UseCaseTemplateLabels } from '@/components/marketing/UseCasePageTe
|
||||
// for hreflang pairing.
|
||||
export type UseCasePageContentDe = UseCasePageContent & {
|
||||
enSlug: string;
|
||||
// Most German use cases map to an English /use-cases/ page. A small number
|
||||
// pair with a more specific English editorial page instead.
|
||||
enPath?: string;
|
||||
enableScrollReveal?: boolean;
|
||||
};
|
||||
|
||||
export const useCaseTemplateLabelsDe: UseCaseTemplateLabels = {
|
||||
@@ -24,9 +28,140 @@ export const useCaseTemplateLabelsDe: UseCaseTemplateLabels = {
|
||||
relatedResourcesTitle: 'Weiterführende Seiten',
|
||||
nextStepEyebrow: 'Nächster Schritt',
|
||||
ctaBannerTitle: 'Starten Sie mit einem QR-Setup, bei dem Ihr Druck dauerhaft gültig bleibt.',
|
||||
answerFirst: {
|
||||
summaryTitle: 'Kurz erklärt',
|
||||
whenToUseTitle: 'Wann ist das sinnvoll?',
|
||||
comparisonTitle: 'Vergleich',
|
||||
howItWorksTitle: 'So funktioniert es',
|
||||
supportedLabel: 'Möglich',
|
||||
unavailableLabel: 'Nicht möglich',
|
||||
},
|
||||
};
|
||||
|
||||
export const useCasePagesDe: Record<string, UseCasePageContentDe> = {
|
||||
'statischer-vs-dynamischer-qr-code': {
|
||||
enSlug: 'static-vs-dynamic-qr-code',
|
||||
enPath: '/blog/static-vs-dynamic-qr-code',
|
||||
enableScrollReveal: true,
|
||||
slug: 'statischer-vs-dynamischer-qr-code',
|
||||
href: '/de/statischer-vs-dynamischer-qr-code',
|
||||
title: 'Statischer oder dynamischer QR-Code?',
|
||||
cluster: 'qr-code-basics',
|
||||
summary:
|
||||
'Entscheiden Sie vor dem Druck, ob Ihr QR-Ziel dauerhaft unverändert bleibt oder später ohne Neudruck aktualisiert werden muss.',
|
||||
parentHref: '/dynamic-qr-code-generator',
|
||||
parentTitle: 'Dynamischer QR-Code-Generator',
|
||||
ctaLabel: 'Dynamischen QR-Code erstellen',
|
||||
eyebrow: 'Vor dem Druck entscheiden',
|
||||
titleSuffix: 'vor dem Druck',
|
||||
metaDescription:
|
||||
'Statischer oder dynamischer QR-Code? Vergleichen Sie Änderbarkeit, Druckrisiko und Tracking – und entscheiden Sie vor Flyer, Speisekarte oder Tischaufsteller.',
|
||||
intro:
|
||||
'Bleibt Ihr Link garantiert unverändert, reicht ein statischer QR-Code. Können sich PDF, Menü, Angebot oder Zielseite später ändern, bleibt ein dynamischer QR-Code nach dem Druck flexibel.',
|
||||
answer:
|
||||
'Ein statischer QR-Code enthält sein Ziel direkt und lässt sich nach dem Druck nicht ändern. Ein dynamischer QR-Code führt über ein verwaltbares Ziel: Sie können die Zielseite später aktualisieren, während der gedruckte Code bestehen bleibt.',
|
||||
whenToUse: [
|
||||
'Das Ziel bleibt dauerhaft gleich, etwa bei einer unveränderlichen PDF-Datei oder einer festen Kontaktinformation.',
|
||||
'Ein Menü, Preis, Angebot, PDF oder Link kann sich ändern, nachdem Flyer, Karten oder Aufsteller gedruckt sind.',
|
||||
'Sie möchten Scans von Tischen, Fenstern, Flyern oder anderen Platzierungen getrennt auswerten.',
|
||||
],
|
||||
comparisonItems: [
|
||||
{ label: 'Ziel nach dem Druck ändern', text: 'Nicht möglich', value: true },
|
||||
{ label: 'Neues PDF oder Angebot verlinken', text: 'Neudruck nötig', value: true },
|
||||
{ label: 'Scans nach Platzierung messen', text: 'Nicht verfügbar', value: true },
|
||||
],
|
||||
howToSteps: [
|
||||
'Erstellen Sie einen dynamischen QR-Code mit Ihrem aktuellen Link, Menü oder PDF.',
|
||||
'Testen Sie den Scan und drucken Sie denselben Code auf Flyer, Speisekarten oder Tischaufsteller.',
|
||||
'Ändern Sie später das Ziel im Dashboard, statt den gedruckten QR-Code zu ersetzen.',
|
||||
],
|
||||
workflowTitle: 'Wann ein dynamischer QR-Code die bessere Entscheidung ist',
|
||||
workflowIntro:
|
||||
'Die wichtige Frage ist nicht, ob ein QR-Code heute funktioniert. Entscheidend ist, ob Ihr gedrucktes Material noch funktioniert, wenn sich dahinter etwas ändert.',
|
||||
workflowCards: [
|
||||
{
|
||||
title: 'Speisekarte oder PDF aktualisieren',
|
||||
description:
|
||||
'Ein Café druckt Tischaufsteller mit QR-Code zur Speisekarte. Ändern sich Preise oder die PDF-Datei, aktualisiert das Team nur das Ziel statt alle Aufsteller neu zu bestellen.',
|
||||
},
|
||||
{
|
||||
title: 'Flyer und Aktionen flexibel halten',
|
||||
description:
|
||||
'Ein gedruckter Aktionsflyer bleibt im Umlauf, auch wenn sich Landingpage, Saisonangebot oder Termin ändern. Der Code führt weiterhin zur aktuellen Aktion.',
|
||||
},
|
||||
{
|
||||
title: 'Platzierungen getrennt verstehen',
|
||||
description:
|
||||
'Mit eigenen dynamischen Codes für Fenster, Tische und Take-away-Flyer erkennen Sie, welche gedruckte Platzierung tatsächlich Scans auslöst.',
|
||||
},
|
||||
],
|
||||
checklistTitle: 'QR-Code vor dem Druck prüfen',
|
||||
checklist: [
|
||||
'Wählen Sie einen statischen Code nur, wenn das Ziel wirklich nicht geändert werden muss.',
|
||||
'Nutzen Sie einen dynamischen Code für wechselnde Menüs, PDFs, Angebote und Kampagnen.',
|
||||
'Testen Sie Größe, Kontrast und Scan-Distanz mit einem echten Smartphone vor der Druckfreigabe.',
|
||||
"Ergänzen Sie einen klaren Hinweis wie 'Scannen für die aktuelle Speisekarte'.",
|
||||
],
|
||||
supportLinks: [
|
||||
{
|
||||
href: '/dynamic-qr-code-generator',
|
||||
title: 'Dynamischer QR-Code-Generator',
|
||||
description:
|
||||
'Erstellen Sie einen QR-Code, dessen Ziel Sie später verwalten können.',
|
||||
},
|
||||
{
|
||||
href: '/qr-code-tracking',
|
||||
title: 'QR-Code-Tracking',
|
||||
description:
|
||||
'Messen Sie Scans nach Zeit, Gerät und gedruckter Platzierung.',
|
||||
},
|
||||
{
|
||||
href: '/reprint-calculator',
|
||||
title: 'Neudruck-Rechner',
|
||||
description:
|
||||
'Vergleichen Sie den Aufwand wiederkehrender Neudrucke mit einem aktualisierbaren QR-Ziel.',
|
||||
},
|
||||
{
|
||||
href: '/qr-code-print-size-guide',
|
||||
title: 'QR-Code-Druckgrößen-Guide',
|
||||
description:
|
||||
'Prüfen Sie die passende QR-Größe, bevor Ihr Material in den Druck geht.',
|
||||
},
|
||||
],
|
||||
faq: [
|
||||
{
|
||||
question: 'Kann ich einen QR-Code nach dem Druck noch ändern?',
|
||||
answer:
|
||||
'Bei einem statischen QR-Code nicht: Das Ziel ist direkt im Code gespeichert. Ist der Link nach dem Druck falsch oder veraltet, brauchen Sie einen neuen QR-Code und in der Regel einen Neudruck. Bei einem dynamischen QR-Code ändern Sie das Ziel im Dashboard, während der gedruckte Code bestehen bleibt.',
|
||||
},
|
||||
{
|
||||
question: 'Wann brauche ich einen dynamischen QR-Code?',
|
||||
answer:
|
||||
'Nutzen Sie einen dynamischen QR-Code, wenn sich Zielseite, Menü, PDF, Angebot oder Kampagne später ändern kann oder wenn Sie Scan-Daten auswerten möchten.',
|
||||
},
|
||||
{
|
||||
question: 'Kann ich ein Menü-PDF hinter einem dynamischen QR-Code austauschen?',
|
||||
answer:
|
||||
'Ja. Sie aktualisieren das Ziel oder die verknüpfte Datei im Dashboard. Der bereits gedruckte dynamische QR-Code kann weiter verwendet werden.',
|
||||
},
|
||||
{
|
||||
question: 'Funktioniert ein dynamischer QR-Code auch auf Flyern und Tischaufstellern?',
|
||||
answer:
|
||||
'Ja. Gerade bei gedruckten Materialien mit wechselnden Angeboten, Links oder Inhalten ist ein dynamisches Ziel sinnvoll, weil der Code physisch unverändert bleiben kann.',
|
||||
},
|
||||
],
|
||||
heroImage: '/images/static-vs-dynamic-qr-de.svg',
|
||||
heroImageAlt:
|
||||
'Illustration: Statischer QR-Code mit festem Link im Vergleich zu einem dynamischen QR-Code mit später änderbarem Ziel',
|
||||
directAnswer:
|
||||
'Kurz gesagt: Ein statischer QR-Code ist sinnvoll, wenn sich das Ziel garantiert nie ändert. Für alles Gedruckte, das länger im Einsatz bleibt oder auf wechselnde Inhalte verweist, reduziert ein dynamischer QR-Code das Risiko eines veralteten Links.',
|
||||
authoritySignals: [
|
||||
'Klarer Vergleich vor der Druckfreigabe statt Technik-Jargon.',
|
||||
'Konkrete Beispiele für Menüs, PDFs, Flyer und Tischaufsteller.',
|
||||
'Keine pauschalen Einsparversprechen – prüfen Sie Ihren eigenen Neudruck-Aufwand.',
|
||||
],
|
||||
},
|
||||
|
||||
'qr-codes-fuer-fitnessstudios': {
|
||||
enSlug: 'qr-codes-for-gyms',
|
||||
slug: 'qr-codes-fuer-fitnessstudios',
|
||||
|
||||
@@ -192,20 +192,153 @@ export async function tiktokApi(url: string, options: RequestInit = {}) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function uploadBinaryToTiktok(uploadUrl: string, buffer: Buffer, mimeType = 'video/mp4') {
|
||||
const res = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
'Content-Length': String(buffer.length),
|
||||
},
|
||||
body: new Uint8Array(buffer),
|
||||
});
|
||||
// ─── Binary upload (Content-Range, chunking, timeout, retries) ─────────────
|
||||
|
||||
const text = await res.text();
|
||||
if (!res.ok && res.status !== 201) {
|
||||
throw new TiktokApiError(`TikTok binary upload failed: ${res.status} ${text}`, res.status);
|
||||
const UPLOAD_CHUNK_TIMEOUT_MS = 120_000;
|
||||
const MAX_CHUNK_ATTEMPTS = 3;
|
||||
// TikTok requires chunks between 5 MB and 64 MB; only a file that fits in a
|
||||
// single chunk may be smaller than 5 MB.
|
||||
export const TIKTOK_MAX_CHUNK_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function retryDelayMs(res: Response | null, attempt: number) {
|
||||
const retryAfter = res?.headers.get('retry-after');
|
||||
if (retryAfter) {
|
||||
const seconds = Number(retryAfter);
|
||||
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1000, 30_000);
|
||||
}
|
||||
return Math.min(1000 * 2 ** attempt, 15_000) + Math.floor(Math.random() * 500);
|
||||
}
|
||||
|
||||
// TikTok expects total_chunk_count = floor(video_size / chunk_size); any
|
||||
// remainder is merged into the final chunk.
|
||||
export function planTiktokChunks(totalBytes: number) {
|
||||
if (totalBytes <= TIKTOK_MAX_CHUNK_BYTES) {
|
||||
return { chunkSize: totalBytes, totalChunkCount: 1 };
|
||||
}
|
||||
const chunkSize = TIKTOK_MAX_CHUNK_BYTES;
|
||||
return { chunkSize, totalChunkCount: Math.floor(totalBytes / chunkSize) };
|
||||
}
|
||||
|
||||
export function sniffVideoMimeType(buffer: Buffer): 'video/mp4' | 'video/quicktime' | 'video/webm' | null {
|
||||
if (buffer.length >= 12 && buffer.toString('ascii', 4, 8) === 'ftyp') {
|
||||
const brand = buffer.toString('ascii', 8, 12);
|
||||
return brand.startsWith('qt') ? 'video/quicktime' : 'video/mp4';
|
||||
}
|
||||
if (buffer.length >= 4 && buffer[0] === 0x1a && buffer[1] === 0x45 && buffer[2] === 0xdf && buffer[3] === 0xa3) {
|
||||
return 'video/webm';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function putTiktokChunk(
|
||||
uploadUrl: string,
|
||||
chunk: Buffer,
|
||||
opts: { start: number; end: number; total: number; mimeType: string; isLast: boolean }
|
||||
) {
|
||||
let lastError: TiktokApiError | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < MAX_CHUNK_ATTEMPTS; attempt++) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), UPLOAD_CHUNK_TIMEOUT_MS);
|
||||
let res: Response | null = null;
|
||||
try {
|
||||
res = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': opts.mimeType,
|
||||
'Content-Length': String(chunk.length),
|
||||
'Content-Range': `bytes ${opts.start}-${opts.end}/${opts.total}`,
|
||||
},
|
||||
body: new Uint8Array(chunk),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await res.text();
|
||||
// Complete upload → 201; intermediate chunk → 206. Anything else is an
|
||||
// error even when it is 2xx, so a silently incomplete upload never
|
||||
// counts as success.
|
||||
const expected = opts.isLast ? 201 : 206;
|
||||
if (res.status === expected) {
|
||||
return { status: res.status, body: text };
|
||||
}
|
||||
lastError = new TiktokApiError(`TikTok binary upload failed: ${res.status} ${text}`, res.status);
|
||||
const retryable = res.status === 429 || res.status >= 500;
|
||||
if (!retryable) throw lastError;
|
||||
} catch (err) {
|
||||
if (err instanceof TiktokApiError && !(err.status === 429 || err.status >= 500)) throw err;
|
||||
lastError =
|
||||
err instanceof TiktokApiError
|
||||
? err
|
||||
: new TiktokApiError(`TikTok binary upload network error: ${err instanceof Error ? err.message : String(err)}`, 502);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (attempt < MAX_CHUNK_ATTEMPTS - 1) await sleep(retryDelayMs(res, attempt));
|
||||
}
|
||||
|
||||
return { status: res.status, body: text };
|
||||
throw lastError ?? new TiktokApiError('TikTok binary upload failed.', 502);
|
||||
}
|
||||
|
||||
export async function uploadBinaryToTiktok(uploadUrl: string, buffer: Buffer, mimeType = 'video/mp4') {
|
||||
const total = buffer.length;
|
||||
if (!total) {
|
||||
throw new TiktokApiError('Refusing to upload an empty file to TikTok.', 400);
|
||||
}
|
||||
|
||||
const { chunkSize, totalChunkCount } = planTiktokChunks(total);
|
||||
let result = { status: 0, body: '' };
|
||||
for (let i = 0; i < totalChunkCount; i++) {
|
||||
const start = i * chunkSize;
|
||||
const isLast = i === totalChunkCount - 1;
|
||||
const end = isLast ? total - 1 : start + chunkSize - 1;
|
||||
result = await putTiktokChunk(uploadUrl, buffer.subarray(start, end + 1), {
|
||||
start,
|
||||
end,
|
||||
total,
|
||||
mimeType,
|
||||
isLast,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Publish status polling ────────────────────────────────────────────────
|
||||
|
||||
export interface TiktokPublishStatus {
|
||||
status: string;
|
||||
failReason?: string;
|
||||
raw: unknown;
|
||||
}
|
||||
|
||||
const TERMINAL_PUBLISH_STATUSES = new Set(['SEND_TO_USER_INBOX', 'PUBLISH_COMPLETE', 'FAILED']);
|
||||
|
||||
// Polls with bounded backoff (TikTok allows max 30 status calls/min). For
|
||||
// draft uploads only SEND_TO_USER_INBOX means the draft actually reached the
|
||||
// creator's inbox.
|
||||
export async function pollTiktokPublishStatus(publishId: string, { maxWaitMs = 60_000 } = {}) {
|
||||
const delays = [2_000, 4_000, 8_000, 15_000, 30_000];
|
||||
const startedAt = Date.now();
|
||||
let last: TiktokPublishStatus = { status: 'PROCESSING_UPLOAD', raw: null };
|
||||
|
||||
for (let i = 0; Date.now() - startedAt < maxWaitMs; i++) {
|
||||
await sleep(delays[Math.min(i, delays.length - 1)]);
|
||||
try {
|
||||
const result = await tiktokApi('https://open.tiktokapis.com/v2/post/publish/status/fetch/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ publish_id: publishId }),
|
||||
});
|
||||
const data = result?.data as Record<string, unknown> | undefined;
|
||||
last = {
|
||||
status: String(data?.status || 'UNKNOWN'),
|
||||
failReason: typeof data?.fail_reason === 'string' && data.fail_reason ? data.fail_reason : undefined,
|
||||
raw: data ?? null,
|
||||
};
|
||||
if (TERMINAL_PUBLISH_STATUSES.has(last.status)) return last;
|
||||
} catch {
|
||||
// Transient status-fetch failures must not fail an already-uploaded
|
||||
// post; keep the last known state and try again within the window.
|
||||
}
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user