TikTok V6

This commit is contained in:
2026-07-13 18:51:52 +02:00
parent 5b4a924de9
commit 48e9e2992c
9 changed files with 747 additions and 41 deletions

View File

@@ -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',

View File

@@ -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;
}