TikTok V5
@@ -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=
|
||||
|
||||
@@ -48,6 +48,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:-}
|
||||
PLANT_IMPORT_ADMIN_KEY: ${PLANT_IMPORT_ADMIN_KEY:-}
|
||||
depends_on:
|
||||
postgres:
|
||||
|
||||
@@ -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
|
||||
|
||||
111
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}`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
100
server/test/tiktok.test.js
Normal file
@@ -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;
|
||||
}
|
||||
});
|
||||
|
After Width: | Height: | Size: 6.9 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
@@ -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
|
||||
30
social_out/2026-07-11/greenlens/01/pack_meta.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
18
social_out/2026-07-11/greenlens/01/post.txt
Normal file
@@ -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
|
||||
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
6
social_out/2026-07-11/greenlens/01_corrected/post.txt
Normal file
@@ -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.
|
||||
18
social_out/README.md
Normal file
@@ -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.
|
||||
50
social_out/greenlens_repotting_2026-07-11_pack.txt
Normal file
@@ -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
|
||||