Skill: zero2ai-hub/skill-amazon-ads-reporter (version 1.1.0)

# skill-amazon-ads-reporter ## Description Fetch Amazon Ads Sponsored Products campaign performance reports using a decoupled async pattern. Avoids timeout issues with the v3 Reporting API (2–10 min generation time) by splitting request and poll into separate steps. Also includes keyword-level winner/dead analysis and a quick bid inspector. ## Why two steps? Amazon's Reporting API v3 is async — you request a report, get a `reportId`, and poll until it's ready. Doing this inline in a cron causes timeouts. The correct pattern: ``` request → save reportId → (wait 1-2 min) → poll + download ``` ## Usage ### Campaign-level report (step-by-step, recommended for crons) ```bash # Step 1: Request report — exits immediately with reportId node scripts/request-report.js --days 7 # Step 2: Poll + download (run 1-2 min later, or from a separate cron) node scripts/poll-report.js ``` ### Campaign-level report (all-in-one, for manual runs) ```bash node scripts/get-report.js --days 7 ``` ### Keyword-level winner/dead analysis (14-day async report) ```bash node scripts/keyword-report.js ``` Output: table of all ENABLED keywords with clicks > 0 OR impressions ≥ 50 (winners), plus count of dead keywords (0 clicks, <50 imp). ### Quick bid inspector (live, across campaigns) ```bash node scripts/get-bids.js ``` Output: all ENABLED + PAUSED keywords per campaign with current bids. Reads live data (no report needed). ## Arguments | Arg | Default | Description | |-----|---------|-------------| | `--days N` | `7` | Number of days to include in report (campaign and keyword reports) | ## Configuration Reads credentials from `AMAZON_ADS_PATH` env var, defaulting to `~/amazon-ads-api.json`. ### `amazon-ads-api.json` format ```json { "refreshToken": "...", "lwaClientId": "...", "lwaClientSecret": "...", "profileId": "...", "region": "EU" } ``` Regions: `EU` (default, includes UAE), `NA` (North America), `FE` (Far East). ## Output - `~/.openclaw/workspace/tmp/amazon-report-pending.json` — created by request-report.js - `~/.openclaw/workspace/tmp/amazon-report-latest.json` — created by poll-report.js after success - Console table: Campaign | Impressions | Clicks | CTR% | Spend | Sales | ACOS% ## Report columns (campaign-level) `campaignName`, `campaignId`, `impressions`, `clicks`, `spend`, `purchases7d`, `sales7d` Paused campaigns are automatically filtered out by cross-referencing `GET /sp/campaigns/list`. ## Report columns (keyword-level — keyword-report.js) `keywordId`, `keywordText`, `matchType`, `impressions`, `clicks`, `cost`, `purchases7d`, `sales7d` ## Dependencies Node.js built-ins only (`https`, `zlib`, `fs`, `path`). No npm install required. ## Notes - Access tokens expire — refresh via Amazon Login with Advertising if needed - The `GZIP_JSON` format is gunzipped automatically by poll-report.js - Reports are only available for the previous day and earlier (endDate = yesterday) - `get-bids.js` uses the live v3 keyword list endpoint — no async report needed, instant response - keyword-report.js uses the same async pattern as campaign reports (30s poll intervals, up to 10 min)

path: scripts/get-bids.js
content: #!/usr/bin/env node
'use strict';
/**
 * Amazon Ads — Quick Bid Inspector
 * Lists all ENABLED + PAUSED keywords with current bids for specified campaigns.
 * Uses the live v3 keyword list endpoint (no async report needed).
 *
 * Usage: node get-bids.js
 * Config: AMAZON_ADS_PATH env var (default: ~/amazon-ads-api.json)
 */
const https = require('https');
const fs = require('fs');

const ADS_PATH = process.env.AMAZON_ADS_PATH || require('os').homedir() + '/amazon-ads-api.json';
const creds = JSON.parse(fs.readFileSync(ADS_PATH, 'utf8'));

// Set your campaign IDs here
const CAMPAIGN_IDS = process.env.CAMPAIGN_IDS
  ? process.env.CAMPAIGN_IDS.split(',').map(Number)
  : [];

if (CAMPAIGN_IDS.length === 0) {
  console.error('Set CAMPAIGN_IDS env var (comma-separated) or edit the array in this script.');
  process.exit(1);
}

