From d70af4e9986952c56e6d360a847133a1056604b3 Mon Sep 17 00:00:00 2001 From: KKKK Date: Fri, 25 Sep 2026 23:55:55 +0800 Subject: [PATCH 1/2] Add n8n workflow for BeatAPI capability discovery and runs --- integrations/n8n/README.md | 20 +++ integrations/n8n/beatapi-capabilities.json | 137 +++++++++++++++++++++ tests/n8n-capabilities.test.mjs | 45 +++++++ 3 files changed, 202 insertions(+) create mode 100644 integrations/n8n/beatapi-capabilities.json create mode 100644 tests/n8n-capabilities.test.mjs diff --git a/integrations/n8n/README.md b/integrations/n8n/README.md index f01f036..24e4535 100644 --- a/integrations/n8n/README.md +++ b/integrations/n8n/README.md @@ -1,5 +1,25 @@ # n8n +## All BeatAPI capabilities + +Import [`beatapi-capabilities.json`](./beatapi-capabilities.json) for a reusable +Search → Inspect → Run workflow. It discovers current text, image, video, +social-data, and web capabilities instead of hard-coding a model list. In the +**Configure Request** node, choose `search`, `inspect`, `start`, `status`, or +`result`. Search and Inspect are free; starting a run may use BeatAPI credits. + +Create a Header Auth credential in n8n with header name `Authorization` and +value `Bearer `, then assign it to the **BeatAPI Capability +API** HTTP Request node. The export does not contain an API key or a credential +ID. Inspect the selected reference before filling the `input` object for a +start. For image and video, poll the returned `task_id` with `status`; for a +stored synchronous result, use its `request_id` with `result`. + +The workflow uses built-in nodes, so it does not require an n8n community-node +installation. It is an importable workflow, not a native BeatAPI node. + +## Music video example + BeatAPI works with standard n8n HTTP Request nodes. A minimal workflow uses: ```text diff --git a/integrations/n8n/beatapi-capabilities.json b/integrations/n8n/beatapi-capabilities.json new file mode 100644 index 0000000..8d8ecbb --- /dev/null +++ b/integrations/n8n/beatapi-capabilities.json @@ -0,0 +1,137 @@ +{ + "name": "BeatAPI Capabilities - Search, Inspect, Run", + "nodes": [ + { + "parameters": { + "content": "# BeatAPI capabilities\n\nBind a Header Auth credential named BeatAPI API Key with header `Authorization` and value `Bearer ` to the HTTP node. Start with `operation: search` (free). Edit the Configure node to inspect a returned reference, then run with input matching that reference's `input_schema`. A start can spend credits. For image/video tasks, copy `task_id`, set `operation: status`, and run again after the advised delay; stop at succeeded/failed. For large social data, use `operation: result` and `request_id` within one hour. Never store keys in this export.", + "height": 330, + "width": 1000 + }, + "id": "beatapi-capabilities-note", + "name": "Read Me", + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 200, + 80 + ] + }, + { + "parameters": {}, + "id": "beatapi-capabilities-trigger", + "name": "Manual Trigger", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 240, + 480 + ] + }, + { + "parameters": { + "jsCode": "// Edit one operation at a time. Search and Inspect are free; start may spend credits.\nreturn [{ json: { operation: 'search', query: 'image model', kind: 'model', reference: '', input: {}, task_id: '', request_id: '', view: 'preview' } }];" + }, + "id": "beatapi-capabilities-configure", + "name": "Configure Request", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 460, + 480 + ] + }, + { + "parameters": { + "jsCode": "const cfg = $input.first().json;\nconst op = cfg.operation;\nlet path;\nlet body;\nif (op === 'search') {\n path = 'search';\n body = { query: cfg.query || '', ...(cfg.kind ? { kind: cfg.kind } : {}) };\n} else if (op === 'inspect') {\n if (!cfg.reference) throw new Error('Copy a reference from Search first.');\n path = 'inspect';\n body = { reference: cfg.reference };\n} else if (op === 'start') {\n if (!cfg.reference || !cfg.input || typeof cfg.input !== 'object' || Array.isArray(cfg.input)) throw new Error('Provide an inspected reference and input object.');\n path = 'run';\n body = { reference: cfg.reference, operation: 'start', input: cfg.input, view: cfg.view || 'preview' };\n} else if (op === 'status') {\n if (!cfg.reference || !cfg.task_id) throw new Error('Reference and task_id are required.');\n path = 'run';\n body = { reference: cfg.reference, operation: 'status', task_id: cfg.task_id, view: cfg.view || 'preview' };\n} else if (op === 'result') {\n if (!cfg.reference || !cfg.request_id) throw new Error('Reference and request_id are required.');\n path = 'run';\n body = { reference: cfg.reference, operation: 'result', request_id: cfg.request_id, view: cfg.view || 'preview' };\n} else {\n throw new Error('Operation must be search, inspect, start, status, or result.');\n}\nreturn [{ json: { url: `https://api.beatapi.io/v1/capabilities/${path}`, body } }];" + }, + "id": "beatapi-capabilities-build", + "name": "Build Capability Request", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 700, + 480 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $json.url }}", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "User-Agent", + "value": "BeatAPI-n8n/0.2.0" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json.body) }}", + "options": { + "response": { + "response": { + "responseFormat": "json" + } + }, + "timeout": 120000 + } + }, + "id": "beatapi-capabilities-http", + "name": "BeatAPI Capability API", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 960, + 480 + ] + } + ], + "connections": { + "Manual Trigger": { + "main": [ + [ + { + "node": "Configure Request", + "type": "main", + "index": 0 + } + ] + ] + }, + "Configure Request": { + "main": [ + [ + { + "node": "Build Capability Request", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Capability Request": { + "main": [ + [ + { + "node": "BeatAPI Capability API", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "tags": [] +} diff --git a/tests/n8n-capabilities.test.mjs b/tests/n8n-capabilities.test.mjs new file mode 100644 index 0000000..db85e71 --- /dev/null +++ b/tests/n8n-capabilities.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import vm from "node:vm"; +import test from "node:test"; + +const source = await readFile("integrations/n8n/beatapi-capabilities.json", "utf8"); +const workflow = JSON.parse(source); + +function buildRequest(input) { + const node = workflow.nodes.find((item) => item.name === "Build Capability Request"); + const run = new vm.Script(`(function () { ${node.parameters.jsCode} })()`); + return run.runInNewContext({ $input: { first: () => ({ json: input }) } })[0].json; +} + +test("n8n workflow has no embedded credential and builds capability calls", () => { + const names = new Set(workflow.nodes.map((node) => node.name)); + assert.equal(workflow.active, false); + assert.ok(names.has("BeatAPI Capability API")); + assert.doesNotMatch(source, /sk_[A-Za-z0-9_-]{8,}/); + assert.ok(workflow.nodes.every((node) => !node.credentials)); + const http = workflow.nodes.find((node) => node.name === "BeatAPI Capability API"); + assert.equal(http.parameters.authentication, "genericCredentialType"); + assert.equal(http.parameters.genericAuthType, "httpHeaderAuth"); + assert.ok(http.parameters.headerParameters.parameters.some((header) => header.name === "User-Agent")); + + const search = buildRequest({ operation: "search", query: "text model", kind: "model" }); + assert.equal(search.url, "https://api.beatapi.io/v1/capabilities/search"); + assert.equal(search.body.query, "text model"); + + const inspect = buildRequest({ operation: "inspect", reference: "model:gpt-5.6-luna" }); + assert.equal(inspect.url, "https://api.beatapi.io/v1/capabilities/inspect"); + assert.equal(inspect.body.reference, "model:gpt-5.6-luna"); + + const start = buildRequest({ operation: "start", reference: "model:gpt-5.6-luna", input: { input: "Hello" } }); + assert.equal(start.body.operation, "start"); + assert.equal(start.body.input.input, "Hello"); + + const status = buildRequest({ operation: "status", reference: "model:seedance-2-mini", task_id: "task_123" }); + assert.equal(status.body.operation, "status"); + assert.equal(status.body.task_id, "task_123"); + const result = buildRequest({ operation: "result", reference: "data:web.search", request_id: "req_123" }); + assert.equal(result.body.operation, "result"); + assert.equal(result.body.request_id, "req_123"); + assert.throws(() => buildRequest({ operation: "start", input: {} }), /inspected reference/); +}); From 123d6970b99317ee00f3fd6b0bc710c45d394c08 Mon Sep 17 00:00:00 2001 From: KKKK Date: Sat, 26 Sep 2026 00:15:26 +0800 Subject: [PATCH 2/2] Add n8n social research templates and model setup guide --- integrations/n8n/README.md | 21 ++ integrations/n8n/beatapi-capabilities.json | 7 +- .../beatapi-instagram-profile-compare.json | 247 ++++++++++++++++++ .../n8n/beatapi-x-keyword-research.json | 229 ++++++++++++++++ .../n8n/beatapi-youtube-comment-themes.json | 229 ++++++++++++++++ tests/n8n-social-templates.test.mjs | 75 ++++++ 6 files changed, 807 insertions(+), 1 deletion(-) create mode 100644 integrations/n8n/beatapi-instagram-profile-compare.json create mode 100644 integrations/n8n/beatapi-x-keyword-research.json create mode 100644 integrations/n8n/beatapi-youtube-comment-themes.json create mode 100644 tests/n8n-social-templates.test.mjs diff --git a/integrations/n8n/README.md b/integrations/n8n/README.md index 24e4535..32cc137 100644 --- a/integrations/n8n/README.md +++ b/integrations/n8n/README.md @@ -1,5 +1,26 @@ # n8n +## Choose an entry point + +| Need | n8n entry point | +| --- | --- | +| Text model in an AI Agent or LLM Chain | Use n8n's built-in **OpenAI Chat Model**. Create an OpenAI credential with your BeatAPI key and Base URL `https://api.beatapi.io/v1`; choose a model ID returned by `GET /v1/models`. No community node is required. | +| Image or video model | Use the capability workflow below with `search`, `inspect`, `start`, and `status`. Image and video calls may return an asynchronous task ID. | +| Public social research | Import one of the specific Social Data workflows below. They use n8n's built-in HTTP Request node and a Header Auth credential. | + +### Social Data research workflows + +- [Research public X keyword conversations](./beatapi-x-keyword-research.json): retrieve a preview of recent posts and draft an evidence-based theme summary. +- [Compare two public Instagram profiles](./beatapi-instagram-profile-compare.json): compare visible profile metadata without claiming performance from follower counts. +- [Find themes in public YouTube comments](./beatapi-youtube-comment-themes.json): summarize a comment preview and retain comment IDs for review. + +Each workflow has a **Configure Inputs** node and a setup sticky note. Create one +Header Auth credential with name `Authorization` and value `Bearer `; assign it to both HTTP Request nodes. The social request and text +summary use BeatAPI credits. The exports have no credential IDs or API keys. +The summaries are research drafts based on a small preview, with source IDs +available for manual verification. + ## All BeatAPI capabilities Import [`beatapi-capabilities.json`](./beatapi-capabilities.json) for a reusable diff --git a/integrations/n8n/beatapi-capabilities.json b/integrations/n8n/beatapi-capabilities.json index 8d8ecbb..31f17b1 100644 --- a/integrations/n8n/beatapi-capabilities.json +++ b/integrations/n8n/beatapi-capabilities.json @@ -1,5 +1,6 @@ { "name": "BeatAPI Capabilities - Search, Inspect, Run", + "id": "bf388867-bf39-4ce0-a6d5-063281420659", "nodes": [ { "parameters": { @@ -74,7 +75,7 @@ }, "sendBody": true, "specifyBody": "json", - "jsonBody": "={{ JSON.stringify($json.body) }}", + "jsonBody": "={{ $json.body }}", "options": { "response": { "response": { @@ -133,5 +134,9 @@ "settings": { "executionOrder": "v1" }, + "versionId": "cc86a620-39d0-425c-981b-3146e11a6828", + "meta": { + "templateCredsSetupCompleted": false + }, "tags": [] } diff --git a/integrations/n8n/beatapi-instagram-profile-compare.json b/integrations/n8n/beatapi-instagram-profile-compare.json new file mode 100644 index 0000000..f4f184c --- /dev/null +++ b/integrations/n8n/beatapi-instagram-profile-compare.json @@ -0,0 +1,247 @@ +{ + "id": "42628c4f-accc-4e0d-888d-15871e14933c", + "name": "Compare public Instagram profiles with BeatAPI Social Data and a text model", + "nodes": [ + { + "parameters": { + "content": "# Compare public Instagram profiles with BeatAPI Social Data and a text model\n\nFor brand and creator research: fetch two public Instagram profiles by username, compare their bios and visible follower/following counts, and generate a short research draft. Change usernames and model in Configure Inputs. The workflow does not log into Instagram or modify either account. If a profile is unavailable, the social request fails visibly instead of inventing data.\n\nSetup: create one n8n Header Auth credential with header name Authorization and value Bearer . Assign it to both HTTP Request nodes. Edit Configure Inputs, then run manually. The social data call and text model call use BeatAPI credits. The workflow only reads public social data and returns a draft research summary; check the cited source items before reuse. The export contains no credential or personal account ID. Social Data API: https://docs.beatapi.io/social-data-catalog", + "height": 360, + "width": 1120 + }, + "id": "03e58c5a-efa3-41c3-b4df-de8aae03b76f", + "name": "How to use this template", + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 200, + 50 + ] + }, + { + "parameters": {}, + "id": "84d6ac46-7856-4db6-a899-ef0e47550095", + "name": "Manual Trigger", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 240, + 480 + ] + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "1572e63b-8adf-4996-8f15-9c65ae89fe99", + "name": "usernames", + "value": "instagram,n8n_io", + "type": "string" + }, + { + "id": "b6790faf-03e3-4e4b-9ae9-5652e84fa241", + "name": "model", + "value": "gpt-5.6-luna", + "type": "string" + } + ] + }, + "options": {} + }, + "id": "3029a039-4717-433e-b1e2-34de14c94f39", + "name": "Configure Inputs", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 460, + 480 + ] + }, + { + "parameters": { + "jsCode": "const names = $('Configure Inputs').first().json.usernames.split(',').map((name) => name.trim().replace(/^@/, '')).filter(Boolean);\nif (names.length !== 2 || names[0] === names[1]) throw new Error('Provide two different public Instagram usernames, separated by a comma.');\nreturn names.map((username) => ({ json: { username } }));" + }, + "id": "6c0ab1bb-d943-4ae5-9cb5-2fea333e8d76", + "name": "Prepare Social Data Requests", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 680, + 480 + ] + }, + { + "parameters": { + "method": "POST", + "url": "https://api.beatapi.io/v1/capabilities/run", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "User-Agent", + "value": "BeatAPI-n8n/0.2.0" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ { reference: 'data:instagram.fetch_user_info_by_username_v3', input: { username: $json.username }, view: 'preview' } }}", + "options": { + "timeout": 120000 + } + }, + "id": "e9738093-1970-4d4f-8924-fad48419194b", + "name": "Fetch Public Social Data", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 900, + 480 + ] + }, + { + "parameters": { + "jsCode": "const results = $input.all().map((item) => item.json);\nif (results.length !== 2 || results.some((result) => result.status !== 'succeeded' || !result.data)) throw new Error('Both public Instagram profiles must return data.');\nconst profiles = results.map((result) => { const d = result.data; return { username: d.username, name: d.full_name, biography: d.biography, followers: d.follower_count, following: d.following_count, category: d.category, is_private: d.is_private }; });\nreturn [{ json: { source: 'Instagram public profile lookup', source_count: profiles.length, request_ids: results.map((result) => result.request_id), prompt: JSON.stringify({ profiles }) } }];" + }, + "id": "e32f9812-2d93-4714-bbcf-1fb6074f66b4", + "name": "Prepare Evidence", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1140, + 480 + ] + }, + { + "parameters": { + "method": "POST", + "url": "https://api.beatapi.io/v1/chat/completions", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "User-Agent", + "value": "BeatAPI-n8n/0.2.0" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ { model: $('Configure Inputs').first().json.model, messages: [{ role: 'system', content: \"Compare these two public Instagram profile snapshots. State the observed bio, category and follower/following numbers exactly, then identify plausible positioning differences as inferences. Do not claim engagement, growth, or performance from profile metadata alone.\" }, { role: 'user', content: $json.prompt }] } }}", + "options": { + "timeout": 120000 + } + }, + "id": "b54bb680-0192-4841-be96-ef83ae60a5ea", + "name": "Summarize with BeatAPI Text Model", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1380, + 480 + ] + }, + { + "parameters": { + "jsCode": "const answer = $input.first().json.choices?.[0]?.message?.content;\nif (!answer) throw new Error('The text model returned no message content.');\nconst evidence = $('Prepare Evidence').first().json;\nreturn [{ json: { summary: answer, source: evidence.source, source_count: evidence.source_count, request_ids: evidence.request_ids } }];" + }, + "id": "900c93f9-0702-4999-a3bc-4a80d983c354", + "name": "Return Research Draft", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1620, + 480 + ] + } + ], + "connections": { + "Manual Trigger": { + "main": [ + [ + { + "node": "Configure Inputs", + "type": "main", + "index": 0 + } + ] + ] + }, + "Configure Inputs": { + "main": [ + [ + { + "node": "Prepare Social Data Requests", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch Public Social Data": { + "main": [ + [ + { + "node": "Prepare Evidence", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prepare Evidence": { + "main": [ + [ + { + "node": "Summarize with BeatAPI Text Model", + "type": "main", + "index": 0 + } + ] + ] + }, + "Summarize with BeatAPI Text Model": { + "main": [ + [ + { + "node": "Return Research Draft", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prepare Social Data Requests": { + "main": [ + [ + { + "node": "Fetch Public Social Data", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "04078504-0ac3-4d6f-9ffc-95b2d16c74c0", + "meta": { + "templateCredsSetupCompleted": false + }, + "tags": [] +} diff --git a/integrations/n8n/beatapi-x-keyword-research.json b/integrations/n8n/beatapi-x-keyword-research.json new file mode 100644 index 0000000..3443b8a --- /dev/null +++ b/integrations/n8n/beatapi-x-keyword-research.json @@ -0,0 +1,229 @@ +{ + "id": "89810281-06ec-4e7f-83c2-e7db8174e229", + "name": "Research X keyword conversations with BeatAPI Social Data and a text model", + "nodes": [ + { + "parameters": { + "content": "# Research X keyword conversations with BeatAPI Social Data and a text model\n\nFor market researchers and product teams: search recent public X posts for a keyword, keep the first preview page with post text and engagement, and turn those posts into a concise evidence-based draft. It returns the original BeatAPI request ID so you can retrieve the full result within one hour if needed. Change keyword, search_type, and model in Configure Inputs. This template does not post to X or use your X account.\n\nSetup: create one n8n Header Auth credential with header name Authorization and value Bearer . Assign it to both HTTP Request nodes. Edit Configure Inputs, then run manually. The social data call and text model call use BeatAPI credits. The workflow only reads public social data and returns a draft research summary; check the cited source items before reuse. The export contains no credential or personal account ID. Social Data API: https://docs.beatapi.io/social-data-catalog", + "height": 360, + "width": 1120 + }, + "id": "9a01adc4-9d4e-490d-af53-ed97f3085f02", + "name": "How to use this template", + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 200, + 50 + ] + }, + { + "parameters": {}, + "id": "6bf931e7-7b6c-45c8-81a7-1c138208eba8", + "name": "Manual Trigger", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 240, + 480 + ] + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "099b873a-9203-499a-b518-590c1b2f8117", + "name": "keyword", + "value": "AI agents", + "type": "string" + }, + { + "id": "db95c586-7399-4a61-b2e0-99a13edde88d", + "name": "search_type", + "value": "Latest", + "type": "string" + }, + { + "id": "d4d73b25-0da4-41b9-bb9c-9a2648d89702", + "name": "model", + "value": "gpt-5.6-luna", + "type": "string" + } + ] + }, + "options": {} + }, + "id": "a8c8ec0c-9ab1-4e57-9069-1285da1893dd", + "name": "Configure Inputs", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 460, + 480 + ] + }, + { + "parameters": { + "method": "POST", + "url": "https://api.beatapi.io/v1/capabilities/run", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "User-Agent", + "value": "BeatAPI-n8n/0.2.0" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ { reference: 'data:twitter.web.fetch_search_timeline', input: { keyword: $json.keyword, search_type: $json.search_type }, view: 'preview' } }}", + "options": { + "timeout": 120000 + } + }, + "id": "e27b5803-cf83-47b2-8484-d6afcc064cf6", + "name": "Fetch Public Social Data", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 680, + 480 + ] + }, + { + "parameters": { + "jsCode": "const result = $input.first().json;\nif (result.status !== 'succeeded' || !Array.isArray(result.items)) throw new Error('X search did not return preview items.');\nconst posts = result.items.map((post) => ({ id: post.tweet_id, author: post.screen_name, text: post.text, likes: post.favorites, replies: post.replies, reposts: post.retweets, created_at: post.created_at }));\nreturn [{ json: { source: 'X public keyword search', source_count: posts.length, request_ids: [result.request_id], prompt: JSON.stringify({ keyword: $('Configure Inputs').first().json.keyword, posts }) } }];" + }, + "id": "ca1e4420-9501-4678-a14b-df6bf7f47f18", + "name": "Prepare Evidence", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 920, + 480 + ] + }, + { + "parameters": { + "method": "POST", + "url": "https://api.beatapi.io/v1/chat/completions", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "User-Agent", + "value": "BeatAPI-n8n/0.2.0" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ { model: $('Configure Inputs').first().json.model, messages: [{ role: 'system', content: \"Summarize the public X posts into themes, notable questions, and 3 concrete observations. Cite post IDs for every factual observation. Treat posts as unverified opinions. Do not invent metrics or claim the sample represents all X users.\" }, { role: 'user', content: $json.prompt }] } }}", + "options": { + "timeout": 120000 + } + }, + "id": "8322b166-d54e-47b1-8483-3641b80b158c", + "name": "Summarize with BeatAPI Text Model", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1160, + 480 + ] + }, + { + "parameters": { + "jsCode": "const answer = $input.first().json.choices?.[0]?.message?.content;\nif (!answer) throw new Error('The text model returned no message content.');\nconst evidence = $('Prepare Evidence').first().json;\nreturn [{ json: { summary: answer, source: evidence.source, source_count: evidence.source_count, request_ids: evidence.request_ids } }];" + }, + "id": "e9f4a2dc-a65a-4c13-aeeb-b2d12e616d93", + "name": "Return Research Draft", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1400, + 480 + ] + } + ], + "connections": { + "Manual Trigger": { + "main": [ + [ + { + "node": "Configure Inputs", + "type": "main", + "index": 0 + } + ] + ] + }, + "Configure Inputs": { + "main": [ + [ + { + "node": "Fetch Public Social Data", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch Public Social Data": { + "main": [ + [ + { + "node": "Prepare Evidence", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prepare Evidence": { + "main": [ + [ + { + "node": "Summarize with BeatAPI Text Model", + "type": "main", + "index": 0 + } + ] + ] + }, + "Summarize with BeatAPI Text Model": { + "main": [ + [ + { + "node": "Return Research Draft", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "6fbd0eb3-c6b9-4147-bdbd-afe607125cf2", + "meta": { + "templateCredsSetupCompleted": false + }, + "tags": [] +} diff --git a/integrations/n8n/beatapi-youtube-comment-themes.json b/integrations/n8n/beatapi-youtube-comment-themes.json new file mode 100644 index 0000000..5231c1d --- /dev/null +++ b/integrations/n8n/beatapi-youtube-comment-themes.json @@ -0,0 +1,229 @@ +{ + "id": "a2919b4e-458b-4680-a75f-8d24d5a327ed", + "name": "Find themes in public YouTube comments with BeatAPI Social Data and a text model", + "nodes": [ + { + "parameters": { + "content": "# Find themes in public YouTube comments with BeatAPI Social Data and a text model\n\nFor creators and product teams: fetch a preview of public comments for one YouTube video, extract comment text and likes, and draft themes with direct comment IDs. Replace the sample video ID in Configure Inputs with your own public video. The preview includes at most ten comments; use the returned request ID to fetch more within one hour if needed. This workflow reads comments only and does not reply or publish.\n\nSetup: create one n8n Header Auth credential with header name Authorization and value Bearer . Assign it to both HTTP Request nodes. Edit Configure Inputs, then run manually. The social data call and text model call use BeatAPI credits. The workflow only reads public social data and returns a draft research summary; check the cited source items before reuse. The export contains no credential or personal account ID. Social Data API: https://docs.beatapi.io/social-data-catalog", + "height": 360, + "width": 1120 + }, + "id": "27bbd201-e561-43b5-a333-55451cfdf899", + "name": "How to use this template", + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 200, + 50 + ] + }, + { + "parameters": {}, + "id": "4cbeb31c-c7ed-4fca-bb98-7ca844411096", + "name": "Manual Trigger", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 240, + 480 + ] + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "387108bc-df28-4214-b1fa-dfd509c8cb72", + "name": "video_id", + "value": "dQw4w9WgXcQ", + "type": "string" + }, + { + "id": "5bf458e7-5d17-40c7-a451-ba90b7854171", + "name": "sort_by", + "value": "top", + "type": "string" + }, + { + "id": "e998a57f-273d-4191-baf8-5a758fb923a0", + "name": "model", + "value": "gpt-5.6-luna", + "type": "string" + } + ] + }, + "options": {} + }, + "id": "ef439f28-2c80-47e3-80cb-89fb87be2e55", + "name": "Configure Inputs", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 460, + 480 + ] + }, + { + "parameters": { + "method": "POST", + "url": "https://api.beatapi.io/v1/capabilities/run", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "User-Agent", + "value": "BeatAPI-n8n/0.2.0" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ { reference: 'data:youtube.web_v2.get_video_comments', input: { video_id: $json.video_id, sort_by: $json.sort_by, language_code: 'en-US', need_format: true }, view: 'preview' } }}", + "options": { + "timeout": 120000 + } + }, + "id": "13dba108-e34b-494b-9c87-b33934bec92f", + "name": "Fetch Public Social Data", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 680, + 480 + ] + }, + { + "parameters": { + "jsCode": "const result = $input.first().json;\nif (result.status !== 'succeeded' || !Array.isArray(result.items)) throw new Error('YouTube comments did not return preview items.');\nconst comments = result.items.map((comment) => ({ id: comment.comment_id, author: comment.author?.display_name, content: comment.content, likes: comment.like_count, published: comment.published_time }));\nreturn [{ json: { source: 'YouTube public comments', source_count: comments.length, request_ids: [result.request_id], prompt: JSON.stringify({ video_id: $('Configure Inputs').first().json.video_id, comments }) } }];" + }, + "id": "6c017bfb-b171-451b-a6c3-c33fdee7a83a", + "name": "Prepare Evidence", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 920, + 480 + ] + }, + { + "parameters": { + "method": "POST", + "url": "https://api.beatapi.io/v1/chat/completions", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "User-Agent", + "value": "BeatAPI-n8n/0.2.0" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ { model: $('Configure Inputs').first().json.model, messages: [{ role: 'system', content: \"Identify recurring themes and questions in these public YouTube comments. Cite comment IDs for each observation. This is a small preview sample, so do not infer audience-wide sentiment or make unsupported numerical claims.\" }, { role: 'user', content: $json.prompt }] } }}", + "options": { + "timeout": 120000 + } + }, + "id": "ac8ae366-5d36-404a-9f04-ba83c44b7dd7", + "name": "Summarize with BeatAPI Text Model", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1160, + 480 + ] + }, + { + "parameters": { + "jsCode": "const answer = $input.first().json.choices?.[0]?.message?.content;\nif (!answer) throw new Error('The text model returned no message content.');\nconst evidence = $('Prepare Evidence').first().json;\nreturn [{ json: { summary: answer, source: evidence.source, source_count: evidence.source_count, request_ids: evidence.request_ids } }];" + }, + "id": "d2f218da-8896-479c-bbaa-96173db24c0e", + "name": "Return Research Draft", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1400, + 480 + ] + } + ], + "connections": { + "Manual Trigger": { + "main": [ + [ + { + "node": "Configure Inputs", + "type": "main", + "index": 0 + } + ] + ] + }, + "Configure Inputs": { + "main": [ + [ + { + "node": "Fetch Public Social Data", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch Public Social Data": { + "main": [ + [ + { + "node": "Prepare Evidence", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prepare Evidence": { + "main": [ + [ + { + "node": "Summarize with BeatAPI Text Model", + "type": "main", + "index": 0 + } + ] + ] + }, + "Summarize with BeatAPI Text Model": { + "main": [ + [ + { + "node": "Return Research Draft", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "c331b81b-3e97-4de9-b973-69e305a6df39", + "meta": { + "templateCredsSetupCompleted": false + }, + "tags": [] +} diff --git a/tests/n8n-social-templates.test.mjs b/tests/n8n-social-templates.test.mjs new file mode 100644 index 0000000..82d1561 --- /dev/null +++ b/tests/n8n-social-templates.test.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import vm from "node:vm"; +import test from "node:test"; + +const templates = [ + ["beatapi-x-keyword-research.json", "data:twitter.web.fetch_search_timeline"], + ["beatapi-instagram-profile-compare.json", "data:instagram.fetch_user_info_by_username_v3"], + ["beatapi-youtube-comment-themes.json", "data:youtube.web_v2.get_video_comments"], +]; + +async function load(name) { + return JSON.parse(await readFile(`integrations/n8n/${name}`, "utf8")); +} + +function runCode(workflow, name, inputs, config = {}) { + const node = workflow.nodes.find((item) => item.name === name); + const script = new vm.Script(`(function () { ${node.parameters.jsCode} })()`); + const context = { + $input: { first: () => ({ json: inputs[0] }), all: () => inputs.map((json) => ({ json })) }, + $: () => ({ first: () => ({ json: config }) }), + }; + return script.runInNewContext(context); +} + +test("social templates contain no secrets and keep text and data calls separate", async () => { + for (const [file, reference] of templates) { + const source = await readFile(`integrations/n8n/${file}`, "utf8"); + const workflow = JSON.parse(source); + assert.ok(workflow.id && workflow.versionId, file); + assert.equal(workflow.active, false, file); + assert.ok(workflow.nodes.every((node) => !node.credentials), file); + assert.doesNotMatch(source, /sk_[A-Za-z0-9_-]{8,}/, file); + assert.ok(workflow.nodes.some((node) => node.type === "n8n-nodes-base.stickyNote"), file); + const social = workflow.nodes.find((node) => node.name === "Fetch Public Social Data"); + const text = workflow.nodes.find((node) => node.name === "Summarize with BeatAPI Text Model"); + assert.equal(social.parameters.url, "https://api.beatapi.io/v1/capabilities/run", file); + assert.ok(social.parameters.jsonBody.includes(reference), file); + assert.equal(text.parameters.url, "https://api.beatapi.io/v1/chat/completions", file); + assert.equal(social.parameters.genericAuthType, "httpHeaderAuth", file); + assert.equal(text.parameters.genericAuthType, "httpHeaderAuth", file); + } +}); + +test("X and YouTube templates retain source IDs for review", async () => { + const x = await load(templates[0][0]); + const xResult = runCode(x, "Prepare Evidence", [ + { status: "succeeded", request_id: "req-x", items: [{ tweet_id: "post-1", screen_name: "example", text: "A public post", favorites: 3 }] }, + ], { keyword: "AI agents" })[0].json; + assert.equal(xResult.source_count, 1); + assert.ok(xResult.prompt.includes("post-1")); + assert.equal(xResult.request_ids[0], "req-x"); + + const youtube = await load(templates[2][0]); + const ytResult = runCode(youtube, "Prepare Evidence", [ + { status: "succeeded", request_id: "req-y", items: [{ comment_id: "comment-1", content: "A public comment", author: { display_name: "example" } }] }, + ], { video_id: "dQw4w9WgXcQ" })[0].json; + assert.equal(ytResult.source_count, 1); + assert.ok(ytResult.prompt.includes("comment-1")); + assert.equal(ytResult.request_ids[0], "req-y"); +}); + +test("Instagram template rejects a partial comparison", async () => { + const instagram = await load(templates[1][0]); + assert.throws( + () => runCode(instagram, "Prepare Evidence", [{ status: "succeeded", data: { username: "one" } }]), + /Both public Instagram profiles/, + ); + const result = runCode(instagram, "Prepare Evidence", [ + { status: "succeeded", request_id: "req-1", data: { username: "one", follower_count: 10 } }, + { status: "succeeded", request_id: "req-2", data: { username: "two", follower_count: 20 } }, + ])[0].json; + assert.equal(result.source_count, 2); + assert.deepEqual(Array.from(result.request_ids), ["req-1", "req-2"]); +});