Bild Carousel
205
server/index.js
@@ -1332,6 +1332,211 @@ app.get('/api/tiktok/status', async (request, response) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── TikTok Upload Helpers ─────────────────────────────────────────────────
|
||||
|
||||
const getLiveTiktokAccessToken = async () => {
|
||||
const tokens = await getTiktokTokens(db);
|
||||
if (!tokens) {
|
||||
const error = new Error('No TikTok account connected.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const expiresAt = new Date(tokens.access_token_expires_at).getTime();
|
||||
if (expiresAt - Date.now() < TIKTOK_REFRESH_BUFFER_MS) {
|
||||
const { clientKey, clientSecret } = getTiktokEnv();
|
||||
if (!clientKey || !clientSecret) {
|
||||
const error = new Error('TikTok client credentials are not configured.');
|
||||
error.status = 500;
|
||||
throw error;
|
||||
}
|
||||
return refreshTiktokTokens(db, { clientKey, clientSecret });
|
||||
}
|
||||
|
||||
return tokens;
|
||||
};
|
||||
|
||||
const tiktokApi = async (url, options = {}) => {
|
||||
const tokens = await getLiveTiktokAccessToken();
|
||||
const accessToken = tokens.access_token;
|
||||
|
||||
const fetchOptions = {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.headers || {}),
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json; charset=UTF-8',
|
||||
},
|
||||
};
|
||||
|
||||
const res = await fetch(url, fetchOptions);
|
||||
const text = await res.text();
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
data = { raw: text };
|
||||
}
|
||||
|
||||
if (!res.ok || data?.error?.code !== 'ok') {
|
||||
const message = data?.error?.message || data?.raw || `TikTok API error: ${res.status}`;
|
||||
const error = new Error(message);
|
||||
error.status = res.status;
|
||||
error.body = data;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const uploadBinaryToTiktok = async (uploadUrl, buffer, mimeType = 'video/mp4') => {
|
||||
const res = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
'Content-Length': String(buffer.length),
|
||||
},
|
||||
body: buffer,
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
if (!res.ok && res.status !== 201) {
|
||||
const error = new Error(`TikTok binary upload failed: ${res.status} ${text}`);
|
||||
error.status = res.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { status: res.status, body: text };
|
||||
};
|
||||
|
||||
// ─── TikTok Video Upload ────────────────────────────────────────────────────
|
||||
|
||||
app.post('/api/tiktok/upload/video', async (request, response) => {
|
||||
try {
|
||||
if (!isAuthorizedAdminNavigation(request)) {
|
||||
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' });
|
||||
}
|
||||
|
||||
const { videoBuffer, mimeType = 'video/mp4' } = request.body || {};
|
||||
if (!Buffer.isBuffer(videoBuffer)) {
|
||||
return response.status(400).json({ code: 'BAD_REQUEST', message: 'videoBuffer must be a binary buffer.' });
|
||||
}
|
||||
|
||||
const videoSize = videoBuffer.length;
|
||||
const initBody = {
|
||||
source_info: {
|
||||
source: 'FILE_UPLOAD',
|
||||
video_size: videoSize,
|
||||
chunk_size: videoSize,
|
||||
total_chunk_count: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const initResult = await tiktokApi('https://open.tiktokapis.com/v2/post/publish/inbox/video/init/', {
|
||||
method: 'POST',
|
||||
body: Buffer.from(JSON.stringify(initBody)),
|
||||
});
|
||||
|
||||
const uploadUrl = initResult?.data?.upload_url;
|
||||
const publishId = initResult?.data?.publish_id;
|
||||
if (!uploadUrl || !publishId) {
|
||||
return response.status(502).json({ code: 'PROVIDER_ERROR', message: 'Missing upload_url or publish_id from TikTok.' });
|
||||
}
|
||||
|
||||
await uploadBinaryToTiktok(uploadUrl, videoBuffer, mimeType || 'video/mp4');
|
||||
|
||||
response.status(200).json({
|
||||
publish_id: publishId,
|
||||
upload_url: uploadUrl,
|
||||
status: 'INITIATED',
|
||||
});
|
||||
} catch (error) {
|
||||
const payload = toApiErrorPayload(error);
|
||||
response.status(payload.status).json(payload.body);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── TikTok Photo/Carousel Upload ───────────────────────────────────────────
|
||||
|
||||
app.post('/api/tiktok/upload/photo', async (request, response) => {
|
||||
try {
|
||||
if (!isAuthorizedAdminNavigation(request)) {
|
||||
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' });
|
||||
}
|
||||
|
||||
const files = Array.isArray(request.body?.files) ? request.body.files : [];
|
||||
if (!files.length) {
|
||||
return response.status(400).json({ code: 'BAD_REQUEST', message: 'files must be a non-empty array.' });
|
||||
}
|
||||
|
||||
if (files.length > 35) {
|
||||
return response.status(400).json({ code: 'BAD_REQUEST', message: 'TikTok allows up to 35 photos per carousel post.' });
|
||||
}
|
||||
|
||||
const fileInfos = [];
|
||||
for (const file of files) {
|
||||
const buffer = file.buffer || file.data;
|
||||
const mimeType = file.contentType || file.mimeType || 'image/jpeg';
|
||||
|
||||
if (!Buffer.isBuffer(buffer)) {
|
||||
return response.status(400).json({ code: 'BAD_REQUEST', message: 'Each file must include a binary buffer.' });
|
||||
}
|
||||
|
||||
const uploadRes = await uploadImage(buffer.toString('base64'), mimeType);
|
||||
fileInfos.push({
|
||||
url: uploadRes.url,
|
||||
mime_type: mimeType,
|
||||
});
|
||||
}
|
||||
|
||||
const initBody = {
|
||||
media_type: 'PHOTO',
|
||||
photo_cover_index: 0,
|
||||
file_paths: fileInfos.map((info) => info.url),
|
||||
file_extensions: fileInfos.map((info) => (info.mime_type || 'image/jpeg').split('/').pop()),
|
||||
post_mode: 'DIRECT_POST',
|
||||
};
|
||||
|
||||
const initResult = await tiktokApi('https://open.tiktokapis.com/v2/post/publish/content/init/', {
|
||||
method: 'POST',
|
||||
body: Buffer.from(JSON.stringify(initBody)),
|
||||
});
|
||||
|
||||
response.status(200).json({
|
||||
publish_id: initResult?.data?.publish_id,
|
||||
status: initResult?.data?.status || 'INITIATED',
|
||||
});
|
||||
} catch (error) {
|
||||
const payload = toApiErrorPayload(error);
|
||||
response.status(payload.status).json(payload.body);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── TikTok Upload Status ───────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/tiktok/upload/status', async (request, response) => {
|
||||
try {
|
||||
if (!isAuthorizedAdminNavigation(request)) {
|
||||
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' });
|
||||
}
|
||||
|
||||
const publishId = String(request.query.publish_id || '').trim();
|
||||
if (!publishId) {
|
||||
return response.status(400).json({ code: 'BAD_REQUEST', message: 'publish_id is required.' });
|
||||
}
|
||||
|
||||
const result = await tiktokApi('https://open.tiktokapis.com/v2/post/publish/status/fetch/', {
|
||||
method: 'POST',
|
||||
body: Buffer.from(JSON.stringify({ publish_id: publishId })),
|
||||
});
|
||||
|
||||
response.status(200).json(result);
|
||||
} catch (error) {
|
||||
const payload = toApiErrorPayload(error);
|
||||
response.status(payload.status).json(payload.body);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Startup ───────────────────────────────────────────────────────────────
|
||||
|
||||
app.delete('/auth/account', async (request, response) => {
|
||||
|
||||
|
After Width: | Height: | Size: 938 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
@@ -0,0 +1,63 @@
|
||||
GreenLens Educational Social Pack – 2026-07-09
|
||||
|
||||
Status: Draft/Preview, keine Live-Posts.
|
||||
Variante: B — Vintage Botanical Infographic
|
||||
A/B-Logik: letzter erkannter GreenLens-Lauf 2026-07-08 war A; dieser Lauf wechselt auf B.
|
||||
Thema: Yellow leaves? Check roots first — Wasser, Licht, Wurzeln, Fungus Gnats.
|
||||
Format: 7 Slides, 1086x1448 (3:4), plus 18s Carousel-Video, 24fps, H.264, 0.5s Crossfade.
|
||||
|
||||
Interne Faktenbasis: UC IPM + Iowa State Extension nennen u.a. Lichtmangel, Überwässerung/schlechte Drainage, Wurzelschäden und bodennahe Insekten/Krankheiten als mögliche Ursachen für gelbe/abfallende Blätter. Keine Quellen in Public Copy.
|
||||
|
||||
Caption IG/TikTok:
|
||||
Yellow leaves are a symptom, not a diagnosis.
|
||||
|
||||
Before changing everything, check the roots, soil moisture, light, and leaf undersides. A wet pot in a low-light corner can look like a leaf problem — but the stress often starts below the soil.
|
||||
|
||||
Save this for your next plant check.
|
||||
|
||||
#PlantCare #Houseplants #GreenLens #PlantTips
|
||||
|
||||
Facebook Caption:
|
||||
Yellow leaves often point to a routine problem: soil staying wet too long, lower light, root stress, or pests hiding early. Check one cause at a time before making big changes.
|
||||
|
||||
#PlantCare #Houseplants #PlantTips #GreenLens
|
||||
|
||||
X/Twitter Caption:
|
||||
Yellow leaves? Don’t guess from the leaf alone.
|
||||
|
||||
Quick check:
|
||||
1. Is the soil staying wet?
|
||||
2. Do roots smell off or look dark?
|
||||
3. Did light change recently?
|
||||
4. Any tiny gnats or pest dots?
|
||||
|
||||
Adjust one thing at a time.
|
||||
|
||||
greenlenspro.com
|
||||
|
||||
A/B-Test-Hinweis:
|
||||
Test zuerst Variante B auf Saves/Kommentare gegen den letzten A-Run. Alternative Hook für nächsten A-Lauf: “Yellow leaves? Check the pot, not the leaf.”
|
||||
|
||||
Messung:
|
||||
- Saves
|
||||
- Kommentare mit Symptomen/Fragen
|
||||
- Profilklicks
|
||||
- Website-Klicks greenlenspro.com
|
||||
|
||||
Asset Mapping:
|
||||
- IG/TikTok Carousel: Slides 01–07
|
||||
- TikTok/IG Reel/Slideshow: C:\Users\timo\AppData\Local\hermes\cache\images\greenlens_social_2026-07-09\greenlens_yellow_roots_carousel_1086x1448.mp4
|
||||
- Facebook: 1:1 Crop aus Mitte von Slide 01 oder Carousel Slides 01–07
|
||||
- X/Twitter: Bildsequenz Slides 01–04 oder Text-only Caption
|
||||
|
||||
Dateien:
|
||||
C:\Users\timo\AppData\Local\hermes\cache\images\greenlens_social_2026-07-09\greenlens_yellow_roots_slide_01.png
|
||||
C:\Users\timo\AppData\Local\hermes\cache\images\greenlens_social_2026-07-09\greenlens_yellow_roots_slide_02.png
|
||||
C:\Users\timo\AppData\Local\hermes\cache\images\greenlens_social_2026-07-09\greenlens_yellow_roots_slide_03.png
|
||||
C:\Users\timo\AppData\Local\hermes\cache\images\greenlens_social_2026-07-09\greenlens_yellow_roots_slide_04.png
|
||||
C:\Users\timo\AppData\Local\hermes\cache\images\greenlens_social_2026-07-09\greenlens_yellow_roots_slide_05.png
|
||||
C:\Users\timo\AppData\Local\hermes\cache\images\greenlens_social_2026-07-09\greenlens_yellow_roots_slide_06.png
|
||||
C:\Users\timo\AppData\Local\hermes\cache\images\greenlens_social_2026-07-09\greenlens_yellow_roots_slide_07.png
|
||||
C:\Users\timo\AppData\Local\hermes\cache\images\greenlens_social_2026-07-09\greenlens_yellow_roots_contact_sheet.png
|
||||
C:\Users\timo\AppData\Local\hermes\cache\images\greenlens_social_2026-07-09\greenlens_yellow_roots_carousel_1086x1448.mp4
|
||||
C:\Users\timo\AppData\Local\hermes\cache\images\greenlens_social_2026-07-09\greenlens_yellow_roots_subtitles.vtt
|
||||
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,30 @@
|
||||
WEBVTT
|
||||
|
||||
1
|
||||
00:00:00.000 --> 00:00:03.000
|
||||
Yellow leaves? Check roots first. Yellowing often starts below the soil line, not on the leaf.
|
||||
|
||||
2
|
||||
00:00:02.500 --> 00:00:05.500
|
||||
Wet soil can block air. Roots need oxygen. If the pot stays wet, leaves may yellow and drop.
|
||||
|
||||
3
|
||||
00:00:05.000 --> 00:00:08.000
|
||||
Low light slows drying. A darker corner can make normal watering feel like overwatering.
|
||||
|
||||
4
|
||||
00:00:07.500 --> 00:00:10.500
|
||||
Gnats point to damp soil. Fungus gnats often appear when the top layer stays moist too long.
|
||||
|
||||
5
|
||||
00:00:10.000 --> 00:00:13.000
|
||||
Check three places. Soil moisture, root smell, and leaf undersides tell the clearest story.
|
||||
|
||||
6
|
||||
00:00:12.500 --> 00:00:15.500
|
||||
Fix the routine slowly. Adjust light, drainage, and watering one step at a time.
|
||||
|
||||
7
|
||||
00:00:15.000 --> 00:00:18.000
|
||||
Get plant diagnosis. greenlenspro.com
|
||||
|
||||
47
social_out/greenlens_social_2026-07-09/pack_meta.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"date": "2026-07-09",
|
||||
"brand": "GreenLens Pro",
|
||||
"topic": "Yellow leaves: roots, water, light and pests",
|
||||
"variant": "B — vintage botanical infographic",
|
||||
"dimensions": "1086x1448",
|
||||
"slides": [
|
||||
{
|
||||
"top": "Yellow leaves? Check roots first",
|
||||
"bottom": "Yellowing often starts below the soil line, not on the leaf."
|
||||
},
|
||||
{
|
||||
"top": "Wet soil can block air",
|
||||
"bottom": "Roots need oxygen. If the pot stays wet, leaves may yellow and drop."
|
||||
},
|
||||
{
|
||||
"top": "Low light slows drying",
|
||||
"bottom": "A darker corner can make normal watering feel like overwatering."
|
||||
},
|
||||
{
|
||||
"top": "Gnats point to damp soil",
|
||||
"bottom": "Fungus gnats often appear when the top layer stays moist too long."
|
||||
},
|
||||
{
|
||||
"top": "Check three places",
|
||||
"bottom": "Soil moisture, root smell, and leaf undersides tell the clearest story."
|
||||
},
|
||||
{
|
||||
"top": "Fix the routine slowly",
|
||||
"bottom": "Adjust light, drainage, and watering one step at a time."
|
||||
},
|
||||
{
|
||||
"top": "Get plant diagnosis",
|
||||
"bottom": "greenlenspro.com",
|
||||
"cta": true
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
"C:\\Users\\timo\\AppData\\Local\\hermes\\cache\\images\\greenlens_social_2026-07-09\\greenlens_yellow_roots_slide_01.png",
|
||||
"C:\\Users\\timo\\AppData\\Local\\hermes\\cache\\images\\greenlens_social_2026-07-09\\greenlens_yellow_roots_slide_02.png",
|
||||
"C:\\Users\\timo\\AppData\\Local\\hermes\\cache\\images\\greenlens_social_2026-07-09\\greenlens_yellow_roots_slide_03.png",
|
||||
"C:\\Users\\timo\\AppData\\Local\\hermes\\cache\\images\\greenlens_social_2026-07-09\\greenlens_yellow_roots_slide_04.png",
|
||||
"C:\\Users\\timo\\AppData\\Local\\hermes\\cache\\images\\greenlens_social_2026-07-09\\greenlens_yellow_roots_slide_05.png",
|
||||
"C:\\Users\\timo\\AppData\\Local\\hermes\\cache\\images\\greenlens_social_2026-07-09\\greenlens_yellow_roots_slide_06.png",
|
||||
"C:\\Users\\timo\\AppData\\Local\\hermes\\cache\\images\\greenlens_social_2026-07-09\\greenlens_yellow_roots_slide_07.png"
|
||||
]
|
||||
}
|
||||
2
social_out/greenlens_social_2026-07-09/variant.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
B — vintage botanical infographic
|
||||
Previous detected run 2026-07-08 was A, so this run alternates to B.
|
||||