function apiReq(hostname, path, method, headers, body) {
  const payload = body ? JSON.stringify(body) : null;
  if (payload) headers['Content-Length'] = Buffer.byteLength(payload);
  return new Promise((resolve, reject) => {
    const req = https.request({ hostname, path, method, headers }, res => {
      let d = ''; res.on('data', c => d += c);
      res.on('end', () => {
        try { resolve({ status: res.statusCode, body: JSON.parse(d) }); }
        catch { resolve({ status: res.statusCode, body: d }); }
      });
    });
    req.on('error', reject);
    if (payload) req.write(payload);
    req.end();
  });
}

async function getToken() {
  const params = `grant_type=refresh_token&refresh_token=${encodeURIComponent(creds.refreshToken)}&client_id=${encodeURIComponent(creds.lwaClientId)}&client_secret=${encodeURIComponent(creds.lwaClientSecret)}`;
  return new Promise((resolve, reject) => {
    const req = https.request({
      hostname: 'api.amazon.com', path: '/auth/o2/token', method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(params) }
    }, res => {
      let d = ''; res.on('data', c => d += c);
      res.on('end', () => {
        const t = JSON.parse(d);
        if (!t.access_token) reject(new Error('LWA failed: ' + d));
        else resolve(t.access_token);
      });
    });
    req.on('error', reject); req.write(params); req.end();
  });
}

(async () => {
  const token = await getToken();
  const endpoint = 'advertising-api-eu.amazon.com';
  const headers = {
    Authorization: `Bearer ${token}`,
    'Amazon-Advertising-API-ClientId': creds.lwaClientId,
    'Amazon-Advertising-API-Scope': String(creds.profileId),
    'Content-Type': 'application/json',
  };

  for (const campaignId of CAMPAIGN_IDS) {
    const r = await apiReq(endpoint, '/sp/keywords/list', 'POST', { ...headers }, {
      campaignIdFilter: { include: [String(campaignId)] },
      stateFilter: { include: ['ENABLED', 'PAUSED'] },
      maxResults: 50
    });
    console.log(`\nCampaign ${campaignId} keywords (HTTP ${r.status}):`);
    if (r.status === 200 && r.body.keywords) {
      for (const kw of r.body.keywords) {
        console.log(`  keywordId=${kw.keywordId} text="${kw.keywordText}" matchType=${kw.matchType} bid=${kw.bid} state=${kw.state}`);
      }
    } else {
      console.log('  Response:', JSON.stringify(r.body).substring(0, 400));
    }
  }
})().catch(e => { console.error(e.message); process.exit(1); });


path: scripts/keyword-report.js
content: /**
 * Fetch keyword-level performance report across all active campaigns
 * Filters: only ENABLED keywords with meaningful data
 * "Winning" = clicks > 0 OR impressions > 50
 */
const zlib = require('zlib');
const fs = require('fs');

const CREDS_PATH = process.env.AMAZON_ADS_PATH || `${process.env.HOME}/amazon-ads-api.json`;
const API_BASE = 'https://advertising-api-eu.amazon.com';

function getCreds() { return JSON.parse(fs.readFileSync(CREDS_PATH, 'utf8')); }

async function getAccessToken() {
  const creds = getCreds();
  const res = await fetch('https://api.amazon.com/auth/o2/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: creds.refreshToken,
      client_id: creds.lwaClientId,
      client_secret: creds.lwaClientSecret,
    }),
  });
  const t = await res.json();
  if (!t.access_token) throw new Error('Ads auth failed: ' + JSON.stringify(t));
  return t.access_token;
}

async function apiCall(path, method = 'GET', body = null, contentType = 'application/json') {
  const creds = getCreds();
  const token = await getAccessToken();
  const opts = {
    method,
    headers: {
      'Authorization': `Bearer ${token}`,
      'Amazon-Advertising-API-ClientId': creds.lwaClientId,
      'Amazon-Advertising-API-Scope': creds.profileId,
      'Content-Type': contentType,
      'Accept': contentType,
    }
  };
  if (body) opts.body = JSON.stringify(body);
  const res = await fetch(API_BASE + path, opts);
  const text = await res.text();
  try { return JSON.parse(text); } catch(e) { return text; }
}

async function downloadGzip(url) {
  const res = await fetch(url);
  const buf = Buffer.from(await res.arrayBuffer());
  return new Promise((resolve, reject) => {
    zlib.gunzip(buf, (err, decoded) => {
      if (err) reject(err);
      else resolve(JSON.parse(decoded.toString()));
    });
  });
}

async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

