Add Metabase dashboard setup and rework the teaser workflow
Build and push image / build (push) Successful in 7m50s
Build and push image / build (push) Successful in 7m50s
The teaser workflow now takes its Postiz routing and API key from a config node instead of hard-coded placeholders inside the Code node, so activating it is a matter of filling two fields rather than editing JavaScript. scripts/setup-metabase-dashboard.mjs registers the hidden11 database in Metabase and builds the funnel dashboard from the SQL views. It is idempotent, so it can be re-run after adding a view. Refs #4 #9 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QvQ1ErZNRUcWgCEkJ9uyrn
This commit is contained in:
@@ -32,8 +32,13 @@ Player data provenance and clue rules are documented in `docs/data-sources.md`.
|
||||
`deploy/sql/hidden11-events.sql`.
|
||||
- **Daily teaser**: `GET /api/teaser` returns a spoiler-free localized teaser for today's puzzle
|
||||
(first Easy clue). `deploy/n8n-daily-teaser.json` is an importable n8n workflow that posts it
|
||||
daily through the Postiz public API (set `POSTIZ_API_KEY` in n8n and fill in the integration
|
||||
ids inside the Code node).
|
||||
daily through the Postiz public API. Fill in the "Postiz config" node: `routing` maps a locale
|
||||
to the integration ids that should post it, e.g. `{"en":["<x-id>"],"es":["<bluesky-id>"]}`
|
||||
(list ids with `GET {POSTIZ_URL}/api/public/v1/integrations`), and `postizApiKey` comes from
|
||||
`pass show homelab/postiz-api-key`.
|
||||
- **Funnel dashboard**: `node scripts/setup-metabase-dashboard.mjs` registers the hidden11
|
||||
Postgres database in Metabase and builds the funnel dashboard (funnel by day, completion and
|
||||
share rate, D1 retention, share channels, reminder opt-in). Re-running is safe.
|
||||
- **IndexNow**: `node scripts/submit-indexnow.mjs [--today]` submits the sitemap URLs to Bing,
|
||||
Yandex and Seznam. The key file lives in `public/<key>.txt`; the key is in
|
||||
`pass show hidden11/indexnow-key`. Google has no ping endpoint any more — its sitemap is
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "hidden11 — daily teaser to Postiz",
|
||||
"name": "hidden11 — daily teaser",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
@@ -12,32 +12,71 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "schedule",
|
||||
"id": "teaser-cron",
|
||||
"name": "Every day 09:00",
|
||||
"type": "n8n-nodes-base.scheduleTrigger",
|
||||
"typeVersion": 1.2,
|
||||
"position": [0, 0]
|
||||
"position": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "routing",
|
||||
"name": "routing",
|
||||
"value": "{}",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"id": "apikey",
|
||||
"name": "postizApiKey",
|
||||
"value": "",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "teaser-config",
|
||||
"name": "Postiz config",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [
|
||||
220,
|
||||
0
|
||||
],
|
||||
"notes": "routing: JSON like {\"en\":[\"<x-id>\"],\"es\":[\"<bluesky-id>\"]}. API key: pass show homelab/postiz-api-key. Never commit these."
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"url": "https://hidden11.app/api/teaser",
|
||||
"options": {}
|
||||
},
|
||||
"id": "teaser",
|
||||
"id": "teaser-fetch",
|
||||
"name": "Fetch teaser",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [220, 0]
|
||||
"position": [
|
||||
440,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "// Pick the languages you want to post and map them to Postiz integration ids.\n// Get the ids with: GET {POSTIZ_URL}/api/public/v1/integrations (same API key).\nconst teaser = $input.first().json;\nconst INTEGRATIONS = {\n // \"<postiz-integration-id-x-en>\": teaser.texts.en,\n // \"<postiz-integration-id-x-es>\": teaser.texts.es,\n};\n\nreturn Object.entries(INTEGRATIONS).map(([id, content]) => ({\n json: {\n type: \"now\",\n posts: [\n {\n integration: { id },\n value: [{ content }],\n },\n ],\n },\n}));"
|
||||
"jsCode": "\n// Maps each locale's teaser text to the Postiz integrations that should post it.\n// Configure the ids in the \"Postiz config\" node; get them with\n// GET {POSTIZ_URL}/api/public/v1/integrations\n// using the same API key.\n\nconst teaser = $input.first().json;\nconst config = $('Postiz config').first().json;\n\n// \"<locale>\": [\"<integration id>\", ...]\nconst routing = JSON.parse(config.routing || '{}');\n\nconst posts = [];\nfor (const [locale, integrationIds] of Object.entries(routing)) {\n const content = teaser.texts?.[locale];\n if (!content) continue;\n for (const id of integrationIds) {\n posts.push({ json: { type: 'now', posts: [{ integration: { id }, value: [{ content }] }] } });\n }\n}\n\nif (posts.length === 0) {\n throw new Error('No routing configured — fill in the \"Postiz config\" node');\n}\n\nreturn posts;\n"
|
||||
},
|
||||
"id": "buildPayload",
|
||||
"id": "teaser-build",
|
||||
"name": "Build Postiz payload",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [440, 0]
|
||||
"position": [
|
||||
660,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
@@ -48,7 +87,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
"value": "={{ $env.POSTIZ_API_KEY }}"
|
||||
"value": "={{ $('Postiz config').first().json.postizApiKey }}"
|
||||
},
|
||||
{
|
||||
"name": "Content-Type",
|
||||
@@ -61,25 +100,63 @@
|
||||
"jsonBody": "={{ JSON.stringify($json) }}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "postiz",
|
||||
"id": "teaser-post",
|
||||
"name": "Post via Postiz",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [660, 0]
|
||||
"position": [
|
||||
880,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Every day 09:00": {
|
||||
"main": [[{ "node": "Fetch teaser", "type": "main", "index": 0 }]]
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Postiz config",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Postiz config": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Fetch teaser",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Fetch teaser": {
|
||||
"main": [[{ "node": "Build Postiz payload", "type": "main", "index": 0 }]]
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Build Postiz payload",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Build Postiz payload": {
|
||||
"main": [[{ "node": "Post via Postiz", "type": "main", "index": 0 }]]
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Post via Postiz",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"timezone": "Europe/Madrid"
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env node
|
||||
// Creates the hidden11 funnel dashboard in Metabase.
|
||||
//
|
||||
// Registers the hidden11 Postgres database (if it is not there yet), creates one
|
||||
// native question per view in deploy/sql/hidden11-events.sql, and puts them on a
|
||||
// dashboard. Re-running is safe: existing items are reused, not duplicated.
|
||||
//
|
||||
// Usage:
|
||||
// MB_URL=https://metabase.alexandre-vazquez.cloud \
|
||||
// MB_USER=you@example.com MB_PASSWORD="$(pass show ...)" \
|
||||
// PG_PASSWORD="$(pass show hidden11/postgres-password)" \
|
||||
// node scripts/setup-metabase-dashboard.mjs
|
||||
|
||||
const MB_URL = (process.env.MB_URL ?? "https://metabase.alexandre-vazquez.cloud").replace(/\/$/, "");
|
||||
const MB_USER = process.env.MB_USER;
|
||||
const MB_PASSWORD = process.env.MB_PASSWORD;
|
||||
const PG_HOST = process.env.PG_HOST ?? "192.168.1.29";
|
||||
const PG_PORT = Number(process.env.PG_PORT ?? 5432);
|
||||
const PG_DB = process.env.PG_DB ?? "hidden11";
|
||||
const PG_USER = process.env.PG_USER ?? "hidden11";
|
||||
const PG_PASSWORD = process.env.PG_PASSWORD;
|
||||
|
||||
const DB_NAME = "hidden11";
|
||||
const DASHBOARD_NAME = "hidden11 — growth funnel";
|
||||
|
||||
if (!MB_USER || !MB_PASSWORD) {
|
||||
console.error("MB_USER and MB_PASSWORD are required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let sessionToken = "";
|
||||
|
||||
async function api(path, { method = "GET", body } = {}) {
|
||||
const response = await fetch(`${MB_URL}/api${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(sessionToken ? { "X-Metabase-Session": sessionToken } : {}),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`${method} ${path} -> ${response.status}: ${text.slice(0, 400)}`);
|
||||
}
|
||||
return text ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
const CARDS = [
|
||||
{
|
||||
name: "Funnel by day",
|
||||
description: "Visitors → started → guessed → completed → shared → challenge accepted.",
|
||||
sql: "SELECT * FROM hidden11_funnel_daily ORDER BY day DESC LIMIT 60",
|
||||
display: "table",
|
||||
size: { col: 0, row: 0, size_x: 24, size_y: 7 },
|
||||
},
|
||||
{
|
||||
name: "Completion and share rate",
|
||||
description: "Share of players who finish the day, and of finishers who share.",
|
||||
sql: "SELECT day, completion_rate, share_rate FROM hidden11_funnel_daily ORDER BY day DESC LIMIT 60",
|
||||
display: "line",
|
||||
size: { col: 0, row: 7, size_x: 12, size_y: 6 },
|
||||
},
|
||||
{
|
||||
name: "D1 retention",
|
||||
description: "Of the visitors seen on a day, how many came back the next day.",
|
||||
sql: "SELECT day, visitors, returned_next_day, d1_retention FROM hidden11_retention_d1 ORDER BY day DESC LIMIT 60",
|
||||
display: "line",
|
||||
size: { col: 12, row: 7, size_x: 12, size_y: 6 },
|
||||
},
|
||||
{
|
||||
name: "Share channels",
|
||||
description: "Which channel players actually use to share.",
|
||||
sql: "SELECT channel, sum(shares) AS shares FROM hidden11_share_channels GROUP BY channel ORDER BY shares DESC",
|
||||
display: "bar",
|
||||
size: { col: 0, row: 13, size_x: 12, size_y: 6 },
|
||||
},
|
||||
{
|
||||
name: "Reminders and installs",
|
||||
description: "Opt-in rate for the daily push reminder.",
|
||||
sql: "SELECT day, enabled_reminder, visitors FROM hidden11_funnel_daily ORDER BY day DESC LIMIT 60",
|
||||
display: "line",
|
||||
size: { col: 12, row: 13, size_x: 12, size_y: 6 },
|
||||
},
|
||||
];
|
||||
|
||||
async function ensureDatabase() {
|
||||
const { data: databases } = await api("/database");
|
||||
const existing = databases.find((database) => database.name === DB_NAME);
|
||||
if (existing) {
|
||||
console.log(`database "${DB_NAME}" already registered (id ${existing.id})`);
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
if (!PG_PASSWORD) {
|
||||
throw new Error("PG_PASSWORD is required to register the database for the first time");
|
||||
}
|
||||
|
||||
const created = await api("/database", {
|
||||
method: "POST",
|
||||
body: {
|
||||
name: DB_NAME,
|
||||
engine: "postgres",
|
||||
details: {
|
||||
host: PG_HOST,
|
||||
port: PG_PORT,
|
||||
dbname: PG_DB,
|
||||
user: PG_USER,
|
||||
password: PG_PASSWORD,
|
||||
ssl: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log(`registered database "${DB_NAME}" (id ${created.id})`);
|
||||
return created.id;
|
||||
}
|
||||
|
||||
async function ensureCard(databaseId, card) {
|
||||
const existing = await api(`/search?q=${encodeURIComponent(card.name)}&models=card`)
|
||||
.then((result) => result.data?.find((item) => item.name === card.name))
|
||||
.catch(() => null);
|
||||
|
||||
if (existing) {
|
||||
console.log(`card "${card.name}" already exists (id ${existing.id})`);
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const created = await api("/card", {
|
||||
method: "POST",
|
||||
body: {
|
||||
name: card.name,
|
||||
description: card.description,
|
||||
display: card.display,
|
||||
visualization_settings: {},
|
||||
dataset_query: {
|
||||
type: "native",
|
||||
database: databaseId,
|
||||
native: { query: card.sql },
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log(`created card "${card.name}" (id ${created.id})`);
|
||||
return created.id;
|
||||
}
|
||||
|
||||
async function ensureDashboard() {
|
||||
const found = await api(`/search?q=${encodeURIComponent(DASHBOARD_NAME)}&models=dashboard`)
|
||||
.then((result) => result.data?.find((item) => item.name === DASHBOARD_NAME))
|
||||
.catch(() => null);
|
||||
|
||||
if (found) {
|
||||
console.log(`dashboard already exists (id ${found.id})`);
|
||||
return found.id;
|
||||
}
|
||||
|
||||
const created = await api("/dashboard", {
|
||||
method: "POST",
|
||||
body: {
|
||||
name: DASHBOARD_NAME,
|
||||
description: "Visits → game started → completed → shared → challenge accepted, from first-party events.",
|
||||
},
|
||||
});
|
||||
console.log(`created dashboard (id ${created.id})`);
|
||||
return created.id;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const session = await api("/session", {
|
||||
method: "POST",
|
||||
body: { username: MB_USER, password: MB_PASSWORD },
|
||||
});
|
||||
sessionToken = session.id;
|
||||
console.log("authenticated");
|
||||
|
||||
const databaseId = await ensureDatabase();
|
||||
|
||||
const cardIds = [];
|
||||
for (const card of CARDS) {
|
||||
cardIds.push({ id: await ensureCard(databaseId, card), card });
|
||||
}
|
||||
|
||||
const dashboardId = await ensureDashboard();
|
||||
const dashboard = await api(`/dashboard/${dashboardId}`);
|
||||
const placed = new Set((dashboard.dashcards ?? []).map((dashcard) => dashcard.card_id));
|
||||
|
||||
const dashcards = [
|
||||
...(dashboard.dashcards ?? []),
|
||||
...cardIds
|
||||
.filter(({ id }) => !placed.has(id))
|
||||
.map(({ id, card }, index) => ({
|
||||
id: -(index + 1), // negative ids mark newly added cards
|
||||
card_id: id,
|
||||
...card.size,
|
||||
})),
|
||||
];
|
||||
|
||||
await api(`/dashboard/${dashboardId}`, { method: "PUT", body: { dashcards } });
|
||||
console.log(`\ndone: ${MB_URL}/dashboard/${dashboardId}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user