From 385f9a7919b4ba4592d467c0f7f27a9caa9aa32a Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Tue, 16 Jun 2026 17:47:21 +0200 Subject: [PATCH] Add optional Xquik social pulse helper --- .env.example | 4 + scripts/xquik-social-pulse.mjs | 224 ++++++++++++++++++++++++++++++ skills/ad-copy-generator/SKILL.md | 10 ++ 3 files changed, 238 insertions(+) create mode 100755 scripts/xquik-social-pulse.mjs diff --git a/.env.example b/.env.example index 2890df2..a04460e 100644 --- a/.env.example +++ b/.env.example @@ -26,5 +26,9 @@ META_KIT_REQUIRE_APPROVAL=true META_KIT_FORCE_PAUSED=true META_KIT_DRY_RUN_DIR=local/dry-runs +# Optional public X conversation source for copy research +XQUIK_API_KEY= +XQUIK_BASE_URL=https://xquik.com/api/v1 + # Optional local env file override for account/client separation # META_KIT_ENV_FILE=.env.client.local diff --git a/scripts/xquik-social-pulse.mjs b/scripts/xquik-social-pulse.mjs new file mode 100755 index 0000000..db6d2a8 --- /dev/null +++ b/scripts/xquik-social-pulse.mjs @@ -0,0 +1,224 @@ +#!/usr/bin/env node + +const DEFAULT_BASE_URL = 'https://xquik.com/api/v1'; +const DEFAULT_LIMIT = 20; + +function printUsage() { + process.stderr.write( + [ + 'Usage: node scripts/xquik-social-pulse.mjs [--limit 20] "query one" "query two"', + '', + 'Environment:', + ' XQUIK_API_KEY Required Xquik API key.', + ' XQUIK_BASE_URL Optional API base URL. Defaults to https://xquik.com/api/v1.', + '', + ].join('\n'), + ); +} + +function fail(message) { + process.stderr.write(`${message}\n`); + process.exit(1); +} + +function readArgs(argv) { + const queries = []; + let limit = DEFAULT_LIMIT; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === '-h' || arg === '--help') { + printUsage(); + process.exit(0); + } + + if (arg === '--limit') { + const rawLimit = argv[index + 1]; + if (!rawLimit) { + fail('Missing value for --limit.'); + } + + const parsedLimit = Number.parseInt(rawLimit, 10); + if (!Number.isInteger(parsedLimit) || parsedLimit < 1 || parsedLimit > 100) { + fail('Limit must be an integer from 1 to 100.'); + } + + limit = parsedLimit; + index += 1; + continue; + } + + if (arg.startsWith('--')) { + fail(`Unknown option: ${arg}`); + } + + queries.push(arg); + } + + return { limit, queries }; +} + +function normalizeBaseUrl(rawBaseUrl) { + return rawBaseUrl.trim().replace(/\/+$/, ''); +} + +function getString(record, keys) { + for (const key of keys) { + const value = record[key]; + if (typeof value === 'string' && value.trim().length > 0) { + return value; + } + } + + return undefined; +} + +function getNumber(record, keys) { + for (const key of keys) { + const value = record[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + } + + return undefined; +} + +function normalizeTweet(value, query) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + + const record = value; + const text = getString(record, ['text', 'full_text', 'content']); + if (!text) { + return undefined; + } + + const normalized = { + query, + text, + }; + + const id = getString(record, ['id', 'tweet_id', 'rest_id']); + if (id) { + normalized.id = id; + } + + const author = getString(record, ['author_username', 'username', 'screen_name']); + if (author) { + normalized.author = author; + } + + const createdAt = getString(record, ['created_at', 'createdAt']); + if (createdAt) { + normalized.created_at = createdAt; + } + + const url = getString(record, ['url', 'tweet_url']); + if (url) { + normalized.url = url; + } + + const likes = getNumber(record, ['likes', 'like_count', 'favorite_count']); + if (likes !== undefined) { + normalized.likes = likes; + } + + const reposts = getNumber(record, ['retweets', 'reposts', 'retweet_count']); + if (reposts !== undefined) { + normalized.reposts = reposts; + } + + const replies = getNumber(record, ['replies', 'reply_count']); + if (replies !== undefined) { + normalized.replies = replies; + } + + return normalized; +} + +function getTweets(payload) { + if (Array.isArray(payload?.tweets)) { + return payload.tweets; + } + + if (Array.isArray(payload?.data?.tweets)) { + return payload.data.tweets; + } + + if (Array.isArray(payload?.items)) { + return payload.items; + } + + return []; +} + +async function fetchTweets({ apiKey, baseUrl, limit, query }) { + const url = new URL(`${baseUrl}/x/tweets/search`); + url.searchParams.set('q', query); + url.searchParams.set('limit', String(limit)); + + const response = await fetch(url, { + headers: { + Accept: 'application/json', + 'X-API-Key': apiKey, + }, + }); + + const payload = await response.json().catch(() => ({})); + + if (!response.ok) { + const message = + typeof payload?.error?.message === 'string' + ? payload.error.message + : `Xquik request failed with HTTP ${response.status}.`; + throw new Error(message); + } + + return getTweets(payload) + .map((tweet) => normalizeTweet(tweet, query)) + .filter(Boolean); +} + +async function main() { + const { limit, queries } = readArgs(process.argv.slice(2)); + const apiKey = process.env.XQUIK_API_KEY?.trim(); + const baseUrl = normalizeBaseUrl(process.env.XQUIK_BASE_URL || DEFAULT_BASE_URL); + + if (!apiKey) { + printUsage(); + fail('Missing XQUIK_API_KEY.'); + } + + if (queries.length === 0) { + printUsage(); + fail('Provide at least 1 query.'); + } + + const results = []; + for (const query of queries) { + const tweets = await fetchTweets({ apiKey, baseUrl, limit, query }); + results.push(...tweets); + } + + process.stdout.write( + `${JSON.stringify( + { + source: 'xquik', + generated_at: new Date().toISOString(), + queries, + tweet_count: results.length, + tweets: results, + }, + null, + 2, + )}\n`, + ); +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : 'Unknown error.'; + fail(message); +}); diff --git a/skills/ad-copy-generator/SKILL.md b/skills/ad-copy-generator/SKILL.md index 03a3d3a..a90dfd3 100644 --- a/skills/ad-copy-generator/SKILL.md +++ b/skills/ad-copy-generator/SKILL.md @@ -78,6 +78,16 @@ If official CLI does not expose a needed creative field yet, use direct Graph AP Show the user what you found: "Your top 3 ads all open with a specific number and close with social proof. Average headline: 32 chars. I'll match that pattern." +### Optional: Public X Conversation Pulse + +If `XQUIK_API_KEY` is set, collect public X wording before writing new hooks: + +```bash +node scripts/xquik-social-pulse.mjs "brand problem" "competitor name" > local/xquik-social-pulse.json +``` + +Use it to extract recurring pains, phrases, objections, and hooks. Keep these findings separate from account performance data. + ### Step 2: Load Brand Context Read the client's CLAUDE.md, brand profile, or ask inline: