Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
224 changes: 224 additions & 0 deletions scripts/xquik-social-pulse.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
10 changes: 10 additions & 0 deletions skills/ad-copy-generator/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down