diff --git a/server/index.js b/server/index.js index e963681..0bf1770 100644 --- a/server/index.js +++ b/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) => { diff --git a/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_carousel_1086x1448.mp4 b/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_carousel_1086x1448.mp4 new file mode 100644 index 0000000..b40f674 Binary files /dev/null and b/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_carousel_1086x1448.mp4 differ diff --git a/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_contact_sheet.png b/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_contact_sheet.png new file mode 100644 index 0000000..7fad22a Binary files /dev/null and b/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_contact_sheet.png differ diff --git a/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_facebook_square.png b/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_facebook_square.png new file mode 100644 index 0000000..92add78 Binary files /dev/null and b/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_facebook_square.png differ diff --git a/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_pack.txt b/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_pack.txt new file mode 100644 index 0000000..ac02b83 --- /dev/null +++ b/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_pack.txt @@ -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 diff --git a/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_slide_01.png b/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_slide_01.png new file mode 100644 index 0000000..025cbc0 Binary files /dev/null and b/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_slide_01.png differ diff --git a/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_slide_02.png b/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_slide_02.png new file mode 100644 index 0000000..d234aa1 Binary files /dev/null and b/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_slide_02.png differ diff --git a/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_slide_03.png b/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_slide_03.png new file mode 100644 index 0000000..f00546c Binary files /dev/null and b/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_slide_03.png differ diff --git a/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_slide_04.png b/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_slide_04.png new file mode 100644 index 0000000..ebce7a5 Binary files /dev/null and b/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_slide_04.png differ diff --git a/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_slide_05.png b/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_slide_05.png new file mode 100644 index 0000000..79b5eaa Binary files /dev/null and b/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_slide_05.png differ diff --git a/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_slide_06.png b/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_slide_06.png new file mode 100644 index 0000000..b1d88b3 Binary files /dev/null and b/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_slide_06.png differ diff --git a/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_slide_07.png b/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_slide_07.png new file mode 100644 index 0000000..7a90a82 Binary files /dev/null and b/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_slide_07.png differ diff --git a/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_subtitles.vtt b/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_subtitles.vtt new file mode 100644 index 0000000..b4ce4e3 --- /dev/null +++ b/social_out/greenlens_social_2026-07-09/greenlens_yellow_roots_subtitles.vtt @@ -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 + diff --git a/social_out/greenlens_social_2026-07-09/pack_meta.json b/social_out/greenlens_social_2026-07-09/pack_meta.json new file mode 100644 index 0000000..314c6a3 --- /dev/null +++ b/social_out/greenlens_social_2026-07-09/pack_meta.json @@ -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" + ] +} \ No newline at end of file diff --git a/social_out/greenlens_social_2026-07-09/variant.txt b/social_out/greenlens_social_2026-07-09/variant.txt new file mode 100644 index 0000000..f0f3f0b --- /dev/null +++ b/social_out/greenlens_social_2026-07-09/variant.txt @@ -0,0 +1,2 @@ +B — vintage botanical infographic +Previous detected run 2026-07-08 was A, so this run alternates to B.