async function main() {
  // Date range: last 14 days for more signal
  const end = new Date(); end.setDate(end.getDate() - 1);
  const start = new Date(); start.setDate(start.getDate() - 14);
  const fmt = d => d.toISOString().split('T')[0];

  console.error(`Requesting keyword report ${fmt(start)}–${fmt(end)}...`);

  const created = await apiCall('/reporting/reports', 'POST', {
    name: `kw-perf-${fmt(start)}`,
    startDate: fmt(start),
    endDate: fmt(end),
    configuration: {
      adProduct: 'SPONSORED_PRODUCTS',
      groupBy: ['adGroup'],
      columns: ['keywordId', 'keywordText', 'matchType', 'impressions', 'clicks', 'cost', 'purchases7d', 'sales7d', 'startDate', 'endDate'],
      reportTypeId: 'spKeywords',
      timeUnit: 'SUMMARY',
      format: 'GZIP_JSON'
    }
  }, 'application/vnd.createasyncreportrequest.v3+json');

  if (!created.reportId) {
    console.error('Failed:', JSON.stringify(created));
    process.exit(1);
  }

  const reportId = created.reportId;
  console.error(`Report ID: ${reportId} — polling...`);

  // Poll up to 20 times (10 min)
  let status;
  for (let i = 0; i < 20; i++) {
    await sleep(30000);
    status = await apiCall(`/reporting/reports/${reportId}`, 'GET', null, 'application/json');
    console.error(`  [${i+1}/20] Status: ${status.status}`);
    if (status.status === 'COMPLETED') break;
    if (status.status === 'FAILED') { console.error('Report failed:', JSON.stringify(status)); process.exit(1); }
  }

  if (status.status !== 'COMPLETED') {
    console.error('Timed out waiting for report');
    process.exit(1);
  }

  const rows = await downloadGzip(status.url);
  console.error(`Downloaded ${rows.length} keyword rows`);

  // Filter: only ENABLED keywords
  const enabled = rows.filter(r => r.state === 'ENABLED');

  // Winning = clicks > 0 OR impressions >= 50
  const winners = enabled.filter(r => (r.clicks || 0) > 0 || (r.impressions || 0) >= 50);

  // Sort by clicks desc, then impressions desc
  winners.sort((a, b) => {
    const clickDiff = (b.clicks || 0) - (a.clicks || 0);
    if (clickDiff !== 0) return clickDiff;
    return (b.impressions || 0) - (a.impressions || 0);
  });

  console.log('\n🏆 WINNING KEYWORDS (14-day, ENABLED only, clicks>0 OR imp≥50)\n');
  console.log(`${'Campaign'.padEnd(25)} ${'Ad Group'.padEnd(18)} ${'Keyword'.padEnd(35)} ${'Match'.padEnd(7)} ${'Bid'.padStart(6)} ${'Imp'.padStart(6)} ${'Clk'.padStart(4)} ${'CTR'.padStart(6)} ${'Spend'.padStart(8)} ${'Sales'.padStart(8)} ${'ACoS'.padStart(6)}`);
  console.log('-'.repeat(140));

  for (const r of winners) {
    const imp = r.impressions || 0;
    const clk = r.clicks || 0;
    const spend = r.spend || 0;
    const sales = r.sales7d || 0;
    const ctr = imp > 0 ? ((clk / imp) * 100).toFixed(2) + '%' : '0.00%';
    const acos = sales > 0 ? ((spend / sales) * 100).toFixed(0) + '%' : '—';
    const camp = (r.campaignName || '').slice(0, 24).padEnd(25);
    const ag = (r.adGroupName || '').slice(0, 17).padEnd(18);
    const kw = (r.keywordText || '').slice(0, 34).padEnd(35);
    const match = (r.matchType || '').slice(0, 6).padEnd(7);
    const bid = (r.keywordBid || 0).toFixed(2).padStart(6);
    console.log(`${camp} ${ag} ${kw} ${match} ${bid} ${String(imp).padStart(6)} ${String(clk).padStart(4)} ${ctr.padStart(6)} ${spend.toFixed(2).padStart(8)} ${sales.toFixed(2).padStart(8)} ${acos.padStart(6)}`);
  }

  console.log(`\nTotal winning keywords: ${winners.length} / ${enabled.length} enabled`);

  // Also show zero-performers for awareness
  const zeros = enabled.filter(r => (r.clicks || 0) === 0 && (r.impressions || 0) < 50);
  console.log(`Dead keywords (0 clicks, <50 imp): ${zeros.length}`);
}

main().catch(e => { console.error(e); process.exit(1); });


Security scan verdicts — ClawScan: clean | static analysis: suspicious | VirusTotal: clean | SkillSpector: clean (LOW)