diff --git a/.env.example b/.env.example index 4ef10a0..fe7f824 100644 --- a/.env.example +++ b/.env.example @@ -28,3 +28,4 @@ DISCORD_WEBHOOK_DOWNLOADS_URL= TIKTOK_CLIENT_KEY= TIKTOK_CLIENT_SECRET= TIKTOK_REDIRECT_URI=https://greenlenspro.com/api/tiktok/callback +TIKTOK_EXPECTED_OPEN_ID= diff --git a/docker-compose.yml b/docker-compose.yml index 8a6bd1f..6c11e06 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,7 +47,8 @@ services: JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} TIKTOK_CLIENT_KEY: ${TIKTOK_CLIENT_KEY:-} TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-} - TIKTOK_REDIRECT_URI: ${TIKTOK_REDIRECT_URI:-https://greenlenspro.com/api/tiktok/callback} + TIKTOK_REDIRECT_URI: ${TIKTOK_REDIRECT_URI:-https://greenlenspro.com/api/tiktok/callback} + TIKTOK_EXPECTED_OPEN_ID: ${TIKTOK_EXPECTED_OPEN_ID:-} PLANT_IMPORT_ADMIN_KEY: ${PLANT_IMPORT_ADMIN_KEY:-} depends_on: postgres: @@ -105,4 +106,4 @@ volumes: networks: greenlens_net: - external: true \ No newline at end of file + external: true diff --git a/greenlns-landing/docker-compose.yml b/greenlns-landing/docker-compose.yml index 50fe5f8..8155c71 100644 --- a/greenlns-landing/docker-compose.yml +++ b/greenlns-landing/docker-compose.yml @@ -52,6 +52,7 @@ services: TIKTOK_CLIENT_KEY: ${TIKTOK_CLIENT_KEY:-} TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-} TIKTOK_REDIRECT_URI: ${TIKTOK_REDIRECT_URI:-https://greenlenspro.com/api/tiktok/callback} + TIKTOK_EXPECTED_OPEN_ID: ${TIKTOK_EXPECTED_OPEN_ID:-} depends_on: postgres: condition: service_healthy diff --git a/server/index.js b/server/index.js index c98f208..57a84fd 100644 --- a/server/index.js +++ b/server/index.js @@ -76,6 +76,7 @@ const { getTiktokTokens, refreshTiktokTokens, saveTiktokTokens, + assertExpectedTiktokAccount, } = require('./lib/tiktok'); const app = express(); @@ -1188,7 +1189,8 @@ const getTiktokEnv = () => { const clientKey = (process.env.TIKTOK_CLIENT_KEY || '').trim(); const clientSecret = (process.env.TIKTOK_CLIENT_SECRET || '').trim(); const redirectUri = (process.env.TIKTOK_REDIRECT_URI || `${process.env.SITE_URL || ''}/api/tiktok/callback`).trim(); - return { clientKey, clientSecret, redirectUri }; + const expectedOpenId = (process.env.TIKTOK_EXPECTED_OPEN_ID || '').trim(); + return { clientKey, clientSecret, redirectUri, expectedOpenId }; }; const TIKTOK_STATE_COOKIE = 'tiktok_oauth_state'; @@ -1223,7 +1225,7 @@ app.get('/api/tiktok/connect', (request, response) => { const authUrl = new URL('https://www.tiktok.com/v2/auth/authorize/'); authUrl.searchParams.set('client_key', clientKey); - authUrl.searchParams.set('scope', 'user.info.basic,video.publish,video.upload'); + authUrl.searchParams.set('scope', 'user.info.basic,user.info.stats,video.publish,video.upload,video.list'); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('redirect_uri', redirectUri); authUrl.searchParams.set('state', oauthState); @@ -1259,6 +1261,7 @@ app.get('/api/tiktok/callback', async (request, response) => { try { const tokens = await exchangeTiktokCode({ code, clientKey, clientSecret, redirectUri }); + assertExpectedTiktokAccount(tokens.open_id, getTiktokEnv().expectedOpenId); await saveTiktokTokens(db, tokens); sendTiktokText(response, 200, 'TikTok account connected. You can close this tab.'); } catch (err) { @@ -1267,8 +1270,7 @@ app.get('/api/tiktok/callback', async (request, response) => { } }); -// Token handoff for Hermes Agent: always returns a valid access token, -// refreshing it automatically when it expires within the buffer window. +// Access tokens stay server-side. Hermes must use the protected upload routes. const TIKTOK_REFRESH_BUFFER_MS = 5 * 60 * 1000; app.get('/api/tiktok/token', async (request, response) => { @@ -1283,40 +1285,10 @@ app.get('/api/tiktok/token', async (request, response) => { return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' }); } - try { - let tokens = await getTiktokTokens(db); - if (!tokens) { - return response.status(404).json({ - code: 'NOT_CONNECTED', - message: 'No TikTok account connected. Visit /api/tiktok/connect first.', - }); - } - - 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) { - return response.status(500).json({ - code: 'SERVER_ERROR', - message: 'TikTok client credentials are not configured.', - }); - } - tokens = await refreshTiktokTokens(db, { clientKey, clientSecret }); - } - - response.json({ - access_token: tokens.access_token, - open_id: tokens.open_id, - scope: tokens.scope, - expires_at: tokens.access_token_expires_at, - }); - } catch (error) { - console.error('TikTok token endpoint error', error); - response.status(error.status || 502).json({ - code: error.code || 'PROVIDER_ERROR', - message: error.message || 'Failed to provide TikTok token.', - }); - } + response.status(410).json({ + code: 'TOKEN_HANDOFF_DISABLED', + message: 'TikTok tokens are server-managed. Use the GreenLens TikTok upload routes.', + }); }); app.get('/api/tiktok/status', async (request, response) => { @@ -1325,19 +1297,46 @@ app.get('/api/tiktok/status', async (request, response) => { } const tokens = await getTiktokTokens(db); + const { expectedOpenId } = getTiktokEnv(); if (!tokens) { - return response.json({ connected: false }); + return response.json({ connected: false, brand: 'greenlens', accountMatch: null, requiresReconnect: true }); } + const refreshExpiresAt = tokens.refresh_token_expires_at + ? new Date(tokens.refresh_token_expires_at).getTime() + : null; + const accountMatch = expectedOpenId ? tokens.open_id === expectedOpenId : null; + const openIdHash = tokens.open_id + ? crypto.createHash('sha256').update(tokens.open_id).digest('hex').slice(0, 12) + : null; response.json({ connected: true, - openId: tokens.open_id, + brand: 'greenlens', + openIdHash, + accountMatch, scope: tokens.scope, accessTokenExpiresAt: tokens.access_token_expires_at, + refreshTokenExpiresAt: tokens.refresh_token_expires_at, + requiresReconnect: refreshExpiresAt !== null && (!Number.isFinite(refreshExpiresAt) || refreshExpiresAt <= Date.now()), }); }); // ─── TikTok Upload Helpers ───────────────────────────────────────────────── +const assertConfiguredTiktokAccount = (tokens) => { + assertExpectedTiktokAccount(tokens?.open_id, getTiktokEnv().expectedOpenId); + return tokens; +}; + +const refreshLiveTiktokTokens = async () => { + const { clientKey, clientSecret } = getTiktokEnv(); + if (!clientKey || !clientSecret) { + const error = new Error('TikTok client credentials are not configured.'); + error.status = 500; + throw error; + } + return assertConfiguredTiktokAccount(await refreshTiktokTokens(db, { clientKey, clientSecret })); +}; + const getLiveTiktokAccessToken = async () => { const tokens = await getTiktokTokens(db); if (!tokens) { @@ -1348,16 +1347,10 @@ const getLiveTiktokAccessToken = async () => { 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 refreshLiveTiktokTokens(); } - return tokens; + return assertConfiguredTiktokAccount(tokens); }; const tiktokApi = async (url, options = {}) => { @@ -1373,14 +1366,22 @@ const tiktokApi = async (url, options = {}) => { }, }; - const res = await fetch(url, fetchOptions); - const text = await res.text(); - let data; - try { - data = JSON.parse(text); - } catch { - data = { raw: text }; + const readTiktokResponse = async (res) => { + const text = await res.text(); + try { + return { res, data: JSON.parse(text) }; + } catch { + return { res, data: { raw: text } }; + } + }; + + let result = await readTiktokResponse(await fetch(url, fetchOptions)); + if (result.res.status === 401 && result.data?.error?.code === 'access_token_invalid') { + const refreshed = await refreshLiveTiktokTokens(); + fetchOptions.headers.Authorization = `Bearer ${refreshed.access_token}`; + result = await readTiktokResponse(await fetch(url, fetchOptions)); } + const { res, data } = result; if (!res.ok || data?.error?.code !== 'ok') { const message = data?.error?.message || data?.raw || `TikTok API error: ${res.status}`; diff --git a/server/lib/tiktok.js b/server/lib/tiktok.js index 17be644..803df83 100644 --- a/server/lib/tiktok.js +++ b/server/lib/tiktok.js @@ -1,11 +1,13 @@ const { get, run } = require('./postgres'); -const TIKTOK_ACCOUNT_KEY = 'hermes-agent'; +const TIKTOK_ACCOUNT_KEY = 'greenlens'; +const LEGACY_TIKTOK_ACCOUNT_KEY = 'hermes-agent'; +let refreshInFlight = null; const saveTiktokTokens = async (db, tokens) => { const now = Date.now(); const accessExpiresAt = new Date(now + Number(tokens.expires_in || 0) * 1000); - const refreshExpiresAt = tokens.refresh_expires_in + const refreshExpiresAt = tokens.refresh_expires_in !== undefined && tokens.refresh_expires_in !== null ? new Date(now + Number(tokens.refresh_expires_in) * 1000) : null; @@ -19,7 +21,7 @@ const saveTiktokTokens = async (db, tokens) => { refresh_token = EXCLUDED.refresh_token, scope = EXCLUDED.scope, access_token_expires_at = EXCLUDED.access_token_expires_at, - refresh_token_expires_at = EXCLUDED.refresh_token_expires_at, + refresh_token_expires_at = COALESCE(EXCLUDED.refresh_token_expires_at, tiktok_tokens.refresh_token_expires_at), updated_at = CURRENT_TIMESTAMP`, [ TIKTOK_ACCOUNT_KEY, @@ -33,8 +35,23 @@ const saveTiktokTokens = async (db, tokens) => { ); }; -const getTiktokTokens = async (db) => - get(db, `SELECT * FROM tiktok_tokens WHERE account_key = ?`, [TIKTOK_ACCOUNT_KEY]); +const getTiktokTokens = async (db) => { + const current = await get(db, `SELECT * FROM tiktok_tokens WHERE account_key = ?`, [TIKTOK_ACCOUNT_KEY]); + if (current) return current; + + // Preserve an existing production connection when upgrading from the old, + // generic key. Future writes use the brand-specific key. + const legacy = await get(db, `SELECT * FROM tiktok_tokens WHERE account_key = ?`, [LEGACY_TIKTOK_ACCOUNT_KEY]); + if (!legacy) return null; + await saveTiktokTokens(db, { + ...legacy, + expires_in: Math.max(0, Math.floor((new Date(legacy.access_token_expires_at).getTime() - Date.now()) / 1000)), + refresh_expires_in: legacy.refresh_token_expires_at + ? Math.max(0, Math.floor((new Date(legacy.refresh_token_expires_at).getTime() - Date.now()) / 1000)) + : undefined, + }); + return get(db, `SELECT * FROM tiktok_tokens WHERE account_key = ?`, [TIKTOK_ACCOUNT_KEY]); +}; const exchangeCodeForTokens = async ({ code, clientKey, clientSecret, redirectUri }) => { const response = await fetch('https://open.tiktokapis.com/v2/oauth/token/', { @@ -59,7 +76,7 @@ const exchangeCodeForTokens = async ({ code, clientKey, clientSecret, redirectUr return data; }; -const refreshTiktokTokens = async (db, { clientKey, clientSecret }) => { +const performTiktokRefresh = async (db, { clientKey, clientSecret }) => { const existing = await getTiktokTokens(db); if (!existing) { const error = new Error('No TikTok account connected.'); @@ -67,6 +84,16 @@ const refreshTiktokTokens = async (db, { clientKey, clientSecret }) => { throw error; } + const refreshExpiresAt = existing.refresh_token_expires_at + ? new Date(existing.refresh_token_expires_at).getTime() + : null; + if (refreshExpiresAt !== null && (!Number.isFinite(refreshExpiresAt) || refreshExpiresAt <= Date.now())) { + const error = new Error('TikTok authorization has expired. Reconnect the GreenLens TikTok account.'); + error.status = 401; + error.code = 'TIKTOK_RECONNECT_REQUIRED'; + throw error; + } + const response = await fetch('https://open.tiktokapis.com/v2/oauth/token/', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, @@ -80,9 +107,11 @@ const refreshTiktokTokens = async (db, { clientKey, clientSecret }) => { const data = await response.json(); if (!response.ok || data.error) { - const message = data.error_description || data.error || 'TikTok token refresh failed.'; + const providerCode = typeof data.error === 'string' ? data.error : data.error?.code; + const message = data.error_description || data.error?.message || providerCode || 'TikTok token refresh failed.'; const error = new Error(message); - error.status = 502; + error.status = providerCode === 'invalid_grant' ? 401 : 502; + error.code = providerCode === 'invalid_grant' ? 'TIKTOK_RECONNECT_REQUIRED' : 'TIKTOK_REFRESH_FAILED'; throw error; } @@ -90,8 +119,34 @@ const refreshTiktokTokens = async (db, { clientKey, clientSecret }) => { return getTiktokTokens(db); }; +const refreshTiktokTokens = async (db, credentials) => { + if (!refreshInFlight) { + refreshInFlight = performTiktokRefresh(db, credentials).finally(() => { + refreshInFlight = null; + }); + } + return refreshInFlight; +}; + +const assertExpectedTiktokAccount = (openId, expectedOpenId) => { + const expected = String(expectedOpenId || '').trim(); + if (!expected) { + const error = new Error('TIKTOK_EXPECTED_OPEN_ID is not configured for GreenLens.'); + error.status = 500; + error.code = 'TIKTOK_ACCOUNT_NOT_CONFIGURED'; + throw error; + } + if (String(openId || '').trim() !== expected) { + const error = new Error('Connected TikTok account does not match the configured GreenLens account.'); + error.status = 409; + error.code = 'TIKTOK_ACCOUNT_MISMATCH'; + throw error; + } +}; + module.exports = { exchangeCodeForTokens, + assertExpectedTiktokAccount, getTiktokTokens, refreshTiktokTokens, saveTiktokTokens, diff --git a/server/test/tiktok.test.js b/server/test/tiktok.test.js new file mode 100644 index 0000000..f38eecf --- /dev/null +++ b/server/test/tiktok.test.js @@ -0,0 +1,100 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { assertExpectedTiktokAccount, refreshTiktokTokens } = require('../lib/tiktok'); + +test('accepts the configured TikTok account', () => { + assert.doesNotThrow(() => assertExpectedTiktokAccount('greenlens-id', 'greenlens-id')); +}); + +test('rejects deployments without an expected GreenLens account', () => { + assert.throws( + () => assertExpectedTiktokAccount('greenlens-id', ''), + (error) => error.code === 'TIKTOK_ACCOUNT_NOT_CONFIGURED' && error.status === 500, + ); +}); + +test('rejects a TikTok account that belongs to another brand', () => { + assert.throws( + () => assertExpectedTiktokAccount('qrmaster-id', 'greenlens-id'), + (error) => error.code === 'TIKTOK_ACCOUNT_MISMATCH' && error.status === 409, + ); +}); + +test('expired refresh tokens require reconnecting before contacting TikTok', async () => { + const row = { + account_key: 'greenlens', + open_id: 'greenlens-id', + access_token: 'expired-access', + refresh_token: 'expired-refresh', + access_token_expires_at: new Date(Date.now() - 60_000), + refresh_token_expires_at: new Date(Date.now() - 1_000), + }; + const db = { + query: async (sql, params) => ({ rows: sql.startsWith('SELECT') && params[0] === 'greenlens' ? [row] : [] }), + }; + + await assert.rejects( + refreshTiktokTokens(db, { clientKey: 'client', clientSecret: 'secret' }), + (error) => error.code === 'TIKTOK_RECONNECT_REQUIRED' && error.status === 401, + ); +}); + +test('concurrent refreshes share one provider request and persist rotated tokens', async () => { + let row = { + account_key: 'greenlens', + open_id: 'greenlens-id', + access_token: 'old-access', + refresh_token: 'old-refresh', + scope: 'video.upload', + access_token_expires_at: new Date(Date.now() - 1_000), + refresh_token_expires_at: new Date(Date.now() + 60_000), + }; + const db = { + query: async (sql, params) => { + if (sql.startsWith('SELECT')) return { rows: params[0] === 'greenlens' ? [row] : [] }; + if (sql.startsWith('INSERT')) { + row = { + ...row, + account_key: params[0], + open_id: params[1], + access_token: params[2], + refresh_token: params[3], + scope: params[4], + access_token_expires_at: params[5], + refresh_token_expires_at: params[6], + }; + } + return { rows: [], rowCount: 1 }; + }, + }; + const originalFetch = global.fetch; + let providerCalls = 0; + global.fetch = async () => { + providerCalls += 1; + await new Promise((resolve) => setTimeout(resolve, 10)); + return { + ok: true, + json: async () => ({ + open_id: 'greenlens-id', + access_token: 'new-access', + refresh_token: 'rotated-refresh', + scope: 'video.upload', + expires_in: 86_400, + refresh_expires_in: 31_536_000, + }), + }; + }; + + try { + const [first, second] = await Promise.all([ + refreshTiktokTokens(db, { clientKey: 'client', clientSecret: 'secret' }), + refreshTiktokTokens(db, { clientKey: 'client', clientSecret: 'secret' }), + ]); + assert.equal(providerCalls, 1); + assert.equal(first.access_token, 'new-access'); + assert.equal(second.refresh_token, 'rotated-refresh'); + } finally { + global.fetch = originalFetch; + } +}); diff --git a/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_carousel.mp4 b/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_carousel.mp4 new file mode 100644 index 0000000..d78b8ca Binary files /dev/null and b/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_carousel.mp4 differ diff --git a/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_contactsheet.png b/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_contactsheet.png new file mode 100644 index 0000000..3f6ab82 Binary files /dev/null and b/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_contactsheet.png differ diff --git a/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_slide01.png b/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_slide01.png new file mode 100644 index 0000000..c6f66e4 Binary files /dev/null and b/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_slide01.png differ diff --git a/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_slide02.png b/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_slide02.png new file mode 100644 index 0000000..b6c969f Binary files /dev/null and b/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_slide02.png differ diff --git a/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_slide03.png b/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_slide03.png new file mode 100644 index 0000000..37ccd16 Binary files /dev/null and b/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_slide03.png differ diff --git a/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_slide04.png b/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_slide04.png new file mode 100644 index 0000000..5e48f2f Binary files /dev/null and b/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_slide04.png differ diff --git a/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_slide05.png b/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_slide05.png new file mode 100644 index 0000000..f9dab1b Binary files /dev/null and b/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_slide05.png differ diff --git a/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_slide06.png b/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_slide06.png new file mode 100644 index 0000000..f867e03 Binary files /dev/null and b/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_slide06.png differ diff --git a/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_slide07.png b/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_slide07.png new file mode 100644 index 0000000..981e5ca Binary files /dev/null and b/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_slide07.png differ diff --git a/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_subtitles.vtt b/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_subtitles.vtt new file mode 100644 index 0000000..18e8d8a --- /dev/null +++ b/social_out/2026-07-11/greenlens/01/greenlens_2026-07-11_01_subtitles.vtt @@ -0,0 +1,22 @@ +WEBVTT + +00:00:00.000 --> 00:00:03.000 +Yellow leaves? Check the roots first. + +00:00:03.000 --> 00:00:06.000 +Often overwatering is only a symptom, not the root cause. + +00:00:06.000 --> 00:00:09.000 +Yellowing often starts at the bottom first. + +00:00:09.000 --> 00:00:12.000 +Rotter around the base? That's often why. + +00:00:12.000 --> 00:00:15.000 +Before repotting, inspect roots and soil moisture. + +00:00:15.000 --> 00:00:18.000 +Use a plant diagnosis before you change the whole setup. + +00:00:18.000 --> 00:00:21.000 +Scan your plant. greenlenspro.com \ No newline at end of file diff --git a/social_out/2026-07-11/greenlens/01/pack_meta.json b/social_out/2026-07-11/greenlens/01/pack_meta.json new file mode 100644 index 0000000..9640415 --- /dev/null +++ b/social_out/2026-07-11/greenlens/01/pack_meta.json @@ -0,0 +1,30 @@ +{ + "post_id": "2026-07-11_GREENLENS_01", + "date": "2026-07-11", + "brand": "GreenLens", + "number": "01", + "topic": "I wish I knew this sooner about plant care", + "format": "Carousel A", + "ab_variant": "A", + "hypothesis": "GreenLens Carousel A: stronger i wish i knew this sooner about plant care hook should raise saves and profile clicks.", + "status": "Draft / Upload bereit", + "asset_paths": { + "slides": [ + "C:\\Users\\timo\\AppData\\Local\\hermes\\cache\\images\\greenlens_social_2026-07-11\\slide_01_a.png", + "C:\\Users\\timo\\AppData\\Local\\hermes\\cache\\images\\greenlens_social_2026-07-11\\slide_02_a.png", + "C:\\Users\\timo\\AppData\\Local\\hermes\\cache\\images\\greenlens_social_2026-07-11\\slide_03_a.png", + "C:\\Users\\timo\\AppData\\Local\\hermes\\cache\\images\\greenlens_social_2026-07-11\\slide_04_a.png", + "C:\\Users\\timo\\AppData\\Local\\hermes\\cache\\images\\greenlens_social_2026-07-11\\slide_05_a.png", + "C:\\Users\\timo\\AppData\\Local\\hermes\\cache\\images\\greenlens_social_2026-07-11\\slide_06_a.png", + "C:\\Users\\timo\\AppData\\Local\\hermes\\cache\\images\\greenlens_social_2026-07-11\\slide_07_a_cta.png" + ], + "video": "C:\\Users\\timo\\AppData\\Local\\hermes\\cache\\images\\greenlens_social_2026-07-11\\carousel_a.mp4", + "vtt": "C:\\Users\\timo\\AppData\\Local\\hermes\\cache\\images\\greenlens_social_2026-07-11\\carousel_a.vtt" + }, + "platform_mapping": { + "instagram": "3:4 carousel alle 7 Slides", + "tiktok": "3:4 carousel; alternativ Video", + "facebook": "1:1 crop aus Mitte / Carousel möglich", + "x_twitter": "Bildsequenz bis zu 4 Images oder Text-Post" + } +} \ No newline at end of file diff --git a/social_out/2026-07-11/greenlens/01/post.txt b/social_out/2026-07-11/greenlens/01/post.txt new file mode 100644 index 0000000..fdb4738 --- /dev/null +++ b/social_out/2026-07-11/greenlens/01/post.txt @@ -0,0 +1,18 @@ +GreenLens Educational Pack + +Post-ID: 2026-07-11_GREENLENS_01 +Tag: Saturday | Slot 01 +Format: Carousel | Variante A +Thema: I wish I knew this sooner about plant care + +Plattform-Regeln: +- Instagram/TikTok: 3:4 Carousel +- Facebook: 1:1 Center-Crop +- X/Twitter: bis 4 Images / Text-only +- Video: C:\Users\timo\AppData\Local\hermes\cache\images\greenlens_social_2026-07-11\carousel_a.mp4 + +Messung: Saves, Kommentare, Profile Clicks, Website Clicks. +A/B-Hypothese: GreenLens Carousel A: stronger i wish i knew this sooner about plant care hook should raise saves and profile clicks. +Status: Draft / Upload bereit + +CTA-Slide: Scan your plant -> greenlenspro.com diff --git a/social_out/2026-07-11/greenlens/01_corrected/greenlens_2026-07-11_01_corrected_slide01.png b/social_out/2026-07-11/greenlens/01_corrected/greenlens_2026-07-11_01_corrected_slide01.png new file mode 100644 index 0000000..18fa5b8 Binary files /dev/null and b/social_out/2026-07-11/greenlens/01_corrected/greenlens_2026-07-11_01_corrected_slide01.png differ diff --git a/social_out/2026-07-11/greenlens/01_corrected/greenlens_2026-07-11_01_corrected_slide02.png b/social_out/2026-07-11/greenlens/01_corrected/greenlens_2026-07-11_01_corrected_slide02.png new file mode 100644 index 0000000..a084d2f Binary files /dev/null and b/social_out/2026-07-11/greenlens/01_corrected/greenlens_2026-07-11_01_corrected_slide02.png differ diff --git a/social_out/2026-07-11/greenlens/01_corrected/greenlens_2026-07-11_01_corrected_slide03.png b/social_out/2026-07-11/greenlens/01_corrected/greenlens_2026-07-11_01_corrected_slide03.png new file mode 100644 index 0000000..2b87af6 Binary files /dev/null and b/social_out/2026-07-11/greenlens/01_corrected/greenlens_2026-07-11_01_corrected_slide03.png differ diff --git a/social_out/2026-07-11/greenlens/01_corrected/greenlens_2026-07-11_01_corrected_slide04.png b/social_out/2026-07-11/greenlens/01_corrected/greenlens_2026-07-11_01_corrected_slide04.png new file mode 100644 index 0000000..5c70ed9 Binary files /dev/null and b/social_out/2026-07-11/greenlens/01_corrected/greenlens_2026-07-11_01_corrected_slide04.png differ diff --git a/social_out/2026-07-11/greenlens/01_corrected/greenlens_2026-07-11_01_corrected_slide05.png b/social_out/2026-07-11/greenlens/01_corrected/greenlens_2026-07-11_01_corrected_slide05.png new file mode 100644 index 0000000..2da8e15 Binary files /dev/null and b/social_out/2026-07-11/greenlens/01_corrected/greenlens_2026-07-11_01_corrected_slide05.png differ diff --git a/social_out/2026-07-11/greenlens/01_corrected/greenlens_2026-07-11_01_corrected_slide06.png b/social_out/2026-07-11/greenlens/01_corrected/greenlens_2026-07-11_01_corrected_slide06.png new file mode 100644 index 0000000..4483d78 Binary files /dev/null and b/social_out/2026-07-11/greenlens/01_corrected/greenlens_2026-07-11_01_corrected_slide06.png differ diff --git a/social_out/2026-07-11/greenlens/01_corrected/greenlens_2026-07-11_01_corrected_slide07.png b/social_out/2026-07-11/greenlens/01_corrected/greenlens_2026-07-11_01_corrected_slide07.png new file mode 100644 index 0000000..91e1500 Binary files /dev/null and b/social_out/2026-07-11/greenlens/01_corrected/greenlens_2026-07-11_01_corrected_slide07.png differ diff --git a/social_out/2026-07-11/greenlens/01_corrected/post.txt b/social_out/2026-07-11/greenlens/01_corrected/post.txt new file mode 100644 index 0000000..bb83761 --- /dev/null +++ b/social_out/2026-07-11/greenlens/01_corrected/post.txt @@ -0,0 +1,6 @@ +GreenLens Pro — corrected visual pack +Post-ID: 2026-07-11_GREENLENS_01 +Format: 3:4 carousel, corrected +Status: Draft / Upload bereit + +Applied: strict 90px safe margins, automatic text wrapping, uniform pure-white typography with soft diffuse shadow, no slide numbers, no text boxes, complete educational copy, app-focused CTA. diff --git a/social_out/README.md b/social_out/README.md new file mode 100644 index 0000000..8c3895a --- /dev/null +++ b/social_out/README.md @@ -0,0 +1,18 @@ +# GreenLens Social Archive + +Daily canonical archive for social drafts/uploads. + +```text +YYYY-MM-DD/greenlens/01/ +YYYY-MM-DD/greenlens/02/ +YYYY-MM-DD/greenlens/03/ +``` + +Each post folder contains: + +- `post.txt` — human-readable copy and platform mapping +- `pack_meta.json` — machine-readable post ID, topic, format, A/B test, status, and asset paths +- generated PNG/MP4 assets + +Post ID format: `YYYY-MM-DD_GREENLENS_NN`. +Numbering resets daily. One topic is selected per day: `01` is carousel A, `02` is carousel B, and `03` is the video test on that same topic. The next catalog topic starts on the following day. TikTok remains upload/draft by default; no live post happens without explicit approval. diff --git a/social_out/greenlens_repotting_2026-07-11_pack.txt b/social_out/greenlens_repotting_2026-07-11_pack.txt new file mode 100644 index 0000000..e843a6a --- /dev/null +++ b/social_out/greenlens_repotting_2026-07-11_pack.txt @@ -0,0 +1,50 @@ +GREENLENS PRO — SOCIAL PACK +Datum: 2026-07-11 +Thema: Umtopfen +Variante: A - Photorealistic warm +Format: 3:4 Carousel (7 Slides) + Slideshow-Video + +SLIDES: +1. Hook: 3 pot mistakes that keep your plant small + - The wrong pot can quietly stunt growth + +2. Signs your plant needs a new pot + - Roots circling, water running straight through, or soil drying in hours + +3. Best repotting window + - Spring and early summer when growth is active + +4. Pick the right pot and soil + - Drainage holes first; loose airy mix second + +5. Repot without shock + - Loosen root ball, center in new pot, firm gently, water lightly + +6. Aftercare matters most + - Bright indirect light, no fertilizer for 2-3 weeks, normal watering after root touch + +7. CTA: + - Scan your plant + - greenlenspro.com + +PLATFORM-ZUORDNUNG: +- Instagram Feed/TikTok: 3:4 Carousel-Bilder slide_01 bis slide_07 +- Facebook: Landscape-Crop aus der Mitte: slide_facebook_key_visual.png +- X/Twitter: Bildsequenz bis zu 4 Images, Reihenfolge: 01→04 oder 01→04→07 CTA + +VIDEO: +- greenlens_repotting_slideshow.mp4 +- 1086x1448, 24fps, H.264, 35s, 3s pro Slide, 0.5s Crossfade + +UNTERTITEL: +- greenlens_repotting_captions.vtt + +A/B TEST HINWEIS: +- Variante A: Photorealistic warm interior +- Nächster Lauf: Variante B Vintage Botanical falls gewünscht, sonst automatischer Wechsel + +MESSUNG: +- Saves, Comments, Profile Clicks, Website Clicks + +LIVE-POSTS: +- Erst nach explizitem Go von Timo