@clawhub-zero2ai-hub-7e93ac7eec
Generate short product videos from images using Runway Gen4 Turbo. Use for TikTok ads, UGC-style product demos, Reels, and YouTube Shorts.
---
name: skill-runway-video-gen
version: 1.0.0
description: Generate short product videos from images using Runway Gen4 Turbo. Use for TikTok ads, UGC-style product demos, Reels, and YouTube Shorts.
metadata:
openclaw:
requires: { bins: ["uv"] }
---
# skill-runway-video-gen
Wraps Runway Gen4 Turbo image-to-video API. Point at a product image, describe the motion, get an MP4. No browser needed.
## Usage
```bash
uv run scripts/generate_video.py \
--image product.jpg \
--prompt "water droplets falling, soft bokeh, slow motion" \
--output output.mp4 \
--duration 10 \
--ratio 720:1280
```
## Args
| Arg | Default | Description |
|---|---|---|
| `--image` | required | Path to source product image |
| `--prompt` | `""` | Motion description (optional but recommended) |
| `--output` | required | Output MP4 path |
| `--duration` | `10` | Video length: 5 or 10 seconds |
| `--ratio` | `720:1280` | Aspect ratio (vertical for TikTok) |
## Config
API key is read from (in order):
1. `RUNWAY_API_KEY` environment variable
2. `~/tiktok-api.json` → `runway.apiKey`
## Cost
| Duration | Cost |
|---|---|
| 5s | $0.25 |
| 10s | $0.50 |
Always use 10s — you get more content to work with.
## Known quirks
**Last 1–2s often freezes.** Fix: stretch to 12s at 0.83x speed using MoviePy:
```python
from moviepy import VideoFileClip
clip = VideoFileClip("output.mp4")
slow = clip.with_effects([vfx.MultiplySpeed(0.83)])
slow.write_videofile("output_12s.mp4", fps=30)
```
## Rate limits
No per-minute quota (unlike Veo). Runway charges per second of output.
## Pipeline integration
This skill feeds into `skill-tiktok-video-pipeline` as the video generation step. The pipeline handles slowmo stretch and caption overlay automatically.
FILE:_meta.json
{
"name": "skill-runway-video-gen",
"version": "1.0.0",
"description": "Generate short product videos from images using Runway Gen4 Turbo. Use for TikTok ads, UGC-style product demos.",
"author": "Zero2Ai-hub",
"license": "MIT"
}
FILE:scripts/generate_video.py
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["requests"]
# ///
"""Runway Gen4 Turbo — image-to-video generator."""
import argparse
import json
import os
import sys
import time
import requests
def get_api_key():
key = os.environ.get("RUNWAY_API_KEY")
if key:
return key
cfg_path = os.path.expanduser("~/tiktok-api.json")
if os.path.exists(cfg_path):
with open(cfg_path) as f:
data = json.load(f)
key = data.get("runway", {}).get("apiKey")
if key:
return key
print("ERROR: No Runway API key found. Set RUNWAY_API_KEY env var or add runway.apiKey to ~/tiktok-api.json", file=sys.stderr)
sys.exit(1)
def encode_image(image_path):
import base64
ext = os.path.splitext(image_path)[1].lower().lstrip(".")
mime_map = {"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png", "webp": "image/webp"}
mime = mime_map.get(ext, "image/jpeg")
with open(image_path, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
return f"data:{mime};base64,{b64}"
def main():
parser = argparse.ArgumentParser(description="Runway Gen4 Turbo image-to-video")
parser.add_argument("--image", required=True, help="Path to source image")
parser.add_argument("--prompt", default="", help="Motion description prompt")
parser.add_argument("--output", required=True, help="Output MP4 path")
parser.add_argument("--duration", type=int, choices=[5, 10], default=10, help="Video duration (5 or 10 seconds)")
parser.add_argument("--ratio", default="720:1280", help="Aspect ratio (default: 720:1280 vertical)")
args = parser.parse_args()
api_key = get_api_key()
headers = {
"Authorization": f"Bearer {api_key}",
"X-Runway-Version": "2024-11-06",
"Content-Type": "application/json",
}
print(f"[runway] Encoding image: {args.image}")
image_data = encode_image(args.image)
payload = {
"model": "gen4_turbo",
"promptImage": image_data,
"promptText": args.prompt,
"duration": args.duration,
"ratio": args.ratio,
}
print(f"[runway] Submitting job — {args.duration}s @ {args.ratio} ...")
resp = requests.post(
"https://api.dev.runwayml.com/v1/image_to_video",
headers=headers,
json=payload,
timeout=30,
)
if not resp.ok:
print(f"ERROR: {resp.status_code} — {resp.text}", file=sys.stderr)
sys.exit(1)
task_id = resp.json().get("id")
if not task_id:
print(f"ERROR: No task ID in response: {resp.text}", file=sys.stderr)
sys.exit(1)
print(f"[runway] Task ID: {task_id} — polling ...")
poll_url = f"https://api.dev.runwayml.com/v1/tasks/{task_id}"
while True:
time.sleep(8)
r = requests.get(poll_url, headers=headers, timeout=15)
if not r.ok:
print(f"ERROR: Poll failed {r.status_code}: {r.text}", file=sys.stderr)
sys.exit(1)
data = r.json()
status = data.get("status", "UNKNOWN")
progress = data.get("progressRatio", 0)
print(f"[runway] Status: {status} ({int(progress * 100)}%)")
if status == "SUCCEEDED":
outputs = data.get("output", [])
if not outputs:
print("ERROR: No output URL in response", file=sys.stderr)
sys.exit(1)
video_url = outputs[0]
print(f"[runway] Downloading video from {video_url[:60]}...")
video_resp = requests.get(video_url, timeout=60)
os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True)
with open(args.output, "wb") as f:
f.write(video_resp.content)
print(f"[runway] ✅ Saved to {args.output}")
break
elif status == "FAILED":
err = data.get("failure", data.get("error", "unknown"))
print(f"ERROR: Task failed — {err}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
Google Analytics 4, Search Console, and Indexing API toolkit. Analyze website traffic, page performance, user demographics, real-time visitors, search querie...
--- name: skill-ga4-analytics description: "Google Analytics 4, Search Console, and Indexing API toolkit. Analyze website traffic, page performance, user demographics, real-time visitors, search queries, and SEO metrics. Use when the user asks to: check site traffic, analyze page views, see traffic sources, view user demographics, get real-time visitor data, check search console queries, analyze SEO performance, request URL re-indexing, inspect index status, compare date ranges, check bounce rates, view conversion data, or get e-commerce revenue. Requires a Google Cloud service account with GA4 and Search Console access." --- # GA4 Analytics Toolkit ## Setup Install dependencies: ```bash cd scripts && npm install ``` Configure credentials by creating a `.env` file in the project root: ``` GA4_PROPERTY_ID=123456789 [email protected] GA4_PRIVATE_KEY="<your-service-account-private-key>" SEARCH_CONSOLE_SITE_URL=https://your-domain.com GA4_DEFAULT_DATE_RANGE=30d ``` **Prerequisites**: A Google Cloud project with the Analytics Data API, Search Console API, and Indexing API enabled. A service account with access to your GA4 property and Search Console. ## Quick Start | User says | Function to call | |-----------|-----------------| | "Show me site traffic for the last 30 days" | `siteOverview("30d")` | | "What are my top search queries?" | `searchConsoleOverview("30d")` | | "Who's on the site right now?" | `liveSnapshot()` | | "Reindex these URLs" | `reindexUrls(["https://example.com/page1", ...])` | | "Compare this month vs last month" | `compareDateRanges({startDate: "30daysAgo", endDate: "today"}, {startDate: "60daysAgo", endDate: "31daysAgo"})` | | "What pages get the most traffic?" | `contentPerformance("30d")` | Execute functions by importing from `scripts/src/index.ts`: ```typescript import { siteOverview, searchConsoleOverview } from './scripts/src/index.js'; const overview = await siteOverview('30d'); ``` Or run directly with tsx: ```bash npx tsx scripts/src/index.ts ``` ## Workflow Pattern Every analysis follows three phases: ### 1. Analyze Run API functions. Each call hits the Google APIs and returns structured data. ### 2. Auto-Save All results automatically save as timestamped JSON files to `results/{category}/`. File naming pattern: `YYYYMMDD_HHMMSS__operation__extra_info.json` ### 3. Summarize After analysis, read the saved JSON files and create a markdown summary in `results/summaries/` with data tables, trends, and recommendations. ## High-Level Functions ### GA4 Analytics | Function | Purpose | What it gathers | |----------|---------|----------------| | `siteOverview(dateRange?)` | Comprehensive site snapshot | Page views, traffic sources, demographics, events | | `trafficAnalysis(dateRange?)` | Traffic deep-dive | Sources, sessions by source/medium, new vs returning | | `contentPerformance(dateRange?)` | Top pages analysis | Page views, landing pages, exit pages | | `userBehavior(dateRange?)` | Engagement patterns | Demographics, events, daily engagement metrics | | `compareDateRanges(range1, range2)` | Period comparison | Side-by-side metrics for two date ranges | | `liveSnapshot()` | Real-time data | Active users, current pages, current events | ### Search Console | Function | Purpose | What it gathers | |----------|---------|----------------| | `searchConsoleOverview(dateRange?)` | SEO snapshot | Top queries, pages, device, country breakdown | | `keywordAnalysis(dateRange?)` | Keyword deep-dive | Queries with device breakdown | | `seoPagePerformance(dateRange?)` | Page SEO metrics | Top pages by clicks, country breakdown | ### Indexing | Function | Purpose | |----------|---------| | `reindexUrls(urls)` | Request re-indexing for multiple URLs | | `checkIndexStatus(urls)` | Check if URLs are indexed | ### Utility | Function | Purpose | |----------|---------| | `getAvailableFields()` | List all available GA4 dimensions and metrics | ### Individual API Functions For granular control, import specific functions from the API modules. See [references/api-reference.md](references/api-reference.md) for the complete list of 30+ API functions with parameters, types, and examples. ## Date Ranges All functions accept flexible date range formats: | Format | Example | Description | |--------|---------|-------------| | Shorthand | `"7d"`, `"30d"`, `"90d"` | Days ago to today | | Explicit | `{startDate: "2024-01-01", endDate: "2024-01-31"}` | Specific dates | | GA4 relative | `{startDate: "30daysAgo", endDate: "today"}` | GA4 relative format | Default is `"30d"` (configurable via `GA4_DEFAULT_DATE_RANGE` in `.env`). ## Results Storage Results auto-save to `results/` with this structure: ``` results/ ├── reports/ # GA4 standard reports ├── realtime/ # Real-time snapshots ├── searchconsole/ # Search Console data ├── indexing/ # Indexing API results └── summaries/ # Human-readable markdown summaries ``` ### Managing Results ```typescript import { listResults, loadResult, getLatestResult } from './scripts/src/index.js'; // List recent results const files = listResults('reports', 10); // Load a specific result const data = loadResult(files[0]); // Get most recent result for an operation const latest = getLatestResult('reports', 'site_overview'); ``` ## Common Dimensions and Metrics ### Dimensions `pagePath`, `pageTitle`, `sessionSource`, `sessionMedium`, `country`, `deviceCategory`, `browser`, `date`, `eventName`, `landingPage`, `newVsReturning` ### Metrics `screenPageViews`, `activeUsers`, `sessions`, `newUsers`, `bounceRate`, `averageSessionDuration`, `engagementRate`, `conversions`, `totalRevenue`, `eventCount` ## Tips 1. **Specify date ranges** — "last 7 days" or "last 90 days" gives different insights than the default 30 days 2. **Request summaries** — After pulling data, ask for a markdown summary with tables and insights 3. **Compare periods** — Use `compareDateRanges()` to spot trends (this month vs last month) 4. **Check real-time data** — `liveSnapshot()` shows who's on the site right now 5. **Combine GA4 + Search Console** — Traffic data plus search query data gives the full picture FILE:README.md # skill-ga4-analytics — GA4 + Search Console + Indexing API Analyze website traffic, page performance, user demographics, and SEO metrics via Google Analytics 4 and Search Console. ## Use When - Checking site traffic, sessions, and page views - Analyzing traffic sources (organic, direct, paid) - Viewing real-time visitor data - Checking Search Console queries, impressions, CTR - Analyzing SEO performance and keyword rankings - Requesting URL re-indexing via Indexing API - Inspecting index status for specific URLs - Comparing date ranges, bounce rates, conversions - Viewing e-commerce revenue data ## Requirements - Google Cloud service account JSON key - GA4 property ID - Search Console property verified - Indexing API enabled (for re-indexing) ## Key Features - GA4 Reporting API v1 — dimensions and metrics - Real-time API for live visitor counts - Search Console performance data - Indexing API for URL submission - Date range comparisons - Exportable JSON/CSV reports ## Quick Start Load the SKILL.md for setup instructions and full API reference. FILE:_meta.json { "ownerId": "kn7e8pzf5zjqcaxjz9rf815hf17zypbn", "slug": "ga4-analytics", "version": "1.0.0", "publishedAt": 1769464446311 } FILE:references/api-reference.md # API Reference Complete function reference for all GA4 Analytics Toolkit modules. ## Table of Contents - [Reports API](#reports-api) (7 functions) - [Realtime API](#realtime-api) (4 functions) - [Metadata API](#metadata-api) (3 functions) - [Search Console API](#search-console-api) (6 functions) - [Indexing API](#indexing-api) (4 functions) - [Bulk Lookup API](#bulk-lookup-api) (3 functions) - [Storage](#storage) (4 functions) --- ## Reports API Import: `from './api/reports.js'` ### `parseDateRange(range?)` Parse shorthand date range (e.g., "7d", "30d") to GA4 date range format. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `range` | `string \| DateRange \| undefined` | Settings default | Date range to parse | **Returns:** `DateRange` — `{startDate: string, endDate: string}` ### `runReport(options)` Run a custom GA4 report with arbitrary dimensions and metrics. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `options.dimensions` | `string[]` | required | GA4 dimension names | | `options.metrics` | `string[]` | required | GA4 metric names | | `options.dateRange` | `string \| DateRange` | `"30d"` | Date range | | `options.filters` | `Record<string, string>` | `undefined` | Dimension filters | | `options.orderBy` | `string[]` | `undefined` | Sort order | | `options.limit` | `number` | `undefined` | Row limit | | `options.save` | `boolean` | `true` | Save results to JSON | **Returns:** `Promise<ReportResponse>` — Rows with dimension and metric values. ### `getPageViews(dateRange?)` Get page view data with paths, titles, users, and session duration. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `dateRange` | `string \| DateRange` | `"30d"` | Date range | **Dimensions:** `pagePath`, `pageTitle` **Metrics:** `screenPageViews`, `activeUsers`, `averageSessionDuration` ### `getTrafficSources(dateRange?)` Get traffic source data by source, medium, and campaign. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `dateRange` | `string \| DateRange` | `"30d"` | Date range | **Dimensions:** `sessionSource`, `sessionMedium`, `sessionCampaignName` **Metrics:** `sessions`, `activeUsers`, `newUsers`, `bounceRate` ### `getUserDemographics(dateRange?)` Get user demographic data by country, device, and browser. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `dateRange` | `string \| DateRange` | `"30d"` | Date range | **Dimensions:** `country`, `deviceCategory`, `browser` **Metrics:** `activeUsers`, `sessions`, `newUsers` ### `getEventCounts(dateRange?)` Get event count data by event name. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `dateRange` | `string \| DateRange` | `"30d"` | Date range | **Dimensions:** `eventName` **Metrics:** `eventCount`, `eventCountPerUser`, `activeUsers` ### `getConversions(dateRange?)` Get conversion data by event name and source. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `dateRange` | `string \| DateRange` | `"30d"` | Date range | **Dimensions:** `eventName`, `sessionSource` **Metrics:** `conversions`, `totalRevenue` ### `getEcommerceRevenue(dateRange?)` Get e-commerce revenue data by date and transaction. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `dateRange` | `string \| DateRange` | `"30d"` | Date range | **Dimensions:** `date`, `transactionId` **Metrics:** `totalRevenue`, `ecommercePurchases`, `averagePurchaseRevenue` --- ## Realtime API Import: `from './api/realtime.js'` ### `getActiveUsers(save?)` Get current active users by screen/page name. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `save` | `boolean` | `true` | Save results to JSON | **Returns:** `Promise<RealtimeResponse>` — Active users by `unifiedScreenName`. ### `getRealtimeEvents(save?)` Get currently firing events. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `save` | `boolean` | `true` | Save results to JSON | **Returns:** `Promise<RealtimeResponse>` — Event counts by `eventName`. ### `getRealtimePages(save?)` Get currently viewed pages. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `save` | `boolean` | `true` | Save results to JSON | **Returns:** `Promise<RealtimeResponse>` — Page views by `unifiedScreenName`. ### `getRealtimeSources(save?)` Get current traffic sources. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `save` | `boolean` | `true` | Save results to JSON | **Returns:** `Promise<RealtimeResponse>` — Active users by `firstUserSource` and `firstUserMedium`. --- ## Metadata API Import: `from './api/metadata.js'` ### `getAvailableDimensions(save?)` Get all available dimensions for the GA4 property. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `save` | `boolean` | `true` | Save results to JSON | **Returns:** `Promise<MetadataResponse>` — Array of `{apiName, uiName, description}`. ### `getAvailableMetrics(save?)` Get all available metrics for the GA4 property. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `save` | `boolean` | `true` | Save results to JSON | **Returns:** `Promise<MetadataResponse>` — Array of `{apiName, uiName, description}`. ### `getPropertyMetadata(save?)` Get full property metadata (dimensions + metrics combined). | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `save` | `boolean` | `true` | Save results to JSON | **Returns:** `Promise<MetadataResponse>` — Full metadata response. --- ## Search Console API Import: `from './api/searchConsole.js'` ### `querySearchAnalytics(options)` Run a custom Search Console analytics query. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `options.dimensions` | `string[]` | `["query"]` | Dimensions: `query`, `page`, `device`, `country`, `searchAppearance` | | `options.dateRange` | `string \| SearchConsoleDateRange` | `"30d"` | Date range | | `options.rowLimit` | `number` | `1000` | Max rows | | `options.startRow` | `number` | `0` | Pagination offset | | `options.save` | `boolean` | `true` | Save results to JSON | **Returns:** `Promise<SearchAnalyticsResponse>` — Rows with `{keys, clicks, impressions, ctr, position}`. ### `getTopQueries(dateRange?)` Get top 100 search queries by clicks. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `dateRange` | `string \| SearchConsoleDateRange` | `"30d"` | Date range | **Returns:** `Promise<SearchAnalyticsResponse>` ### `getTopPages(dateRange?)` Get top 100 pages by search impressions. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `dateRange` | `string \| SearchConsoleDateRange` | `"30d"` | Date range | **Returns:** `Promise<SearchAnalyticsResponse>` ### `getDevicePerformance(dateRange?)` Get search performance breakdown by device type (desktop, mobile, tablet). | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `dateRange` | `string \| SearchConsoleDateRange` | `"30d"` | Date range | **Returns:** `Promise<SearchAnalyticsResponse>` ### `getCountryPerformance(dateRange?)` Get search performance by country (top 50). | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `dateRange` | `string \| SearchConsoleDateRange` | `"30d"` | Date range | **Returns:** `Promise<SearchAnalyticsResponse>` ### `getSearchAppearance(dateRange?)` Get search appearance data (rich results, AMP, etc.). | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `dateRange` | `string \| SearchConsoleDateRange` | `"30d"` | Date range | **Returns:** `Promise<SearchAnalyticsResponse>` --- ## Indexing API Import: `from './api/indexing.js'` ### `requestIndexing(url, options?)` Request Google to re-crawl a single URL. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `url` | `string` | required | Full URL to request indexing for | | `options.save` | `boolean` | `true` | Save results to JSON | **Returns:** `Promise<UrlNotificationResult>` — `{url, type: 'URL_UPDATED', notifyTime}`. ### `requestIndexingBatch(urls, options?)` Request re-crawling for multiple URLs (processed sequentially to avoid rate limits). | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `urls` | `string[]` | required | Array of URLs | | `options.save` | `boolean` | `true` | Save results to JSON | **Returns:** `Promise<UrlNotificationResult[]>` ### `removeFromIndex(url, options?)` Request URL removal from Google's index. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `url` | `string` | required | URL to remove | | `options.save` | `boolean` | `true` | Save results to JSON | **Returns:** `Promise<UrlNotificationResult>` — `{url, type: 'URL_DELETED', notifyTime}`. ### `inspectUrl(url, options?)` Check a URL's index status, mobile usability, and rich results. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `url` | `string` | required | URL to inspect | | `options.save` | `boolean` | `true` | Save results to JSON | **Returns:** `Promise<UrlInspectionResult>` — Contains `indexStatus.verdict` ('PASS' | 'FAIL' | 'NEUTRAL'), `coverageState`, `lastCrawlTime`, `mobileUsability`, `richResults`. --- ## Bulk Lookup API Import: `from './api/bulk-lookup.js'` ### `normalizeUrls(urls)` Normalize page paths: trim whitespace, add leading slash. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `urls` | `string[]` | required | Array of page paths | **Returns:** `string[]` — Normalized paths. ### `buildUrlFilter(urls)` Build a GA4 dimension filter expression for a list of page paths. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `urls` | `string[]` | required | Normalized page paths | **Returns:** `DimensionFilterExpression | null` ### `getMetricsForUrls(urls, options?)` Get GA4 metrics for specific page paths (bulk lookup). | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `urls` | `string[]` | required | Page paths (e.g., `["/pricing", "/about"]`) | | `options.dateRange` | `string \| DateRange` | `"30d"` | Date range | | `options.metrics` | `string[]` | `["screenPageViews", "activeUsers", "averageSessionDuration", "bounceRate", "engagementRate"]` | Metrics to retrieve | | `options.save` | `boolean` | `true` | Save results to JSON | **Returns:** `Promise<ReportResponse>` — Metrics for each URL. --- ## Storage Import: `from './core/storage.js'` ### `saveResult<T>(data, category, operation, extraInfo?)` Save result data to a timestamped JSON file with metadata wrapper. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `data` | `T` | required | Data to save | | `category` | `string` | required | Category directory (e.g., `"reports"`, `"realtime"`) | | `operation` | `string` | required | Operation name (e.g., `"page_views"`) | | `extraInfo` | `string` | `undefined` | Optional extra info for filename | **Returns:** `string` — Full path to the saved file. ### `loadResult<T>(filepath)` Load a previously saved result from a JSON file. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `filepath` | `string` | required | Path to the JSON file | **Returns:** `SavedResult<T> | null` ### `listResults(category, limit?)` List saved result files for a category, sorted newest first. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `category` | `string` | required | Category to list | | `limit` | `number` | `undefined` | Max results to return | **Returns:** `string[]` — Array of file paths. ### `getLatestResult<T>(category, operation?)` Get the most recent result for a category, optionally filtered by operation. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `category` | `string` | required | Category to search | | `operation` | `string` | `undefined` | Filter by operation name | **Returns:** `SavedResult<T> | null` FILE:scripts/dist/api/bulk-lookup.d.ts /** * Bulk URL Lookup - Get GA4 metrics for specific page paths * * This module provides a convenient way to look up analytics data * for a list of specific URLs, similar to a bulk URL lookup field. */ import type { ReportResponse, DateRange } from './reports.js'; /** * Options for bulk URL lookup */ export interface BulkLookupOptions { /** Date range (e.g., "7d", "30d") or explicit dates */ dateRange?: string | DateRange; /** Custom metrics to retrieve (defaults to standard page metrics) */ metrics?: string[]; /** Whether to save results to file (default: true) */ save?: boolean; } /** * Dimension filter expression for GA4 API */ export interface DimensionFilterExpression { filter: { fieldName: string; inListFilter?: { values: string[]; caseSensitive?: boolean; }; stringFilter?: { matchType: string; value: string; caseSensitive?: boolean; }; }; } /** * Normalize URLs to ensure consistent format * * - Trims whitespace * - Adds leading slash if missing * - Preserves trailing slashes * * @param urls - Array of URLs to normalize * @returns Normalized URL array */ export declare function normalizeUrls(urls: string[]): string[]; /** * Build a dimension filter expression for the given URLs * * @param urls - Array of page paths to filter by * @returns Filter expression or null if no URLs provided */ export declare function buildUrlFilter(urls: string[]): DimensionFilterExpression | null; /** * Get GA4 metrics for specific page paths (bulk URL lookup) * * @param urls - Array of page paths to look up (e.g., ['/pricing', '/about']) * @param options - Optional configuration * @returns Report response with metrics for the specified URLs * * @example * ```typescript * // Get metrics for specific pages * const result = await getMetricsForUrls(['/pricing', '/about', '/blog']); * * // With custom date range and metrics * const result = await getMetricsForUrls(['/pricing'], { * dateRange: '7d', * metrics: ['sessions', 'bounceRate'], * }); * ``` */ export declare function getMetricsForUrls(urls: string[], options?: BulkLookupOptions): Promise<ReportResponse>; //# sourceMappingURL=bulk-lookup.d.ts.map FILE:scripts/dist/api/bulk-lookup.js /** * Bulk URL Lookup - Get GA4 metrics for specific page paths * * This module provides a convenient way to look up analytics data * for a list of specific URLs, similar to a bulk URL lookup field. */ import { getClient, getPropertyId } from '../core/client.js'; import { saveResult } from '../core/storage.js'; import { getSettings } from '../config/settings.js'; /** * Default metrics for bulk URL lookup */ const DEFAULT_METRICS = [ 'screenPageViews', 'activeUsers', 'averageSessionDuration', 'bounceRate', 'engagementRate', ]; /** * Normalize URLs to ensure consistent format * * - Trims whitespace * - Adds leading slash if missing * - Preserves trailing slashes * * @param urls - Array of URLs to normalize * @returns Normalized URL array */ export function normalizeUrls(urls) { return urls.map(url => { // Trim whitespace let normalized = url.trim(); // Add leading slash if missing if (!normalized.startsWith('/')) { normalized = '/' + normalized; } return normalized; }); } /** * Build a dimension filter expression for the given URLs * * @param urls - Array of page paths to filter by * @returns Filter expression or null if no URLs provided */ export function buildUrlFilter(urls) { if (urls.length === 0) { return null; } return { filter: { fieldName: 'pagePath', inListFilter: { values: urls, caseSensitive: false, }, }, }; } /** * Parse shorthand date range (e.g., "7d", "30d") to GA4 date range format */ function parseDateRange(range) { if (!range) { const settings = getSettings(); range = settings.defaultDateRange; } if (typeof range === 'object') { return range; } // Parse shorthand like "7d", "30d", "90d" const match = range.match(/^(\d+)d$/); if (match) { const days = parseInt(match[1], 10); return { startDate: `daysdaysAgo`, endDate: 'today', }; } // Default to 30 days return { startDate: '30daysAgo', endDate: 'today', }; } /** * Get GA4 metrics for specific page paths (bulk URL lookup) * * @param urls - Array of page paths to look up (e.g., ['/pricing', '/about']) * @param options - Optional configuration * @returns Report response with metrics for the specified URLs * * @example * ```typescript * // Get metrics for specific pages * const result = await getMetricsForUrls(['/pricing', '/about', '/blog']); * * // With custom date range and metrics * const result = await getMetricsForUrls(['/pricing'], { * dateRange: '7d', * metrics: ['sessions', 'bounceRate'], * }); * ``` */ export async function getMetricsForUrls(urls, options = {}) { const { dateRange, metrics = DEFAULT_METRICS, save = true } = options; // Normalize URLs const normalizedUrls = normalizeUrls(urls); // Handle empty URL array if (normalizedUrls.length === 0) { return { rows: [], rowCount: 0, }; } // Build filter const dimensionFilter = buildUrlFilter(normalizedUrls); // Get client and property const client = getClient(); const property = getPropertyId(); const parsedDateRange = parseDateRange(dateRange); // Build and execute request const request = { property, dateRanges: [parsedDateRange], dimensions: [{ name: 'pagePath' }, { name: 'pageTitle' }], metrics: metrics.map(name => ({ name })), dimensionFilter: dimensionFilter, }; const response = (await client.runReport(request))[0]; // Save results if requested if (save) { const dateStr = typeof dateRange === 'string' ? dateRange : 'custom'; saveResult(response, 'reports', 'bulk_url_lookup', dateStr); } return response; } //# sourceMappingURL=bulk-lookup.js.map FILE:scripts/dist/api/indexing.d.ts /** * Indexing API - Request re-indexing and URL inspection */ /** * Indexing request options */ export interface IndexingOptions { save?: boolean; } /** * URL notification result */ export interface UrlNotificationResult { url: string; type: 'URL_UPDATED' | 'URL_DELETED'; notifyTime: string; } /** * URL inspection result */ export interface UrlInspectionResult { inspectionResultLink?: string; indexStatus: { verdict: 'PASS' | 'FAIL' | 'NEUTRAL'; coverageState: string; robotsTxtState?: string; indexingState?: string; lastCrawlTime?: string; pageFetchState?: string; googleCanonical?: string; userCanonical?: string; crawledAs?: string; }; mobileUsability?: { verdict: string; issues?: unknown[]; }; richResults?: { verdict: string; detectedItems?: unknown[]; }; } /** * Request indexing for a single URL (notify Google to re-crawl) * * @param url - The URL to request indexing for * @param options - Optional settings (save to file, etc.) * @returns Notification result with timestamp */ export declare function requestIndexing(url: string, options?: IndexingOptions): Promise<UrlNotificationResult>; /** * Request indexing for multiple URLs * * @param urls - Array of URLs to request indexing for * @param options - Optional settings * @returns Array of notification results */ export declare function requestIndexingBatch(urls: string[], options?: IndexingOptions): Promise<UrlNotificationResult[]>; /** * Request URL removal from index * * @param url - The URL to request removal for * @param options - Optional settings * @returns Notification result */ export declare function removeFromIndex(url: string, options?: IndexingOptions): Promise<UrlNotificationResult>; /** * Inspect a URL to check its index status * * @param url - The URL to inspect * @param options - Optional settings * @returns URL inspection result with index status */ export declare function inspectUrl(url: string, options?: IndexingOptions): Promise<UrlInspectionResult>; //# sourceMappingURL=indexing.d.ts.map FILE:scripts/dist/api/indexing.js /** * Indexing API - Request re-indexing and URL inspection */ import { getIndexingClient, getSearchConsoleClient, getSiteUrl } from '../core/client.js'; import { saveResult } from '../core/storage.js'; /** * Request indexing for a single URL (notify Google to re-crawl) * * @param url - The URL to request indexing for * @param options - Optional settings (save to file, etc.) * @returns Notification result with timestamp */ export async function requestIndexing(url, options = {}) { const { save = true } = options; const client = getIndexingClient(); const response = await client.urlNotifications.publish({ requestBody: { url, type: 'URL_UPDATED', }, }); const result = { url: response.data.urlNotificationMetadata?.url || url, type: 'URL_UPDATED', notifyTime: response.data.urlNotificationMetadata?.latestUpdate?.notifyTime || new Date().toISOString(), }; if (save) { saveResult(result, 'indexing', 'request_indexing'); } return result; } /** * Request indexing for multiple URLs * * @param urls - Array of URLs to request indexing for * @param options - Optional settings * @returns Array of notification results */ export async function requestIndexingBatch(urls, options = {}) { const { save = true } = options; const results = []; // Process URLs sequentially to avoid rate limiting for (const url of urls) { const result = await requestIndexing(url, { save: false }); results.push(result); } if (save) { saveResult(results, 'indexing', 'batch_indexing'); } return results; } /** * Request URL removal from index * * @param url - The URL to request removal for * @param options - Optional settings * @returns Notification result */ export async function removeFromIndex(url, options = {}) { const { save = true } = options; const client = getIndexingClient(); const response = await client.urlNotifications.publish({ requestBody: { url, type: 'URL_DELETED', }, }); const result = { url: response.data.urlNotificationMetadata?.url || url, type: 'URL_DELETED', notifyTime: response.data.urlNotificationMetadata?.latestRemove?.notifyTime || new Date().toISOString(), }; if (save) { saveResult(result, 'indexing', 'remove_from_index'); } return result; } /** * Inspect a URL to check its index status * * @param url - The URL to inspect * @param options - Optional settings * @returns URL inspection result with index status */ export async function inspectUrl(url, options = {}) { const { save = true } = options; const client = getSearchConsoleClient(); const siteUrl = getSiteUrl(); const response = await client.urlInspection.index.inspect({ requestBody: { inspectionUrl: url, siteUrl, }, }); const inspectionResult = response.data.inspectionResult; const result = { inspectionResultLink: inspectionResult?.inspectionResultLink || undefined, indexStatus: { verdict: inspectionResult?.indexStatusResult?.verdict || 'NEUTRAL', coverageState: inspectionResult?.indexStatusResult?.coverageState || 'Unknown', robotsTxtState: inspectionResult?.indexStatusResult?.robotsTxtState || undefined, indexingState: inspectionResult?.indexStatusResult?.indexingState || undefined, lastCrawlTime: inspectionResult?.indexStatusResult?.lastCrawlTime || undefined, pageFetchState: inspectionResult?.indexStatusResult?.pageFetchState || undefined, googleCanonical: inspectionResult?.indexStatusResult?.googleCanonical || undefined, userCanonical: inspectionResult?.indexStatusResult?.userCanonical || undefined, crawledAs: inspectionResult?.indexStatusResult?.crawledAs || undefined, }, mobileUsability: inspectionResult?.mobileUsabilityResult ? { verdict: inspectionResult.mobileUsabilityResult.verdict || 'NEUTRAL', issues: inspectionResult.mobileUsabilityResult.issues || [], } : undefined, richResults: inspectionResult?.richResultsResult ? { verdict: inspectionResult.richResultsResult.verdict || 'NEUTRAL', detectedItems: inspectionResult.richResultsResult.detectedItems || [], } : undefined, }; if (save) { saveResult(result, 'indexing', 'url_inspection'); } return result; } //# sourceMappingURL=indexing.js.map FILE:scripts/dist/api/metadata.d.ts /** * Metadata API - Available dimensions and metrics */ /** * Dimension metadata */ export interface DimensionMetadata { apiName: string; uiName: string; description: string; } /** * Metric metadata */ export interface MetricMetadata { apiName: string; uiName: string; description: string; } /** * Full property metadata response */ export interface MetadataResponse { name?: string; dimensions?: DimensionMetadata[]; metrics?: MetricMetadata[]; } /** * Get all available dimensions for the property */ export declare function getAvailableDimensions(save?: boolean): Promise<MetadataResponse>; /** * Get all available metrics for the property */ export declare function getAvailableMetrics(save?: boolean): Promise<MetadataResponse>; /** * Get full property metadata (dimensions and metrics) */ export declare function getPropertyMetadata(save?: boolean): Promise<MetadataResponse>; //# sourceMappingURL=metadata.d.ts.map FILE:scripts/dist/api/metadata.js /** * Metadata API - Available dimensions and metrics */ import { getClient, getPropertyId } from '../core/client.js'; import { saveResult } from '../core/storage.js'; /** * Get all available dimensions for the property */ export async function getAvailableDimensions(save = true) { const client = getClient(); const property = getPropertyId(); const [response] = await client.getMetadata({ name: `property/metadata`, }); const result = { dimensions: response.dimensions || [], }; if (save) { saveResult(result, 'metadata', 'dimensions'); } return result; } /** * Get all available metrics for the property */ export async function getAvailableMetrics(save = true) { const client = getClient(); const property = getPropertyId(); const [response] = await client.getMetadata({ name: `property/metadata`, }); const result = { metrics: response.metrics || [], }; if (save) { saveResult(result, 'metadata', 'metrics'); } return result; } /** * Get full property metadata (dimensions and metrics) */ export async function getPropertyMetadata(save = true) { const client = getClient(); const property = getPropertyId(); const [response] = await client.getMetadata({ name: `property/metadata`, }); if (save) { saveResult(response, 'metadata', 'full'); } return response; } //# sourceMappingURL=metadata.js.map FILE:scripts/dist/api/realtime.d.ts /** * Realtime API - Live GA4 data */ /** * Realtime report response structure */ export interface RealtimeResponse { dimensionHeaders?: Array<{ name: string; }>; metricHeaders?: Array<{ name: string; }>; rows?: Array<{ dimensionValues: Array<{ value: string; }>; metricValues: Array<{ value: string; }>; }>; rowCount?: number; } /** * Get current active users */ export declare function getActiveUsers(save?: boolean): Promise<RealtimeResponse>; /** * Get current event data */ export declare function getRealtimeEvents(save?: boolean): Promise<RealtimeResponse>; /** * Get currently viewed pages */ export declare function getRealtimePages(save?: boolean): Promise<RealtimeResponse>; /** * Get realtime traffic sources */ export declare function getRealtimeSources(save?: boolean): Promise<RealtimeResponse>; //# sourceMappingURL=realtime.d.ts.map FILE:scripts/dist/api/realtime.js /** * Realtime API - Live GA4 data */ import { getClient, getPropertyId } from '../core/client.js'; import { saveResult } from '../core/storage.js'; /** * Get current active users */ export async function getActiveUsers(save = true) { const client = getClient(); const property = getPropertyId(); const [response] = await client.runRealtimeReport({ property, dimensions: [{ name: 'unifiedScreenName' }], metrics: [{ name: 'activeUsers' }], }); if (save) { saveResult(response, 'realtime', 'active_users'); } return response; } /** * Get current event data */ export async function getRealtimeEvents(save = true) { const client = getClient(); const property = getPropertyId(); const [response] = await client.runRealtimeReport({ property, dimensions: [{ name: 'eventName' }], metrics: [{ name: 'eventCount' }], }); if (save) { saveResult(response, 'realtime', 'events'); } return response; } /** * Get currently viewed pages */ export async function getRealtimePages(save = true) { const client = getClient(); const property = getPropertyId(); const [response] = await client.runRealtimeReport({ property, dimensions: [{ name: 'unifiedScreenName' }], metrics: [{ name: 'screenPageViews' }], }); if (save) { saveResult(response, 'realtime', 'pages'); } return response; } /** * Get realtime traffic sources */ export async function getRealtimeSources(save = true) { const client = getClient(); const property = getPropertyId(); const [response] = await client.runRealtimeReport({ property, dimensions: [{ name: 'firstUserSource' }, { name: 'firstUserMedium' }], metrics: [{ name: 'activeUsers' }], }); if (save) { saveResult(response, 'realtime', 'sources'); } return response; } //# sourceMappingURL=realtime.js.map FILE:scripts/dist/api/reports.d.ts /** * Reports API - Standard GA4 report generation */ /** * Date range configuration */ export interface DateRange { startDate: string; endDate: string; } /** * Report options */ export interface ReportOptions { dimensions: string[]; metrics: string[]; dateRange?: string | DateRange; filters?: Record<string, string>; orderBy?: string[]; limit?: number; save?: boolean; } /** * Report response structure */ export interface ReportResponse { dimensionHeaders?: Array<{ name: string; }>; metricHeaders?: Array<{ name: string; }>; rows?: Array<{ dimensionValues: Array<{ value: string; }>; metricValues: Array<{ value: string; }>; }>; rowCount?: number; metadata?: Record<string, unknown>; } /** * Parse shorthand date range (e.g., "7d", "30d") to GA4 date range format */ export declare function parseDateRange(range: string | DateRange | undefined): DateRange; /** * Run a custom GA4 report */ export declare function runReport(options: ReportOptions): Promise<ReportResponse>; /** * Get page view data */ export declare function getPageViews(dateRange?: string | DateRange): Promise<ReportResponse>; /** * Get traffic source data */ export declare function getTrafficSources(dateRange?: string | DateRange): Promise<ReportResponse>; /** * Get user demographic data (country, device, browser) */ export declare function getUserDemographics(dateRange?: string | DateRange): Promise<ReportResponse>; /** * Get event count data */ export declare function getEventCounts(dateRange?: string | DateRange): Promise<ReportResponse>; /** * Get conversion data */ export declare function getConversions(dateRange?: string | DateRange): Promise<ReportResponse>; /** * Get e-commerce revenue data */ export declare function getEcommerceRevenue(dateRange?: string | DateRange): Promise<ReportResponse>; //# sourceMappingURL=reports.d.ts.map FILE:scripts/dist/api/reports.js /** * Reports API - Standard GA4 report generation */ import { getClient, getPropertyId } from '../core/client.js'; import { saveResult } from '../core/storage.js'; import { getSettings } from '../config/settings.js'; /** * Parse shorthand date range (e.g., "7d", "30d") to GA4 date range format */ export function parseDateRange(range) { if (!range) { const settings = getSettings(); range = settings.defaultDateRange; } if (typeof range === 'object') { return range; } // Parse shorthand like "7d", "30d", "90d" const match = range.match(/^(\d+)d$/); if (match) { const days = parseInt(match[1], 10); return { startDate: `daysdaysAgo`, endDate: 'today', }; } // Default to 30 days return { startDate: '30daysAgo', endDate: 'today', }; } /** * Run a custom GA4 report */ export async function runReport(options) { const { dimensions, metrics, dateRange, filters, orderBy, limit, save = true, } = options; const client = getClient(); const property = getPropertyId(); const parsedDateRange = parseDateRange(dateRange); const request = { property, dateRanges: [parsedDateRange], dimensions: dimensions.map(name => ({ name })), metrics: metrics.map(name => ({ name })), ...(limit && { limit }), }; const [response] = await client.runReport(request); if (save) { const operation = dimensions.join('_') || 'custom'; const extra = typeof dateRange === 'string' ? dateRange : undefined; saveResult(response, 'reports', operation, extra); } return response; } /** * Get page view data */ export async function getPageViews(dateRange) { return runReport({ dimensions: ['pagePath', 'pageTitle'], metrics: ['screenPageViews', 'activeUsers', 'averageSessionDuration'], dateRange, }); } /** * Get traffic source data */ export async function getTrafficSources(dateRange) { return runReport({ dimensions: ['sessionSource', 'sessionMedium', 'sessionCampaignName'], metrics: ['sessions', 'activeUsers', 'newUsers', 'bounceRate'], dateRange, }); } /** * Get user demographic data (country, device, browser) */ export async function getUserDemographics(dateRange) { return runReport({ dimensions: ['country', 'deviceCategory', 'browser'], metrics: ['activeUsers', 'sessions', 'newUsers'], dateRange, }); } /** * Get event count data */ export async function getEventCounts(dateRange) { return runReport({ dimensions: ['eventName'], metrics: ['eventCount', 'eventCountPerUser', 'activeUsers'], dateRange, }); } /** * Get conversion data */ export async function getConversions(dateRange) { return runReport({ dimensions: ['eventName', 'sessionSource'], metrics: ['conversions', 'totalRevenue'], dateRange, }); } /** * Get e-commerce revenue data */ export async function getEcommerceRevenue(dateRange) { return runReport({ dimensions: ['date', 'transactionId'], metrics: ['totalRevenue', 'ecommercePurchases', 'averagePurchaseRevenue'], dateRange, }); } //# sourceMappingURL=reports.js.map FILE:scripts/dist/api/searchConsole.d.ts /** * Search Console API - Google Search Console data retrieval */ /** * Date range configuration for Search Console */ export interface SearchConsoleDateRange { startDate: string; endDate: string; } /** * Search analytics query options */ export interface SearchAnalyticsOptions { dimensions?: string[]; dateRange?: string | SearchConsoleDateRange; rowLimit?: number; startRow?: number; save?: boolean; } /** * Search analytics row structure */ export interface SearchAnalyticsRow { keys: string[]; clicks: number; impressions: number; ctr: number; position: number; } /** * Search analytics response structure */ export interface SearchAnalyticsResponse { rows?: SearchAnalyticsRow[]; responseAggregationType?: string; } /** * Parse shorthand date range (e.g., "7d", "30d") to Search Console date format * Note: Search Console requires YYYY-MM-DD format, not GA4's "NdaysAgo" format */ export declare function parseSearchConsoleDateRange(range: string | SearchConsoleDateRange | undefined): SearchConsoleDateRange; /** * Query search analytics data */ export declare function querySearchAnalytics(options: SearchAnalyticsOptions): Promise<SearchAnalyticsResponse>; /** * Get top search queries */ export declare function getTopQueries(dateRange?: string | SearchConsoleDateRange): Promise<SearchAnalyticsResponse>; /** * Get top pages by search performance */ export declare function getTopPages(dateRange?: string | SearchConsoleDateRange): Promise<SearchAnalyticsResponse>; /** * Get search performance by device type */ export declare function getDevicePerformance(dateRange?: string | SearchConsoleDateRange): Promise<SearchAnalyticsResponse>; /** * Get search performance by country */ export declare function getCountryPerformance(dateRange?: string | SearchConsoleDateRange): Promise<SearchAnalyticsResponse>; /** * Get search appearance data (rich results, AMP, etc.) */ export declare function getSearchAppearance(dateRange?: string | SearchConsoleDateRange): Promise<SearchAnalyticsResponse>; //# sourceMappingURL=searchConsole.d.ts.map FILE:scripts/dist/api/searchConsole.js /** * Search Console API - Google Search Console data retrieval */ import { getSearchConsoleClient, getSiteUrl } from '../core/client.js'; import { saveResult } from '../core/storage.js'; import { getSettings } from '../config/settings.js'; /** * Parse shorthand date range (e.g., "7d", "30d") to Search Console date format * Note: Search Console requires YYYY-MM-DD format, not GA4's "NdaysAgo" format */ export function parseSearchConsoleDateRange(range) { if (!range) { const settings = getSettings(); range = settings.defaultDateRange; } if (typeof range === 'object') { return range; } // Parse shorthand like "7d", "30d", "90d" const match = range.match(/^(\d+)d$/); if (match) { const days = parseInt(match[1], 10); const endDate = new Date(); const startDate = new Date(); startDate.setDate(startDate.getDate() - days); return { startDate: startDate.toISOString().split('T')[0], endDate: endDate.toISOString().split('T')[0], }; } // Default to 30 days const endDate = new Date(); const startDate = new Date(); startDate.setDate(startDate.getDate() - 30); return { startDate: startDate.toISOString().split('T')[0], endDate: endDate.toISOString().split('T')[0], }; } /** * Query search analytics data */ export async function querySearchAnalytics(options) { const { dimensions = ['query'], dateRange, rowLimit = 1000, startRow = 0, save = true, } = options; const client = getSearchConsoleClient(); const siteUrl = getSiteUrl(); const parsedDateRange = parseSearchConsoleDateRange(dateRange); const response = await client.searchanalytics.query({ siteUrl, requestBody: { startDate: parsedDateRange.startDate, endDate: parsedDateRange.endDate, dimensions, rowLimit, startRow, }, }); const result = response.data; if (save) { const operation = dimensions.join('_') || 'query'; const extra = typeof dateRange === 'string' ? dateRange : undefined; saveResult(result, 'searchconsole', operation, extra); } return result; } /** * Get top search queries */ export async function getTopQueries(dateRange) { return querySearchAnalytics({ dimensions: ['query'], dateRange, rowLimit: 100, }); } /** * Get top pages by search performance */ export async function getTopPages(dateRange) { return querySearchAnalytics({ dimensions: ['page'], dateRange, rowLimit: 100, }); } /** * Get search performance by device type */ export async function getDevicePerformance(dateRange) { return querySearchAnalytics({ dimensions: ['device'], dateRange, }); } /** * Get search performance by country */ export async function getCountryPerformance(dateRange) { return querySearchAnalytics({ dimensions: ['country'], dateRange, rowLimit: 50, }); } /** * Get search appearance data (rich results, AMP, etc.) */ export async function getSearchAppearance(dateRange) { return querySearchAnalytics({ dimensions: ['searchAppearance'], dateRange, }); } //# sourceMappingURL=searchConsole.js.map FILE:scripts/dist/config/settings.d.ts /** * Settings Module - Environment configuration for GA4 API */ /** * Settings interface for GA4 API configuration */ export interface Settings { /** GA4 Property ID */ propertyId: string; /** Service account email */ clientEmail: string; /** Service account private key */ privateKey: string; /** Default date range for reports (e.g., "30d", "7d") */ defaultDateRange: string; /** Directory path for storing results */ resultsDir: string; /** Search Console site URL (e.g., "https://example.com") */ siteUrl: string; } /** * Validation result from validateSettings() */ export interface ValidationResult { valid: boolean; errors: string[]; } /** * Get current settings from environment variables */ export declare function getSettings(): Settings; /** * Validate that all required settings are present */ export declare function validateSettings(): ValidationResult; //# sourceMappingURL=settings.d.ts.map FILE:scripts/dist/config/settings.js /** * Settings Module - Environment configuration for GA4 API */ import { config } from 'dotenv'; import { join } from 'path'; // Load .env file from current working directory config(); /** * Get current settings from environment variables */ export function getSettings() { return { propertyId: process.env.GA4_PROPERTY_ID || '', clientEmail: process.env.GA4_CLIENT_EMAIL || '', privateKey: (process.env.GA4_PRIVATE_KEY || '').replace(/\\n/g, '\n'), defaultDateRange: process.env.GA4_DEFAULT_DATE_RANGE || '30d', resultsDir: join(process.cwd(), 'results'), siteUrl: process.env.SEARCH_CONSOLE_SITE_URL || '', }; } /** * Validate that all required settings are present */ export function validateSettings() { const settings = getSettings(); const errors = []; if (!settings.propertyId) { errors.push('GA4_PROPERTY_ID is required'); } if (!settings.clientEmail) { errors.push('GA4_CLIENT_EMAIL is required'); } if (!settings.privateKey) { errors.push('GA4_PRIVATE_KEY is required'); } return { valid: errors.length === 0, errors, }; } //# sourceMappingURL=settings.js.map FILE:scripts/dist/core/client.d.ts /** * GA4 API Client - Singleton wrapper for BetaAnalyticsDataClient * Also includes Search Console and Indexing API clients */ import { BetaAnalyticsDataClient } from '@google-analytics/data'; import { searchconsole } from '@googleapis/searchconsole'; import { indexing } from '@googleapis/indexing'; /** * Get the GA4 Analytics Data API client (singleton) * * @returns The BetaAnalyticsDataClient instance * @throws Error if credentials are invalid */ export declare function getClient(): BetaAnalyticsDataClient; /** * Get the GA4 property ID formatted for API calls * * @returns Property ID with "properties/" prefix */ export declare function getPropertyId(): string; /** * Reset the client singleton (useful for testing) */ export declare function resetClient(): void; /** * Get the Search Console API client (singleton) * * @returns The Search Console client instance * @throws Error if credentials are invalid */ export declare function getSearchConsoleClient(): ReturnType<typeof searchconsole>; /** * Get the Indexing API client (singleton) * * @returns The Indexing client instance * @throws Error if credentials are invalid */ export declare function getIndexingClient(): ReturnType<typeof indexing>; /** * Get the Search Console site URL * * @returns Site URL from settings */ export declare function getSiteUrl(): string; //# sourceMappingURL=client.d.ts.map FILE:scripts/dist/core/client.js /** * GA4 API Client - Singleton wrapper for BetaAnalyticsDataClient * Also includes Search Console and Indexing API clients */ import { BetaAnalyticsDataClient } from '@google-analytics/data'; import { searchconsole } from '@googleapis/searchconsole'; import { indexing } from '@googleapis/indexing'; import { google } from 'googleapis'; import { getSettings, validateSettings } from '../config/settings.js'; // Singleton client instances let clientInstance = null; let searchConsoleClientInstance = null; let indexingClientInstance = null; /** * Get the GA4 Analytics Data API client (singleton) * * @returns The BetaAnalyticsDataClient instance * @throws Error if credentials are invalid */ export function getClient() { if (clientInstance) { return clientInstance; } const validation = validateSettings(); if (!validation.valid) { throw new Error(`Invalid GA4 credentials: validation.errors.join(', ')`); } const settings = getSettings(); clientInstance = new BetaAnalyticsDataClient({ credentials: { client_email: settings.clientEmail, private_key: settings.privateKey, }, }); return clientInstance; } /** * Get the GA4 property ID formatted for API calls * * @returns Property ID with "properties/" prefix */ export function getPropertyId() { const settings = getSettings(); return `properties/settings.propertyId`; } /** * Reset the client singleton (useful for testing) */ export function resetClient() { clientInstance = null; searchConsoleClientInstance = null; indexingClientInstance = null; } /** * Get Google Auth client for Search Console and Indexing APIs */ function getGoogleAuth() { const settings = getSettings(); return new google.auth.GoogleAuth({ credentials: { client_email: settings.clientEmail, private_key: settings.privateKey, }, scopes: [ 'https://www.googleapis.com/auth/webmasters.readonly', 'https://www.googleapis.com/auth/indexing', ], }); } /** * Get the Search Console API client (singleton) * * @returns The Search Console client instance * @throws Error if credentials are invalid */ export function getSearchConsoleClient() { if (searchConsoleClientInstance) { return searchConsoleClientInstance; } const validation = validateSettings(); if (!validation.valid) { throw new Error(`Invalid credentials: validation.errors.join(', ')`); } const auth = getGoogleAuth(); searchConsoleClientInstance = searchconsole({ version: 'v1', auth }); return searchConsoleClientInstance; } /** * Get the Indexing API client (singleton) * * @returns The Indexing client instance * @throws Error if credentials are invalid */ export function getIndexingClient() { if (indexingClientInstance) { return indexingClientInstance; } const validation = validateSettings(); if (!validation.valid) { throw new Error(`Invalid credentials: validation.errors.join(', ')`); } const auth = getGoogleAuth(); indexingClientInstance = indexing({ version: 'v3', auth }); return indexingClientInstance; } /** * Get the Search Console site URL * * @returns Site URL from settings */ export function getSiteUrl() { const settings = getSettings(); return settings.siteUrl; } //# sourceMappingURL=client.js.map FILE:scripts/dist/core/storage.d.ts /** * Storage Module - Auto-save results to JSON files with metadata */ /** * Metadata wrapper for saved results */ export interface ResultMetadata { savedAt: string; category: string; operation: string; propertyId: string; extraInfo?: string; } /** * Wrapped result with metadata */ export interface SavedResult<T = unknown> { metadata: ResultMetadata; data: T; } /** * Save result data to a JSON file with metadata wrapper * * @param data - The data to save * @param category - Category directory (e.g., 'reports', 'realtime') * @param operation - Operation name (e.g., 'page_views', 'traffic_sources') * @param extraInfo - Optional extra info for filename * @returns The full path to the saved file */ export declare function saveResult<T>(data: T, category: string, operation: string, extraInfo?: string): string; /** * Load a saved result from a JSON file * * @param filepath - Path to the JSON file * @returns The parsed result or null if file doesn't exist */ export declare function loadResult<T = unknown>(filepath: string): SavedResult<T> | null; /** * List saved result files for a category * * @param category - Category to list results for * @param limit - Maximum number of results to return * @returns Array of file paths, sorted by date descending (newest first) */ export declare function listResults(category: string, limit?: number): string[]; /** * Get the most recent result for a category/operation * * @param category - Category to search * @param operation - Optional operation to filter by * @returns The most recent result or null */ export declare function getLatestResult<T = unknown>(category: string, operation?: string): SavedResult<T> | null; //# sourceMappingURL=storage.d.ts.map FILE:scripts/dist/core/storage.js /** * Storage Module - Auto-save results to JSON files with metadata */ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from 'fs'; import { join } from 'path'; import { getSettings } from '../config/settings.js'; /** * Generate timestamp string for filenames: YYYYMMDD_HHMMSS */ function getTimestamp() { const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); const hours = String(now.getHours()).padStart(2, '0'); const minutes = String(now.getMinutes()).padStart(2, '0'); const seconds = String(now.getSeconds()).padStart(2, '0'); return `yearmonthday_hoursminutesseconds`; } /** * Sanitize string for use in filename */ function sanitizeFilename(str) { return str.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase(); } /** * Save result data to a JSON file with metadata wrapper * * @param data - The data to save * @param category - Category directory (e.g., 'reports', 'realtime') * @param operation - Operation name (e.g., 'page_views', 'traffic_sources') * @param extraInfo - Optional extra info for filename * @returns The full path to the saved file */ export function saveResult(data, category, operation, extraInfo) { const settings = getSettings(); const categoryDir = join(settings.resultsDir, category); // Ensure category directory exists if (!existsSync(categoryDir)) { mkdirSync(categoryDir, { recursive: true }); } // Build filename const timestamp = getTimestamp(); const sanitizedOperation = sanitizeFilename(operation); const sanitizedExtra = extraInfo ? `__sanitizeFilename(extraInfo)` : ''; const filename = `timestamp__sanitizedOperationsanitizedExtra.json`; const filepath = join(categoryDir, filename); // Build wrapped result const result = { metadata: { savedAt: new Date().toISOString(), category, operation, propertyId: settings.propertyId, ...(extraInfo && { extraInfo }), }, data, }; // Write to file writeFileSync(filepath, JSON.stringify(result, null, 2), 'utf-8'); return filepath; } /** * Load a saved result from a JSON file * * @param filepath - Path to the JSON file * @returns The parsed result or null if file doesn't exist */ export function loadResult(filepath) { if (!existsSync(filepath)) { return null; } try { const content = readFileSync(filepath, 'utf-8'); return JSON.parse(content); } catch { return null; } } /** * List saved result files for a category * * @param category - Category to list results for * @param limit - Maximum number of results to return * @returns Array of file paths, sorted by date descending (newest first) */ export function listResults(category, limit) { const settings = getSettings(); const categoryDir = join(settings.resultsDir, category); if (!existsSync(categoryDir)) { return []; } const files = readdirSync(categoryDir) .filter(f => f.endsWith('.json')) .map(f => join(categoryDir, f)) .sort((a, b) => { // Sort by filename (which starts with timestamp) descending const nameA = a.split('/').pop() || ''; const nameB = b.split('/').pop() || ''; return nameB.localeCompare(nameA); }); if (limit !== undefined) { return files.slice(0, limit); } return files; } /** * Get the most recent result for a category/operation * * @param category - Category to search * @param operation - Optional operation to filter by * @returns The most recent result or null */ export function getLatestResult(category, operation) { let files = listResults(category); if (operation) { const sanitized = sanitizeFilename(operation); files = files.filter(f => f.includes(`__sanitized`)); } if (files.length === 0) { return null; } return loadResult(files[0]); } //# sourceMappingURL=storage.js.map FILE:scripts/dist/index.d.ts /** * GA4 Analytics Toolkit - Main Entry Point * * Simple interface for Google Analytics 4 data analysis. * All results are automatically saved to the /results directory with timestamps. * * Usage: * import { siteOverview, trafficAnalysis } from './index.js'; * const overview = await siteOverview('30d'); */ export * from './api/reports.js'; export * from './api/realtime.js'; export * from './api/metadata.js'; export * from './api/searchConsole.js'; export * from './api/indexing.js'; export * from './api/bulk-lookup.js'; export { getClient, getPropertyId, getSearchConsoleClient, getIndexingClient, getSiteUrl, resetClient } from './core/client.js'; export { saveResult, loadResult, listResults, getLatestResult } from './core/storage.js'; export { getSettings, validateSettings } from './config/settings.js'; import { type DateRange } from './api/reports.js'; import { type SearchConsoleDateRange } from './api/searchConsole.js'; /** * Comprehensive site overview - combines multiple reports */ export declare function siteOverview(dateRange?: string | DateRange): Promise<Record<string, unknown>>; /** * Deep dive on traffic sources */ export declare function trafficAnalysis(dateRange?: string | DateRange): Promise<Record<string, unknown>>; /** * Content performance analysis */ export declare function contentPerformance(dateRange?: string | DateRange): Promise<Record<string, unknown>>; /** * User behavior analysis */ export declare function userBehavior(dateRange?: string | DateRange): Promise<Record<string, unknown>>; /** * Compare two date ranges */ export declare function compareDateRanges(range1: DateRange, range2: DateRange, dimensions?: string[], metrics?: string[]): Promise<{ period1: { dateRange: DateRange; data: import("./api/reports.js").ReportResponse; }; period2: { dateRange: DateRange; data: import("./api/reports.js").ReportResponse; }; }>; /** * Get current live data snapshot */ export declare function liveSnapshot(): Promise<Record<string, unknown>>; /** * Comprehensive Search Console overview */ export declare function searchConsoleOverview(dateRange?: string | SearchConsoleDateRange): Promise<Record<string, unknown>>; /** * Deep dive into keyword/query analysis */ export declare function keywordAnalysis(dateRange?: string | SearchConsoleDateRange): Promise<Record<string, unknown>>; /** * SEO page performance analysis */ export declare function seoPagePerformance(dateRange?: string | SearchConsoleDateRange): Promise<Record<string, unknown>>; /** * Request re-indexing for updated URLs */ export declare function reindexUrls(urls: string[]): Promise<{ url: string; status: string; error?: string; }[]>; /** * Check index status for URLs */ export declare function checkIndexStatus(urls: string[]): Promise<{ url: string; indexed: boolean; status: unknown; }[]>; /** * Get available dimensions and metrics */ export declare function getAvailableFields(): Promise<import("./api/metadata.js").MetadataResponse>; //# sourceMappingURL=index.d.ts.map FILE:scripts/dist/index.js /** * GA4 Analytics Toolkit - Main Entry Point * * Simple interface for Google Analytics 4 data analysis. * All results are automatically saved to the /results directory with timestamps. * * Usage: * import { siteOverview, trafficAnalysis } from './index.js'; * const overview = await siteOverview('30d'); */ // Re-export all API functions export * from './api/reports.js'; export * from './api/realtime.js'; export * from './api/metadata.js'; export * from './api/searchConsole.js'; export * from './api/indexing.js'; export * from './api/bulk-lookup.js'; // Re-export core utilities export { getClient, getPropertyId, getSearchConsoleClient, getIndexingClient, getSiteUrl, resetClient } from './core/client.js'; export { saveResult, loadResult, listResults, getLatestResult } from './core/storage.js'; export { getSettings, validateSettings } from './config/settings.js'; // Import for orchestration functions import { runReport, getPageViews, getTrafficSources, getUserDemographics, getEventCounts, } from './api/reports.js'; import { getActiveUsers, getRealtimeEvents, getRealtimePages } from './api/realtime.js'; import { getPropertyMetadata } from './api/metadata.js'; import { saveResult } from './core/storage.js'; import { getTopQueries, getTopPages as getSearchConsoleTopPages, getDevicePerformance, getCountryPerformance, } from './api/searchConsole.js'; import { requestIndexing, inspectUrl } from './api/indexing.js'; // ============================================================================ // HIGH-LEVEL ORCHESTRATION FUNCTIONS // ============================================================================ /** * Comprehensive site overview - combines multiple reports */ export async function siteOverview(dateRange) { console.log('\n📊 Generating site overview...'); const results = {}; console.log(' → Getting page views...'); results.pageViews = await getPageViews(dateRange); console.log(' → Getting traffic sources...'); results.trafficSources = await getTrafficSources(dateRange); console.log(' → Getting user demographics...'); results.demographics = await getUserDemographics(dateRange); console.log(' → Getting event counts...'); results.events = await getEventCounts(dateRange); // Save combined results const dateStr = typeof dateRange === 'string' ? dateRange : 'custom'; saveResult(results, 'reports', 'site_overview', dateStr); console.log('✅ Site overview complete\n'); return results; } /** * Deep dive on traffic sources */ export async function trafficAnalysis(dateRange) { console.log('\n🚗 Analyzing traffic sources...'); const results = {}; console.log(' → Getting traffic sources...'); results.sources = await getTrafficSources(dateRange); console.log(' → Getting session data by source...'); results.sessions = await runReport({ dimensions: ['sessionSource', 'sessionMedium'], metrics: ['sessions', 'engagedSessions', 'averageSessionDuration', 'bounceRate'], dateRange, }); console.log(' → Getting new vs returning users...'); results.newVsReturning = await runReport({ dimensions: ['newVsReturning'], metrics: ['activeUsers', 'sessions', 'conversions'], dateRange, }); const dateStr = typeof dateRange === 'string' ? dateRange : 'custom'; saveResult(results, 'reports', 'traffic_analysis', dateStr); console.log('✅ Traffic analysis complete\n'); return results; } /** * Content performance analysis */ export async function contentPerformance(dateRange) { console.log('\n📄 Analyzing content performance...'); const results = {}; console.log(' → Getting page views...'); results.pages = await getPageViews(dateRange); console.log(' → Getting landing pages...'); results.landingPages = await runReport({ dimensions: ['landingPage'], metrics: ['sessions', 'activeUsers', 'bounceRate', 'averageSessionDuration'], dateRange, }); console.log(' → Getting exit pages...'); results.exitPages = await runReport({ dimensions: ['pagePath'], metrics: ['exits', 'screenPageViews'], dateRange, }); const dateStr = typeof dateRange === 'string' ? dateRange : 'custom'; saveResult(results, 'reports', 'content_performance', dateStr); console.log('✅ Content performance analysis complete\n'); return results; } /** * User behavior analysis */ export async function userBehavior(dateRange) { console.log('\n👤 Analyzing user behavior...'); const results = {}; console.log(' → Getting demographics...'); results.demographics = await getUserDemographics(dateRange); console.log(' → Getting events...'); results.events = await getEventCounts(dateRange); console.log(' → Getting engagement metrics...'); results.engagement = await runReport({ dimensions: ['date'], metrics: ['activeUsers', 'engagedSessions', 'engagementRate', 'averageSessionDuration'], dateRange, }); const dateStr = typeof dateRange === 'string' ? dateRange : 'custom'; saveResult(results, 'reports', 'user_behavior', dateStr); console.log('✅ User behavior analysis complete\n'); return results; } /** * Compare two date ranges */ export async function compareDateRanges(range1, range2, dimensions = ['date'], metrics = ['activeUsers', 'sessions', 'screenPageViews']) { console.log('\n📈 Comparing date ranges...'); console.log(` → Getting data for range1.startDate to range1.endDate...`); const period1 = await runReport({ dimensions, metrics, dateRange: range1, save: false, }); console.log(` → Getting data for range2.startDate to range2.endDate...`); const period2 = await runReport({ dimensions, metrics, dateRange: range2, save: false, }); const comparison = { period1: { dateRange: range1, data: period1 }, period2: { dateRange: range2, data: period2 }, }; saveResult(comparison, 'reports', 'date_comparison'); console.log('✅ Date range comparison complete\n'); return comparison; } /** * Get current live data snapshot */ export async function liveSnapshot() { console.log('\n⚡ Getting live data snapshot...'); const results = {}; console.log(' → Getting active users...'); results.activeUsers = await getActiveUsers(); console.log(' → Getting current pages...'); results.currentPages = await getRealtimePages(); console.log(' → Getting current events...'); results.currentEvents = await getRealtimeEvents(); saveResult(results, 'realtime', 'snapshot'); console.log('✅ Live snapshot complete\n'); return results; } // ============================================================================ // SEARCH CONSOLE ORCHESTRATION FUNCTIONS // ============================================================================ /** * Comprehensive Search Console overview */ export async function searchConsoleOverview(dateRange) { console.log('\n🔍 Generating Search Console overview...'); const results = {}; console.log(' → Getting top queries...'); results.topQueries = await getTopQueries(dateRange); console.log(' → Getting top pages...'); results.topPages = await getSearchConsoleTopPages(dateRange); console.log(' → Getting device performance...'); results.devicePerformance = await getDevicePerformance(dateRange); console.log(' → Getting country performance...'); results.countryPerformance = await getCountryPerformance(dateRange); const dateStr = typeof dateRange === 'string' ? dateRange : 'custom'; saveResult(results, 'searchconsole', 'overview', dateStr); console.log('✅ Search Console overview complete\n'); return results; } /** * Deep dive into keyword/query analysis */ export async function keywordAnalysis(dateRange) { console.log('\n🔑 Analyzing keywords...'); const results = {}; console.log(' → Getting top queries...'); results.queries = await getTopQueries(dateRange); console.log(' → Getting device breakdown for queries...'); results.deviceBreakdown = await getDevicePerformance(dateRange); const dateStr = typeof dateRange === 'string' ? dateRange : 'custom'; saveResult(results, 'searchconsole', 'keyword_analysis', dateStr); console.log('✅ Keyword analysis complete\n'); return results; } /** * SEO page performance analysis */ export async function seoPagePerformance(dateRange) { console.log('\n📄 Analyzing SEO page performance...'); const results = {}; console.log(' → Getting top pages by clicks...'); results.topPages = await getSearchConsoleTopPages(dateRange); console.log(' → Getting country breakdown...'); results.countryBreakdown = await getCountryPerformance(dateRange); const dateStr = typeof dateRange === 'string' ? dateRange : 'custom'; saveResult(results, 'searchconsole', 'seo_page_performance', dateStr); console.log('✅ SEO page performance analysis complete\n'); return results; } /** * Request re-indexing for updated URLs */ export async function reindexUrls(urls) { console.log(`\n🔄 Requesting re-indexing for urls.length URL(s)...`); const results = []; for (const url of urls) { try { console.log(` → Requesting indexing: url`); const result = await requestIndexing(url, { save: false }); results.push({ ...result, url, status: 'submitted' }); } catch (error) { console.log(` ✗ Failed: url`); results.push({ url, status: 'failed', error: String(error) }); } } saveResult(results, 'indexing', 'reindex_batch'); console.log('✅ Re-indexing requests complete\n'); return results; } /** * Check index status for URLs */ export async function checkIndexStatus(urls) { console.log(`\n🔎 Checking index status for urls.length URL(s)...`); const results = []; for (const url of urls) { try { console.log(` → Inspecting: url`); const result = await inspectUrl(url, { save: false }); results.push({ url, indexed: result.indexStatus.verdict === 'PASS', status: result.indexStatus, }); } catch (error) { console.log(` ✗ Failed: url`); results.push({ url, indexed: false, status: { error: String(error) } }); } } saveResult(results, 'indexing', 'index_status_check'); console.log('✅ Index status check complete\n'); return results; } // ============================================================================ // UTILITY FUNCTIONS // ============================================================================ /** * Get available dimensions and metrics */ export async function getAvailableFields() { console.log('\n📋 Getting available fields...'); const metadata = await getPropertyMetadata(); console.log(` → Found metadata.dimensions?.length || 0 dimensions`); console.log(` → Found metadata.metrics?.length || 0 metrics`); console.log('✅ Field retrieval complete\n'); return metadata; } // Print help when run directly if (process.argv[1] === new URL(import.meta.url).pathname) { console.log(` GA4 Analytics Toolkit ===================== GA4 High-level functions: - siteOverview(dateRange?) Comprehensive site snapshot - trafficAnalysis(dateRange?) Deep dive on sources - contentPerformance(dateRange?) Top pages analysis - userBehavior(dateRange?) Engagement patterns - compareDateRanges(range1, range2) Period comparison - liveSnapshot() Real-time data Search Console functions: - searchConsoleOverview(dateRange?) Combined SEO snapshot - keywordAnalysis(dateRange?) Query/keyword analysis - seoPagePerformance(dateRange?) Page-level SEO metrics - getTopQueries(dateRange?) Top search queries - getTopPages(dateRange?) Top pages by clicks - getDevicePerformance(dateRange?) Mobile vs desktop - getCountryPerformance(dateRange?) Traffic by country Indexing functions: - reindexUrls(urls) Request re-indexing for URLs - checkIndexStatus(urls) Check if URLs are indexed - requestIndexing(url) Request single URL re-crawl - inspectUrl(url) Inspect URL index status Low-level GA4 functions: - runReport({ dimensions, metrics, dateRange }) - getPageViews(dateRange?) - getTrafficSources(dateRange?) - getUserDemographics(dateRange?) - getEventCounts(dateRange?) - getActiveUsers() - getRealtimeEvents() - getPropertyMetadata() All results are automatically saved to /results directory. `); } //# sourceMappingURL=index.js.map FILE:scripts/package-lock.json { "name": "ga4-toolkit", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ga4-toolkit", "version": "1.0.0", "dependencies": { "@google-analytics/data": "^4.9.0", "@googleapis/indexing": "^6.0.1", "@googleapis/searchconsole": "^6.0.1", "dotenv": "^16.4.7", "googleapis": "^170.0.0", "tsx": "^4.19.2", "typescript": "^5.7.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", "cpu": [ "ppc64" ], "license": "MIT", "optional": true, "os": [ "aix" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/android-arm": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", "cpu": [ "arm" ], "license": "MIT", "optional": true, "os": [ "android" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", "cpu": [ "arm64" ], "license": "MIT", "optional": true, "os": [ "android" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/android-x64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", "cpu": [ "x64" ], "license": "MIT", "optional": true, "os": [ "android" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", "cpu": [ "arm64" ], "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", "cpu": [ "x64" ], "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", "cpu": [ "arm64" ], "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", "cpu": [ "x64" ], "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", "cpu": [ "arm" ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", "cpu": [ "arm64" ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", "cpu": [ "ia32" ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", "cpu": [ "loong64" ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", "cpu": [ "mips64el" ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", "cpu": [ "ppc64" ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", "cpu": [ "riscv64" ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", "cpu": [ "s390x" ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", "cpu": [ "x64" ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/netbsd-arm64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", "cpu": [ "arm64" ], "license": "MIT", "optional": true, "os": [ "netbsd" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", "cpu": [ "x64" ], "license": "MIT", "optional": true, "os": [ "netbsd" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/openbsd-arm64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", "cpu": [ "arm64" ], "license": "MIT", "optional": true, "os": [ "openbsd" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", "cpu": [ "x64" ], "license": "MIT", "optional": true, "os": [ "openbsd" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/openharmony-arm64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", "cpu": [ "arm64" ], "license": "MIT", "optional": true, "os": [ "openharmony" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", "cpu": [ "x64" ], "license": "MIT", "optional": true, "os": [ "sunos" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", "cpu": [ "arm64" ], "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", "cpu": [ "ia32" ], "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", "cpu": [ "x64" ], "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { "node": ">=18" } }, "node_modules/@google-analytics/data": { "version": "4.12.1", "resolved": "https://registry.npmjs.org/@google-analytics/data/-/data-4.12.1.tgz", "integrity": "sha512-LzyrkVrnVUTYTmdmHayOZoroc+YA9GHEUrkSSuiXSmMSNbesuWy/MoTXugC1V7+8PCGqb2eQ1UtVVv/2BCAQYA==", "license": "Apache-2.0", "dependencies": { "google-gax": "^4.0.3" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@googleapis/indexing": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@googleapis/indexing/-/indexing-6.0.1.tgz", "integrity": "sha512-JQuHax6UaTv9Y/sYsmlyRe5nZvPKPjHfe6AhM2wnl3xg2iuD6MGAU3wX3SdjOiM6r6iEggGNTfMdrtsZhuR5AA==", "license": "Apache-2.0", "dependencies": { "googleapis-common": "^8.0.0" }, "engines": { "node": ">=12.0.0" } }, "node_modules/@googleapis/searchconsole": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@googleapis/searchconsole/-/searchconsole-6.0.1.tgz", "integrity": "sha512-aSBv0yp9HV9BgqX3bUj2DRY2kaIwcoN6ift+fKwPXj8eMoccJZzceQNshxt44V2ECkqwn7gvVEXVYTkw6zaTeQ==", "license": "Apache-2.0", "dependencies": { "googleapis-common": "^8.0.0" }, "engines": { "node": ">=12.0.0" } }, "node_modules/@grpc/grpc-js": { "version": "1.14.3", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" }, "engines": { "node": ">=12.10.0" } }, "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", "license": "Apache-2.0", "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" }, "engines": { "node": ">=6" } }, "node_modules/@grpc/proto-loader": { "version": "0.7.15", "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", "license": "Apache-2.0", "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" }, "engines": { "node": ">=6" } }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "license": "ISC", "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" }, "engines": { "node": ">=12" } }, "node_modules/@isaacs/cliui/node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" }, "funding": { "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, "node_modules/@isaacs/cliui/node_modules/ansi-styles": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", "engines": { "node": ">=12" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/@isaacs/cliui/node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" }, "engines": { "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/@isaacs/cliui/node_modules/strip-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" }, "engines": { "node": ">=12" }, "funding": { "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" }, "engines": { "node": ">=12" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/@js-sdsl/ordered-map": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/js-sdsl" } }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "license": "MIT", "optional": true, "engines": { "node": ">=14" } }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/base64": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "node_modules/@protobufjs/float": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/inquire": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/pool": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", "license": "MIT", "engines": { "node": ">= 10" } }, "node_modules/@types/caseless": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", "license": "MIT" }, "node_modules/@types/long": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", "license": "MIT" }, "node_modules/@types/node": { "version": "25.0.10", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz", "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", "license": "MIT", "dependencies": { "undici-types": "~7.16.0" } }, "node_modules/@types/request": { "version": "2.48.13", "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", "license": "MIT", "dependencies": { "@types/caseless": "*", "@types/node": "*", "@types/tough-cookie": "*", "form-data": "^2.5.5" } }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", "license": "MIT" }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", "license": "MIT", "dependencies": { "event-target-shim": "^5.0.0" }, "engines": { "node": ">=6.5" } }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "license": "MIT", "engines": { "node": ">= 14" } }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, "engines": { "node": ">=8" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/feross" }, { "type": "patreon", "url": "https://www.patreon.com/feross" }, { "type": "consulting", "url": "https://feross.org/support" } ], "license": "MIT" }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", "license": "MIT", "engines": { "node": "*" } }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" }, "engines": { "node": ">= 0.4" } }, "node_modules/call-bound": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "license": "ISC", "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" }, "engines": { "node": ">=12" } }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, "engines": { "node": ">=7.0.0" } }, "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" }, "engines": { "node": ">= 0.8" } }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" }, "engines": { "node": ">= 8" } }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", "license": "MIT", "engines": { "node": ">= 12" } }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" }, "engines": { "node": ">=6.0" }, "peerDependenciesMeta": { "supports-color": { "optional": true } } }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "license": "MIT", "engines": { "node": ">=0.4.0" } }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "license": "BSD-2-Clause", "engines": { "node": ">=12" }, "funding": { "url": "https://dotenvx.com" } }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" }, "engines": { "node": ">= 0.4" } }, "node_modules/duplexify": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", "license": "MIT", "dependencies": { "end-of-stream": "^1.4.1", "inherits": "^2.0.3", "readable-stream": "^3.1.1", "stream-shift": "^1.0.2" } }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" } }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "license": "MIT", "dependencies": { "once": "^1.4.0" } }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", "engines": { "node": ">= 0.4" } }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", "engines": { "node": ">= 0.4" } }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" }, "engines": { "node": ">= 0.4" } }, "node_modules/es-set-tostringtag": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" } }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "hasInstallScript": true, "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, "engines": { "node": ">=18" }, "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" } }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/jimmywarting" }, { "type": "paypal", "url": "https://paypal.me/jimmywarting" } ], "license": "MIT", "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" }, "engines": { "node": "^12.20 || >= 14.13" } }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" }, "engines": { "node": ">=14" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/form-data": { "version": "2.5.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.35", "safe-buffer": "^5.2.1" }, "engines": { "node": ">= 0.12" } }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", "license": "MIT", "dependencies": { "fetch-blob": "^3.1.2" }, "engines": { "node": ">=12.20.0" } }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/gaxios": { "version": "6.7.1", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", "license": "Apache-2.0", "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "is-stream": "^2.0.0", "node-fetch": "^2.6.9", "uuid": "^9.0.1" }, "engines": { "node": ">=14" } }, "node_modules/gcp-metadata": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", "license": "Apache-2.0", "dependencies": { "gaxios": "^6.1.1", "google-logging-utils": "^0.0.2", "json-bigint": "^1.0.0" }, "engines": { "node": ">=14" } }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" } }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" } }, "node_modules/get-tsconfig": { "version": "4.13.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" }, "funding": { "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/google-auth-library": { "version": "9.15.1", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", "license": "Apache-2.0", "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" }, "engines": { "node": ">=14" } }, "node_modules/google-gax": { "version": "4.6.1", "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", "integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==", "license": "Apache-2.0", "dependencies": { "@grpc/grpc-js": "^1.10.9", "@grpc/proto-loader": "^0.7.13", "@types/long": "^4.0.0", "abort-controller": "^3.0.0", "duplexify": "^4.0.0", "google-auth-library": "^9.3.0", "node-fetch": "^2.7.0", "object-hash": "^3.0.0", "proto3-json-serializer": "^2.0.2", "protobufjs": "^7.3.2", "retry-request": "^7.0.0", "uuid": "^9.0.1" }, "engines": { "node": ">=14" } }, "node_modules/google-logging-utils": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", "license": "Apache-2.0", "engines": { "node": ">=14" } }, "node_modules/googleapis": { "version": "170.1.0", "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-170.1.0.tgz", "integrity": "sha512-RLbc7yG6qzZqvAmGcgjvNIoZ7wpcCFxtc+HN+46etxDrlO4a8l5Cb7NxNQGhV91oRmL7mt56VoRoypAtEQEIKg==", "license": "Apache-2.0", "dependencies": { "google-auth-library": "^10.2.0", "googleapis-common": "^8.0.0" }, "engines": { "node": ">=18" } }, "node_modules/googleapis-common": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.1.tgz", "integrity": "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==", "license": "Apache-2.0", "dependencies": { "extend": "^3.0.2", "gaxios": "^7.0.0-rc.4", "google-auth-library": "^10.1.0", "qs": "^6.7.0", "url-template": "^2.0.8" }, "engines": { "node": ">=18.0.0" } }, "node_modules/googleapis-common/node_modules/gaxios": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", "license": "Apache-2.0", "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" }, "engines": { "node": ">=18" } }, "node_modules/googleapis-common/node_modules/gcp-metadata": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", "license": "Apache-2.0", "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" }, "engines": { "node": ">=18" } }, "node_modules/googleapis-common/node_modules/google-auth-library": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", "license": "Apache-2.0", "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" }, "engines": { "node": ">=18" } }, "node_modules/googleapis-common/node_modules/google-logging-utils": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", "license": "Apache-2.0", "engines": { "node": ">=14" } }, "node_modules/googleapis-common/node_modules/gtoken": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", "license": "MIT", "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" }, "engines": { "node": ">=18" } }, "node_modules/googleapis-common/node_modules/node-fetch": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", "license": "MIT", "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" }, "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/node-fetch" } }, "node_modules/googleapis/node_modules/gaxios": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", "license": "Apache-2.0", "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" }, "engines": { "node": ">=18" } }, "node_modules/googleapis/node_modules/gcp-metadata": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", "license": "Apache-2.0", "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" }, "engines": { "node": ">=18" } }, "node_modules/googleapis/node_modules/google-auth-library": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", "license": "Apache-2.0", "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" }, "engines": { "node": ">=18" } }, "node_modules/googleapis/node_modules/google-logging-utils": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", "license": "Apache-2.0", "engines": { "node": ">=14" } }, "node_modules/googleapis/node_modules/gtoken": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", "license": "MIT", "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" }, "engines": { "node": ">=18" } }, "node_modules/googleapis/node_modules/node-fetch": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", "license": "MIT", "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" }, "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/node-fetch" } }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/gtoken": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", "license": "MIT", "dependencies": { "gaxios": "^6.0.0", "jws": "^4.0.0" }, "engines": { "node": ">=14.0.0" } }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/has-tostringtag": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, "engines": { "node": ">= 0.4" } }, "node_modules/http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", "license": "MIT", "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" }, "engines": { "node": ">= 6" } }, "node_modules/http-proxy-agent/node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "license": "MIT", "dependencies": { "debug": "4" }, "engines": { "node": ">= 6.0.0" } }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "license": "MIT", "dependencies": { "agent-base": "^7.1.2", "debug": "4" }, "engines": { "node": ">= 14" } }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "license": "MIT", "engines": { "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" }, "funding": { "url": "https://github.com/sponsors/isaacs" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", "license": "MIT", "dependencies": { "bignumber.js": "^9.0.0" } }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", "license": "MIT", "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "node_modules/jws": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", "license": "MIT", "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "license": "MIT" }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", "engines": { "node": ">= 0.4" } }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", "dependencies": { "mime-db": "1.52.0" }, "engines": { "node": ">= 0.6" } }, "node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "license": "ISC", "dependencies": { "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" } }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", "deprecated": "Use your platform's native DOMException instead", "funding": [ { "type": "github", "url": "https://github.com/sponsors/jimmywarting" }, { "type": "github", "url": "https://paypal.me/jimmywarting" } ], "license": "MIT", "engines": { "node": ">=10.5.0" } }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", "dependencies": { "whatwg-url": "^5.0.0" }, "engines": { "node": "4.x || >=6.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "peerDependenciesMeta": { "encoding": { "optional": true } } }, "node_modules/object-hash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", "license": "MIT", "engines": { "node": ">= 6" } }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "license": "ISC", "dependencies": { "wrappy": "1" } }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/proto3-json-serializer": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", "license": "Apache-2.0", "dependencies": { "protobufjs": "^7.2.5" }, "engines": { "node": ">=14.0.0" } }, "node_modules/protobufjs": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" }, "engines": { "node": ">=12.0.0" } }, "node_modules/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" }, "engines": { "node": ">= 6" } }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, "node_modules/retry-request": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", "license": "MIT", "dependencies": { "@types/request": "^2.48.8", "extend": "^3.0.2", "teeny-request": "^9.0.0" }, "engines": { "node": ">=14" } }, "node_modules/rimraf": { "version": "5.0.10", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", "license": "ISC", "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/feross" }, { "type": "patreon", "url": "https://www.patreon.com/feross" }, { "type": "consulting", "url": "https://feross.org/support" } ], "license": "MIT" }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" }, "engines": { "node": ">=8" } }, "node_modules/shebang-regex": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/side-channel-list": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/side-channel-map": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/side-channel-weakmap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "license": "ISC", "engines": { "node": ">=14" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/stream-events": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", "license": "MIT", "dependencies": { "stubs": "^3.0.0" } }, "node_modules/stream-shift": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", "license": "MIT" }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" } }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" }, "engines": { "node": ">=8" } }, "node_modules/string-width-cjs": { "name": "string-width", "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" }, "engines": { "node": ">=8" } }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, "engines": { "node": ">=8" } }, "node_modules/strip-ansi-cjs": { "name": "strip-ansi", "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, "engines": { "node": ">=8" } }, "node_modules/stubs": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", "license": "MIT" }, "node_modules/teeny-request": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", "license": "Apache-2.0", "dependencies": { "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.0", "node-fetch": "^2.6.9", "stream-events": "^1.0.5", "uuid": "^9.0.0" }, "engines": { "node": ">=14" } }, "node_modules/teeny-request/node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "license": "MIT", "dependencies": { "debug": "4" }, "engines": { "node": ">= 6.0.0" } }, "node_modules/teeny-request/node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", "license": "MIT", "dependencies": { "agent-base": "6", "debug": "4" }, "engines": { "node": ">= 6" } }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "license": "MIT", "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "bin": { "tsx": "dist/cli.mjs" }, "engines": { "node": ">=18.0.0" }, "optionalDependencies": { "fsevents": "~2.3.3" } }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { "node": ">=14.17" } }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, "node_modules/url-template": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", "license": "BSD" }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, "node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { "uuid": "dist/bin/uuid" } }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", "license": "MIT", "engines": { "node": ">= 8" } }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "license": "MIT", "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "license": "ISC", "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" }, "engines": { "node": ">= 8" } }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" }, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/wrap-ansi-cjs": { "name": "wrap-ansi", "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" }, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "license": "ISC", "engines": { "node": ">=10" } }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "license": "MIT", "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" }, "engines": { "node": ">=12" } }, "node_modules/yargs-parser": { "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "license": "ISC", "engines": { "node": ">=12" } } } } FILE:scripts/package.json { "name": "ga4-toolkit", "version": "1.0.0", "type": "module", "dependencies": { "@google-analytics/data": "^4.9.0", "@googleapis/indexing": "^6.0.1", "@googleapis/searchconsole": "^6.0.1", "dotenv": "^16.4.7", "googleapis": "^170.0.0", "tsx": "^4.19.2", "typescript": "^5.7.2" }, "engines": { "node": ">=18.0.0" } } FILE:scripts/setup.sh #!/bin/bash cd "$(dirname "$0")" && npm install FILE:scripts/src/api/bulk-lookup.ts /** * Bulk URL Lookup - Get GA4 metrics for specific page paths * * This module provides a convenient way to look up analytics data * for a list of specific URLs, similar to a bulk URL lookup field. */ import { getClient, getPropertyId } from '../core/client.js'; import { saveResult } from '../core/storage.js'; import { getSettings } from '../config/settings.js'; import type { ReportResponse, DateRange } from './reports.js'; /** * Options for bulk URL lookup */ export interface BulkLookupOptions { /** Date range (e.g., "7d", "30d") or explicit dates */ dateRange?: string | DateRange; /** Custom metrics to retrieve (defaults to standard page metrics) */ metrics?: string[]; /** Whether to save results to file (default: true) */ save?: boolean; } /** * Dimension filter expression for GA4 API */ export interface DimensionFilterExpression { filter: { fieldName: string; inListFilter?: { values: string[]; caseSensitive?: boolean; }; stringFilter?: { matchType: string; value: string; caseSensitive?: boolean; }; }; } /** * Default metrics for bulk URL lookup */ const DEFAULT_METRICS = [ 'screenPageViews', 'activeUsers', 'averageSessionDuration', 'bounceRate', 'engagementRate', ]; /** * Normalize URLs to ensure consistent format * * - Trims whitespace * - Adds leading slash if missing * - Preserves trailing slashes * * @param urls - Array of URLs to normalize * @returns Normalized URL array */ export function normalizeUrls(urls: string[]): string[] { return urls.map(url => { // Trim whitespace let normalized = url.trim(); // Add leading slash if missing if (!normalized.startsWith('/')) { normalized = '/' + normalized; } return normalized; }); } /** * Build a dimension filter expression for the given URLs * * @param urls - Array of page paths to filter by * @returns Filter expression or null if no URLs provided */ export function buildUrlFilter(urls: string[]): DimensionFilterExpression | null { if (urls.length === 0) { return null; } return { filter: { fieldName: 'pagePath', inListFilter: { values: urls, caseSensitive: false, }, }, }; } /** * Parse shorthand date range (e.g., "7d", "30d") to GA4 date range format */ function parseDateRange(range: string | DateRange | undefined): DateRange { if (!range) { const settings = getSettings(); range = settings.defaultDateRange; } if (typeof range === 'object') { return range; } // Parse shorthand like "7d", "30d", "90d" const match = range.match(/^(\d+)d$/); if (match) { const days = parseInt(match[1], 10); return { startDate: `daysdaysAgo`, endDate: 'today', }; } // Default to 30 days return { startDate: '30daysAgo', endDate: 'today', }; } /** * Get GA4 metrics for specific page paths (bulk URL lookup) * * @param urls - Array of page paths to look up (e.g., ['/pricing', '/about']) * @param options - Optional configuration * @returns Report response with metrics for the specified URLs * * @example * ```typescript * // Get metrics for specific pages * const result = await getMetricsForUrls(['/pricing', '/about', '/blog']); * * // With custom date range and metrics * const result = await getMetricsForUrls(['/pricing'], { * dateRange: '7d', * metrics: ['sessions', 'bounceRate'], * }); * ``` */ export async function getMetricsForUrls( urls: string[], options: BulkLookupOptions = {} ): Promise<ReportResponse> { const { dateRange, metrics = DEFAULT_METRICS, save = true } = options; // Normalize URLs const normalizedUrls = normalizeUrls(urls); // Handle empty URL array if (normalizedUrls.length === 0) { return { rows: [], rowCount: 0, }; } // Build filter const dimensionFilter = buildUrlFilter(normalizedUrls); // Get client and property const client = getClient(); const property = getPropertyId(); const parsedDateRange = parseDateRange(dateRange); // Build and execute request const request = { property, dateRanges: [parsedDateRange], dimensions: [{ name: 'pagePath' }, { name: 'pageTitle' }], metrics: metrics.map(name => ({ name })), dimensionFilter: dimensionFilter as any, }; const response = ((await client.runReport(request)) as any)[0]; // Save results if requested if (save) { const dateStr = typeof dateRange === 'string' ? dateRange : 'custom'; saveResult(response, 'reports', 'bulk_url_lookup', dateStr); } return response as ReportResponse; } FILE:scripts/src/api/indexing.ts /** * Indexing API - Request re-indexing and URL inspection */ import { getIndexingClient, getSearchConsoleClient, getSiteUrl } from '../core/client.js'; import { saveResult } from '../core/storage.js'; /** * Indexing request options */ export interface IndexingOptions { save?: boolean; } /** * URL notification result */ export interface UrlNotificationResult { url: string; type: 'URL_UPDATED' | 'URL_DELETED'; notifyTime: string; } /** * URL inspection result */ export interface UrlInspectionResult { inspectionResultLink?: string; indexStatus: { verdict: 'PASS' | 'FAIL' | 'NEUTRAL'; coverageState: string; robotsTxtState?: string; indexingState?: string; lastCrawlTime?: string; pageFetchState?: string; googleCanonical?: string; userCanonical?: string; crawledAs?: string; }; mobileUsability?: { verdict: string; issues?: unknown[]; }; richResults?: { verdict: string; detectedItems?: unknown[]; }; } /** * Request indexing for a single URL (notify Google to re-crawl) * * @param url - The URL to request indexing for * @param options - Optional settings (save to file, etc.) * @returns Notification result with timestamp */ export async function requestIndexing(url: string, options: IndexingOptions = {}): Promise<UrlNotificationResult> { const { save = true } = options; const client = getIndexingClient(); const response = await client.urlNotifications.publish({ requestBody: { url, type: 'URL_UPDATED', }, }); const result: UrlNotificationResult = { url: response.data.urlNotificationMetadata?.url || url, type: 'URL_UPDATED', notifyTime: response.data.urlNotificationMetadata?.latestUpdate?.notifyTime || new Date().toISOString(), }; if (save) { saveResult(result, 'indexing', 'request_indexing'); } return result; } /** * Request indexing for multiple URLs * * @param urls - Array of URLs to request indexing for * @param options - Optional settings * @returns Array of notification results */ export async function requestIndexingBatch(urls: string[], options: IndexingOptions = {}): Promise<UrlNotificationResult[]> { const { save = true } = options; const results: UrlNotificationResult[] = []; // Process URLs sequentially to avoid rate limiting for (const url of urls) { const result = await requestIndexing(url, { save: false }); results.push(result); } if (save) { saveResult(results, 'indexing', 'batch_indexing'); } return results; } /** * Request URL removal from index * * @param url - The URL to request removal for * @param options - Optional settings * @returns Notification result */ export async function removeFromIndex(url: string, options: IndexingOptions = {}): Promise<UrlNotificationResult> { const { save = true } = options; const client = getIndexingClient(); const response = await client.urlNotifications.publish({ requestBody: { url, type: 'URL_DELETED', }, }); const result: UrlNotificationResult = { url: response.data.urlNotificationMetadata?.url || url, type: 'URL_DELETED', notifyTime: response.data.urlNotificationMetadata?.latestRemove?.notifyTime || new Date().toISOString(), }; if (save) { saveResult(result, 'indexing', 'remove_from_index'); } return result; } /** * Inspect a URL to check its index status * * @param url - The URL to inspect * @param options - Optional settings * @returns URL inspection result with index status */ export async function inspectUrl(url: string, options: IndexingOptions = {}): Promise<UrlInspectionResult> { const { save = true } = options; const client = getSearchConsoleClient(); const siteUrl = getSiteUrl(); const response = await client.urlInspection.index.inspect({ requestBody: { inspectionUrl: url, siteUrl, }, }); const inspectionResult = response.data.inspectionResult; const result: UrlInspectionResult = { inspectionResultLink: inspectionResult?.inspectionResultLink || undefined, indexStatus: { verdict: (inspectionResult?.indexStatusResult?.verdict as 'PASS' | 'FAIL' | 'NEUTRAL') || 'NEUTRAL', coverageState: inspectionResult?.indexStatusResult?.coverageState || 'Unknown', robotsTxtState: inspectionResult?.indexStatusResult?.robotsTxtState || undefined, indexingState: inspectionResult?.indexStatusResult?.indexingState || undefined, lastCrawlTime: inspectionResult?.indexStatusResult?.lastCrawlTime || undefined, pageFetchState: inspectionResult?.indexStatusResult?.pageFetchState || undefined, googleCanonical: inspectionResult?.indexStatusResult?.googleCanonical || undefined, userCanonical: inspectionResult?.indexStatusResult?.userCanonical || undefined, crawledAs: inspectionResult?.indexStatusResult?.crawledAs || undefined, }, mobileUsability: inspectionResult?.mobileUsabilityResult ? { verdict: inspectionResult.mobileUsabilityResult.verdict || 'NEUTRAL', issues: inspectionResult.mobileUsabilityResult.issues || [], } : undefined, richResults: inspectionResult?.richResultsResult ? { verdict: inspectionResult.richResultsResult.verdict || 'NEUTRAL', detectedItems: inspectionResult.richResultsResult.detectedItems || [], } : undefined, }; if (save) { saveResult(result, 'indexing', 'url_inspection'); } return result; } FILE:scripts/src/api/metadata.ts /** * Metadata API - Available dimensions and metrics */ import { getClient, getPropertyId } from '../core/client.js'; import { saveResult } from '../core/storage.js'; /** * Dimension metadata */ export interface DimensionMetadata { apiName: string; uiName: string; description: string; } /** * Metric metadata */ export interface MetricMetadata { apiName: string; uiName: string; description: string; } /** * Full property metadata response */ export interface MetadataResponse { name?: string; dimensions?: DimensionMetadata[]; metrics?: MetricMetadata[]; } /** * Get all available dimensions for the property */ export async function getAvailableDimensions(save = true): Promise<MetadataResponse> { const client = getClient(); const property = getPropertyId(); const [response] = await client.getMetadata({ name: `property/metadata`, }); const result = { dimensions: response.dimensions || [], }; if (save) { saveResult(result, 'metadata', 'dimensions'); } return result as MetadataResponse; } /** * Get all available metrics for the property */ export async function getAvailableMetrics(save = true): Promise<MetadataResponse> { const client = getClient(); const property = getPropertyId(); const [response] = await client.getMetadata({ name: `property/metadata`, }); const result = { metrics: response.metrics || [], }; if (save) { saveResult(result, 'metadata', 'metrics'); } return result as MetadataResponse; } /** * Get full property metadata (dimensions and metrics) */ export async function getPropertyMetadata(save = true): Promise<MetadataResponse> { const client = getClient(); const property = getPropertyId(); const [response] = await client.getMetadata({ name: `property/metadata`, }); if (save) { saveResult(response, 'metadata', 'full'); } return response as MetadataResponse; } FILE:scripts/src/api/realtime.ts /** * Realtime API - Live GA4 data */ import { getClient, getPropertyId } from '../core/client.js'; import { saveResult } from '../core/storage.js'; /** * Realtime report response structure */ export interface RealtimeResponse { dimensionHeaders?: Array<{ name: string }>; metricHeaders?: Array<{ name: string }>; rows?: Array<{ dimensionValues: Array<{ value: string }>; metricValues: Array<{ value: string }>; }>; rowCount?: number; } /** * Get current active users */ export async function getActiveUsers(save = true): Promise<RealtimeResponse> { const client = getClient(); const property = getPropertyId(); const [response] = await client.runRealtimeReport({ property, dimensions: [{ name: 'unifiedScreenName' }], metrics: [{ name: 'activeUsers' }], }); if (save) { saveResult(response, 'realtime', 'active_users'); } return response as RealtimeResponse; } /** * Get current event data */ export async function getRealtimeEvents(save = true): Promise<RealtimeResponse> { const client = getClient(); const property = getPropertyId(); const [response] = await client.runRealtimeReport({ property, dimensions: [{ name: 'eventName' }], metrics: [{ name: 'eventCount' }], }); if (save) { saveResult(response, 'realtime', 'events'); } return response as RealtimeResponse; } /** * Get currently viewed pages */ export async function getRealtimePages(save = true): Promise<RealtimeResponse> { const client = getClient(); const property = getPropertyId(); const [response] = await client.runRealtimeReport({ property, dimensions: [{ name: 'unifiedScreenName' }], metrics: [{ name: 'screenPageViews' }], }); if (save) { saveResult(response, 'realtime', 'pages'); } return response as RealtimeResponse; } /** * Get realtime traffic sources */ export async function getRealtimeSources(save = true): Promise<RealtimeResponse> { const client = getClient(); const property = getPropertyId(); const [response] = await client.runRealtimeReport({ property, dimensions: [{ name: 'firstUserSource' }, { name: 'firstUserMedium' }], metrics: [{ name: 'activeUsers' }], }); if (save) { saveResult(response, 'realtime', 'sources'); } return response as RealtimeResponse; } FILE:scripts/src/api/reports.ts /** * Reports API - Standard GA4 report generation */ import { getClient, getPropertyId } from '../core/client.js'; import { saveResult } from '../core/storage.js'; import { getSettings } from '../config/settings.js'; /** * Date range configuration */ export interface DateRange { startDate: string; endDate: string; } /** * Report options */ export interface ReportOptions { dimensions: string[]; metrics: string[]; dateRange?: string | DateRange; filters?: Record<string, string>; orderBy?: string[]; limit?: number; save?: boolean; } /** * Report response structure */ export interface ReportResponse { dimensionHeaders?: Array<{ name: string }>; metricHeaders?: Array<{ name: string }>; rows?: Array<{ dimensionValues: Array<{ value: string }>; metricValues: Array<{ value: string }>; }>; rowCount?: number; metadata?: Record<string, unknown>; } /** * Parse shorthand date range (e.g., "7d", "30d") to GA4 date range format */ export function parseDateRange(range: string | DateRange | undefined): DateRange { if (!range) { const settings = getSettings(); range = settings.defaultDateRange; } if (typeof range === 'object') { return range; } // Parse shorthand like "7d", "30d", "90d" const match = range.match(/^(\d+)d$/); if (match) { const days = parseInt(match[1], 10); return { startDate: `daysdaysAgo`, endDate: 'today', }; } // Default to 30 days return { startDate: '30daysAgo', endDate: 'today', }; } /** * Run a custom GA4 report */ export async function runReport(options: ReportOptions): Promise<ReportResponse> { const { dimensions, metrics, dateRange, filters, orderBy, limit, save = true, } = options; const client = getClient(); const property = getPropertyId(); const parsedDateRange = parseDateRange(dateRange); const request = { property, dateRanges: [parsedDateRange], dimensions: dimensions.map(name => ({ name })), metrics: metrics.map(name => ({ name })), ...(limit && { limit }), }; const [response] = await client.runReport(request); if (save) { const operation = dimensions.join('_') || 'custom'; const extra = typeof dateRange === 'string' ? dateRange : undefined; saveResult(response, 'reports', operation, extra); } return response as ReportResponse; } /** * Get page view data */ export async function getPageViews(dateRange?: string | DateRange): Promise<ReportResponse> { return runReport({ dimensions: ['pagePath', 'pageTitle'], metrics: ['screenPageViews', 'activeUsers', 'averageSessionDuration'], dateRange, }); } /** * Get traffic source data */ export async function getTrafficSources(dateRange?: string | DateRange): Promise<ReportResponse> { return runReport({ dimensions: ['sessionSource', 'sessionMedium', 'sessionCampaignName'], metrics: ['sessions', 'activeUsers', 'newUsers', 'bounceRate'], dateRange, }); } /** * Get user demographic data (country, device, browser) */ export async function getUserDemographics(dateRange?: string | DateRange): Promise<ReportResponse> { return runReport({ dimensions: ['country', 'deviceCategory', 'browser'], metrics: ['activeUsers', 'sessions', 'newUsers'], dateRange, }); } /** * Get event count data */ export async function getEventCounts(dateRange?: string | DateRange): Promise<ReportResponse> { return runReport({ dimensions: ['eventName'], metrics: ['eventCount', 'eventCountPerUser', 'activeUsers'], dateRange, }); } /** * Get conversion data */ export async function getConversions(dateRange?: string | DateRange): Promise<ReportResponse> { return runReport({ dimensions: ['eventName', 'sessionSource'], metrics: ['conversions', 'totalRevenue'], dateRange, }); } /** * Get e-commerce revenue data */ export async function getEcommerceRevenue(dateRange?: string | DateRange): Promise<ReportResponse> { return runReport({ dimensions: ['date', 'transactionId'], metrics: ['totalRevenue', 'ecommercePurchases', 'averagePurchaseRevenue'], dateRange, }); } FILE:scripts/src/api/searchConsole.ts /** * Search Console API - Google Search Console data retrieval */ import { getSearchConsoleClient, getSiteUrl } from '../core/client.js'; import { saveResult } from '../core/storage.js'; import { getSettings } from '../config/settings.js'; /** * Date range configuration for Search Console */ export interface SearchConsoleDateRange { startDate: string; endDate: string; } /** * Search analytics query options */ export interface SearchAnalyticsOptions { dimensions?: string[]; dateRange?: string | SearchConsoleDateRange; rowLimit?: number; startRow?: number; save?: boolean; } /** * Search analytics row structure */ export interface SearchAnalyticsRow { keys: string[]; clicks: number; impressions: number; ctr: number; position: number; } /** * Search analytics response structure */ export interface SearchAnalyticsResponse { rows?: SearchAnalyticsRow[]; responseAggregationType?: string; } /** * Parse shorthand date range (e.g., "7d", "30d") to Search Console date format * Note: Search Console requires YYYY-MM-DD format, not GA4's "NdaysAgo" format */ export function parseSearchConsoleDateRange(range: string | SearchConsoleDateRange | undefined): SearchConsoleDateRange { if (!range) { const settings = getSettings(); range = settings.defaultDateRange; } if (typeof range === 'object') { return range; } // Parse shorthand like "7d", "30d", "90d" const match = range.match(/^(\d+)d$/); if (match) { const days = parseInt(match[1], 10); const endDate = new Date(); const startDate = new Date(); startDate.setDate(startDate.getDate() - days); return { startDate: startDate.toISOString().split('T')[0], endDate: endDate.toISOString().split('T')[0], }; } // Default to 30 days const endDate = new Date(); const startDate = new Date(); startDate.setDate(startDate.getDate() - 30); return { startDate: startDate.toISOString().split('T')[0], endDate: endDate.toISOString().split('T')[0], }; } /** * Query search analytics data */ export async function querySearchAnalytics(options: SearchAnalyticsOptions): Promise<SearchAnalyticsResponse> { const { dimensions = ['query'], dateRange, rowLimit = 1000, startRow = 0, save = true, } = options; const client = getSearchConsoleClient(); const siteUrl = getSiteUrl(); const parsedDateRange = parseSearchConsoleDateRange(dateRange); const response = await client.searchanalytics.query({ siteUrl, requestBody: { startDate: parsedDateRange.startDate, endDate: parsedDateRange.endDate, dimensions, rowLimit, startRow, }, }); const result = response.data as SearchAnalyticsResponse; if (save) { const operation = dimensions.join('_') || 'query'; const extra = typeof dateRange === 'string' ? dateRange : undefined; saveResult(result, 'searchconsole', operation, extra); } return result; } /** * Get top search queries */ export async function getTopQueries(dateRange?: string | SearchConsoleDateRange): Promise<SearchAnalyticsResponse> { return querySearchAnalytics({ dimensions: ['query'], dateRange, rowLimit: 100, }); } /** * Get top pages by search performance */ export async function getTopPages(dateRange?: string | SearchConsoleDateRange): Promise<SearchAnalyticsResponse> { return querySearchAnalytics({ dimensions: ['page'], dateRange, rowLimit: 100, }); } /** * Get search performance by device type */ export async function getDevicePerformance(dateRange?: string | SearchConsoleDateRange): Promise<SearchAnalyticsResponse> { return querySearchAnalytics({ dimensions: ['device'], dateRange, }); } /** * Get search performance by country */ export async function getCountryPerformance(dateRange?: string | SearchConsoleDateRange): Promise<SearchAnalyticsResponse> { return querySearchAnalytics({ dimensions: ['country'], dateRange, rowLimit: 50, }); } /** * Get search appearance data (rich results, AMP, etc.) */ export async function getSearchAppearance(dateRange?: string | SearchConsoleDateRange): Promise<SearchAnalyticsResponse> { return querySearchAnalytics({ dimensions: ['searchAppearance'], dateRange, }); } FILE:scripts/src/config/settings.ts /** * Settings Module - Environment configuration for GA4 API */ import { config } from 'dotenv'; import { join } from 'path'; // Load .env file from current working directory config(); /** * Settings interface for GA4 API configuration */ export interface Settings { /** GA4 Property ID */ propertyId: string; /** Service account email */ clientEmail: string; /** Service account private key */ privateKey: string; /** Default date range for reports (e.g., "30d", "7d") */ defaultDateRange: string; /** Directory path for storing results */ resultsDir: string; /** Search Console site URL (e.g., "https://example.com") */ siteUrl: string; } /** * Validation result from validateSettings() */ export interface ValidationResult { valid: boolean; errors: string[]; } /** * Get current settings from environment variables */ export function getSettings(): Settings { return { propertyId: process.env.GA4_PROPERTY_ID || '', clientEmail: process.env.GA4_CLIENT_EMAIL || '', privateKey: (process.env.GA4_PRIVATE_KEY || '').replace(/\\n/g, '\n'), defaultDateRange: process.env.GA4_DEFAULT_DATE_RANGE || '30d', resultsDir: join(process.cwd(), 'results'), siteUrl: process.env.SEARCH_CONSOLE_SITE_URL || '', }; } /** * Validate that all required settings are present */ export function validateSettings(): ValidationResult { const settings = getSettings(); const errors: string[] = []; if (!settings.propertyId) { errors.push('GA4_PROPERTY_ID is required'); } if (!settings.clientEmail) { errors.push('GA4_CLIENT_EMAIL is required'); } if (!settings.privateKey) { errors.push('GA4_PRIVATE_KEY is required'); } return { valid: errors.length === 0, errors, }; } FILE:scripts/src/core/client.ts /** * GA4 API Client - Singleton wrapper for BetaAnalyticsDataClient * Also includes Search Console and Indexing API clients */ import { BetaAnalyticsDataClient } from '@google-analytics/data'; import { searchconsole } from '@googleapis/searchconsole'; import { indexing } from '@googleapis/indexing'; import { google } from 'googleapis'; import { getSettings, validateSettings } from '../config/settings.js'; // Singleton client instances let clientInstance: BetaAnalyticsDataClient | null = null; let searchConsoleClientInstance: ReturnType<typeof searchconsole> | null = null; let indexingClientInstance: ReturnType<typeof indexing> | null = null; /** * Get the GA4 Analytics Data API client (singleton) * * @returns The BetaAnalyticsDataClient instance * @throws Error if credentials are invalid */ export function getClient(): BetaAnalyticsDataClient { if (clientInstance) { return clientInstance; } const validation = validateSettings(); if (!validation.valid) { throw new Error(`Invalid GA4 credentials: validation.errors.join(', ')`); } const settings = getSettings(); clientInstance = new BetaAnalyticsDataClient({ credentials: { client_email: settings.clientEmail, private_key: settings.privateKey, }, }); return clientInstance; } /** * Get the GA4 property ID formatted for API calls * * @returns Property ID with "properties/" prefix */ export function getPropertyId(): string { const settings = getSettings(); return `properties/settings.propertyId`; } /** * Reset the client singleton (useful for testing) */ export function resetClient(): void { clientInstance = null; searchConsoleClientInstance = null; indexingClientInstance = null; } /** * Get Google Auth client for Search Console and Indexing APIs */ function getGoogleAuth() { const settings = getSettings(); return new google.auth.GoogleAuth({ credentials: { client_email: settings.clientEmail, private_key: settings.privateKey, }, scopes: [ 'https://www.googleapis.com/auth/webmasters.readonly', 'https://www.googleapis.com/auth/indexing', ], }); } /** * Get the Search Console API client (singleton) * * @returns The Search Console client instance * @throws Error if credentials are invalid */ export function getSearchConsoleClient(): ReturnType<typeof searchconsole> { if (searchConsoleClientInstance) { return searchConsoleClientInstance; } const validation = validateSettings(); if (!validation.valid) { throw new Error(`Invalid credentials: validation.errors.join(', ')`); } const auth = getGoogleAuth(); searchConsoleClientInstance = searchconsole({ version: 'v1', auth }); return searchConsoleClientInstance; } /** * Get the Indexing API client (singleton) * * @returns The Indexing client instance * @throws Error if credentials are invalid */ export function getIndexingClient(): ReturnType<typeof indexing> { if (indexingClientInstance) { return indexingClientInstance; } const validation = validateSettings(); if (!validation.valid) { throw new Error(`Invalid credentials: validation.errors.join(', ')`); } const auth = getGoogleAuth(); indexingClientInstance = indexing({ version: 'v3', auth }); return indexingClientInstance; } /** * Get the Search Console site URL * * @returns Site URL from settings */ export function getSiteUrl(): string { const settings = getSettings(); return settings.siteUrl; } FILE:scripts/src/core/storage.ts /** * Storage Module - Auto-save results to JSON files with metadata */ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from 'fs'; import { join } from 'path'; import { getSettings } from '../config/settings.js'; /** * Metadata wrapper for saved results */ export interface ResultMetadata { savedAt: string; category: string; operation: string; propertyId: string; extraInfo?: string; } /** * Wrapped result with metadata */ export interface SavedResult<T = unknown> { metadata: ResultMetadata; data: T; } /** * Generate timestamp string for filenames: YYYYMMDD_HHMMSS */ function getTimestamp(): string { const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); const hours = String(now.getHours()).padStart(2, '0'); const minutes = String(now.getMinutes()).padStart(2, '0'); const seconds = String(now.getSeconds()).padStart(2, '0'); return `yearmonthday_hoursminutesseconds`; } /** * Sanitize string for use in filename */ function sanitizeFilename(str: string): string { return str.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase(); } /** * Save result data to a JSON file with metadata wrapper * * @param data - The data to save * @param category - Category directory (e.g., 'reports', 'realtime') * @param operation - Operation name (e.g., 'page_views', 'traffic_sources') * @param extraInfo - Optional extra info for filename * @returns The full path to the saved file */ export function saveResult<T>( data: T, category: string, operation: string, extraInfo?: string ): string { const settings = getSettings(); const categoryDir = join(settings.resultsDir, category); // Ensure category directory exists if (!existsSync(categoryDir)) { mkdirSync(categoryDir, { recursive: true }); } // Build filename const timestamp = getTimestamp(); const sanitizedOperation = sanitizeFilename(operation); const sanitizedExtra = extraInfo ? `__sanitizeFilename(extraInfo)` : ''; const filename = `timestamp__sanitizedOperationsanitizedExtra.json`; const filepath = join(categoryDir, filename); // Build wrapped result const result: SavedResult<T> = { metadata: { savedAt: new Date().toISOString(), category, operation, propertyId: settings.propertyId, ...(extraInfo && { extraInfo }), }, data, }; // Write to file writeFileSync(filepath, JSON.stringify(result, null, 2), 'utf-8'); return filepath; } /** * Load a saved result from a JSON file * * @param filepath - Path to the JSON file * @returns The parsed result or null if file doesn't exist */ export function loadResult<T = unknown>(filepath: string): SavedResult<T> | null { if (!existsSync(filepath)) { return null; } try { const content = readFileSync(filepath, 'utf-8'); return JSON.parse(content) as SavedResult<T>; } catch { return null; } } /** * List saved result files for a category * * @param category - Category to list results for * @param limit - Maximum number of results to return * @returns Array of file paths, sorted by date descending (newest first) */ export function listResults(category: string, limit?: number): string[] { const settings = getSettings(); const categoryDir = join(settings.resultsDir, category); if (!existsSync(categoryDir)) { return []; } const files = readdirSync(categoryDir) .filter(f => f.endsWith('.json')) .map(f => join(categoryDir, f)) .sort((a, b) => { // Sort by filename (which starts with timestamp) descending const nameA = a.split('/').pop() || ''; const nameB = b.split('/').pop() || ''; return nameB.localeCompare(nameA); }); if (limit !== undefined) { return files.slice(0, limit); } return files; } /** * Get the most recent result for a category/operation * * @param category - Category to search * @param operation - Optional operation to filter by * @returns The most recent result or null */ export function getLatestResult<T = unknown>( category: string, operation?: string ): SavedResult<T> | null { let files = listResults(category); if (operation) { const sanitized = sanitizeFilename(operation); files = files.filter(f => f.includes(`__sanitized`)); } if (files.length === 0) { return null; } return loadResult<T>(files[0]); } FILE:scripts/src/index.ts /** * GA4 Analytics Toolkit - Main Entry Point * * Simple interface for Google Analytics 4 data analysis. * All results are automatically saved to the /results directory with timestamps. * * Usage: * import { siteOverview, trafficAnalysis } from './index.js'; * const overview = await siteOverview('30d'); */ // Re-export all API functions export * from './api/reports.js'; export * from './api/realtime.js'; export * from './api/metadata.js'; export * from './api/searchConsole.js'; export * from './api/indexing.js'; export * from './api/bulk-lookup.js'; // Re-export core utilities export { getClient, getPropertyId, getSearchConsoleClient, getIndexingClient, getSiteUrl, resetClient } from './core/client.js'; export { saveResult, loadResult, listResults, getLatestResult } from './core/storage.js'; export { getSettings, validateSettings } from './config/settings.js'; // Import for orchestration functions import { runReport, getPageViews, getTrafficSources, getUserDemographics, getEventCounts, getConversions, parseDateRange, type DateRange, } from './api/reports.js'; import { getActiveUsers, getRealtimeEvents, getRealtimePages } from './api/realtime.js'; import { getPropertyMetadata } from './api/metadata.js'; import { saveResult } from './core/storage.js'; import { getTopQueries, getTopPages as getSearchConsoleTopPages, getDevicePerformance, getCountryPerformance, type SearchConsoleDateRange, } from './api/searchConsole.js'; import { requestIndexing, inspectUrl } from './api/indexing.js'; // ============================================================================ // HIGH-LEVEL ORCHESTRATION FUNCTIONS // ============================================================================ /** * Comprehensive site overview - combines multiple reports */ export async function siteOverview(dateRange?: string | DateRange) { console.log('\n📊 Generating site overview...'); const results: Record<string, unknown> = {}; console.log(' → Getting page views...'); results.pageViews = await getPageViews(dateRange); console.log(' → Getting traffic sources...'); results.trafficSources = await getTrafficSources(dateRange); console.log(' → Getting user demographics...'); results.demographics = await getUserDemographics(dateRange); console.log(' → Getting event counts...'); results.events = await getEventCounts(dateRange); // Save combined results const dateStr = typeof dateRange === 'string' ? dateRange : 'custom'; saveResult(results, 'reports', 'site_overview', dateStr); console.log('✅ Site overview complete\n'); return results; } /** * Deep dive on traffic sources */ export async function trafficAnalysis(dateRange?: string | DateRange) { console.log('\n🚗 Analyzing traffic sources...'); const results: Record<string, unknown> = {}; console.log(' → Getting traffic sources...'); results.sources = await getTrafficSources(dateRange); console.log(' → Getting session data by source...'); results.sessions = await runReport({ dimensions: ['sessionSource', 'sessionMedium'], metrics: ['sessions', 'engagedSessions', 'averageSessionDuration', 'bounceRate'], dateRange, }); console.log(' → Getting new vs returning users...'); results.newVsReturning = await runReport({ dimensions: ['newVsReturning'], metrics: ['activeUsers', 'sessions', 'conversions'], dateRange, }); const dateStr = typeof dateRange === 'string' ? dateRange : 'custom'; saveResult(results, 'reports', 'traffic_analysis', dateStr); console.log('✅ Traffic analysis complete\n'); return results; } /** * Content performance analysis */ export async function contentPerformance(dateRange?: string | DateRange) { console.log('\n📄 Analyzing content performance...'); const results: Record<string, unknown> = {}; console.log(' → Getting page views...'); results.pages = await getPageViews(dateRange); console.log(' → Getting landing pages...'); results.landingPages = await runReport({ dimensions: ['landingPage'], metrics: ['sessions', 'activeUsers', 'bounceRate', 'averageSessionDuration'], dateRange, }); console.log(' → Getting exit pages...'); results.exitPages = await runReport({ dimensions: ['pagePath'], metrics: ['exits', 'screenPageViews'], dateRange, }); const dateStr = typeof dateRange === 'string' ? dateRange : 'custom'; saveResult(results, 'reports', 'content_performance', dateStr); console.log('✅ Content performance analysis complete\n'); return results; } /** * User behavior analysis */ export async function userBehavior(dateRange?: string | DateRange) { console.log('\n👤 Analyzing user behavior...'); const results: Record<string, unknown> = {}; console.log(' → Getting demographics...'); results.demographics = await getUserDemographics(dateRange); console.log(' → Getting events...'); results.events = await getEventCounts(dateRange); console.log(' → Getting engagement metrics...'); results.engagement = await runReport({ dimensions: ['date'], metrics: ['activeUsers', 'engagedSessions', 'engagementRate', 'averageSessionDuration'], dateRange, }); const dateStr = typeof dateRange === 'string' ? dateRange : 'custom'; saveResult(results, 'reports', 'user_behavior', dateStr); console.log('✅ User behavior analysis complete\n'); return results; } /** * Compare two date ranges */ export async function compareDateRanges( range1: DateRange, range2: DateRange, dimensions: string[] = ['date'], metrics: string[] = ['activeUsers', 'sessions', 'screenPageViews'] ) { console.log('\n📈 Comparing date ranges...'); console.log(` → Getting data for range1.startDate to range1.endDate...`); const period1 = await runReport({ dimensions, metrics, dateRange: range1, save: false, }); console.log(` → Getting data for range2.startDate to range2.endDate...`); const period2 = await runReport({ dimensions, metrics, dateRange: range2, save: false, }); const comparison = { period1: { dateRange: range1, data: period1 }, period2: { dateRange: range2, data: period2 }, }; saveResult(comparison, 'reports', 'date_comparison'); console.log('✅ Date range comparison complete\n'); return comparison; } /** * Get current live data snapshot */ export async function liveSnapshot() { console.log('\n⚡ Getting live data snapshot...'); const results: Record<string, unknown> = {}; console.log(' → Getting active users...'); results.activeUsers = await getActiveUsers(); console.log(' → Getting current pages...'); results.currentPages = await getRealtimePages(); console.log(' → Getting current events...'); results.currentEvents = await getRealtimeEvents(); saveResult(results, 'realtime', 'snapshot'); console.log('✅ Live snapshot complete\n'); return results; } // ============================================================================ // SEARCH CONSOLE ORCHESTRATION FUNCTIONS // ============================================================================ /** * Comprehensive Search Console overview */ export async function searchConsoleOverview(dateRange?: string | SearchConsoleDateRange) { console.log('\n🔍 Generating Search Console overview...'); const results: Record<string, unknown> = {}; console.log(' → Getting top queries...'); results.topQueries = await getTopQueries(dateRange); console.log(' → Getting top pages...'); results.topPages = await getSearchConsoleTopPages(dateRange); console.log(' → Getting device performance...'); results.devicePerformance = await getDevicePerformance(dateRange); console.log(' → Getting country performance...'); results.countryPerformance = await getCountryPerformance(dateRange); const dateStr = typeof dateRange === 'string' ? dateRange : 'custom'; saveResult(results, 'searchconsole', 'overview', dateStr); console.log('✅ Search Console overview complete\n'); return results; } /** * Deep dive into keyword/query analysis */ export async function keywordAnalysis(dateRange?: string | SearchConsoleDateRange) { console.log('\n🔑 Analyzing keywords...'); const results: Record<string, unknown> = {}; console.log(' → Getting top queries...'); results.queries = await getTopQueries(dateRange); console.log(' → Getting device breakdown for queries...'); results.deviceBreakdown = await getDevicePerformance(dateRange); const dateStr = typeof dateRange === 'string' ? dateRange : 'custom'; saveResult(results, 'searchconsole', 'keyword_analysis', dateStr); console.log('✅ Keyword analysis complete\n'); return results; } /** * SEO page performance analysis */ export async function seoPagePerformance(dateRange?: string | SearchConsoleDateRange) { console.log('\n📄 Analyzing SEO page performance...'); const results: Record<string, unknown> = {}; console.log(' → Getting top pages by clicks...'); results.topPages = await getSearchConsoleTopPages(dateRange); console.log(' → Getting country breakdown...'); results.countryBreakdown = await getCountryPerformance(dateRange); const dateStr = typeof dateRange === 'string' ? dateRange : 'custom'; saveResult(results, 'searchconsole', 'seo_page_performance', dateStr); console.log('✅ SEO page performance analysis complete\n'); return results; } /** * Request re-indexing for updated URLs */ export async function reindexUrls(urls: string[]) { console.log(`\n🔄 Requesting re-indexing for urls.length URL(s)...`); const results: Array<{ url: string; status: string; error?: string }> = []; for (const url of urls) { try { console.log(` → Requesting indexing: url`); const result = await requestIndexing(url, { save: false }); results.push({ ...result, url, status: 'submitted' }); } catch (error) { console.log(` ✗ Failed: url`); results.push({ url, status: 'failed', error: String(error) }); } } saveResult(results, 'indexing', 'reindex_batch'); console.log('✅ Re-indexing requests complete\n'); return results; } /** * Check index status for URLs */ export async function checkIndexStatus(urls: string[]) { console.log(`\n🔎 Checking index status for urls.length URL(s)...`); const results: Array<{ url: string; indexed: boolean; status: unknown }> = []; for (const url of urls) { try { console.log(` → Inspecting: url`); const result = await inspectUrl(url, { save: false }); results.push({ url, indexed: result.indexStatus.verdict === 'PASS', status: result.indexStatus, }); } catch (error) { console.log(` ✗ Failed: url`); results.push({ url, indexed: false, status: { error: String(error) } }); } } saveResult(results, 'indexing', 'index_status_check'); console.log('✅ Index status check complete\n'); return results; } // ============================================================================ // UTILITY FUNCTIONS // ============================================================================ /** * Get available dimensions and metrics */ export async function getAvailableFields() { console.log('\n📋 Getting available fields...'); const metadata = await getPropertyMetadata(); console.log(` → Found metadata.dimensions?.length || 0 dimensions`); console.log(` → Found metadata.metrics?.length || 0 metrics`); console.log('✅ Field retrieval complete\n'); return metadata; } // Print help when run directly if (process.argv[1] === new URL(import.meta.url).pathname) { console.log(` GA4 Analytics Toolkit ===================== GA4 High-level functions: - siteOverview(dateRange?) Comprehensive site snapshot - trafficAnalysis(dateRange?) Deep dive on sources - contentPerformance(dateRange?) Top pages analysis - userBehavior(dateRange?) Engagement patterns - compareDateRanges(range1, range2) Period comparison - liveSnapshot() Real-time data Search Console functions: - searchConsoleOverview(dateRange?) Combined SEO snapshot - keywordAnalysis(dateRange?) Query/keyword analysis - seoPagePerformance(dateRange?) Page-level SEO metrics - getTopQueries(dateRange?) Top search queries - getTopPages(dateRange?) Top pages by clicks - getDevicePerformance(dateRange?) Mobile vs desktop - getCountryPerformance(dateRange?) Traffic by country Indexing functions: - reindexUrls(urls) Request re-indexing for URLs - checkIndexStatus(urls) Check if URLs are indexed - requestIndexing(url) Request single URL re-crawl - inspectUrl(url) Inspect URL index status Low-level GA4 functions: - runReport({ dimensions, metrics, dateRange }) - getPageViews(dateRange?) - getTrafficSources(dateRange?) - getUserDemographics(dateRange?) - getEventCounts(dateRange?) - getActiveUsers() - getRealtimeEvents() - getPropertyMetadata() All results are automatically saved to /results directory. `); } FILE:scripts/tsconfig.json { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "lib": ["ES2022"], "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "declaration": true, "declarationMap": true, "sourceMap": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "tests"] }
Generate an AI image via Gemini and post it to X (Twitter) using OAuth1. Supports text-only or text+image tweets.
---
name: skill-x-post-ai-image
description: Generate an AI image via Gemini and post it to X (Twitter) using OAuth1. Supports text-only or text+image tweets.
metadata:
openclaw:
requires: { bins: ["uv", "xurl"] }
---
# X Post with AI-Generated Image
Generate a Gemini AI image from a text prompt, compress it, and post it as a tweet — all in one command.
## Prerequisites
- `xurl` CLI (X/Twitter auth) — see xurl skill
- `GEMINI_API_KEY` env var
- `nano-banana-pro` skill installed (OpenClaw)
- `uv` Python runner
## Setup
```bash
export GEMINI_API_KEY="your-gemini-api-key"
export X_CONSUMER_KEY="..."
export X_CONSUMER_SECRET="..."
export X_ACCESS_TOKEN="..."
export X_ACCESS_TOKEN_SECRET="..."
```
## Usage
```bash
# Post tweet with AI-generated image
python3 scripts/post_with_image.py \
--text "Your tweet text here" \
--prompt "AI image prompt — describe the visual"
# Text-only tweet
python3 scripts/post_with_image.py \
--text "Your tweet text" \
--no-image
```
## How it works
1. Calls Gemini image generation with prompt → PNG
2. Compresses + resizes to 1200×675 JPEG (Twitter optimal)
3. Uploads via Twitter media API (OAuth1)
4. Posts tweet with media via `xurl`
## Inputs
| Param | Description |
|-------|-------------|
| `--text` | Tweet text (required) |
| `--prompt` | Image generation prompt (optional) |
| `--no-image` | Skip image, post text only |
## Output
Tweet posted; media ID and confirmation logged to stdout.
FILE:README.md
# skill-x-post-ai-image
Post to X/Twitter with AI-generated images via Gemini. One command: prompt → image → tweet.
FILE:scripts/post_with_image.py
#!/usr/bin/env python3
"""
Post to X (Twitter) with an AI-generated image via Gemini.
Usage: python3 post_with_image.py --text "tweet" --prompt "image prompt"
"""
import argparse, base64, os, subprocess, sys, tempfile
# Set via env vars
CK = os.environ["X_CONSUMER_KEY"]
CS = os.environ["X_CONSUMER_SECRET"]
AT = os.environ["X_ACCESS_TOKEN"]
ATS = os.environ["X_ACCESS_TOKEN_SECRET"]
NANO_BANANA_SCRIPT = os.environ.get(
"NANO_BANANA_SCRIPT",
"/home/linuxbrew/.linuxbrew/lib/node_modules/openclaw/skills/nano-banana-pro/scripts/generate_image.py"
)
def generate_image(prompt: str, out_path: str) -> str:
result = subprocess.run(
["uv", "run", NANO_BANANA_SCRIPT, "--prompt", prompt, "--filename", out_path, "--resolution", "1K"],
capture_output=True, text=True,
env={**os.environ, "GEMINI_API_KEY": os.environ.get("GEMINI_API_KEY", "")}
)
if result.returncode != 0:
raise RuntimeError(f"Image gen failed: {result.stderr}")
return out_path
def compress_image(src: str, dst: str, width=1200, height=675):
from PIL import Image
img = Image.open(src).resize((width, height))
img.save(dst, "JPEG", quality=82)
return dst
def upload_image(path: str) -> str:
from requests_oauthlib import OAuth1Session
oauth = OAuth1Session(CK, client_secret=CS, resource_owner_key=AT, resource_owner_secret=ATS)
with open(path, "rb") as f:
img_data = base64.b64encode(f.read()).decode()
r = oauth.post(
"https://upload.twitter.com/1.1/media/upload.json",
data={"media_data": img_data, "media_category": "tweet_image"}
)
r.raise_for_status()
return r.json()["media_id_string"]
def post_tweet(text: str, media_id: str = None):
cmd = ["xurl", "post", text]
if media_id:
cmd += ["--media-id", media_id]
result = subprocess.run(cmd, capture_output=True, text=True)
print(result.stdout)
if result.returncode != 0:
raise RuntimeError(result.stderr)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--text", required=True)
parser.add_argument("--prompt", default=None)
parser.add_argument("--no-image", action="store_true")
args = parser.parse_args()
media_id = None
if args.prompt and not args.no_image:
with tempfile.TemporaryDirectory() as tmpdir:
raw = f"{tmpdir}/raw.png"
compressed = f"{tmpdir}/post.jpg"
print("Generating image...")
generate_image(args.prompt, raw)
print("Compressing...")
compress_image(raw, compressed)
print("Uploading...")
media_id = upload_image(compressed)
print(f"Media ID: {media_id}")
print("Posting tweet...")
post_tweet(args.text, media_id)
print("Done!")
Generate TikTok-style short-form ad videos with animated pill captions. Takes a base MP4 (Veo/Runway/Kling), overlays animated captions with fade in/out, mix...
---
name: skill-tiktok-ads-video
version: 2.1.0
description: Generate TikTok-style short-form ad videos with animated pill captions. Takes a base MP4 (Veo/Runway/Kling), overlays animated captions with fade in/out, mixes background audio. Built-in product presets — no captions JSON file needed. Use for TikTok ads, Reels, YouTube Shorts product videos.
metadata:
openclaw:
requires: { bins: ["uv"] }
---
# skill-tiktok-ads-video v2.0.0
Animated pill-style caption overlays for short-form video. No Premiere, no CapCut — pure Python. v2 ships with built-in product presets — no more manual captions JSON.
## Usage (v2 — product presets)
```bash
uv run --with moviepy --with pillow scripts/overlay.py \
--video base.mp4 \
--output final.mp4 \
--product rain_cloud \
--style subtitle_talk
```
### Products
- `rain_cloud` — Rain Cloud Humidifier
- `hydro_bottle` — Hydrogen Water Bottle
- `mini_cam` — Mini Clip Camera
### Styles
- `phrase_slam` — Bold full-screen phrase drops
- `subtitle_talk` — Conversational subtitle-style captions
- `big_center` — Large centered text with price bar at top. Best for bold product reveals.
- `random` — Randomly picks a style
### Optional audio
```bash
--audio music.mp3 \
--audio-start 8 \
--audio-vol 0.5
```
No `--audio` keeps the original video audio.
## Custom fonts
```bash
--font-black /path/to/Montserrat-Black.ttf \
--font-bold /path/to/Montserrat-Bold.ttf
```
Falls back to Montserrat from `~/.local/share/fonts/` if not specified.
## Legacy usage (v1 — manual captions JSON)
Still supported for backward compatibility:
```bash
uv run --with moviepy --with pillow scripts/overlay.py \
--video base.mp4 \
--output final.mp4 \
--captions scripts/example_captions.json
```
## PIL textbbox fix
PIL's `textbbox((0,0), text, font)` returns `(x0, y0, x1, y1)` where `y0` is a non-zero offset (typically 7–15px depending on font size). Drawing text at `(x, y)` without compensating for this offset causes text to appear below the pill's visual center.
**Fix implemented in `pill()`:**
```python
bb = draw.textbbox((0, 0), text, font=font)
x_off, y_off = bb[0], bb[1]
vis_w = bb[2] - bb[0] # actual visual width
vis_h = bb[3] - bb[1] # actual visual height
# Compensate offsets when drawing text
tx = cx - vis_w // 2 - x_off
ty = y - y_off
draw.text((tx, ty), text, font=font, fill=fg)
```
## Emoji note
NotoColorEmoji.ttf fails with PIL at arbitrary sizes (bitmap font with limited supported sizes). Use text alternatives (`"Free delivery"` instead of `"Free delivery 🚚"`) for reliable rendering.
FILE:_meta.json
{
"name": "skill-tiktok-ads-video",
"version": "2.1.0",
"description": "Generate TikTok-style short-form ad videos with animated pill captions. Built-in product presets — no captions JSON needed.",
"author": "Zero2Ai-hub",
"license": "MIT"
}
FILE:scripts/example_captions.json
[
{
"start": 0,
"end": 3.2,
"y_frac": 0.06,
"lines": [
{
"text": "POV:",
"size": 28,
"bold": true,
"bg": [0, 195, 255],
"fg": [0, 0, 0],
"bg_opacity": 0.9,
"px": 20, "py": 9, "r": 12
},
{
"text": "you said you'd",
"size": 50,
"bg": [255, 255, 255],
"fg": [0, 0, 0],
"bg_opacity": 0.93
},
{
"text": "drink more water",
"size": 50,
"bg": [0, 195, 255],
"fg": [0, 0, 0],
"bg_opacity": 0.93
}
]
},
{
"start": 2.8,
"end": 5.8,
"y_frac": 0.06,
"lines": [
{
"text": "hydrogen water",
"size": 50,
"bg": [0, 0, 0],
"fg": [255, 255, 255],
"bg_opacity": 0.87
},
{
"text": "hits different",
"size": 50,
"bg": [0, 195, 255],
"fg": [255, 255, 255],
"bg_opacity": 0.93
}
]
},
{
"start": 5.3,
"end": 8.0,
"y_frac": 0.76,
"lines": [
{
"text": "149 AED",
"size": 56,
"bg": [255, 210, 0],
"fg": [0, 0, 0],
"bg_opacity": 0.97,
"px": 30, "py": 15, "r": 20
},
{
"text": "Free delivery UAE",
"size": 30,
"bold": true,
"bg": [20, 20, 20],
"fg": [255, 255, 255],
"bg_opacity": 0.9
},
{
"text": "Link in bio",
"size": 30,
"bold": true,
"bg": [255, 210, 0],
"fg": [0, 0, 0],
"bg_opacity": 0.9
}
]
}
]
FILE:scripts/overlay.py
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["moviepy>=2.0", "pillow>=10.0", "numpy"]
# ///
"""TikTok Overlay Engine v3 — Context-aware timing, keep Veo native audio."""
import argparse, random
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from moviepy import VideoFileClip, CompositeVideoClip, VideoClip
import os as _os
F_BLACK = _os.path.expanduser("~/.local/share/fonts/Montserrat-Black.ttf")
F_BOLD = _os.path.expanduser("~/.local/share/fonts/Montserrat-Bold.ttf")
F_DEJA = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
def get_font(path, size):
try: return ImageFont.truetype(path, size)
except: return ImageFont.truetype(F_DEJA, size)
def phrase_duration(text, min_dur=0.9, wps=2.8):
"""Auto-calculate how long a phrase should stay on screen."""
words = len(text.split())
return max(min_dur, words / wps)
def al(t, s, e, f=0.18):
fi = min(1.0,(t-s)/f) if t>s else 0.0
fo = 1.0-min(1.0,(t-(e-f))/f) if t>(e-f) else 1.0
return int(255*max(0.0,min(fi,fo)))
def pill(draw, text, font, cx, y, bg, fg, px=24, py=12, r=16):
bb = draw.textbbox((0,0),text,font=font)
xo,yo=bb[0],bb[1]; vw=bb[2]-bb[0]; vh=bb[3]-bb[1]
draw.rounded_rectangle([cx-vw//2-px,y-py,cx+vw//2+(vw%2)+px,y+vh+py],radius=r,fill=bg)
draw.text((cx-vw//2-xo,y-yo),text,font=font,fill=fg)
return vh+py*2+8
def stroke_text(draw, text, font, cx, y, fg, sw=4, max_w=None, img_w=720):
"""Auto-scale font down if text would overflow safe zone."""
safe_w = max_w or int(img_w * 0.88)
# Measure and scale down if needed
actual_font = font
bb = draw.textbbox((0,0), text, font=actual_font)
vw = bb[2]-bb[0]
if vw > safe_w:
# Estimate new size
scale = safe_w / vw
new_size = max(28, int(actual_font.size * scale))
actual_font = get_font(actual_font.path if hasattr(actual_font, "path") else F_BLACK, new_size)
bb = draw.textbbox((0,0), text, font=actual_font)
xo,yo=bb[0],bb[1]; vw=bb[2]-bb[0]; vh=bb[3]-bb[1]
x = cx-vw//2-xo
draw.text((x,y-yo),text,font=actual_font,fill=(0,0,0,fg[3]),stroke_width=sw,stroke_fill=(0,0,0,fg[3]))
draw.text((x,y-yo),text,font=actual_font,fill=fg)
return vh
# ── Product scripts ─────────────────────────────────────────────────────────
# Durations are now AUTO-CALCULATED from word count — no hardcoding
def make_script(phrases_with_size, accent):
"""phrases_with_size = list of (text, font_size)"""
return [(text, phrase_duration(text), size) for text, size in phrases_with_size]
PRODUCTS = {
'rain_cloud': {
'accent': (140,180,255),
'cta': '129 AED',
'hook': [
("your room feels like a desert.", 72), # 6 words = 2.1s
("we fixed that.", 80), # 3 words = 1.1s
],
'claim': [
("rain cloud humidifier.", 54), # 3 words
("soft mist. cozy vibes.", 62), # 4 words
("every single night.", 72), # 3 words
],
},
'hydro_bottle': {
'accent': (0,200,255),
'cta': '149 AED',
'hook': [
("i said i'd drink more water.", 68), # 7 words = 2.5s
("i lied.", 90), # 2 words = SHORT = punch
],
'claim': [
("then i got hydrogen water.", 64), # 5 words
("3 liters a day now.", 72), # 5 words
("no more excuses.", 80), # 3 words
],
},
'mini_cam': {
'accent': (255,100,60),
'cta': '89 AED',
'hook': [
("your phone camera is mid.", 72), # 5 words = 1.8s
("this one isn't.", 82), # 3 words = 1.1s
],
'claim': [
("mini clip cam.", 74), # 3 words
("fits in your pocket.", 66), # 4 words
("content that actually slaps.", 64), # 4 words
],
},
}
# ── STYLE: Phrase Slam ───────────────────────────────────────────────────────
def style_phrase_slam(t, w, h, hook, claim, cta, accent, duration):
img = Image.new('RGBA',(w,h),(0,0,0,0))
draw = ImageDraw.Draw(img)
cx = w//2; cy = int(h*0.44)
cta_start = duration - 2.8
content = hook + claim
cursor = 0.0
for text, dur, size in content:
end = cursor + dur
if cursor - 0.05 <= t < end + 0.12 and t < cta_start:
a = al(t, cursor, end + 0.1, f=0.15)
# Pop scale on entry only
elapsed = t - cursor
scale = max(1.0, 1.1 - elapsed * 0.8) if elapsed < 0.13 else 1.0
fsize = int(size * scale)
f = get_font(F_BLACK, fsize)
stroke_text(draw, text, f, cx, cy, (*accent, a), sw=4, img_w=w)
cursor += dur
if cursor >= cta_start: break
if t >= cta_start:
a = al(t, cta_start, duration)
y = int(h*0.76)
y += pill(draw, cta, get_font(F_BLACK,56), cx, y, (255,210,0,int(a*.97)), (0,0,0,a), px=30,py=14,r=20)
y += pill(draw, "Free delivery UAE", get_font(F_BOLD,30), cx, y, (20,20,20,int(a*.9)), (255,255,255,a))
pill(draw, "Link in bio", get_font(F_BOLD,30), cx, y, (255,210,0,int(a*.9)), (0,0,0,a))
return np.array(img)
# ── STYLE: Subtitle Talk ────────────────────────────────────────────────────
def style_subtitle_talk(t, w, h, hook, claim, cta, accent, duration):
img = Image.new('RGBA',(w,h),(0,0,0,0))
draw = ImageDraw.Draw(img)
cx = w//2; bar_y = int(h*0.79)
cta_start = duration - 2.8
content = hook + claim
cursor = 0.0
for text, dur, size in content:
end = cursor + dur
if cursor - 0.05 <= t < end + 0.15 and t < cta_start:
a = al(t, cursor, end + 0.12, f=0.18)
f = get_font(F_BLACK, size)
bb = draw.textbbox((0,0), text.lower(), font=f)
xo,yo=bb[0],bb[1]; vw=bb[2]-bb[0]; vh=bb[3]-bb[1]
pad = 18
draw.rectangle([0, bar_y-pad, w, bar_y+vh+pad], fill=(0,0,0,int(a*0.78)))
draw.text((cx-vw//2-xo, bar_y-yo), text.lower(), font=f, fill=(255,255,255,a))
cursor += dur
if cursor >= cta_start: break
if t >= cta_start:
a = al(t, cta_start, duration)
y = int(h*0.06)
y += pill(draw, cta, get_font(F_BLACK,56), cx, y, (255,210,0,int(a*.97)), (0,0,0,a), px=30,py=14,r=20)
y += pill(draw,"Free delivery UAE",get_font(F_BOLD,30),cx,y,(20,20,20,int(a*.9)),(255,255,255,a))
pill(draw,"Link in bio",get_font(F_BOLD,30),cx,y,(255,210,0,int(a*.9)),(0,0,0,a))
return np.array(img)
# ── STYLE: Big Center ───────────────────────────────────────────────────────
def style_big_center(t, w, h, hook, claim, cta, accent, duration):
"""Large centered text with price bar at top. Best for bold product reveals."""
img = Image.new('RGBA', (w, h), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
cx = w // 2
cy = int(h * 0.50)
# ── Top price bar (always visible) ──────────────────────────────────────
bar_h = 70
draw.rectangle([0, 0, w, bar_h], fill=(15, 15, 15, 220))
price_font = get_font(F_BLACK, 38)
bb = draw.textbbox((0, 0), cta, font=price_font)
pw = bb[2] - bb[0]; ph = bb[3] - bb[1]
draw.text(
(cx - pw // 2 - bb[0], (bar_h - ph) // 2 - bb[1]),
cta, font=price_font, fill=(255, 215, 0, 230)
)
# ── Centered phrases with timing ─────────────────────────────────────────
content = hook + claim
cursor = 0.0
for text, dur, size in content:
end = cursor + dur
if cursor - 0.05 <= t < end + 0.12:
a = al(t, cursor, end + 0.1, f=0.16)
elapsed = t - cursor
scale = max(1.0, 1.08 - elapsed * 0.7) if elapsed < 0.15 else 1.0
fsize = int(size * scale)
f = get_font(F_BLACK, fsize)
bb2 = draw.textbbox((0, 0), text, font=f)
xo, yo = bb2[0], bb2[1]
vw = bb2[2] - bb2[0]; vh = bb2[3] - bb2[1]
x = cx - vw // 2 - xo
y = cy - vh // 2 - yo
draw.text((x, y), text, font=f,
fill=(0, 0, 0, a), stroke_width=4, stroke_fill=(0, 0, 0, a))
draw.text((x, y), text, font=f, fill=(*accent, a))
cursor += dur
return np.array(img)
STYLES = {
'phrase_slam': style_phrase_slam,
'subtitle_talk': style_subtitle_talk,
'big_center': style_big_center,
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--video', required=True)
ap.add_argument('--output', required=True)
ap.add_argument('--product', required=True, choices=list(PRODUCTS.keys()))
ap.add_argument('--style', default='random', choices=list(STYLES.keys()) + ['random'])
args = ap.parse_args()
p = PRODUCTS[args.product]
hook = make_script(p['hook'], p['accent'])
claim = make_script(p['claim'], p['accent'])
cta = p['cta']
accent = p['accent']
style_name = random.choice(list(STYLES.keys())) if args.style=='random' else args.style
style_fn = STYLES[style_name]
print(f"Style: {style_name} | Product: {args.product}")
# Keep Veo native audio — no override
video = VideoFileClip(args.video)
w,h = video.size
dur = video.duration
total_content = sum(d for _,d,_ in hook+claim) + 2.8
print(f"Script duration: {total_content:.1f}s | Video: {dur:.1f}s")
# Auto-compress: if script longer than video, scale phrase durations down
available = dur - 2.8 # time for phrases (CTA takes 2.8s)
phrase_total = sum(d for _,d,_ in hook+claim)
if phrase_total > available:
ratio = available / phrase_total
hook = [(t, d*ratio, s) for t,d,s in hook]
claim = [(t, d*ratio, s) for t,d,s in claim]
print(f" Compressed to fit: ratio={ratio:.2f}")
def make_frame(t):
return style_fn(t, w, h, hook, claim, cta, accent, dur)
overlay = VideoClip(make_frame, duration=dur).with_fps(video.fps)
final = CompositeVideoClip([video, overlay])
final.write_videofile(args.output, codec='libx264', audio_codec='aac', fps=video.fps, logger=None)
print(f"Done! → {args.output}")
if __name__ == '__main__':
main()
Render TikTok-style animated pill captions onto short-form videos using MoviePy + PIL. Takes a base MP4, a captions JSON, and optional background audio — out...
---
name: skill-video-caption-overlay
description: Render TikTok-style animated pill captions onto short-form videos using MoviePy + PIL. Takes a base MP4, a captions JSON, and optional background audio — outputs a final video with fade-in/out pill overlays. Fixes the PIL textbbox y-offset bug that causes text to sit outside pill boundaries. Use for TikTok ads, Reels, YouTube Shorts.
metadata:
openclaw:
requires: { bins: ["uv"] }
---
# Video Caption Overlay
Animated pill-style caption overlays for short-form video. No Premiere, no CapCut — pure Python.
## Usage
```bash
uv run --with moviepy --with pillow scripts/overlay.py \
--video base.mp4 \
--output final.mp4 \
--captions scripts/example_captions.json \
--audio music.mp3 \
--audio-start 8 \
--audio-vol 0.5
```
No `--audio` if you want to keep the original video audio.
## Custom fonts
```bash
--font-black /path/to/Montserrat-Black.ttf \
--font-bold /path/to/Montserrat-Bold.ttf
```
Falls back to Montserrat from `~/.local/share/fonts/` if not specified.
## captions.json format
Array of **phases** — each phase is a time window with one or more pill lines stacked vertically.
```json
[
{
"start": 0,
"end": 3.2,
"y_frac": 0.06,
"lines": [
{
"text": "POV:",
"size": 28,
"bold": true,
"bg": [0, 195, 255],
"fg": [0, 0, 0],
"bg_opacity": 0.9,
"px": 20, "py": 9, "r": 12
},
{
"text": "drink more water",
"size": 50,
"bg": [255, 255, 255],
"fg": [0, 0, 0]
}
]
}
]
```
| Field | Type | Default | Description |
|---|---|---|---|
| `start` | float | required | Phase start time (seconds) |
| `end` | float | required | Phase end time (seconds) |
| `y_frac` | float | 0.06 | Vertical position as fraction of video height |
| `lines[].text` | string | required | Caption text |
| `lines[].size` | int | 50 | Font size (px) |
| `lines[].bold` | bool | false | Use bold font (vs black/heavy) |
| `lines[].bg` | [R,G,B] | [255,255,255] | Pill background color |
| `lines[].fg` | [R,G,B] | [0,0,0] | Text color |
| `lines[].bg_opacity` | float | 0.93 | Pill background opacity (0–1) |
| `lines[].px` | int | 26 | Horizontal padding |
| `lines[].py` | int | 13 | Vertical padding |
| `lines[].r` | int | 18 | Border radius |
## PIL textbbox fix
PIL's `textbbox((0,0), text, font)` returns `(x0, y0, x1, y1)` where `y0` is a non-zero offset (typically 7–15px depending on font size). Drawing text at `(x, y)` without compensating for this offset causes text to appear below the pill's visual center.
**Fix implemented in `pill()`:**
```python
bb = draw.textbbox((0, 0), text, font=font)
x_off, y_off = bb[0], bb[1]
vis_w = bb[2] - bb[0] # actual visual width
vis_h = bb[3] - bb[1] # actual visual height
# Compensate offsets when drawing text
tx = cx - vis_w // 2 - x_off
ty = y - y_off
draw.text((tx, ty), text, font=font, fill=fg)
```
## Emoji note
NotoColorEmoji.ttf fails with PIL at arbitrary sizes (bitmap font with limited supported sizes). Use text alternatives (`"Free delivery"` instead of `"Free delivery 🚚"`) for reliable rendering.
## Example output
See `scripts/example_captions.json` for the full 3-phase TikTok ad structure:
- Phase 1 (0–3.2s): Hook — top-screen pill stack
- Phase 2 (2.8–5.8s): Product claim — overlapping fade
- Phase 3 (5.3–8.0s): CTA — bottom-screen price + delivery + bio link
FILE:scripts/example_captions.json
[
{
"start": 0,
"end": 3.2,
"y_frac": 0.06,
"lines": [
{
"text": "POV:",
"size": 28,
"bold": true,
"bg": [0, 195, 255],
"fg": [0, 0, 0],
"bg_opacity": 0.9,
"px": 20, "py": 9, "r": 12
},
{
"text": "you said you'd",
"size": 50,
"bg": [255, 255, 255],
"fg": [0, 0, 0],
"bg_opacity": 0.93
},
{
"text": "drink more water",
"size": 50,
"bg": [0, 195, 255],
"fg": [0, 0, 0],
"bg_opacity": 0.93
}
]
},
{
"start": 2.8,
"end": 5.8,
"y_frac": 0.06,
"lines": [
{
"text": "hydrogen water",
"size": 50,
"bg": [0, 0, 0],
"fg": [255, 255, 255],
"bg_opacity": 0.87
},
{
"text": "hits different",
"size": 50,
"bg": [0, 195, 255],
"fg": [255, 255, 255],
"bg_opacity": 0.93
}
]
},
{
"start": 5.3,
"end": 8.0,
"y_frac": 0.76,
"lines": [
{
"text": "149 AED",
"size": 56,
"bg": [255, 210, 0],
"fg": [0, 0, 0],
"bg_opacity": 0.97,
"px": 30, "py": 15, "r": 20
},
{
"text": "Free delivery UAE",
"size": 30,
"bold": true,
"bg": [20, 20, 20],
"fg": [255, 255, 255],
"bg_opacity": 0.9
},
{
"text": "Link in bio",
"size": 30,
"bold": true,
"bg": [255, 210, 0],
"fg": [0, 0, 0],
"bg_opacity": 0.9
}
]
}
]
FILE:scripts/overlay.py
#!/usr/bin/env python3
"""
TikTok-style animated pill caption overlay for short-form videos.
Usage:
uv run --with moviepy --with pillow overlay.py \
--video base.mp4 \
--audio music.mp3 \
--output final.mp4 \
--captions captions.json
captions.json format: see --help or README in SKILL.md
Dependencies (auto-installed by uv):
moviepy, pillow
"""
import argparse, json, sys
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from moviepy import VideoFileClip, AudioFileClip, CompositeVideoClip, VideoClip
# ── Core pill renderer ──────────────────────────────────────────────────────
def pill(draw: ImageDraw.Draw, text: str, font_path: str, size: int,
cx: int, y: int, bg: tuple, fg: tuple,
px: int = 26, py: int = 13, r: int = 18) -> int:
"""
Render a rounded pill with perfectly centered text.
PIL's textbbox() returns a non-zero y-offset (typically 7–15px depending on
font size) which causes text to draw outside the pill if not compensated.
This function accounts for exact (x0, y0) offsets from textbbox.
Args:
draw: PIL ImageDraw instance
text: Text to render
font_path: Absolute path to .ttf font file
size: Font size in px
cx: Horizontal center of the pill
y: TOP of the pill's visual content area (not baseline)
bg: Background RGBA tuple
fg: Foreground (text) RGBA tuple
px: Horizontal padding
py: Vertical padding
r: Border radius
Returns:
Total height consumed (pill height + 10px gap) — add to y for next pill
"""
font = ImageFont.truetype(font_path, size)
bb = draw.textbbox((0, 0), text, font=font)
x_off, y_off = bb[0], bb[1]
vis_w = bb[2] - bb[0]
vis_h = bb[3] - bb[1]
pill_x0 = cx - vis_w // 2 - px
pill_y0 = y - py
pill_x1 = cx + vis_w // 2 + (vis_w % 2) + px
pill_y1 = y + vis_h + py
draw.rounded_rectangle([pill_x0, pill_y0, pill_x1, pill_y1], radius=r, fill=bg)
tx = cx - vis_w // 2 - x_off
ty = y - y_off
draw.text((tx, ty), text, font=font, fill=fg)
return vis_h + py * 2 + 10
# ── Alpha animation ─────────────────────────────────────────────────────────
def alpha(t: float, start: float, end: float, fade: float = 0.3) -> int:
"""Fade in/out alpha value (0–255) for a caption phase."""
fade_in = min(1.0, (t - start) / fade) if t > start else 0.0
fade_out = 1.0 - min(1.0, (t - (end - fade)) / fade) if t > (end - fade) else 1.0
return int(255 * max(0.0, min(fade_in, fade_out)))
# ── Frame renderer ──────────────────────────────────────────────────────────
def make_overlay_frame(t: float, captions: list, w: int, h: int,
font_black: str, font_bold: str) -> np.ndarray:
"""
Generate a single RGBA overlay frame for time t.
captions: list of phase dicts (see captions.json format in SKILL.md)
"""
img = Image.new('RGBA', (w, h), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
cx = w // 2
for phase in captions:
start = phase['start']
end = phase['end']
if not (start - 0.05 <= t < end + 0.05):
continue
a = alpha(t, start, end)
y = int(h * phase.get('y_frac', 0.06))
gap = phase.get('gap', 10)
for line in phase['lines']:
text = line['text']
fsize = line.get('size', 50)
bold = line.get('bold', False)
fpath = font_bold if bold else font_black
bg_color = tuple(line.get('bg', [255, 255, 255]))
fg_color = tuple(line.get('fg', [0, 0, 0]))
bg_alpha = int(a * line.get('bg_opacity', 0.93))
fg_alpha = a
px = line.get('px', 26)
py_pad = line.get('py', 13)
radius = line.get('r', 18)
consumed = pill(
draw, text, fpath, fsize, cx, y,
bg=(*bg_color, bg_alpha),
fg=(*fg_color, fg_alpha),
px=px, py=py_pad, r=radius
)
y += consumed
return np.array(img)
# ── Main ────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description='TikTok pill caption overlay')
parser.add_argument('--video', required=True, help='Input video path')
parser.add_argument('--output', required=True, help='Output video path')
parser.add_argument('--captions', required=True, help='Captions JSON file path')
parser.add_argument('--audio', default=None, help='Background audio path (optional)')
parser.add_argument('--audio-start', type=float, default=0, help='Audio clip start (seconds)')
parser.add_argument('--audio-vol', type=float, default=0.5, help='Audio volume (0–1)')
parser.add_argument('--font-black', default=None, help='Path to heavy/black font .ttf')
parser.add_argument('--font-bold', default=None, help='Path to bold font .ttf')
args = parser.parse_args()
# Font defaults — override with --font-black / --font-bold
font_black = args.font_black or '/home/aladdin/.local/share/fonts/Montserrat-Black.ttf'
font_bold = args.font_bold or '/home/aladdin/.local/share/fonts/Montserrat-Bold.ttf'
with open(args.captions) as f:
captions = json.load(f)
video = VideoFileClip(args.video)
w, h = video.size
dur = video.duration
if args.audio:
clip_end = args.audio_start + dur
audio = AudioFileClip(args.audio).subclipped(args.audio_start, clip_end)
audio = audio.with_volume_scaled(args.audio_vol)
video = video.with_audio(audio)
overlay = VideoClip(
lambda t: make_overlay_frame(t, captions, w, h, font_black, font_bold),
duration=dur
).with_fps(video.fps)
final = CompositeVideoClip([video, overlay])
final.write_videofile(args.output, codec='libx264', audio_codec='aac',
fps=video.fps, logger=None)
print(f'Done → {args.output}')
if __name__ == '__main__':
main()
Security auditing for git commits, repos, and skills before publishing. Run automatically before any `git commit`, `git push`, or `clawhub publish`. Detects...
---
name: zero2ai-security-audit
description: Security auditing for git commits, repos, and skills before publishing. Run automatically before any `git commit`, `git push`, or `clawhub publish`. Detects hardcoded secrets, API keys, tokens, absolute paths, committed node_modules, .env files, and other sensitive patterns. Use when reviewing code for security issues, pre-publishing skills, or investigating a potential secret exposure.
---
# Security Audit
Run `scripts/audit.py` before every commit, push, or skill publish. No exceptions.
## When to run
| Trigger | Command |
|---|---|
| Before `git commit` | `python3 {skill_dir}/scripts/audit.py --staged` |
| Before `git push` | `python3 {skill_dir}/scripts/audit.py --last-commit` |
| Before `clawhub publish <path>` | `python3 {skill_dir}/scripts/audit.py <skill_path>` |
| Ad-hoc scan any path | `python3 {skill_dir}/scripts/audit.py <path>` |
`{skill_dir}` = `/home/aladdin/.openclaw/workspace/skills/skill-security-audit`
## Exit codes
- `0` = clean
- `1` = HIGH or MEDIUM findings (block publish/push)
- `2` = usage error
## What it detects
| Severity | Pattern |
|---|---|
| 🔴 HIGH | API keys, secrets, passwords, JWT tokens, WooCommerce keys, AWS keys, private key blocks, bearer tokens, `.env` files |
| 🟡 MEDIUM | Absolute `/home/<user>/` paths, `/root/` paths, refresh tokens, `node_modules/` committed |
| 🔵 LOW | Hardcoded IPs, long base64 strings |
## Rules
1. **HIGH findings = hard block.** Never commit or publish with HIGH findings. Rotate any exposed secret immediately.
2. **MEDIUM findings = fix before publish.** Replace absolute paths with relative or env-var defaults. Remove `node_modules/`.
3. **LOW findings = review.** Not blocking but investigate.
4. **False positives:** If a match is a variable name or safe placeholder (not an actual value), document why it's safe in a comment and re-run.
## After finding a real secret
1. **Do NOT push the commit.** If already pushed: rotate the secret immediately, then rewrite history or delete the file from git.
2. Rotate in the provider portal (TikTok Dev, AWS IAM, WooCommerce, etc.)
3. Move to env var: `process.env.SECRET_NAME` or read from a local config file outside the repo.
4. Add the config file path to `.gitignore`.
5. Report to Aladdin immediately with severity and what was exposed.
## Skill publish checklist
Before `clawhub publish`:
- [ ] `audit.py <skill_path>` returns 0 (clean)
- [ ] `node_modules/` not present in skill folder
- [ ] No absolute paths to user home directories
- [ ] No hardcoded business-specific IDs or credentials
- [ ] `package.json` name matches skill folder name
- [ ] SKILL.md description updated if renamed
FILE:scripts/audit.py
#!/usr/bin/env python3
"""
Security Audit Scanner
Scans files/directories for hardcoded secrets, absolute paths, and sensitive patterns.
Usage:
python3 audit.py <path> [--json] [--strict]
python3 audit.py --staged # Scan git staged files only
python3 audit.py --last-commit # Scan files changed in last commit
Exit codes: 0=clean, 1=findings, 2=error
"""
import sys, os, re, json, subprocess, argparse
from pathlib import Path
# ── Pattern definitions ────────────────────────────────────────────────────
SKIP_DIRS = {'.git', 'node_modules', '__pycache__', '.venv', 'venv', 'dist', 'build', '.next'}
SKIP_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.mp4', '.zip', '.tar', '.gz',
'.lock', '.map', '.min.js', '.min.css', '.pdf', '.ico', '.ttf', '.woff',
'.woff2', '.eot', '.svg'}
PATTERNS = [
# Real secrets — HIGH severity
{
"id": "hardcoded-api-key",
"severity": "HIGH",
"description": "Hardcoded API key or token",
"regex": r'(?i)(api[_-]?key|apikey|access[_-]?token|auth[_-]?token)\s*[=:]\s*["\']([A-Za-z0-9_\-]{20,})["\']',
"group": 2,
"min_entropy": 3.5,
},
{
"id": "hardcoded-secret",
"severity": "HIGH",
"description": "Hardcoded secret or password",
"regex": r'(?i)(secret|password|passwd|client_secret|client_key)\s*[=:]\s*["\']([^"\']{8,})["\']',
"group": 2,
"exclude_vars": True, # Skip if value looks like a variable/env ref
},
{
"id": "aws-key",
"severity": "HIGH",
"description": "AWS Access Key ID",
"regex": r'AKIA[0-9A-Z]{16}',
},
{
"id": "aws-secret",
"severity": "HIGH",
"description": "AWS Secret Access Key",
"regex": r'(?i)aws[_-]?secret[_-]?access[_-]?key\s*[=:]\s*["\']([A-Za-z0-9+/]{40})["\']',
},
{
"id": "jwt-token",
"severity": "HIGH",
"description": "Hardcoded JWT token",
"regex": r'eyJ[A-Za-z0-9_\-]{20,}\.[A-Za-z0-9_\-]{20,}\.[A-Za-z0-9_\-]{10,}',
},
{
"id": "woocommerce-key",
"severity": "HIGH",
"description": "WooCommerce consumer key/secret",
"regex": r'(ck|cs)_[a-f0-9]{40}',
},
{
"id": "private-key-block",
"severity": "HIGH",
"description": "Private key block",
"regex": r'-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----',
},
{
"id": "generic-bearer",
"severity": "HIGH",
"description": "Bearer token value",
"regex": r'Bearer\s+[A-Za-z0-9_\-\.]{40,}',
},
# Medium — suspicious patterns
{
"id": "absolute-home-path",
"severity": "MEDIUM",
"description": "Absolute home directory path hardcoded",
"regex": r'/home/[a-z_][a-z0-9_\-]*/(?!\.openclaw/workspace/skills)',
"exclude_comments": False,
},
{
"id": "absolute-root-path",
"severity": "MEDIUM",
"description": "Absolute /root/ path hardcoded",
"regex": r'/root/[^\s"\']+',
},
{
"id": "ip-address",
"severity": "LOW",
"description": "Hardcoded IP address (non-localhost)",
"regex": r'\b(?!127\.0\.0\.1|0\.0\.0\.0|localhost)(\d{1,3}\.){3}\d{1,3}\b',
},
{
"id": "refresh-token",
"severity": "MEDIUM",
"description": "Possible refresh/access token value",
"regex": r'(Atzr|Atza|ATNR)\|[A-Za-z0-9+/\|]{40,}',
},
{
"id": "base64-long",
"severity": "LOW",
"description": "Long base64 string (possible encoded secret)",
"regex": r'["\'][A-Za-z0-9+/]{60,}={0,2}["\']',
},
{
"id": "node-modules-in-skill",
"severity": "MEDIUM",
"description": "node_modules committed — should be gitignored",
"path_pattern": r'node_modules/',
"file_level": True,
},
{
"id": "env-file-committed",
"severity": "HIGH",
"description": ".env file committed",
"path_pattern": r'(^|/)\.env(\.|$)',
"file_level": True,
},
]
# Safe placeholder values — don't flag these
SAFE_VALUES = {
'your_api_key_here', 'your-api-key', 'YOUR_API_KEY', 'REPLACE_ME',
'process.env', 'os.environ', 'config.', 'cfg.', '', 'process.env.',
'placeholder', 'example', 'changeme', 'todo', 'fixme',
def shannon_entropy(s):
import math
if not s:
return 0
freq = {}
for c in s:
freq[c] = freq.get(c, 0) + 1
return -sum((f/len(s)) * math.log2(f/len(s)) for f in freq.values())
def is_safe_value(val):
val_lower = val.lower()
for safe in SAFE_VALUES:
if safe.lower() in val_lower:
return True
if re.match(r'^[A-Z_]+$', val): # ALL_CAPS env var reference
return True
if val.startswith('$') or val.startswith('{{') or val.startswith('%('):
return True
return False
def scan_file_content(filepath, content):
findings = []
lines = content.split('\n')
for pattern in PATTERNS:
if pattern.get('file_level'):
continue
regex = pattern.get('regex')
if not regex:
continue
compiled = re.compile(regex)
for lineno, line in enumerate(lines, 1):
# Skip obvious comments for some patterns
stripped = line.strip()
if stripped.startswith('//') or stripped.startswith('#'):
if pattern.get('exclude_comments', True) and pattern['severity'] in ('LOW',):
continue
for match in compiled.finditer(line):
value = match.group(pattern.get('group', 0))
# Check entropy for API key patterns
if pattern.get('min_entropy') and shannon_entropy(value) < pattern['min_entropy']:
continue
# Skip safe placeholder values
if pattern.get('exclude_vars') and is_safe_value(value):
continue
findings.append({
"id": pattern['id'],
"severity": pattern['severity'],
"description": pattern['description'],
"file": str(filepath),
"line": lineno,
"match": line.strip()[:120],
"value_preview": value[:40] + ('...' if len(value) > 40 else ''),
})
return findings
def scan_path_patterns(filepath):
findings = []
fstr = str(filepath)
for pattern in PATTERNS:
if not pattern.get('file_level') or not pattern.get('path_pattern'):
continue
if re.search(pattern['path_pattern'], fstr):
findings.append({
"id": pattern['id'],
"severity": pattern['severity'],
"description": pattern['description'],
"file": fstr,
"line": 0,
"match": f"Path: {fstr}",
"value_preview": "",
})
return findings
def should_skip(path):
p = Path(path)
for part in p.parts:
if part in SKIP_DIRS:
return True
ext = ''.join(p.suffixes)
return ext in SKIP_EXTENSIONS
def scan_directory(target):
all_findings = []
target_path = Path(target)
if target_path.is_file():
files = [target_path]
else:
files = [f for f in target_path.rglob('*') if f.is_file()]
for f in files:
if should_skip(f):
continue
all_findings.extend(scan_path_patterns(f))
try:
content = f.read_text(errors='ignore')
all_findings.extend(scan_file_content(f, content))
except Exception:
pass
return all_findings
def scan_git_files(mode='staged'):
if mode == 'staged':
result = subprocess.run(['git', 'diff', '--cached', '--name-only'], capture_output=True, text=True)
else:
result = subprocess.run(['git', 'diff', 'HEAD~1', '--name-only'], capture_output=True, text=True)
files = [f.strip() for f in result.stdout.split('\n') if f.strip()]
all_findings = []
for f in files:
if os.path.exists(f):
all_findings.extend(scan_directory(f))
return all_findings
def group_by_severity(findings):
grouped = {'HIGH': [], 'MEDIUM': [], 'LOW': []}
for f in findings:
grouped.setdefault(f['severity'], []).append(f)
return grouped
def main():
parser = argparse.ArgumentParser()
parser.add_argument('path', nargs='?', default='.', help='Path to scan')
parser.add_argument('--json', action='store_true', help='Output as JSON')
parser.add_argument('--staged', action='store_true', help='Scan git staged files')
parser.add_argument('--last-commit', action='store_true', help='Scan last commit files')
parser.add_argument('--strict', action='store_true', help='Exit 1 on any finding including LOW')
args = parser.parse_args()
if args.staged:
findings = scan_git_files('staged')
elif args.last_commit:
findings = scan_git_files('last-commit')
else:
findings = scan_directory(args.path)
# Deduplicate
seen = set()
unique = []
for f in findings:
key = (f['file'], f['line'], f['id'])
if key not in seen:
seen.add(key)
unique.append(f)
grouped = group_by_severity(unique)
if args.json:
print(json.dumps({'findings': unique, 'summary': {k: len(v) for k, v in grouped.items()}}, indent=2))
else:
total = len(unique)
if total == 0:
print('✅ CLEAN — No security findings.')
sys.exit(0)
print(f'\n🔍 Security Audit — {total} finding(s)\n')
for severity in ['HIGH', 'MEDIUM', 'LOW']:
items = grouped.get(severity, [])
if not items:
continue
icon = {'HIGH': '🔴', 'MEDIUM': '🟡', 'LOW': '🔵'}[severity]
print(f'{icon} {severity} ({len(items)})')
for item in items:
loc = f"{item['file']}:{item['line']}" if item['line'] else item['file']
print(f" [{item['id']}] {item['description']}")
print(f" → {loc}")
if item['value_preview']:
print(f" → Value: {item['value_preview']}")
print(f" → {item['match'][:100]}")
print()
high = len(grouped.get('HIGH', []))
medium = len(grouped.get('MEDIUM', []))
print(f"Summary: {high} HIGH, {medium} MEDIUM, {len(grouped.get('LOW', []))} LOW")
has_high = bool(grouped.get('HIGH'))
has_medium = bool(grouped.get('MEDIUM'))
if has_high or (args.strict and (has_medium or grouped.get('LOW'))):
sys.exit(1)
sys.exit(0)
if __name__ == '__main__':
main()
Automates order fulfillment by pushing WooCommerce orders to CJ Dropshipping. Fetches "Processing" orders, matches line items to CJ variants via a supplier s...
---
name: skill-dropshipping-fulfillment
description: Automates order fulfillment by pushing WooCommerce orders to CJ Dropshipping. Fetches "Processing" orders, matches line items to CJ variants via a supplier selection map, submits orders to CJ API, updates WooCommerce order status, and logs results. Supports dry-run and single-order modes.
metadata:
openclaw:
requires: { bins: ["node"] }
---
# CJ Fulfillment Engine
Automates the WooCommerce → CJ Dropshipping order flow. No manual copy-paste.
## What it does
1. Fetches all `processing` orders from WooCommerce
2. Maps line items to CJ variant IDs via `cj-supplier-selection.json`
3. Submits matched items to CJ API as a dropship order
4. Updates WooCommerce order status to `on-hold` (awaiting CJ dispatch)
5. Adds an order note with the CJ order ID
6. Logs all results to `cj-fulfillment-log.json`
## Credentials / paths
| File | Contents |
|---|---|
| `woo-api.json` | `{ url, consumerKey, consumerSecret }` |
| `cj-api.json` | `{ apiKey, baseUrl, accessToken, tokenExpiry }` |
| `cj-supplier-selection.json` | Array of `{ sku, cjProductId, variantId, ... }` |
## Usage
```bash
# Dry run — preview without placing orders
node {baseDir}/scripts/fulfill.js --dry-run
# Fulfill all processing orders
node {baseDir}/scripts/fulfill.js
# Fulfill a single WooCommerce order
node {baseDir}/scripts/fulfill.js --order 1234
```
## cj-supplier-selection.json format
Full 6-field schema — one entry per product variant. Matching is SKU-first with fallback to `wooProductId:wooVariationId`.
```json
[
{
"wooProductId": 77261,
"wooVariationId": 77265,
"sku": "CJYD2360896-BLACK",
"cjProductId": "CJ-PRODUCT-ID",
"variantId": "CJ-VARIANT-ID",
"productName": "My Product — Black"
}
]
```
Generate or rebuild this file from CJ API automatically:
```bash
node {baseDir}/scripts/rebuild-mapping.js
```
## FBA / excluded products
Products that should never be fulfilled via CJ (e.g. FBA, in-house). Set via env var:
```bash
FBA_PRODUCT_IDS=75927,75808,2382 node fulfill.js
```
These are skipped with a log entry: `"FBA product — manual fulfillment required"`.
## Output
- Console: per-order summary with matched/unmatched items and CJ order ID
- `cj-fulfillment-log.json`: append-only log with `{ orderId, status, cjOrderId, timestamp }`
- `cj-rejection-log.json`: unmatched/skipped items for manual review
## Unmatched items
If a line item has no SKU match and no `wooProductId:wooVariationId` match, it's logged to the rejection log and the order is skipped. Fix by running `rebuild-mapping.js` or adding the entry manually.
## Environment overrides
```bash
CJ_SELECTION_PATH=/custom/path/selection.json node fulfill.js
FULFILL_LOG_PATH=/custom/path/log.json node fulfill.js
```
FILE:package-lock.json
{
"name": "skill-dropshipping-fulfillment",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "skill-dropshipping-fulfillment",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"axios": "^1.13.6"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
}
}
}
FILE:package.json
{
"name": "skill-dropshipping-fulfillment",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"axios": "^1.13.6"
}
}
FILE:scripts/cj-api.js
/**
* CJ Dropshipping API helper
* Reads ./cj-api.json: { apiKey, baseUrl, accessToken, tokenExpiry }
*/
const axios = require('axios');
const fs = require('fs');
const CJ_API_PATH = process.env.CJ_API_PATH || './cj-api.json';
function readCfg() { return JSON.parse(fs.readFileSync(CJ_API_PATH, 'utf8')); }
function writeCfg(o) { fs.writeFileSync(CJ_API_PATH, JSON.stringify(o, null, 2)); }
async function ensureToken() {
const cfg = readCfg();
const now = Date.now();
const exp = Number(cfg.tokenExpiry || 0);
if (cfg.accessToken && exp && now < exp - 10 * 60 * 1000) return cfg.accessToken;
// Refresh
const baseUrl = (cfg.baseUrl || 'https://developers.cjdropshipping.com/api2.0/v1').replace(/\/$/, '');
const res = await axios.post(`baseUrl/authentication/getAccessToken`, { apiKey: cfg.apiKey }, {
headers: { 'Content-Type': 'application/json' }, timeout: 30000,
});
if (!res.data?.result) throw new Error(`CJ token refresh failed: JSON.stringify(res.data).slice(0, 200)`);
const token = res.data.data.accessToken;
cfg.accessToken = token;
cfg.tokenExpiry = now + 14 * 24 * 3600 * 1000;
writeCfg(cfg);
return token;
}
function baseUrl() {
return (readCfg().baseUrl || 'https://developers.cjdropshipping.com/api2.0/v1').replace(/\/$/, '');
}
async function headers() {
return { 'CJ-Access-Token': await ensureToken(), 'Content-Type': 'application/json' };
}
/**
* Get variant info by CJ variant ID to confirm stock/price.
*/
async function getVariant(variantId) {
const res = await axios.get(`baseUrl()/product/variant/query`, {
headers: await headers(),
params: { vid: variantId },
timeout: 30000,
});
return res.data;
}
/**
* Create an order on CJ.
* orderData: { orderNumber, shippingZip, shippingCountry, shippingCountryCode,
* shippingProvince, shippingCity, shippingAddress, shippingPhone,
* shippingCustomerName, shippingEmail, products: [{ vid, quantity }] }
*/
async function createOrder(orderData) {
const res = await axios.post(`baseUrl()/shopping/order/createOrder`, orderData, {
headers: await headers(),
timeout: 60000,
});
return res.data;
}
module.exports = { ensureToken, getVariant, createOrder };
FILE:scripts/fulfill.js
#!/usr/bin/env node
/**
* CJ Fulfillment Engine
*
* Fetches WooCommerce "processing" orders, matches line items to CJ variants
* via cj-supplier-selection.json, and submits orders to CJ Dropshipping.
*
* Usage:
* node fulfill.js [--dry-run] [--order <woo_order_id>]
*
* --dry-run Preview what would be submitted without actually placing orders
* --order ID Process only a specific WooCommerce order ID
*/
const fs = require('fs');
const axios = require('axios');
const WOO_API_PATH = process.env.WOO_API_PATH || '/home/aladdin/woo-api.json';
const CJ_API_PATH = process.env.CJ_API_PATH || '/home/aladdin/cj-api.json';
const SELECTION_PATH = process.env.CJ_SELECTION_PATH || '/home/aladdin/cj-supplier-selection.json';
const LOG_PATH = process.env.FULFILL_LOG_PATH || '/home/aladdin/cj-fulfillment-log.json';
const REJECTION_LOG_PATH = process.env.REJECTION_LOG_PATH || '/home/aladdin/cj-rejection-log.json';
const args = process.argv.slice(2);
const DRY_RUN = args.includes('--dry-run');
const ORDER_FILTER = (() => {
const i = args.indexOf('--order');
return i >= 0 ? String(args[i + 1]) : null;
})();
// FBA/excluded product IDs — never fulfilled via CJ
// Set via env: FBA_PRODUCT_IDS=75927,75808,2382 or leave empty for none
const FBA_PRODUCT_IDS = new Set(
(process.env.FBA_PRODUCT_IDS || '').split(',').map(s => parseInt(s.trim())).filter(Boolean)
);
// ── Utility ───────────────────────────────────────────────────────────────
function readJson(p) {
if (!fs.existsSync(p)) return [];
return JSON.parse(fs.readFileSync(p, 'utf8'));
}
function appendLog(entry) {
const log = readJson(LOG_PATH);
const arr = Array.isArray(log) ? log : [];
arr.push({ ...entry, timestamp: new Date().toISOString() });
fs.writeFileSync(LOG_PATH, JSON.stringify(arr, null, 2));
}
function appendRejectionLog(entry) {
const existing = readJson(REJECTION_LOG_PATH);
const log = Array.isArray(existing) ? existing : [];
log.unshift({ ...entry, timestamp: new Date().toISOString() });
if (log.length > 500) log.splice(500);
fs.writeFileSync(REJECTION_LOG_PATH, JSON.stringify(log, null, 2));
}
async function withRetry(fn, label, retries = 1) {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await fn();
} catch (err) {
const isNetwork = err.code && ['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'ENOTFOUND'].includes(err.code);
if (attempt < retries && isNetwork) {
console.log(` ⟳ Retrying label (attempt attempt + 2)...`);
await new Promise(r => setTimeout(r, 2000));
} else {
throw err;
}
}
}
}
// ── WooCommerce ───────────────────────────────────────────────────────────
function wooCfg() {
const cfg = JSON.parse(fs.readFileSync(WOO_API_PATH, 'utf8'));
const base = cfg.url.replace(/\/$/, '') + '/wp-json/wc/v3';
const auth = { username: cfg.consumerKey, password: cfg.consumerSecret };
return { base, auth };
}
async function getOrders(status = 'processing', perPage = 50) {
const { base, auth } = wooCfg();
const res = await axios.get(`base/orders`, {
auth,
params: { status, per_page: perPage, orderby: 'date', order: 'asc' },
timeout: 30000,
});
return res.data;
}
async function updateOrderStatus(orderId, status) {
const { base, auth } = wooCfg();
const res = await axios.put(`base/orders/orderId`, { status }, { auth, timeout: 30000 });
return res.data;
}
async function addOrderNote(orderId, note, customerNote = false) {
const { base, auth } = wooCfg();
const res = await axios.post(`base/orders/orderId/notes`, {
note,
customer_note: customerNote,
}, { auth, timeout: 30000 });
return res.data;
}
// ── CJ API ────────────────────────────────────────────────────────────────
function cjCfg() {
return JSON.parse(fs.readFileSync(CJ_API_PATH, 'utf8'));
}
async function cjEnsureToken() {
const cfg = cjCfg();
const now = Date.now();
const exp = Number(cfg.tokenExpiry || 0);
if (cfg.accessToken && exp && now < exp - 10 * 60 * 1000) return cfg.accessToken;
console.log(' 🔑 Refreshing CJ access token...');
const baseUrl = (cfg.baseUrl || 'https://developers.cjdropshipping.com/api2.0/v1').replace(/\/$/, '');
const res = await axios.post(`baseUrl/authentication/getAccessToken`, { apiKey: cfg.apiKey }, {
headers: { 'Content-Type': 'application/json' }, timeout: 30000,
});
if (!res.data?.result) throw new Error(`CJ token refresh failed: JSON.stringify(res.data).slice(0, 200)`);
const token = res.data.data.accessToken;
cfg.accessToken = token;
cfg.tokenExpiry = now + 14 * 24 * 3600 * 1000;
fs.writeFileSync(CJ_API_PATH, JSON.stringify(cfg, null, 2));
console.log(' ✅ CJ token refreshed');
return token;
}
function cjBaseUrl() {
return (cjCfg().baseUrl || 'https://developers.cjdropshipping.com/api2.0/v1').replace(/\/$/, '');
}
async function cjHeaders() {
return { 'CJ-Access-Token': await cjEnsureToken(), 'Content-Type': 'application/json' };
}
async function createCjOrder(orderData) {
const res = await axios.post(`cjBaseUrl()/shopping/order/createOrder`, orderData, {
headers: await cjHeaders(),
timeout: 60000,
});
return res.data;
}
// ── Selection / SKU map ───────────────────────────────────────────────────
/**
* Build lookup maps from cj-supplier-selection.json:
* - by SKU (uppercase)
* - by wooProductId + wooVariationId
* - by wooProductId alone (for simple products with wooVariationId === null)
*/
function buildMaps(selection) {
const bySkU = {};
const byWooId = {}; // key: "productId:variationId" or "productId:null"
for (const item of selection) {
if (item.sku) bySkU[item.sku.toUpperCase()] = item;
const key = `item.wooProductId:item.wooVariationId ?? 'null'`;
byWooId[key] = item;
}
return { bySkU, byWooId };
}
function findMatch(lineItem, maps) {
// 1. Match by SKU first
const sku = (lineItem.sku || '').toUpperCase();
if (sku && maps.bySkU[sku]) return maps.bySkU[sku];
// 2. Fall back to wooProductId + variationId
const varId = lineItem.variation_id || null;
const prodId = lineItem.product_id;
const key = `prodId:varId || 'null'`;
if (maps.byWooId[key]) return maps.byWooId[key];
// 3. Try product ID with null variation (simple product)
const simpleKey = `prodId:null`;
if (maps.byWooId[simpleKey]) return maps.byWooId[simpleKey];
return null;
}
// ── Order mapping ─────────────────────────────────────────────────────────
function mapOrder(order, maps) {
const shipping = order.shipping || {};
const billing = order.billing || {};
const products = [];
const unmatched = [];
const fbaItems = [];
for (const item of order.line_items || []) {
const prodId = item.product_id;
// FBA/LUUCCO exclusion
if (FBA_PRODUCT_IDS.has(prodId)) {
fbaItems.push({ id: item.id, name: item.name, productId: prodId });
continue;
}
const match = findMatch(item, maps);
if (!match) {
unmatched.push({ id: item.id, name: item.name, sku: item.sku, productId: prodId, variationId: item.variation_id });
continue;
}
products.push({
vid: match.variantId || match.cjProductId,
quantity: item.quantity,
productName: item.name,
matchedSku: match.sku,
});
}
return {
orderId: order.id,
orderNumber: `WOO-order.id`,
matched: products,
unmatched,
fbaItems,
shippingName: `shipping.first_name || '' shipping.last_name || ''`.trim()
|| `billing.first_name || '' billing.last_name || ''`.trim(),
shippingEmail: billing.email || '',
shippingPhone: billing.phone || shipping.phone || '',
shippingAddress: [shipping.address_1, shipping.address_2].filter(Boolean).join(', '),
shippingCity: shipping.city || '',
shippingProvince: shipping.state || '',
shippingZip: shipping.postcode || '',
shippingCountry: shipping.country || '',
};
}
// ── Main ──────────────────────────────────────────────────────────────────
async function run() {
console.log(`\n⚡ CJ Fulfillment Engine — 'LIVE'\n`);
// Ensure CJ token is valid before processing
await cjEnsureToken();
const selection = readJson(SELECTION_PATH);
if (!Array.isArray(selection) || !selection.length) {
console.error(`❌ No products in selection file: SELECTION_PATH`);
console.error(` Run: node scripts/rebuild-mapping.js`);
process.exit(1);
}
const maps = buildMaps(selection);
console.log(`✅ Loaded selection.length entries from selection file`);
// Fetch processing orders
let orders = await withRetry(() => getOrders('processing', 50), 'fetch orders');
if (ORDER_FILTER) {
orders = orders.filter(o => String(o.id) === ORDER_FILTER);
if (!orders.length) {
console.log(`No processing order found with ID: ORDER_FILTER`);
process.exit(0);
}
}
console.log(`📦 Found orders.length processing order(s)\n`);
if (!orders.length) {
appendRejectionLog({
type: 'no_orders',
reason: 'zero_processing_orders',
evaluated: { filter: ORDER_FILTER || 'all' },
note: 'Engine ran successfully but found no processing orders to fulfill.',
});
return;
}
let submitted = 0;
let skipped = 0;
let failed = 0;
for (const order of orders) {
const mapped = mapOrder(order, maps);
console.log(`\n--- Order #mapped.orderId ---`);
console.log(` Customer: mapped.shippingName <mapped.shippingEmail>`);
console.log(` Address: mapped.shippingAddress, mapped.shippingCity, mapped.shippingCountry mapped.shippingZip`);
// Log FBA items
if (mapped.fbaItems.length) {
console.log(` 🏷️ FBA items (manual fulfillment required):`);
for (const f of mapped.fbaItems) {
console.log(` - f.name (product #f.productId) — FBA product, manual fulfillment required`);
}
}
console.log(` Matched CJ items: mapped.matched.length`);
if (mapped.matched.length) {
for (const m of mapped.matched) {
console.log(` ✓ m.productName × m.quantity (SKU: m.matchedSku, vid: m.vid)`);
}
}
if (mapped.unmatched.length) {
console.log(` ⚠️ Unmatched items:`);
for (const u of mapped.unmatched) {
console.log(` - u.name (SKU: u.sku || 'n/a', product #u.productId)`);
}
}
// Skip if no CJ items to fulfill
if (!mapped.matched.length) {
const reason = mapped.fbaItems.length && !mapped.unmatched.length
? 'all_items_fba'
: 'no_matched_cj_items';
console.log(` ⏭️ Skipping — reason`);
skipped++;
appendLog({ orderId: mapped.orderId, status: 'skipped', reason });
appendRejectionLog({
type: 'order_skipped',
orderId: mapped.orderId,
reason,
evaluated: {
totalLineItems: (order.line_items || []).length,
fbaItems: mapped.fbaItems.map(f => ({ name: f.name, productId: f.productId })),
unmatchedItems: mapped.unmatched.map(u => ({ name: u.name, sku: u.sku })),
},
});
continue;
}
// Log partial matches
if (mapped.unmatched.length > 0 || mapped.fbaItems.length > 0) {
appendRejectionLog({
type: 'partial_match',
orderId: mapped.orderId,
reason: 'some_items_not_cj',
evaluated: {
totalLineItems: (order.line_items || []).length,
matchedCount: mapped.matched.length,
unmatchedCount: mapped.unmatched.length,
fbaCount: mapped.fbaItems.length,
},
});
}
const payload = {
orderNumber: mapped.orderNumber,
shippingZip: mapped.shippingZip,
shippingCountry: mapped.shippingCountry,
shippingCountryCode: mapped.shippingCountry,
shippingProvince: mapped.shippingProvince,
shippingCity: mapped.shippingCity,
shippingAddress: mapped.shippingAddress,
shippingPhone: mapped.shippingPhone,
shippingCustomerName: mapped.shippingName,
shippingEmail: mapped.shippingEmail,
products: mapped.matched.map(p => ({ vid: p.vid, quantity: p.quantity })),
};
if (DRY_RUN) {
console.log(`\n 🔍 DRY RUN — would submit payload:`);
console.log(' ' + JSON.stringify(payload, null, 2).split('\n').join('\n '));
submitted++;
continue;
}
try {
const result = await withRetry(() => createCjOrder(payload), `create CJ order for #mapped.orderId`);
if (result?.result) {
const cjOrderId = result?.data?.orderId || 'unknown';
console.log(` ✅ CJ order created: cjOrderId`);
await withRetry(() => updateOrderStatus(mapped.orderId, 'on-hold'), 'update woo status');
await withRetry(() => addOrderNote(mapped.orderId, `✅ CJ order submitted: cjOrderId`), 'add order note');
appendLog({ orderId: mapped.orderId, status: 'submitted', cjOrderId });
submitted++;
} else {
const errDetail = JSON.stringify(result).slice(0, 300);
console.error(` ❌ CJ rejected: errDetail`);
appendLog({ orderId: mapped.orderId, status: 'failed', error: errDetail });
appendRejectionLog({
type: 'cj_api_rejection',
orderId: mapped.orderId,
reason: 'cj_api_returned_failure',
evaluated: { payload, cjResponse: errDetail },
});
failed++;
}
} catch (err) {
const errMsg = err.response
? `HTTP err.response.status: JSON.stringify(err.response.data).slice(0, 200)`
: err.message;
console.error(` ❌ Error: errMsg`);
appendLog({ orderId: mapped.orderId, status: 'error', error: errMsg });
appendRejectionLog({
type: 'submission_error',
orderId: mapped.orderId,
reason: 'exception_thrown',
evaluated: { errorMessage: errMsg },
});
failed++;
}
}
console.log(`\n📊 Summary: submitted submitted, skipped skipped, failed failed\n`);
}
run().catch(e => { console.error(e?.stack || String(e)); process.exit(1); });
FILE:scripts/rebuild-mapping.js
#!/usr/bin/env node
/**
* Rebuild CJ supplier selection mapping + backfill WooCommerce SKUs
*
* Usage:
* node scripts/rebuild-mapping.js [--dry-run]
*
* What it does:
* 1. Fetches all published WooCommerce products (excluding LUUCCO/FBA IDs)
* 2. For each product with _cj_product_id meta, queries CJ for variant list
* 3. Matches WooCommerce variations to CJ variants by attributes
* 4. Backfills SKUs on WooCommerce products/variations if missing
* 5. Writes ./cj-supplier-selection.json with proper mappings
*/
const axios = require('axios');
const fs = require('fs');
const WOO_API_PATH = process.env.WOO_API_PATH || '/home/aladdin/woo-api.json';
const CJ_API_PATH = process.env.CJ_API_PATH || '/home/aladdin/cj-api.json';
const SELECTION_PATH = process.env.CJ_SELECTION_PATH || '/home/aladdin/cj-supplier-selection.json';
const DRY_RUN = process.argv.includes('--dry-run');
// FBA/excluded product IDs — customize via env FBA_PRODUCT_IDS=id1,id2,...
const LUUCCO_IDS = new Set(
(process.env.FBA_PRODUCT_IDS || '').split(',').map(s => parseInt(s.trim())).filter(Boolean)
);
function readJson(p) {
if (!fs.existsSync(p)) return null;
return JSON.parse(fs.readFileSync(p, 'utf8'));
}
// ── WooCommerce helpers ────────────────────────────────────────────────────
function wooCreds() {
const cfg = readJson(WOO_API_PATH);
const base = cfg.url.replace(/\/$/, '') + '/wp-json/wc/v3';
const auth = { username: cfg.consumerKey, password: cfg.consumerSecret };
return { base, auth };
}
async function wooGet(path, params = {}) {
const { base, auth } = wooCreds();
const res = await axios.get(`basepath`, { auth, params, timeout: 30000 });
return res.data;
}
async function wooPut(path, data) {
const { base, auth } = wooCreds();
const res = await axios.put(`basepath`, data, { auth, timeout: 30000 });
return res.data;
}
async function getAllPublishedProducts() {
let page = 1;
const all = [];
while (true) {
const batch = await wooGet('/products', { status: 'publish', per_page: 100, page });
if (!batch.length) break;
all.push(...batch);
if (batch.length < 100) break;
page++;
}
return all;
}
async function getVariations(productId) {
let page = 1;
const all = [];
while (true) {
const batch = await wooGet(`/products/productId/variations`, { per_page: 100, page });
if (!batch.length) break;
all.push(...batch);
if (batch.length < 100) break;
page++;
}
return all;
}
function getMeta(product, key) {
const m = (product.meta_data || []).find(x => x.key === key);
return m ? m.value : null;
}
// ── CJ helpers ────────────────────────────────────────────────────────────
async function cjEnsureToken() {
const cfg = readJson(CJ_API_PATH);
const now = Date.now();
const exp = Number(cfg.tokenExpiry || 0);
if (cfg.accessToken && exp && now < exp - 10 * 60 * 1000) return cfg.accessToken;
const baseUrl = (cfg.baseUrl || 'https://developers.cjdropshipping.com/api2.0/v1').replace(/\/$/, '');
const res = await axios.post(`baseUrl/authentication/getAccessToken`, { apiKey: cfg.apiKey }, {
headers: { 'Content-Type': 'application/json' }, timeout: 30000,
});
if (!res.data?.result) throw new Error(`CJ token refresh failed: JSON.stringify(res.data).slice(0, 200)`);
const token = res.data.data.accessToken;
cfg.accessToken = token;
cfg.tokenExpiry = now + 14 * 24 * 3600 * 1000;
fs.writeFileSync(CJ_API_PATH, JSON.stringify(cfg, null, 2));
return token;
}
async function cjGet(path, params = {}) {
const cfg = readJson(CJ_API_PATH);
const baseUrl = (cfg.baseUrl || 'https://developers.cjdropshipping.com/api2.0/v1').replace(/\/$/, '');
const token = await cjEnsureToken();
const res = await axios.get(`baseUrlpath`, {
headers: { 'CJ-Access-Token': token, 'Content-Type': 'application/json' },
params,
timeout: 30000,
});
return res.data;
}
async function getCjProductVariants(cjProductId) {
try {
const res = await cjGet('/product/variant/query', { pid: cjProductId });
if (res.result && Array.isArray(res.data)) return res.data;
// fallback: try as vid
return [];
} catch (e) {
console.error(` ⚠️ CJ variant query failed for cjProductId: e.message`);
return [];
}
}
async function getCjProductDetail(cjProductId) {
try {
const res = await cjGet('/product/query', { pid: cjProductId });
if (res.result && res.data) return res.data;
return null;
} catch (e) {
console.error(` ⚠️ CJ product detail failed for cjProductId: e.message`);
return null;
}
}
// ── Attribute matching ────────────────────────────────────────────────────
/**
* Given a WooCommerce variation (has attributes array) and a list of CJ variants,
* try to find the best match by attribute values (case-insensitive).
*/
function matchVariantByAttributes(wooVariation, cjVariants) {
if (!cjVariants.length) return null;
if (cjVariants.length === 1) return cjVariants[0];
const wooAttrs = (wooVariation.attributes || []).map(a => a.option?.toLowerCase?.() || '');
let best = null;
let bestScore = -1;
for (const v of cjVariants) {
// CJ variant name typically looks like "Red,L" or "Blue"
const vName = (v.variantKey || v.variantNameEn || v.sku || '').toLowerCase();
let score = 0;
for (const wa of wooAttrs) {
if (wa && vName.includes(wa)) score++;
}
if (score > bestScore) {
bestScore = score;
best = v;
}
}
return best;
}
// ── Main ──────────────────────────────────────────────────────────────────
async function main() {
console.log(`\n🔧 CJ Mapping Rebuild — 'LIVE'\n`);
// Ensure CJ token works
console.log('🔑 Verifying CJ token...');
await cjEnsureToken();
console.log('✅ CJ token OK\n');
console.log('📥 Fetching published WooCommerce products...');
const allProducts = await getAllPublishedProducts();
console.log(` Found allProducts.length published products\n`);
const cjProducts = allProducts.filter(p => {
if (LUUCCO_IDS.has(p.id)) return false;
const cjId = getMeta(p, '_cj_product_id');
return !!cjId;
});
const luuccoCount = allProducts.filter(p => LUUCCO_IDS.has(p.id)).length;
const noCjMeta = allProducts.filter(p => !LUUCCO_IDS.has(p.id) && !getMeta(p, '_cj_product_id')).length;
console.log(`📊 Breakdown:`);
console.log(` cjProducts.length CJ dropship products`);
console.log(` luuccoCount LUUCCO/FBA (skipped)`);
console.log(` noCjMeta no CJ meta (skipped)\n`);
const selectionEntries = [];
let skuBackfillCount = 0;
for (const product of cjProducts) {
const cjProductId = getMeta(product, '_cj_product_id');
console.log(`\n🛍️ Product #product.id: product.name.slice(0, 60)`);
console.log(` CJ Product ID: cjProductId`);
// Fetch CJ product detail + variants
const cjDetail = await getCjProductDetail(cjProductId);
const cjVariants = await getCjProductVariants(cjProductId);
console.log(` CJ variants found: cjVariants.length`);
if (product.type === 'simple') {
// Simple product — use first/only variant or product-level SKU
const cjVariant = cjVariants[0] || null;
const sku = cjVariant?.sku || cjDetail?.productSku || getMeta(product, '_cj_sku') || '';
// Backfill WooCommerce SKU if missing
if (!product.sku && sku && !DRY_RUN) {
console.log(` 📝 Backfilling SKU: sku`);
await wooPut(`/products/product.id`, { sku });
skuBackfillCount++;
} else if (!product.sku && sku) {
console.log(` 📝 [DRY RUN] Would set SKU: sku`);
skuBackfillCount++;
}
selectionEntries.push({
wooProductId: product.id,
wooVariationId: null,
sku: sku || product.sku || '',
cjProductId,
variantId: cjVariant?.vid || cjVariant?.variantId || '',
productName: product.name,
});
} else if (product.type === 'variable') {
// Variable product — fetch each variation and match to CJ variant
const variations = await getVariations(product.id);
console.log(` Woo variations: variations.length`);
for (const variation of variations) {
const matched = matchVariantByAttributes(variation, cjVariants);
const sku = matched?.sku || variation.sku || '';
if (!variation.sku && sku && !DRY_RUN) {
console.log(` 📝 Variation #variation.id — backfilling SKU: sku`);
await wooPut(`/products/product.id/variations/variation.id`, { sku });
skuBackfillCount++;
} else if (!variation.sku && sku) {
const attrStr = (variation.attributes || []).map(a => a.option).join('/');
console.log(` 📝 [DRY RUN] Variation #variation.id (attrStr) — would set SKU: sku`);
skuBackfillCount++;
}
const attrStr = (variation.attributes || []).map(a => a.option).join(' / ');
selectionEntries.push({
wooProductId: product.id,
wooVariationId: variation.id,
sku: sku,
cjProductId,
variantId: matched?.vid || matched?.variantId || '',
productName: `product.name — attrStr`,
});
}
}
// Small delay to avoid rate limits
await new Promise(r => setTimeout(r, 200));
}
console.log(`\n\n✅ Mapping complete!`);
console.log(` Total selection entries: selectionEntries.length`);
console.log(` SKUs to backfill: skuBackfillCount`);
if (!DRY_RUN) {
fs.writeFileSync(SELECTION_PATH, JSON.stringify(selectionEntries, null, 2));
console.log(`\n💾 Written to SELECTION_PATH`);
} else {
console.log(`\n[DRY RUN] Would write selectionEntries.length entries to SELECTION_PATH`);
console.log('\nSample entries:');
console.log(JSON.stringify(selectionEntries.slice(0, 3), null, 2));
}
return selectionEntries;
}
main().catch(e => { console.error(e?.stack || String(e)); process.exit(1); });
FILE:scripts/woo-api.js
/**
* WooCommerce API helper
* Reads ./woo-api.json: { url, consumerKey, consumerSecret }
*/
const axios = require('axios');
const fs = require('fs');
const WOO_API_PATH = process.env.WOO_API_PATH || './woo-api.json';
function getCfg() {
return JSON.parse(fs.readFileSync(WOO_API_PATH, 'utf8'));
}
function client() {
const { url, consumerKey, consumerSecret } = getCfg();
const base = url.replace(/\/$/, '') + '/wp-json/wc/v3';
const auth = { username: consumerKey, password: consumerSecret };
return { base, auth };
}
async function getOrders(status = 'processing', perPage = 50) {
const { base, auth } = client();
const res = await axios.get(`base/orders`, {
auth,
params: { status, per_page: perPage, orderby: 'date', order: 'asc' },
timeout: 30000,
});
return res.data;
}
async function updateOrderStatus(orderId, status, note = '') {
const { base, auth } = client();
const res = await axios.put(`base/orders/orderId`, {
status,
...(note ? { customer_note: note } : {}),
}, { auth, timeout: 30000 });
return res.data;
}
async function addOrderNote(orderId, note, customerNote = false) {
const { base, auth } = client();
const res = await axios.post(`base/orders/orderId/notes`, {
note,
customer_note: customerNote,
}, { auth, timeout: 30000 });
return res.data;
}
module.exports = { getOrders, updateOrderStatus, addOrderNote };
Query CJ Dropshipping API v2.0 to source products and fetch details for catalog building. Use for CJ keyword search, pulling product records (SPU/SKU, images...
---
name: skill-dropshipping-sourcing
description: Query CJ Dropshipping API v2.0 to source products and fetch details for catalog building. Use for CJ keyword search, pulling product records (SPU/SKU, images, categories, variants/colors when available), refreshing access tokens, and producing normalized JSON outputs for dropshipping catalog automation.
---
# CJ Sourcing
Use this skill to reliably pull CJ product data (instead of manual browsing).
## Files / creds (local convention)
- Config: `./cj-api.json`
- `apiKey`, `baseUrl`, `accessToken`, `tokenExpiry`
## 1) Refresh access token
```bash
node scripts/token.js
```
## 2) Search products by keyword (listV2)
```bash
node scripts/source.js --keyword "sunset lamp" --size 20 --out cj-results.json
```
Output: `cj-results.json` with normalized fields.
## Notes
- Token refresh is conservative (refreshes ~10 minutes before expiry).
- `source.js` uses `GET /product/listV2` and requests `enable_description` + category fields.
FILE:scripts/source.js
#!/usr/bin/env node
/**
* CJ Sourcing — search products via CJ API (listV2) and write normalized results.
*
* Outputs a JSON file suitable for downstream selection/curation.
*/
const fs = require('fs');
const axios = require('axios');
const CJ_API_PATH = process.env.CJ_API_PATH || './cj-api.json';
function readJson(p){ return JSON.parse(fs.readFileSync(p,'utf8')); }
function writeJson(p,o){ fs.writeFileSync(p, JSON.stringify(o,null,2)); }
function parseArgs(){
const a = process.argv.slice(2);
const out = { keyword:'', page:1, size:20, out:'cj-search-results.json' };
for(let i=0;i<a.length;i++){
if(a[i]==='--keyword' || a[i]==='--keywords') out.keyword = String(a[++i]||'');
else if(a[i]==='--page') out.page = Number(a[++i]);
else if(a[i]==='--size' || a[i]==='--max') out.size = Number(a[++i]);
else if(a[i]==='--out') out.out = String(a[++i]);
else if(a[i]==='--help' || a[i]==='-h'){
console.log('Usage: source.js --keyword "sunset lamp" --size 20 --out cj-results.json');
process.exit(0);
}
}
if(!out.keyword) throw new Error('Missing --keyword');
return out;
}
async function main(){
const args = parseArgs();
const cfg = readJson(CJ_API_PATH);
const baseUrl = (cfg.baseUrl || 'https://developers.cjdropshipping.com/api2.0/v1').replace(/\/$/,'');
const token = cfg.accessToken;
if(!token) throw new Error('Missing accessToken in cj-api.json (run token.js)');
const url = `baseUrl/product/listV2`;
const res = await axios.get(url, {
headers: { 'CJ-Access-Token': token },
params: {
keyWord: args.keyword,
page: args.page,
size: Math.min(Math.max(args.size,1),100),
features: ['enable_description','enable_category','enable_combine']
},
timeout: 90000
});
const data = res.data;
if(!data?.result) throw new Error(`CJ listV2 failed: JSON.stringify(data).slice(0,300)`);
// normalize
// CJ returns: data.content = [{ productList: [...] , relatedCategoryList: ... }]
const content = data.data?.content;
let products = [];
if (Array.isArray(content) && content[0] && Array.isArray(content[0].productList)) {
products = content[0].productList;
} else if (Array.isArray(content)) {
products = content;
} else if (content && Array.isArray(content.productList)) {
products = content.productList;
}
const norm = products.map(p=>({
id: String(p.id||''),
name: p.nameEn || p.name || '',
sku: p.sku || p.spu || '',
sellPrice: p.sellPrice || p.nowPrice || '',
listedNum: p.listedNum,
bigImage: p.bigImage,
categoryId: p.categoryId,
category: p.threeCategoryName || p.twoCategoryName || p.oneCategoryName || '',
variantKeyEn: p.variantKeyEn || '',
variantInventories: p.variantInventories || '',
description: p.description || ''
}));
writeJson(args.out, { keyword: args.keyword, fetchedAt: new Date().toISOString(), count: norm.length, products: norm });
console.log(`Wrote args.out (count=norm.length)`);
}
main().catch(e=>{console.error(e?.stack||String(e));process.exit(1)});
FILE:scripts/token.js
#!/usr/bin/env node
/**
* CJ token helper.
* Reads ./cj-api.json by default and refreshes token if expired.
* Writes updated accessToken + tokenExpiry back to cj-api.json.
*/
const fs = require('fs');
const axios = require('axios');
const CJ_API_PATH = process.env.CJ_API_PATH || './cj-api.json';
function readJson(p){ return JSON.parse(fs.readFileSync(p,'utf8')); }
function writeJson(p,o){ fs.writeFileSync(p, JSON.stringify(o,null,2)); }
async function refreshToken(apiKey, baseUrl){
const url = `baseUrl.replace(/\/$/,'')/authentication/getAccessToken`;
const r = await axios.post(url, { apiKey }, { headers: { 'Content-Type':'application/json' }, timeout: 60000 });
if(!r.data?.result) throw new Error(`Token refresh failed: JSON.stringify(r.data).slice(0,300)`);
return r.data.data.accessToken;
}
(async()=>{
const cfg = readJson(CJ_API_PATH);
const apiKey = cfg.apiKey;
const baseUrl = cfg.baseUrl || 'https://developers.cjdropshipping.com/api2.0/v1';
if(!apiKey) throw new Error('Missing apiKey in cj-api.json');
const now = Date.now();
const exp = Number(cfg.tokenExpiry||0);
const token = String(cfg.accessToken||'');
const needs = !token || !exp || (now > (exp - 10*60*1000)); // refresh 10m before expiry
if(!needs){
console.log(JSON.stringify({ok:true,refreshed:false,tokenExpiry:exp},null,2));
process.exit(0);
}
const newToken = await refreshToken(apiKey, baseUrl);
// CJ tokens are JWT-ish; if API doesn't provide expiry, keep 15 days default as in previous runs.
const newExp = now + 14*24*3600*1000;
cfg.accessToken = newToken;
cfg.tokenExpiry = newExp;
writeJson(CJ_API_PATH, cfg);
console.log(JSON.stringify({ok:true,refreshed:true,tokenExpiry:newExp},null,2));
})().catch(e=>{console.error(e?.stack||String(e));process.exit(1)});
Amazon Ads API v3 skill for OpenClaw agents. List profiles, manage Sponsored Products campaigns, view budgets and performance. Works with any advertiser acco...
---
name: skill-amazon-ads
description: "Amazon Ads API v3 skill for OpenClaw agents. List profiles, manage Sponsored Products campaigns, view budgets and performance. Works with any advertiser account."
metadata:
openclaw:
requires: { bins: ["node"] }
---
# Amazon Ads API Skill
Manage Amazon Sponsored Products campaigns from your OpenClaw agent — list profiles, view campaigns, check budgets, and pull performance data.
---
## Setup
### 1. Create credentials file
```json
{
"lwaClientId": "amzn1.application-oa2-client.YOUR_CLIENT_ID",
"lwaClientSecret": "YOUR_CLIENT_SECRET",
"refreshToken": "Atzr|YOUR_REFRESH_TOKEN",
"profileId": "YOUR_ADS_PROFILE_ID",
"region": "eu"
}
```
Save as `amazon-ads-api.json`. Set `AMAZON_ADS_PATH` env var to point to it (default: `./amazon-ads-api.json`).
> **Regions & endpoints:**
> - `na` → `advertising-api.amazon.com`
> - `eu` → `advertising-api-eu.amazon.com`
> - `fe` → `advertising-api-fe.amazon.com`
### 2. Get your Profile ID
```bash
node scripts/ads.js --profiles
```
Copy the `profileId` for your brand/marketplace and add it to the credentials file.
---
## Scripts
### `ads.js` — Campaigns & Summary
```bash
node scripts/ads.js --profiles # list all advertiser profiles
node scripts/ads.js --campaigns # list all SP campaigns
node scripts/ads.js --summary # active campaigns + budgets summary
node scripts/ads.js --campaigns --out c.json # save to file
```
---
## Credentials Schema
| Field | Description |
|-------|-------------|
| `lwaClientId` | Ads app client ID (separate from SP-API) |
| `lwaClientSecret` | Ads app client secret |
| `refreshToken` | LWA refresh token |
| `profileId` | Advertising profile ID (from `--profiles`) |
| `region` | `na`, `eu`, or `fe` |
---
## Notes
- Ads API uses a **separate LWA app** from SP-API — different client ID/secret
- Profile ID is required for all campaign operations
- Tokens are fetched fresh per request (no caching overhead for CLI use)
- For production/high-frequency use, add token caching
## Related
- [skill-amazon-spapi](https://github.com/Zero2Ai-hub/skill-amazon-spapi) — Orders, inventory & listings
FILE:README.md
# skill-amazon-ads-optimizer
> **OpenClaw Agent Skill** — Amazon Ads API v3 integration. List profiles, manage Sponsored Products campaigns, monitor budgets and performance — all from your AI agent.
---
## What It Does
| Script | What it does |
|--------|-------------|
| `ads.js` | List profiles, view all campaigns, get budget summary |
Automate your daily advertising operations:
- Pull live campaign status and budgets
- Identify active vs paused campaigns
- Export campaign data for analysis
- Feed into bid optimization workflows
---
## Quick Start
```bash
# 1. Create credentials file: amazon-ads-api.json
{
"lwaClientId": "amzn1.application-oa2-client.YOUR_ADS_CLIENT_ID",
"lwaClientSecret": "YOUR_ADS_SECRET",
"refreshToken": "Atzr|YOUR_TOKEN",
"profileId": "YOUR_ADS_PROFILE_ID",
"region": "eu"
}
# 2. Get your profile ID
node scripts/ads.js --profiles
# 3. View all campaigns
node scripts/ads.js --campaigns
# 4. Get budget summary
node scripts/ads.js --summary
```
---
## Usage Examples
```bash
# List all advertiser profiles (run first to get your profileId)
node scripts/ads.js --profiles
# List all Sponsored Products campaigns
node scripts/ads.js --campaigns
node scripts/ads.js --campaigns --out campaigns.json
# Summary: active campaigns + total daily budget
node scripts/ads.js --summary
```
**Example output:**
```
📊 Amazon Ads Summary
Active campaigns : 3
Paused campaigns : 1
Total daily budget: 15.00
[ENABLED] Manual | Exact | Product A — 5/day (MANUAL)
[ENABLED] Manual | Phrase | Product B — 5/day (MANUAL)
[ENABLED] Auto | Launch — 5/day (AUTO)
[PAUSED] Old Campaign — 0/day (MANUAL)
```
---
## API Endpoints by Region
| Region | Endpoint |
|--------|---------|
| North America | `advertising-api.amazon.com` |
| Europe / Middle East | `advertising-api-eu.amazon.com` |
| Far East | `advertising-api-fe.amazon.com` |
---
## Important Notes
- The Ads API uses a **separate LWA app** from SP-API — different Client ID/Secret
- You must set `profileId` in credentials (run `--profiles` to find yours)
- Tokens are fetched fresh per run — no stale token issues
- Requires `Advertising` permission scope on your LWA app
---
## Part of the Zero2AI Skill Library
Built and battle-tested in production. Part of a growing open-source library of AI agent skills for e-commerce automation.
- 🔗 [skill-amazon-spapi](https://github.com/Zero2Ai-hub/skill-amazon-spapi) — Orders, inventory & listings
- 🔗 [skill-amazon-listing-optimizer](https://github.com/Zero2Ai-hub/skill-amazon-listing-optimizer) — Image audit & fix
---
**Built by [Zero2AI](https://zeerotoai.com) · Published on [ClawHub](https://clawhub.ai)**
FILE:scripts/ads.js
#!/usr/bin/env node
/**
* Amazon Ads API v3 — Campaigns & Performance
* Usage:
* node ads.js --profiles
* node ads.js --campaigns [--out file.json]
* node ads.js --summary
*/
const fs = require('fs');
const CREDS_PATH = process.env.AMAZON_ADS_PATH || './amazon-ads-api.json';
const ENDPOINTS = { na: 'advertising-api.amazon.com', eu: 'advertising-api-eu.amazon.com', fe: 'advertising-api-fe.amazon.com' };
function getCreds() { return JSON.parse(fs.readFileSync(CREDS_PATH, 'utf8')); }
function getApiBase() {
const creds = getCreds();
return 'https://' + (ENDPOINTS[creds.region] || ENDPOINTS.eu);
}
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: {
'Amazon-Advertising-API-ClientId': creds.lwaClientId,
'Amazon-Advertising-API-Scope': creds.profileId,
'Authorization': 'Bearer ' + token,
'Content-Type': contentType,
'Accept': contentType,
},
};
if (body) opts.body = JSON.stringify(body);
const res = await fetch(getApiBase() + path, opts);
return res.json();
}
async function getProfiles() {
const creds = getCreds();
const token = await getAccessToken();
const res = await fetch(getApiBase() + '/v2/profiles', {
headers: {
'Amazon-Advertising-API-ClientId': creds.lwaClientId,
'Authorization': 'Bearer ' + token,
},
});
return res.json();
}
async function getCampaigns() {
return apiCall('/sp/campaigns/list', 'POST', {}, 'application/vnd.spcampaign.v3+json');
}
function parseArgs() {
const a = process.argv.slice(2);
const out = { command: null, out: null };
for (let i = 0; i < a.length; i++) {
if (a[i] === '--profiles') out.command = 'profiles';
else if (a[i] === '--campaigns') out.command = 'campaigns';
else if (a[i] === '--summary') out.command = 'summary';
else if (a[i] === '--out') out.out = a[++i];
}
return out;
}
async function main() {
const args = parseArgs();
if (!args.command) {
console.log('Usage:');
console.log(' node ads.js --profiles');
console.log(' node ads.js --campaigns [--out file.json]');
console.log(' node ads.js --summary');
process.exit(0);
}
if (args.command === 'profiles') {
const profiles = await getProfiles();
console.log(JSON.stringify(profiles, null, 2));
return;
}
if (args.command === 'campaigns' || args.command === 'summary') {
const data = await getCampaigns();
const campaigns = data.campaigns || [];
const enabled = campaigns.filter(c => c.state === 'ENABLED');
const paused = campaigns.filter(c => c.state === 'PAUSED');
const totalBudget = enabled.reduce((sum, c) => sum + (c.budget?.budget || 0), 0);
console.log(`\n📊 Amazon Ads Summary\n`);
console.log(`Active campaigns : enabled.length`);
console.log(`Paused campaigns : paused.length`);
console.log(`Total daily budget: totalBudget.toFixed(2)\n`);
campaigns.forEach(c => {
console.log(` [c.state] c.name — c.budget?.budget/day (c.targetingType)`);
});
if (args.out) {
fs.writeFileSync(args.out, JSON.stringify({ fetchedAt: new Date().toISOString(), totalResults: data.totalResults, campaigns }, null, 2));
console.log(`\nSaved to args.out`);
}
return campaigns;
}
}
main().catch(e => { console.error(e.message || e); process.exit(1); });
module.exports = { getProfiles, getCampaigns, getAccessToken };
Audit Amazon product listing images for non-square dimensions, auto-pad them to 2000×2000 white background, and push corrected images to live listings via SP...
---
name: skill-listing-image-optimizer
description: "Audit Amazon product listing images for non-square dimensions, auto-pad them to 2000×2000 white background, and push corrected images to live listings via SP-API. Works with any marketplace and seller account."
metadata:
openclaw:
requires: { bins: ["node", "python3"] }
---
# Amazon Listing Image Optimizer
Automatically fix non-square product images on Amazon listings — download, pad to 2000×2000 white background, and push back to live listings via SP-API. No manual Seller Central work required.
---
## Why This Exists
Amazon penalizes listings with non-square images (aspect ratio != 1:1). Common offenders:
- Landscape 16:9 or 4:3 product shots
- Portrait hero images
- Tiny low-resolution images
This skill detects, fixes, and re-uploads — all automatically.
---
## Setup
### 1. Install dependencies
```bash
pip3 install Pillow
npm install amazon-sp-api
```
### 2. Create SP-API credentials file
```json
{
"lwaClientId": "amzn1.application-oa2-client.YOUR_CLIENT_ID",
"lwaClientSecret": "YOUR_CLIENT_SECRET",
"refreshToken": "Atzr|YOUR_REFRESH_TOKEN",
"region": "eu",
"marketplace": "YOUR_MARKETPLACE_ID",
"sellerId": "YOUR_SELLER_ID"
}
```
Set `AMAZON_SPAPI_PATH` env var to point to it (default: `./amazon-sp-api.json`).
---
## Scripts
### `audit.js` — Detect non-square images
```bash
node scripts/audit.js --sku "MY-SKU" # audit single SKU
node scripts/audit.js --all # audit all FBA SKUs
node scripts/audit.js --all --out report.json # save report
```
Outputs: list of non-conforming image slots with dimensions.
### `pad_to_square.py` — Fix images locally
```bash
# After audit.js downloads originals to ./image_fix/
python3 scripts/pad_to_square.py ./image_fix/
```
Pads all `*_orig.jpg` files to 2000×2000 white background, outputs `*_fixed.jpg`.
### `push_images.js` — Upload fixed images to Amazon
```bash
node scripts/push_images.js --dir ./image_fix/ --sku "MY-SKU" --slots PT03,PT05
```
Spins up a local HTTP server on a public port, submits image URLs to SP-API, then auto-kills the server after 15 minutes (time for Amazon to crawl).
### `fix_title.js` — Patch listing title
```bash
node scripts/fix_title.js --sku "MY-SKU" --title "New optimized title here"
```
---
## Full Pipeline (one command)
```bash
node scripts/audit.js --all --out report.json
python3 scripts/pad_to_square.py ./image_fix/
node scripts/push_images.js --dir ./image_fix/ --from-report report.json
```
---
## Image Slot Reference
| Slot | Attribute | Description |
|------|-----------|-------------|
| MAIN | `main_product_image_locator` | Hero image (must be white bg) |
| PT01–PT08 | `other_product_image_locator_1` … `_8` | Secondary images |
---
## Notes
- Amazon processes image updates within 15–30 mins of ACCEPTED response
- VPS must have a publicly accessible IP/port for the temp HTTP server (or use S3/Cloudflare)
- PIL uses LANCZOS resampling for best quality when resizing
- Keep images under 10MB; target 2000×2000px @ 95% JPEG quality
## Related
- [skill-amazon-spapi](https://github.com/Zero2Ai-hub/skill-amazon-spapi) — Core SP-API auth & orders
FILE:README.md
# skill-amazon-listing-optimizer
> **OpenClaw Agent Skill** — Automatically detect, fix, and re-upload non-square Amazon product listing images via SP-API. No Seller Central. No manual work. Just run and it's done.
---
## The Problem
Amazon penalizes listings with non-square or low-resolution images — lower ranking, suppressed buy box, reduced conversion. Common issues:
- 16:9 landscape shots (2560×1440)
- 4:3 product photography (2560×1920)
- Portrait images (305×518)
- Sub-1000px anything
Fixing them manually means downloading, editing in Photoshop, re-uploading through Seller Central — for every single slot, every product.
**This skill does it in one command.**
---
## What It Does
| Script | What it does |
|--------|-------------|
| `audit.js` | Scans all your listing image slots, flags non-square and undersized images |
| `pad_to_square.py` | Pads flagged images to 2000×2000 white background (Amazon standard) |
| `push_images.js` | Serves fixed images via HTTP, submits URLs to SP-API, auto-closes after Amazon crawls |
---
## Quick Start
```bash
# 1. Install dependencies
npm install amazon-sp-api
pip3 install Pillow
# 2. Set up SP-API credentials (see skill-amazon-spapi)
export AMAZON_SPAPI_PATH=./amazon-sp-api.json
# 3. Full pipeline — audit, fix, push
node scripts/audit.js --all --out report.json
python3 scripts/pad_to_square.py ./image_fix/
node scripts/push_images.js --dir ./image_fix/ --from-report report.json
```
Amazon processes updates within **15–30 minutes**.
---
## Step by Step
### Step 1 — Audit
```bash
node scripts/audit.js --all
```
```
🔍 Auditing 5 SKUs...
SKU-001... ✅ all images OK
SKU-002... ⚠️ 2 issue(s)
[PT03] 2560x1920 (non-square)
[PT05] 2560x1440 (non-square)
SKU-003... ⚠️ 1 issue(s)
[PT06] 305x518 (non-square, too small)
📊 Summary: 2/5 SKUs have image issues
```
### Step 2 — Fix
```bash
python3 scripts/pad_to_square.py ./image_fix/
```
```
Processing 3 images...
✅ SKU-002_PT03_orig.jpg (2560x1920) → SKU-002_PT03_fixed.jpg (2000x2000)
✅ SKU-002_PT05_orig.jpg (2560x1440) → SKU-002_PT05_fixed.jpg (2000x2000)
✅ SKU-003_PT06_orig.jpg (305x518) → SKU-003_PT06_fixed.jpg (2000x2000)
```
### Step 3 — Push
```bash
node scripts/push_images.js --dir ./image_fix/ --from-report report.json
```
```
🚀 Amazon Listing Image Pusher
📡 Serving images at http://YOUR.IP:8899
SKU-002 [PT03] ... ✅ ACCEPTED
SKU-002 [PT05] ... ✅ ACCEPTED
SKU-003 [PT06] ... ✅ ACCEPTED
✅ 3/3 patches accepted
⏳ Keeping server alive 15 mins for Amazon to crawl...
🔒 Server closed. Done.
```
---
## Image Slot Reference
| Slot | Description |
|------|-------------|
| `MAIN` | Hero image — must be white background |
| `PT01`–`PT08` | Secondary images |
---
## Requirements
- Python 3 + Pillow (`pip3 install Pillow`)
- Node.js + `amazon-sp-api` (`npm install amazon-sp-api`)
- SP-API credentials with `listingsItems` write permission
- VPS/server with a publicly accessible IP and open port (default: 8899)
---
## Part of the Zero2AI Skill Library
Built and battle-tested in production. Part of a growing open-source library of AI agent skills for e-commerce automation.
- 🔗 [skill-amazon-spapi](https://github.com/Zero2Ai-hub/skill-amazon-spapi) — Core SP-API auth & orders
- 🔗 [skill-amazon-ads-optimizer](https://github.com/Zero2Ai-hub/skill-amazon-ads-optimizer) — Campaign & bid management
---
**Built by [Zero2AI](https://zeerotoai.com) · Published on [ClawHub](https://clawhub.ai)**
FILE:scripts/audit.js
#!/usr/bin/env node
/**
* Amazon Listing Image Auditor
* Detects non-square or undersized images across your listings.
*
* Usage:
* node audit.js --sku "MY-SKU"
* node audit.js --all
* node audit.js --all --out report.json
*/
const fs = require('fs');
const path = require('path');
const SellingPartnerAPI = require('amazon-sp-api');
const CREDS_PATH = process.env.AMAZON_SPAPI_PATH || './amazon-sp-api.json';
function getCfg() { return JSON.parse(fs.readFileSync(CREDS_PATH, 'utf8')); }
function getClient() {
const creds = getCfg();
return new SellingPartnerAPI({
region: creds.region || 'eu',
refresh_token: creds.refreshToken,
credentials: {
SELLING_PARTNER_APP_CLIENT_ID: creds.lwaClientId,
SELLING_PARTNER_APP_CLIENT_SECRET: creds.lwaClientSecret,
}
});
}
const IMAGE_ATTRS = [
'main_product_image_locator',
...Array.from({ length: 8 }, (_, i) => `other_product_image_locator_i + 1`)
];
const SLOT_NAMES = ['MAIN', 'PT01', 'PT02', 'PT03', 'PT04', 'PT05', 'PT06', 'PT07', 'PT08'];
async function getImageDimensions(url) {
try {
const res = await fetch(url, { method: 'HEAD' });
// Parse dimensions from Amazon CDN URL pattern
const match = url.match(/\/(\d+)x(\d+)\//);
if (match) return { w: parseInt(match[1]), h: parseInt(match[2]) };
// Fallback: download and check
const buf = await fetch(url).then(r => r.arrayBuffer());
const bytes = new Uint8Array(buf);
// JPEG SOF0 marker
for (let i = 0; i < bytes.length - 8; i++) {
if (bytes[i] === 0xFF && (bytes[i+1] === 0xC0 || bytes[i+1] === 0xC2)) {
return { w: (bytes[i+7] << 8) | bytes[i+8], h: (bytes[i+5] << 8) | bytes[i+6] };
}
}
} catch (e) { return null; }
return null;
}
async function auditSku(sp, sku) {
const cfg = getCfg();
const item = await sp.callAPI({
operation: 'getListingsItem',
endpoint: 'listingsItems',
path: { sellerId: cfg.sellerId, sku },
query: { marketplaceIds: cfg.marketplace, includedData: 'attributes' },
});
const issues = [];
const attrs = item.attributes || {};
for (let i = 0; i < IMAGE_ATTRS.length; i++) {
const attr = IMAGE_ATTRS[i];
const slot = SLOT_NAMES[i];
const images = attrs[attr];
if (!images || !images.length) continue;
const url = images[0]?.media_location;
if (!url) continue;
const dims = await getImageDimensions(url);
if (!dims) continue;
const isSquare = dims.w === dims.h;
const isTooSmall = dims.w < 1000 || dims.h < 1000;
if (!isSquare || isTooSmall) {
issues.push({ slot, attr, url, dims, isSquare, isTooSmall });
}
}
return { sku, issues };
}
async function getAllSkus(sp) {
const cfg = getCfg();
const res = await sp.callAPI({
operation: 'getInventorySummaries',
endpoint: 'fbaInventory',
query: {
details: true,
marketplaceIds: cfg.marketplace,
granularityType: 'Marketplace',
granularityId: cfg.marketplace,
},
});
return (res.inventorySummaries || []).map(s => s.sellerSku);
}
function parseArgs() {
const a = process.argv.slice(2);
const out = { sku: null, all: false, out: null };
for (let i = 0; i < a.length; i++) {
if (a[i] === '--sku') out.sku = a[++i];
else if (a[i] === '--all') out.all = true;
else if (a[i] === '--out') out.out = a[++i];
}
return out;
}
async function main() {
const args = parseArgs();
if (!args.sku && !args.all) {
console.log('Usage: node audit.js --sku "MY-SKU"');
console.log(' node audit.js --all [--out report.json]');
process.exit(0);
}
const sp = getClient();
let skus = args.sku ? [args.sku] : await getAllSkus(sp);
console.log(`\n🔍 Auditing skus.length SKU(s)...\n`);
const report = [];
for (const sku of skus) {
process.stdout.write(` sku... `);
try {
const result = await auditSku(sp, sku);
if (result.issues.length === 0) {
console.log('✅ all images OK');
} else {
console.log(`⚠️ result.issues.length issue(s)`);
result.issues.forEach(issue => {
console.log(` [issue.slot] issue.dims.wxissue.dims.h''''`);
});
}
report.push(result);
} catch (e) {
console.log(`❌ e.message.slice(0, 60)`);
}
}
const issues = report.filter(r => r.issues.length > 0);
console.log(`\n📊 Summary: issues.length/report.length SKUs have image issues\n`);
if (args.out) {
fs.writeFileSync(args.out, JSON.stringify({ auditedAt: new Date().toISOString(), report }, null, 2));
console.log(`Saved to args.out`);
}
return report;
}
main().catch(e => { console.error(e.message); process.exit(1); });
module.exports = { auditSku, getAllSkus };
FILE:scripts/pad_to_square.py
#!/usr/bin/env python3
"""
Pad images to 2000x2000 square with white background (Amazon standard).
Usage:
python3 pad_to_square.py <directory>
python3 pad_to_square.py ./image_fix/
Processes all *_orig.jpg files in the directory.
Outputs *_fixed.jpg files at 2000x2000px.
"""
from PIL import Image
import os, sys, glob
dir_path = sys.argv[1] if len(sys.argv) > 1 else "."
files = glob.glob(os.path.join(dir_path, "*_orig.jpg"))
if not files:
print(f"No *_orig.jpg files found in {dir_path}")
sys.exit(0)
print(f"Processing {len(files)} images...\n")
for f in files:
img = Image.open(f).convert("RGB")
w, h = img.size
size = max(w, h, 2000)
new_img = Image.new("RGB", (size, size), (255, 255, 255))
x = (size - w) // 2
y = (size - h) // 2
new_img.paste(img, (x, y))
new_img = new_img.resize((2000, 2000), Image.LANCZOS)
out = f.replace("_orig.jpg", "_fixed.jpg")
new_img.save(out, "JPEG", quality=95)
print(f" ✅ {os.path.basename(f)} ({w}x{h}) → {os.path.basename(out)} (2000x2000)")
print(f"\nDone. {len(files)} images fixed.")
FILE:scripts/push_images.js
#!/usr/bin/env node
/**
* Push fixed images to Amazon listings via SP-API.
* Serves images via local HTTP server on a public port.
* Amazon crawls the URLs and updates listings within 15-30 mins.
*
* Usage:
* node push_images.js --dir ./image_fix/ --sku "MY-SKU" --slots PT03,PT05
* node push_images.js --dir ./image_fix/ --from-report report.json
*/
const fs = require('fs');
const path = require('path');
const http = require('http');
const SellingPartnerAPI = require('amazon-sp-api');
const CREDS_PATH = process.env.AMAZON_SPAPI_PATH || './amazon-sp-api.json';
function getCfg() { return JSON.parse(fs.readFileSync(CREDS_PATH, 'utf8')); }
function getClient() {
const creds = getCfg();
return new SellingPartnerAPI({
region: creds.region || 'eu',
refresh_token: creds.refreshToken,
credentials: {
SELLING_PARTNER_APP_CLIENT_ID: creds.lwaClientId,
SELLING_PARTNER_APP_CLIENT_SECRET: creds.lwaClientSecret,
}
});
}
const SLOT_ATTR_MAP = {
MAIN: 'main_product_image_locator',
PT01: 'other_product_image_locator_1',
PT02: 'other_product_image_locator_2',
PT03: 'other_product_image_locator_3',
PT04: 'other_product_image_locator_4',
PT05: 'other_product_image_locator_5',
PT06: 'other_product_image_locator_6',
PT07: 'other_product_image_locator_7',
PT08: 'other_product_image_locator_8',
};
function startServer(dir, port = 8899) {
const server = http.createServer((req, res) => {
const filePath = path.join(dir, req.url.replace(/^\//, ''));
if (fs.existsSync(filePath)) {
res.writeHead(200, { 'Content-Type': 'image/jpeg' });
fs.createReadStream(filePath).pipe(res);
} else {
res.writeHead(404); res.end();
}
});
server.listen(port, '0.0.0.0');
return server;
}
async function getPublicIp() {
try {
const res = await fetch('https://api.ipify.org');
return res.text();
} catch { return 'localhost'; }
}
async function patchImage(sp, sku, slot, imageUrl) {
const cfg = getCfg();
const attr = SLOT_ATTR_MAP[slot];
if (!attr) throw new Error(`Unknown slot: slot`);
return sp.callAPI({
operation: 'patchListingsItem',
endpoint: 'listingsItems',
path: { sellerId: cfg.sellerId, sku },
query: { marketplaceIds: cfg.marketplace },
body: {
productType: process.env.PRODUCT_TYPE || 'PRODUCT',
patches: [{
op: 'replace',
path: `/attributes/attr`,
value: [{ media_location: imageUrl, marketplace_id: cfg.marketplace }]
}]
}
});
}
function parseArgs() {
const a = process.argv.slice(2);
const out = { dir: null, sku: null, slots: [], report: null, port: 8899 };
for (let i = 0; i < a.length; i++) {
if (a[i] === '--dir') out.dir = a[++i];
else if (a[i] === '--sku') out.sku = a[++i];
else if (a[i] === '--slots') out.slots = a[++i].split(',');
else if (a[i] === '--from-report') out.report = a[++i];
else if (a[i] === '--port') out.port = parseInt(a[++i]);
}
return out;
}
async function main() {
const args = parseArgs();
if (!args.dir) { console.error('--dir required'); process.exit(1); }
const ip = await getPublicIp();
const port = args.port;
const baseUrl = `http://ip:port`;
console.log(`\n🚀 Amazon Listing Image Pusher`);
console.log(`📡 Serving images at baseUrl\n`);
const server = startServer(args.dir, port);
const sp = getClient();
// Build fix list
let fixes = [];
if (args.report) {
const report = JSON.parse(fs.readFileSync(args.report, 'utf8'));
for (const entry of report.report) {
for (const issue of entry.issues) {
const fixedFile = path.join(args.dir, `entry.sku_issue.slot_fixed.jpg`);
if (fs.existsSync(fixedFile)) {
fixes.push({ sku: entry.sku, slot: issue.slot, file: path.basename(fixedFile) });
}
}
}
} else if (args.sku && args.slots.length) {
for (const slot of args.slots) {
const files = fs.readdirSync(args.dir).filter(f => f.includes('_fixed.jpg'));
const file = files.find(f => f.toLowerCase().includes(slot.toLowerCase()));
if (file) fixes.push({ sku: args.sku, slot, file });
}
} else {
// Auto-detect: all *_fixed.jpg files in dir
const files = fs.readdirSync(args.dir).filter(f => f.endsWith('_fixed.jpg'));
console.log(`Found files.length fixed images. Use --sku + --slots or --from-report for targeted push.`);
server.close();
process.exit(0);
}
console.log(`Pushing fixes.length image(s)...\n`);
let success = 0;
for (const fix of fixes) {
process.stdout.write(` fix.sku [fix.slot] fix.file... `);
try {
const r = await patchImage(sp, fix.sku, fix.slot, `baseUrl/fix.file`);
console.log(`✅ r?.status || 'ACCEPTED'`);
success++;
} catch (e) {
console.log(`❌ e.message.slice(0, 80)`);
}
await new Promise(r => setTimeout(r, 600));
}
console.log(`\n✅ success/fixes.length patches accepted`);
console.log(`⏳ Keeping server alive 15 mins for Amazon to crawl...`);
setTimeout(() => {
server.close();
console.log('🔒 Server closed. Done.');
process.exit(0);
}, 15 * 60 * 1000);
}
main().catch(e => { console.error(e.message); process.exit(1); });
Audit Amazon product listing images for non-square dimensions, auto-pad them to 2000×2000 white background, and push corrected images to live listings via SP...
---
name: skill-listing-image-optimizer
description: "Audit Amazon product listing images for non-square dimensions, auto-pad them to 2000×2000 white background, and push corrected images to live listings via SP-API. Works with any marketplace and seller account."
metadata:
openclaw:
requires: { bins: ["node", "python3"] }
---
# Amazon Listing Image Optimizer
Automatically fix non-square product images on Amazon listings — download, pad to 2000×2000 white background, and push back to live listings via SP-API. No manual Seller Central work required.
---
## Why This Exists
Amazon penalizes listings with non-square images (aspect ratio != 1:1). Common offenders:
- Landscape 16:9 or 4:3 product shots
- Portrait hero images
- Tiny low-resolution images
This skill detects, fixes, and re-uploads — all automatically.
---
## Setup
### 1. Install dependencies
```bash
pip3 install Pillow
npm install amazon-sp-api
```
### 2. Create SP-API credentials file
```json
{
"lwaClientId": "amzn1.application-oa2-client.YOUR_CLIENT_ID",
"lwaClientSecret": "YOUR_CLIENT_SECRET",
"refreshToken": "Atzr|YOUR_REFRESH_TOKEN",
"region": "eu",
"marketplace": "YOUR_MARKETPLACE_ID",
"sellerId": "YOUR_SELLER_ID"
}
```
Set `AMAZON_SPAPI_PATH` env var to point to it (default: `./amazon-sp-api.json`).
---
## Scripts
### `audit.js` — Detect non-square images
```bash
node scripts/audit.js --sku "MY-SKU" # audit single SKU
node scripts/audit.js --all # audit all FBA SKUs
node scripts/audit.js --all --out report.json # save report
```
Outputs: list of non-conforming image slots with dimensions.
### `pad_to_square.py` — Fix images locally
```bash
# After audit.js downloads originals to ./image_fix/
python3 scripts/pad_to_square.py ./image_fix/
```
Pads all `*_orig.jpg` files to 2000×2000 white background, outputs `*_fixed.jpg`.
### `push_images.js` — Upload fixed images to Amazon
```bash
node scripts/push_images.js --dir ./image_fix/ --sku "MY-SKU" --slots PT03,PT05
```
Spins up a local HTTP server on a public port, submits image URLs to SP-API, then auto-kills the server after 15 minutes (time for Amazon to crawl).
### `fix_title.js` — Patch listing title
```bash
node scripts/fix_title.js --sku "MY-SKU" --title "New optimized title here"
```
---
## Full Pipeline (one command)
```bash
node scripts/audit.js --all --out report.json
python3 scripts/pad_to_square.py ./image_fix/
node scripts/push_images.js --dir ./image_fix/ --from-report report.json
```
---
## Image Slot Reference
| Slot | Attribute | Description |
|------|-----------|-------------|
| MAIN | `main_product_image_locator` | Hero image (must be white bg) |
| PT01–PT08 | `other_product_image_locator_1` … `_8` | Secondary images |
---
## Notes
- Amazon processes image updates within 15–30 mins of ACCEPTED response
- VPS must have a publicly accessible IP/port for the temp HTTP server (or use S3/Cloudflare)
- PIL uses LANCZOS resampling for best quality when resizing
- Keep images under 10MB; target 2000×2000px @ 95% JPEG quality
## Related
- [skill-amazon-spapi](https://github.com/Zero2Ai-hub/skill-amazon-spapi) — Core SP-API auth & orders
FILE:README.md
# skill-listing-image-optimizer
OpenClaw Agent Skill — Audit Amazon listing images for non-square dimensions, auto-pad to 2000×2000 white background, and push corrected images via SP-API.
FILE:scripts/audit.js
#!/usr/bin/env node
/**
* Amazon Listing Image Auditor
* Detects non-square or undersized images across your listings.
*
* Usage:
* node audit.js --sku "MY-SKU"
* node audit.js --all
* node audit.js --all --out report.json
*/
const fs = require('fs');
const path = require('path');
const SellingPartnerAPI = require('amazon-sp-api');
const CREDS_PATH = process.env.AMAZON_SPAPI_PATH || './amazon-sp-api.json';
function getCfg() { return JSON.parse(fs.readFileSync(CREDS_PATH, 'utf8')); }
function getClient() {
const creds = getCfg();
return new SellingPartnerAPI({
region: creds.region || 'eu',
refresh_token: creds.refreshToken,
credentials: {
SELLING_PARTNER_APP_CLIENT_ID: creds.lwaClientId,
SELLING_PARTNER_APP_CLIENT_SECRET: creds.lwaClientSecret,
}
});
}
const IMAGE_ATTRS = [
'main_product_image_locator',
...Array.from({ length: 8 }, (_, i) => `other_product_image_locator_i + 1`)
];
const SLOT_NAMES = ['MAIN', 'PT01', 'PT02', 'PT03', 'PT04', 'PT05', 'PT06', 'PT07', 'PT08'];
async function getImageDimensions(url) {
try {
const res = await fetch(url, { method: 'HEAD' });
// Parse dimensions from Amazon CDN URL pattern
const match = url.match(/\/(\d+)x(\d+)\//);
if (match) return { w: parseInt(match[1]), h: parseInt(match[2]) };
// Fallback: download and check
const buf = await fetch(url).then(r => r.arrayBuffer());
const bytes = new Uint8Array(buf);
// JPEG SOF0 marker
for (let i = 0; i < bytes.length - 8; i++) {
if (bytes[i] === 0xFF && (bytes[i+1] === 0xC0 || bytes[i+1] === 0xC2)) {
return { w: (bytes[i+7] << 8) | bytes[i+8], h: (bytes[i+5] << 8) | bytes[i+6] };
}
}
} catch (e) { return null; }
return null;
}
async function auditSku(sp, sku) {
const cfg = getCfg();
const item = await sp.callAPI({
operation: 'getListingsItem',
endpoint: 'listingsItems',
path: { sellerId: cfg.sellerId, sku },
query: { marketplaceIds: cfg.marketplace, includedData: 'attributes' },
});
const issues = [];
const attrs = item.attributes || {};
for (let i = 0; i < IMAGE_ATTRS.length; i++) {
const attr = IMAGE_ATTRS[i];
const slot = SLOT_NAMES[i];
const images = attrs[attr];
if (!images || !images.length) continue;
const url = images[0]?.media_location;
if (!url) continue;
const dims = await getImageDimensions(url);
if (!dims) continue;
const isSquare = dims.w === dims.h;
const isTooSmall = dims.w < 1000 || dims.h < 1000;
if (!isSquare || isTooSmall) {
issues.push({ slot, attr, url, dims, isSquare, isTooSmall });
}
}
return { sku, issues };
}
async function getAllSkus(sp) {
const cfg = getCfg();
const res = await sp.callAPI({
operation: 'getInventorySummaries',
endpoint: 'fbaInventory',
query: {
details: true,
marketplaceIds: cfg.marketplace,
granularityType: 'Marketplace',
granularityId: cfg.marketplace,
},
});
return (res.inventorySummaries || []).map(s => s.sellerSku);
}
function parseArgs() {
const a = process.argv.slice(2);
const out = { sku: null, all: false, out: null };
for (let i = 0; i < a.length; i++) {
if (a[i] === '--sku') out.sku = a[++i];
else if (a[i] === '--all') out.all = true;
else if (a[i] === '--out') out.out = a[++i];
}
return out;
}
async function main() {
const args = parseArgs();
if (!args.sku && !args.all) {
console.log('Usage: node audit.js --sku "MY-SKU"');
console.log(' node audit.js --all [--out report.json]');
process.exit(0);
}
const sp = getClient();
let skus = args.sku ? [args.sku] : await getAllSkus(sp);
console.log(`\n🔍 Auditing skus.length SKU(s)...\n`);
const report = [];
for (const sku of skus) {
process.stdout.write(` sku... `);
try {
const result = await auditSku(sp, sku);
if (result.issues.length === 0) {
console.log('✅ all images OK');
} else {
console.log(`⚠️ result.issues.length issue(s)`);
result.issues.forEach(issue => {
console.log(` [issue.slot] issue.dims.wxissue.dims.h''''`);
});
}
report.push(result);
} catch (e) {
console.log(`❌ e.message.slice(0, 60)`);
}
}
const issues = report.filter(r => r.issues.length > 0);
console.log(`\n📊 Summary: issues.length/report.length SKUs have image issues\n`);
if (args.out) {
fs.writeFileSync(args.out, JSON.stringify({ auditedAt: new Date().toISOString(), report }, null, 2));
console.log(`Saved to args.out`);
}
return report;
}
main().catch(e => { console.error(e.message); process.exit(1); });
module.exports = { auditSku, getAllSkus };
FILE:scripts/pad_to_square.py
#!/usr/bin/env python3
"""
Pad images to 2000x2000 square with white background (Amazon standard).
Usage:
python3 pad_to_square.py <directory>
python3 pad_to_square.py ./image_fix/
Processes all *_orig.jpg files in the directory.
Outputs *_fixed.jpg files at 2000x2000px.
"""
from PIL import Image
import os, sys, glob
dir_path = sys.argv[1] if len(sys.argv) > 1 else "."
files = glob.glob(os.path.join(dir_path, "*_orig.jpg"))
if not files:
print(f"No *_orig.jpg files found in {dir_path}")
sys.exit(0)
print(f"Processing {len(files)} images...\n")
for f in files:
img = Image.open(f).convert("RGB")
w, h = img.size
size = max(w, h, 2000)
new_img = Image.new("RGB", (size, size), (255, 255, 255))
x = (size - w) // 2
y = (size - h) // 2
new_img.paste(img, (x, y))
new_img = new_img.resize((2000, 2000), Image.LANCZOS)
out = f.replace("_orig.jpg", "_fixed.jpg")
new_img.save(out, "JPEG", quality=95)
print(f" ✅ {os.path.basename(f)} ({w}x{h}) → {os.path.basename(out)} (2000x2000)")
print(f"\nDone. {len(files)} images fixed.")
FILE:scripts/push_images.js
#!/usr/bin/env node
/**
* Push fixed images to Amazon listings via SP-API.
* Serves images via local HTTP server on a public port.
* Amazon crawls the URLs and updates listings within 15-30 mins.
*
* Usage:
* node push_images.js --dir ./image_fix/ --sku "MY-SKU" --slots PT03,PT05
* node push_images.js --dir ./image_fix/ --from-report report.json
*/
const fs = require('fs');
const path = require('path');
const http = require('http');
const SellingPartnerAPI = require('amazon-sp-api');
const CREDS_PATH = process.env.AMAZON_SPAPI_PATH || './amazon-sp-api.json';
function getCfg() { return JSON.parse(fs.readFileSync(CREDS_PATH, 'utf8')); }
function getClient() {
const creds = getCfg();
return new SellingPartnerAPI({
region: creds.region || 'eu',
refresh_token: creds.refreshToken,
credentials: {
SELLING_PARTNER_APP_CLIENT_ID: creds.lwaClientId,
SELLING_PARTNER_APP_CLIENT_SECRET: creds.lwaClientSecret,
}
});
}
const SLOT_ATTR_MAP = {
MAIN: 'main_product_image_locator',
PT01: 'other_product_image_locator_1',
PT02: 'other_product_image_locator_2',
PT03: 'other_product_image_locator_3',
PT04: 'other_product_image_locator_4',
PT05: 'other_product_image_locator_5',
PT06: 'other_product_image_locator_6',
PT07: 'other_product_image_locator_7',
PT08: 'other_product_image_locator_8',
};
function startServer(dir, port = 8899) {
const server = http.createServer((req, res) => {
const filePath = path.join(dir, req.url.replace(/^\//, ''));
if (fs.existsSync(filePath)) {
res.writeHead(200, { 'Content-Type': 'image/jpeg' });
fs.createReadStream(filePath).pipe(res);
} else {
res.writeHead(404); res.end();
}
});
server.listen(port, '0.0.0.0');
return server;
}
async function getPublicIp() {
try {
const res = await fetch('https://api.ipify.org');
return res.text();
} catch { return 'localhost'; }
}
async function patchImage(sp, sku, slot, imageUrl) {
const cfg = getCfg();
const attr = SLOT_ATTR_MAP[slot];
if (!attr) throw new Error(`Unknown slot: slot`);
return sp.callAPI({
operation: 'patchListingsItem',
endpoint: 'listingsItems',
path: { sellerId: cfg.sellerId, sku },
query: { marketplaceIds: cfg.marketplace },
body: {
productType: process.env.PRODUCT_TYPE || 'PRODUCT',
patches: [{
op: 'replace',
path: `/attributes/attr`,
value: [{ media_location: imageUrl, marketplace_id: cfg.marketplace }]
}]
}
});
}
function parseArgs() {
const a = process.argv.slice(2);
const out = { dir: null, sku: null, slots: [], report: null, port: 8899 };
for (let i = 0; i < a.length; i++) {
if (a[i] === '--dir') out.dir = a[++i];
else if (a[i] === '--sku') out.sku = a[++i];
else if (a[i] === '--slots') out.slots = a[++i].split(',');
else if (a[i] === '--from-report') out.report = a[++i];
else if (a[i] === '--port') out.port = parseInt(a[++i]);
}
return out;
}
async function main() {
const args = parseArgs();
if (!args.dir) { console.error('--dir required'); process.exit(1); }
const ip = await getPublicIp();
const port = args.port;
const baseUrl = `http://ip:port`;
console.log(`\n🚀 Amazon Listing Image Pusher`);
console.log(`📡 Serving images at baseUrl\n`);
const server = startServer(args.dir, port);
const sp = getClient();
// Build fix list
let fixes = [];
if (args.report) {
const report = JSON.parse(fs.readFileSync(args.report, 'utf8'));
for (const entry of report.report) {
for (const issue of entry.issues) {
const fixedFile = path.join(args.dir, `entry.sku_issue.slot_fixed.jpg`);
if (fs.existsSync(fixedFile)) {
fixes.push({ sku: entry.sku, slot: issue.slot, file: path.basename(fixedFile) });
}
}
}
} else if (args.sku && args.slots.length) {
for (const slot of args.slots) {
const files = fs.readdirSync(args.dir).filter(f => f.includes('_fixed.jpg'));
const file = files.find(f => f.toLowerCase().includes(slot.toLowerCase()));
if (file) fixes.push({ sku: args.sku, slot, file });
}
} else {
// Auto-detect: all *_fixed.jpg files in dir
const files = fs.readdirSync(args.dir).filter(f => f.endsWith('_fixed.jpg'));
console.log(`Found files.length fixed images. Use --sku + --slots or --from-report for targeted push.`);
server.close();
process.exit(0);
}
console.log(`Pushing fixes.length image(s)...\n`);
let success = 0;
for (const fix of fixes) {
process.stdout.write(` fix.sku [fix.slot] fix.file... `);
try {
const r = await patchImage(sp, fix.sku, fix.slot, `baseUrl/fix.file`);
console.log(`✅ r?.status || 'ACCEPTED'`);
success++;
} catch (e) {
console.log(`❌ e.message.slice(0, 80)`);
}
await new Promise(r => setTimeout(r, 600));
}
console.log(`\n✅ success/fixes.length patches accepted`);
console.log(`⏳ Keeping server alive 15 mins for Amazon to crawl...`);
setTimeout(() => {
server.close();
console.log('🔒 Server closed. Done.');
process.exit(0);
}, 15 * 60 * 1000);
}
main().catch(e => { console.error(e.message); process.exit(1); });
Amazon Ads API v3 skill for OpenClaw agents. List profiles, manage Sponsored Products campaigns, view budgets and performance. Works with any advertiser acco...
---
name: skill-amazon-ads
description: "Amazon Ads API v3 skill for OpenClaw agents. List profiles, manage Sponsored Products campaigns, view budgets and performance. Works with any advertiser account."
metadata:
openclaw:
requires: { bins: ["node"] }
---
# Amazon Ads API Skill
Manage Amazon Sponsored Products campaigns from your OpenClaw agent — list profiles, view campaigns, check budgets, and pull performance data.
---
## Setup
### 1. Create credentials file
```json
{
"lwaClientId": "amzn1.application-oa2-client.YOUR_CLIENT_ID",
"lwaClientSecret": "YOUR_CLIENT_SECRET",
"refreshToken": "Atzr|YOUR_REFRESH_TOKEN",
"profileId": "YOUR_ADS_PROFILE_ID",
"region": "eu"
}
```
Save as `amazon-ads-api.json`. Set `AMAZON_ADS_PATH` env var to point to it (default: `./amazon-ads-api.json`).
> **Regions & endpoints:**
> - `na` → `advertising-api.amazon.com`
> - `eu` → `advertising-api-eu.amazon.com`
> - `fe` → `advertising-api-fe.amazon.com`
### 2. Get your Profile ID
```bash
node scripts/ads.js --profiles
```
Copy the `profileId` for your brand/marketplace and add it to the credentials file.
---
## Scripts
### `ads.js` — Campaigns & Summary
```bash
node scripts/ads.js --profiles # list all advertiser profiles
node scripts/ads.js --campaigns # list all SP campaigns
node scripts/ads.js --summary # active campaigns + budgets summary
node scripts/ads.js --campaigns --out c.json # save to file
```
---
## Credentials Schema
| Field | Description |
|-------|-------------|
| `lwaClientId` | Ads app client ID (separate from SP-API) |
| `lwaClientSecret` | Ads app client secret |
| `refreshToken` | LWA refresh token |
| `profileId` | Advertising profile ID (from `--profiles`) |
| `region` | `na`, `eu`, or `fe` |
---
## Notes
- Ads API uses a **separate LWA app** from SP-API — different client ID/secret
- Profile ID is required for all campaign operations
- Tokens are fetched fresh per request (no caching overhead for CLI use)
- For production/high-frequency use, add token caching
## Related
- [skill-amazon-spapi](https://github.com/Zero2Ai-hub/skill-amazon-spapi) — Orders, inventory & listings
FILE:README.md
# skill-amazon-ads
OpenClaw Agent Skill — Amazon Ads API v3: manage campaigns, keywords, bids, and run daily optimization. Plug-and-play for any OpenClaw agent.
FILE:scripts/ads.js
#!/usr/bin/env node
/**
* Amazon Ads API v3 — Campaigns & Performance
* Usage:
* node ads.js --profiles
* node ads.js --campaigns [--out file.json]
* node ads.js --summary
*/
const fs = require('fs');
const CREDS_PATH = process.env.AMAZON_ADS_PATH || './amazon-ads-api.json';
const ENDPOINTS = { na: 'advertising-api.amazon.com', eu: 'advertising-api-eu.amazon.com', fe: 'advertising-api-fe.amazon.com' };
function getCreds() { return JSON.parse(fs.readFileSync(CREDS_PATH, 'utf8')); }
function getApiBase() {
const creds = getCreds();
return 'https://' + (ENDPOINTS[creds.region] || ENDPOINTS.eu);
}
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: {
'Amazon-Advertising-API-ClientId': creds.lwaClientId,
'Amazon-Advertising-API-Scope': creds.profileId,
'Authorization': 'Bearer ' + token,
'Content-Type': contentType,
'Accept': contentType,
},
};
if (body) opts.body = JSON.stringify(body);
const res = await fetch(getApiBase() + path, opts);
return res.json();
}
async function getProfiles() {
const creds = getCreds();
const token = await getAccessToken();
const res = await fetch(getApiBase() + '/v2/profiles', {
headers: {
'Amazon-Advertising-API-ClientId': creds.lwaClientId,
'Authorization': 'Bearer ' + token,
},
});
return res.json();
}
async function getCampaigns() {
return apiCall('/sp/campaigns/list', 'POST', {}, 'application/vnd.spcampaign.v3+json');
}
function parseArgs() {
const a = process.argv.slice(2);
const out = { command: null, out: null };
for (let i = 0; i < a.length; i++) {
if (a[i] === '--profiles') out.command = 'profiles';
else if (a[i] === '--campaigns') out.command = 'campaigns';
else if (a[i] === '--summary') out.command = 'summary';
else if (a[i] === '--out') out.out = a[++i];
}
return out;
}
async function main() {
const args = parseArgs();
if (!args.command) {
console.log('Usage:');
console.log(' node ads.js --profiles');
console.log(' node ads.js --campaigns [--out file.json]');
console.log(' node ads.js --summary');
process.exit(0);
}
if (args.command === 'profiles') {
const profiles = await getProfiles();
console.log(JSON.stringify(profiles, null, 2));
return;
}
if (args.command === 'campaigns' || args.command === 'summary') {
const data = await getCampaigns();
const campaigns = data.campaigns || [];
const enabled = campaigns.filter(c => c.state === 'ENABLED');
const paused = campaigns.filter(c => c.state === 'PAUSED');
const totalBudget = enabled.reduce((sum, c) => sum + (c.budget?.budget || 0), 0);
console.log(`\n📊 Amazon Ads Summary\n`);
console.log(`Active campaigns : enabled.length`);
console.log(`Paused campaigns : paused.length`);
console.log(`Total daily budget: totalBudget.toFixed(2)\n`);
campaigns.forEach(c => {
console.log(` [c.state] c.name — c.budget?.budget/day (c.targetingType)`);
});
if (args.out) {
fs.writeFileSync(args.out, JSON.stringify({ fetchedAt: new Date().toISOString(), totalResults: data.totalResults, campaigns }, null, 2));
console.log(`\nSaved to args.out`);
}
return campaigns;
}
}
main().catch(e => { console.error(e.message || e); process.exit(1); });
module.exports = { getProfiles, getCampaigns, getAccessToken };
Amazon SP-API skill for OpenClaw agents. Fetch orders, check FBA inventory, manage listings and pricing. Works with any marketplace and seller account.
---
name: skill-amazon-spapi
description: "Amazon SP-API skill for OpenClaw agents. Fetch orders, check FBA inventory, manage listings and pricing. Works with any marketplace and seller account."
metadata:
openclaw:
requires: { bins: ["node"] }
---
# Amazon SP-API Skill
Fetch orders, check FBA inventory, and manage listings — plug-and-play for any OpenClaw agent.
---
## Setup
### 1. Install dependency
```bash
npm install amazon-sp-api
```
### 2. Create credentials file
```json
{
"lwaClientId": "amzn1.application-oa2-client.YOUR_CLIENT_ID",
"lwaClientSecret": "YOUR_CLIENT_SECRET",
"refreshToken": "Atzr|YOUR_REFRESH_TOKEN",
"region": "eu",
"marketplace": "YOUR_MARKETPLACE_ID",
"sellerId": "YOUR_SELLER_ID"
}
```
Save as `amazon-sp-api.json`. Set `AMAZON_SPAPI_PATH` env var to point to it (default: `./amazon-sp-api.json`).
> **Regions:** `na` | `eu` | `fe`
> **Marketplace IDs:** [Full list](https://developer-docs.amazon.com/sp-api/docs/marketplace-ids)
---
## Scripts
### `auth.js` — Test Connection
```bash
node scripts/auth.js
```
### `orders.js` — Orders
```bash
node scripts/orders.js --list # last 7 days
node scripts/orders.js --list --days 30
node scripts/orders.js --list --status Unshipped
node scripts/orders.js --list --out orders.json
node scripts/orders.js --get ORDER-ID
```
### `inventory.js` — FBA Inventory
```bash
node scripts/inventory.js
node scripts/inventory.js --sku "MY-SKU"
node scripts/inventory.js --out inventory.json
```
### `listings.js` — Listings & Pricing
```bash
node scripts/listings.js --get "MY-SKU"
node scripts/listings.js --update "MY-SKU" --price 99.00
node scripts/listings.js --update "MY-SKU" --price 99.00 --currency USD
```
---
## Notes
- Tokens auto-refresh via LWA — no manual management
- Keep credential files at `chmod 600`
- Respect SP-API rate limits per endpoint
## Related
- [skill-amazon-ads](https://github.com/Zero2Ai-hub/skill-amazon-ads) — Campaign & bid management
FILE:README.md
# skill-amazon-spapi
OpenClaw Agent Skill — Amazon SP-API: fetch orders, manage listings, check FBA inventory. Plug-and-play for any OpenClaw agent.
FILE:scripts/auth.js
#!/usr/bin/env node
/**
* Amazon SP-API Auth
* Tests connection and lists marketplace participations.
*/
const SellingPartnerAPI = require('amazon-sp-api');
const fs = require('fs');
const CREDS_PATH = process.env.AMAZON_SPAPI_PATH || './amazon-sp-api.json';
function getCfg() { return JSON.parse(fs.readFileSync(CREDS_PATH, 'utf8')); }
function getClient() {
const creds = getCfg();
return new SellingPartnerAPI({
region: creds.region || 'eu',
refresh_token: creds.refreshToken,
credentials: {
SELLING_PARTNER_APP_CLIENT_ID: creds.lwaClientId,
SELLING_PARTNER_APP_CLIENT_SECRET: creds.lwaClientSecret,
}
});
}
module.exports = { getClient, getCfg };
if (require.main === module) {
(async () => {
try {
const sp = getClient();
const res = await sp.callAPI({ operation: 'getMarketplaceParticipations', endpoint: 'sellers' });
console.log('✅ SP-API Connected');
const marketplaces = res.map(p => ({
id: p.marketplace?.id,
name: p.marketplace?.name,
country: p.marketplace?.countryCode,
}));
console.log(JSON.stringify(marketplaces, null, 2));
} catch (e) {
console.error('❌ Auth failed:', e.message);
process.exit(1);
}
})();
}
FILE:scripts/inventory.js
#!/usr/bin/env node
/**
* Amazon SP-API — FBA Inventory
* Usage:
* node inventory.js [--sku "MY-SKU"] [--out inventory.json]
*/
const fs = require('fs');
const auth = require('./auth');
function parseArgs() {
const a = process.argv.slice(2);
const out = { sku: null, out: null };
for (let i = 0; i < a.length; i++) {
if (a[i] === '--sku') out.sku = a[++i];
else if (a[i] === '--out') out.out = a[++i];
}
return out;
}
async function getInventory(sellerSku = null) {
const sp = auth.getClient();
const cfg = auth.getCfg();
const marketplace = cfg.marketplace;
const query = {
details: true,
marketplaceIds: marketplace,
granularityType: 'Marketplace',
granularityId: marketplace,
};
if (sellerSku) query.sellerSkus = sellerSku;
const res = await sp.callAPI({ operation: 'getInventorySummaries', endpoint: 'fbaInventory', query });
return res.inventorySummaries || [];
}
async function main() {
const args = parseArgs();
const inventory = await getInventory(args.sku);
console.log(`\nInventory (inventory.length SKUs):\n`);
inventory.forEach(item => {
const qty = item.inventoryDetails?.fulfillableQuantity ?? item.totalQuantity ?? 'N/A';
console.log(` item.sellerSku | ASIN: item.asin | Fulfillable: qty | Condition: item.condition`);
});
if (args.out) {
fs.writeFileSync(args.out, JSON.stringify({ fetchedAt: new Date().toISOString(), count: inventory.length, inventory }, null, 2));
console.log(`\nSaved to args.out`);
}
return inventory;
}
main().catch(e => { console.error(e?.response?.data || e.message); process.exit(1); });
module.exports = { getInventory };
FILE:scripts/listings.js
#!/usr/bin/env node
/**
* Amazon SP-API — Listings & Pricing
* Usage:
* node listings.js --get <sku>
* node listings.js --update <sku> --price <amount> [--currency USD]
*/
const fs = require('fs');
const auth = require('./auth');
function parseArgs() {
const a = process.argv.slice(2);
const out = { command: null, sku: null, price: null, currency: 'USD', out: null };
for (let i = 0; i < a.length; i++) {
if (a[i] === '--get') { out.command = 'get'; out.sku = a[++i]; }
else if (a[i] === '--update') { out.command = 'update'; out.sku = a[++i]; }
else if (a[i] === '--price') out.price = a[++i];
else if (a[i] === '--currency') out.currency = a[++i];
else if (a[i] === '--out') out.out = a[++i];
}
return out;
}
async function getListing(sku) {
const sp = auth.getClient();
const cfg = auth.getCfg();
return sp.callAPI({
operation: 'getListingsItem',
endpoint: 'listingsItems',
path: { sellerId: cfg.sellerId, sku: encodeURIComponent(sku) },
query: { marketplaceIds: cfg.marketplace, includedData: 'summaries,attributes,issues,offers,fulfillmentAvailability' },
});
}
async function updatePrice(sku, price, currency) {
const sp = auth.getClient();
const cfg = auth.getCfg();
return sp.callAPI({
operation: 'patchListingsItem',
endpoint: 'listingsItems',
path: { sellerId: cfg.sellerId, sku: encodeURIComponent(sku) },
query: { marketplaceIds: cfg.marketplace },
body: {
productType: 'PRODUCT',
patches: [{
op: 'replace',
path: '/attributes/purchasable_offer',
value: [{
marketplace_id: cfg.marketplace,
currency,
our_price: [{ schedule: [{ value_with_tax: parseFloat(price) }] }],
}],
}],
},
});
}
async function main() {
const args = parseArgs();
if (!args.command) {
console.log('Usage:');
console.log(' node listings.js --get <sku>');
console.log(' node listings.js --update <sku> --price <amount> [--currency USD]');
process.exit(0);
}
if (args.command === 'get') {
const listing = await getListing(args.sku);
console.log(JSON.stringify(listing, null, 2));
if (args.out) fs.writeFileSync(args.out, JSON.stringify(listing, null, 2));
}
if (args.command === 'update') {
if (!args.price) { console.error('--price required'); process.exit(1); }
const result = await updatePrice(args.sku, args.price, args.currency);
console.log(JSON.stringify(result, null, 2));
}
}
main().catch(e => { console.error(e?.response?.data || e.message); process.exit(1); });
module.exports = { getListing, updatePrice };
FILE:scripts/orders.js
#!/usr/bin/env node
/**
* Amazon SP-API — Orders
* Usage:
* node orders.js --list [--days 7] [--status Unshipped] [--out orders.json]
* node orders.js --get <orderId>
*/
const fs = require('fs');
const auth = require('./auth');
function parseArgs() {
const a = process.argv.slice(2);
const out = { command: null, orderId: null, days: 7, status: null, out: null };
for (let i = 0; i < a.length; i++) {
if (a[i] === '--list') out.command = 'list';
else if (a[i] === '--get') { out.command = 'get'; out.orderId = a[++i]; }
else if (a[i] === '--days') out.days = Number(a[++i]);
else if (a[i] === '--status') out.status = a[++i];
else if (a[i] === '--out') out.out = a[++i];
}
return out;
}
async function listOrders(days = 7, status = null) {
const sp = auth.getClient();
const cfg = auth.getCfg();
const createdAfter = new Date(Date.now() - days * 24 * 3600 * 1000).toISOString();
const query = { MarketplaceIds: [cfg.marketplace], CreatedAfter: createdAfter };
if (status) query.OrderStatuses = [status];
const res = await sp.callAPI({ operation: 'getOrders', endpoint: 'orders', query });
return res.Orders || [];
}
async function getOrder(orderId) {
const sp = auth.getClient();
return sp.callAPI({ operation: 'getOrder', endpoint: 'orders', path: { orderId } });
}
async function getOrderItems(orderId) {
const sp = auth.getClient();
const res = await sp.callAPI({ operation: 'getOrderItems', endpoint: 'orders', path: { orderId } });
return res.OrderItems || [];
}
async function main() {
const args = parseArgs();
if (!args.command) {
console.log('Usage: node orders.js --list [--days 7] [--status Unshipped] [--out file.json]');
console.log(' node orders.js --get <orderId>');
process.exit(0);
}
if (args.command === 'list') {
const orders = await listOrders(args.days, args.status);
const summary = orders.map(o => ({
orderId: o.AmazonOrderId,
status: o.OrderStatus,
total: `o.OrderTotal?.Amount o.OrderTotal?.CurrencyCode`,
date: o.PurchaseDate,
}));
console.log(`\nFound summary.length orders (last args.days days):\n`);
summary.forEach(o => console.log(` o.orderId | o.status | o.total | o.date?.slice(0, 10)`));
if (args.out) fs.writeFileSync(args.out, JSON.stringify({ fetchedAt: new Date().toISOString(), count: summary.length, orders: summary }, null, 2));
return summary;
}
if (args.command === 'get') {
const order = await getOrder(args.orderId);
const items = await getOrderItems(args.orderId);
const result = { order, items };
console.log(JSON.stringify(result, null, 2));
if (args.out) fs.writeFileSync(args.out, JSON.stringify(result, null, 2));
return result;
}
}
main().catch(e => { console.error(e?.response?.data || e.message || e); process.exit(1); });
module.exports = { listOrders, getOrder, getOrderItems };
Audit Amazon product listing images for non-square dimensions, auto-pad them to 2000×2000 white background, and push corrected images to live listings via SP...
---
name: skill-listing-image-optimizer
description: "Audit Amazon product listing images for non-square dimensions, auto-pad them to 2000×2000 white background, and push corrected images to live listings via SP-API. Works with any marketplace and seller account."
metadata:
openclaw:
requires: { bins: ["node", "python3"] }
---
# Amazon Listing Image Optimizer
Automatically fix non-square product images on Amazon listings — download, pad to 2000×2000 white background, and push back to live listings via SP-API. No manual Seller Central work required.
---
## Why This Exists
Amazon penalizes listings with non-square images (aspect ratio != 1:1). Common offenders:
- Landscape 16:9 or 4:3 product shots
- Portrait hero images
- Tiny low-resolution images
This skill detects, fixes, and re-uploads — all automatically.
---
## Setup
### 1. Install dependencies
```bash
pip3 install Pillow
npm install amazon-sp-api
```
### 2. Create SP-API credentials file
```json
{
"lwaClientId": "amzn1.application-oa2-client.YOUR_CLIENT_ID",
"lwaClientSecret": "YOUR_CLIENT_SECRET",
"refreshToken": "Atzr|YOUR_REFRESH_TOKEN",
"region": "eu",
"marketplace": "YOUR_MARKETPLACE_ID",
"sellerId": "YOUR_SELLER_ID"
}
```
Set `AMAZON_SPAPI_PATH` env var to point to it (default: `./amazon-sp-api.json`).
---
## Scripts
### `audit.js` — Detect non-square images
```bash
node scripts/audit.js --sku "MY-SKU" # audit single SKU
node scripts/audit.js --all # audit all FBA SKUs
node scripts/audit.js --all --out report.json # save report
```
Outputs: list of non-conforming image slots with dimensions.
### `pad_to_square.py` — Fix images locally
```bash
# After audit.js downloads originals to ./image_fix/
python3 scripts/pad_to_square.py ./image_fix/
```
Pads all `*_orig.jpg` files to 2000×2000 white background, outputs `*_fixed.jpg`.
### `push_images.js` — Upload fixed images to Amazon
```bash
node scripts/push_images.js --dir ./image_fix/ --sku "MY-SKU" --slots PT03,PT05
```
Spins up a local HTTP server on a public port, submits image URLs to SP-API, then auto-kills the server after 15 minutes (time for Amazon to crawl).
### `fix_title.js` — Patch listing title
```bash
node scripts/fix_title.js --sku "MY-SKU" --title "New optimized title here"
```
---
## Full Pipeline (one command)
```bash
node scripts/audit.js --all --out report.json
python3 scripts/pad_to_square.py ./image_fix/
node scripts/push_images.js --dir ./image_fix/ --from-report report.json
```
---
## Image Slot Reference
| Slot | Attribute | Description |
|------|-----------|-------------|
| MAIN | `main_product_image_locator` | Hero image (must be white bg) |
| PT01–PT08 | `other_product_image_locator_1` … `_8` | Secondary images |
---
## Notes
- Amazon processes image updates within 15–30 mins of ACCEPTED response
- VPS must have a publicly accessible IP/port for the temp HTTP server (or use S3/Cloudflare)
- PIL uses LANCZOS resampling for best quality when resizing
- Keep images under 10MB; target 2000×2000px @ 95% JPEG quality
## Related
- [skill-amazon-spapi](https://github.com/Zero2Ai-hub/skill-amazon-spapi) — Core SP-API auth & orders
FILE:README.md
# skill-listing-image-optimizer
OpenClaw Agent Skill — Audit Amazon listing images for non-square dimensions, auto-pad to 2000×2000 white background, and push corrected images via SP-API.
FILE:scripts/audit.js
#!/usr/bin/env node
/**
* Amazon Listing Image Auditor
* Detects non-square or undersized images across your listings.
*
* Usage:
* node audit.js --sku "MY-SKU"
* node audit.js --all
* node audit.js --all --out report.json
*/
const fs = require('fs');
const path = require('path');
const SellingPartnerAPI = require('amazon-sp-api');
const CREDS_PATH = process.env.AMAZON_SPAPI_PATH || './amazon-sp-api.json';
function getCfg() { return JSON.parse(fs.readFileSync(CREDS_PATH, 'utf8')); }
function getClient() {
const creds = getCfg();
return new SellingPartnerAPI({
region: creds.region || 'eu',
refresh_token: creds.refreshToken,
credentials: {
SELLING_PARTNER_APP_CLIENT_ID: creds.lwaClientId,
SELLING_PARTNER_APP_CLIENT_SECRET: creds.lwaClientSecret,
}
});
}
const IMAGE_ATTRS = [
'main_product_image_locator',
...Array.from({ length: 8 }, (_, i) => `other_product_image_locator_i + 1`)
];
const SLOT_NAMES = ['MAIN', 'PT01', 'PT02', 'PT03', 'PT04', 'PT05', 'PT06', 'PT07', 'PT08'];
async function getImageDimensions(url) {
try {
const res = await fetch(url, { method: 'HEAD' });
// Parse dimensions from Amazon CDN URL pattern
const match = url.match(/\/(\d+)x(\d+)\//);
if (match) return { w: parseInt(match[1]), h: parseInt(match[2]) };
// Fallback: download and check
const buf = await fetch(url).then(r => r.arrayBuffer());
const bytes = new Uint8Array(buf);
// JPEG SOF0 marker
for (let i = 0; i < bytes.length - 8; i++) {
if (bytes[i] === 0xFF && (bytes[i+1] === 0xC0 || bytes[i+1] === 0xC2)) {
return { w: (bytes[i+7] << 8) | bytes[i+8], h: (bytes[i+5] << 8) | bytes[i+6] };
}
}
} catch (e) { return null; }
return null;
}
async function auditSku(sp, sku) {
const cfg = getCfg();
const item = await sp.callAPI({
operation: 'getListingsItem',
endpoint: 'listingsItems',
path: { sellerId: cfg.sellerId, sku },
query: { marketplaceIds: cfg.marketplace, includedData: 'attributes' },
});
const issues = [];
const attrs = item.attributes || {};
for (let i = 0; i < IMAGE_ATTRS.length; i++) {
const attr = IMAGE_ATTRS[i];
const slot = SLOT_NAMES[i];
const images = attrs[attr];
if (!images || !images.length) continue;
const url = images[0]?.media_location;
if (!url) continue;
const dims = await getImageDimensions(url);
if (!dims) continue;
const isSquare = dims.w === dims.h;
const isTooSmall = dims.w < 1000 || dims.h < 1000;
if (!isSquare || isTooSmall) {
issues.push({ slot, attr, url, dims, isSquare, isTooSmall });
}
}
return { sku, issues };
}
async function getAllSkus(sp) {
const cfg = getCfg();
const res = await sp.callAPI({
operation: 'getInventorySummaries',
endpoint: 'fbaInventory',
query: {
details: true,
marketplaceIds: cfg.marketplace,
granularityType: 'Marketplace',
granularityId: cfg.marketplace,
},
});
return (res.inventorySummaries || []).map(s => s.sellerSku);
}
function parseArgs() {
const a = process.argv.slice(2);
const out = { sku: null, all: false, out: null };
for (let i = 0; i < a.length; i++) {
if (a[i] === '--sku') out.sku = a[++i];
else if (a[i] === '--all') out.all = true;
else if (a[i] === '--out') out.out = a[++i];
}
return out;
}
async function main() {
const args = parseArgs();
if (!args.sku && !args.all) {
console.log('Usage: node audit.js --sku "MY-SKU"');
console.log(' node audit.js --all [--out report.json]');
process.exit(0);
}
const sp = getClient();
let skus = args.sku ? [args.sku] : await getAllSkus(sp);
console.log(`\n🔍 Auditing skus.length SKU(s)...\n`);
const report = [];
for (const sku of skus) {
process.stdout.write(` sku... `);
try {
const result = await auditSku(sp, sku);
if (result.issues.length === 0) {
console.log('✅ all images OK');
} else {
console.log(`⚠️ result.issues.length issue(s)`);
result.issues.forEach(issue => {
console.log(` [issue.slot] issue.dims.wxissue.dims.h''''`);
});
}
report.push(result);
} catch (e) {
console.log(`❌ e.message.slice(0, 60)`);
}
}
const issues = report.filter(r => r.issues.length > 0);
console.log(`\n📊 Summary: issues.length/report.length SKUs have image issues\n`);
if (args.out) {
fs.writeFileSync(args.out, JSON.stringify({ auditedAt: new Date().toISOString(), report }, null, 2));
console.log(`Saved to args.out`);
}
return report;
}
main().catch(e => { console.error(e.message); process.exit(1); });
module.exports = { auditSku, getAllSkus };
FILE:scripts/pad_to_square.py
#!/usr/bin/env python3
"""
Pad images to 2000x2000 square with white background (Amazon standard).
Usage:
python3 pad_to_square.py <directory>
python3 pad_to_square.py ./image_fix/
Processes all *_orig.jpg files in the directory.
Outputs *_fixed.jpg files at 2000x2000px.
"""
from PIL import Image
import os, sys, glob
dir_path = sys.argv[1] if len(sys.argv) > 1 else "."
files = glob.glob(os.path.join(dir_path, "*_orig.jpg"))
if not files:
print(f"No *_orig.jpg files found in {dir_path}")
sys.exit(0)
print(f"Processing {len(files)} images...\n")
for f in files:
img = Image.open(f).convert("RGB")
w, h = img.size
size = max(w, h, 2000)
new_img = Image.new("RGB", (size, size), (255, 255, 255))
x = (size - w) // 2
y = (size - h) // 2
new_img.paste(img, (x, y))
new_img = new_img.resize((2000, 2000), Image.LANCZOS)
out = f.replace("_orig.jpg", "_fixed.jpg")
new_img.save(out, "JPEG", quality=95)
print(f" ✅ {os.path.basename(f)} ({w}x{h}) → {os.path.basename(out)} (2000x2000)")
print(f"\nDone. {len(files)} images fixed.")
FILE:scripts/push_images.js
#!/usr/bin/env node
/**
* Push fixed images to Amazon listings via SP-API.
* Serves images via local HTTP server on a public port.
* Amazon crawls the URLs and updates listings within 15-30 mins.
*
* Usage:
* node push_images.js --dir ./image_fix/ --sku "MY-SKU" --slots PT03,PT05
* node push_images.js --dir ./image_fix/ --from-report report.json
*/
const fs = require('fs');
const path = require('path');
const http = require('http');
const SellingPartnerAPI = require('amazon-sp-api');
const CREDS_PATH = process.env.AMAZON_SPAPI_PATH || './amazon-sp-api.json';
function getCfg() { return JSON.parse(fs.readFileSync(CREDS_PATH, 'utf8')); }
function getClient() {
const creds = getCfg();
return new SellingPartnerAPI({
region: creds.region || 'eu',
refresh_token: creds.refreshToken,
credentials: {
SELLING_PARTNER_APP_CLIENT_ID: creds.lwaClientId,
SELLING_PARTNER_APP_CLIENT_SECRET: creds.lwaClientSecret,
}
});
}
const SLOT_ATTR_MAP = {
MAIN: 'main_product_image_locator',
PT01: 'other_product_image_locator_1',
PT02: 'other_product_image_locator_2',
PT03: 'other_product_image_locator_3',
PT04: 'other_product_image_locator_4',
PT05: 'other_product_image_locator_5',
PT06: 'other_product_image_locator_6',
PT07: 'other_product_image_locator_7',
PT08: 'other_product_image_locator_8',
};
function startServer(dir, port = 8899) {
const server = http.createServer((req, res) => {
const filePath = path.join(dir, req.url.replace(/^\//, ''));
if (fs.existsSync(filePath)) {
res.writeHead(200, { 'Content-Type': 'image/jpeg' });
fs.createReadStream(filePath).pipe(res);
} else {
res.writeHead(404); res.end();
}
});
server.listen(port, '0.0.0.0');
return server;
}
async function getPublicIp() {
try {
const res = await fetch('https://api.ipify.org');
return res.text();
} catch { return 'localhost'; }
}
async function patchImage(sp, sku, slot, imageUrl) {
const cfg = getCfg();
const attr = SLOT_ATTR_MAP[slot];
if (!attr) throw new Error(`Unknown slot: slot`);
return sp.callAPI({
operation: 'patchListingsItem',
endpoint: 'listingsItems',
path: { sellerId: cfg.sellerId, sku },
query: { marketplaceIds: cfg.marketplace },
body: {
productType: process.env.PRODUCT_TYPE || 'PRODUCT',
patches: [{
op: 'replace',
path: `/attributes/attr`,
value: [{ media_location: imageUrl, marketplace_id: cfg.marketplace }]
}]
}
});
}
function parseArgs() {
const a = process.argv.slice(2);
const out = { dir: null, sku: null, slots: [], report: null, port: 8899 };
for (let i = 0; i < a.length; i++) {
if (a[i] === '--dir') out.dir = a[++i];
else if (a[i] === '--sku') out.sku = a[++i];
else if (a[i] === '--slots') out.slots = a[++i].split(',');
else if (a[i] === '--from-report') out.report = a[++i];
else if (a[i] === '--port') out.port = parseInt(a[++i]);
}
return out;
}
async function main() {
const args = parseArgs();
if (!args.dir) { console.error('--dir required'); process.exit(1); }
const ip = await getPublicIp();
const port = args.port;
const baseUrl = `http://ip:port`;
console.log(`\n🚀 Amazon Listing Image Pusher`);
console.log(`📡 Serving images at baseUrl\n`);
const server = startServer(args.dir, port);
const sp = getClient();
// Build fix list
let fixes = [];
if (args.report) {
const report = JSON.parse(fs.readFileSync(args.report, 'utf8'));
for (const entry of report.report) {
for (const issue of entry.issues) {
const fixedFile = path.join(args.dir, `entry.sku_issue.slot_fixed.jpg`);
if (fs.existsSync(fixedFile)) {
fixes.push({ sku: entry.sku, slot: issue.slot, file: path.basename(fixedFile) });
}
}
}
} else if (args.sku && args.slots.length) {
for (const slot of args.slots) {
const files = fs.readdirSync(args.dir).filter(f => f.includes('_fixed.jpg'));
const file = files.find(f => f.toLowerCase().includes(slot.toLowerCase()));
if (file) fixes.push({ sku: args.sku, slot, file });
}
} else {
// Auto-detect: all *_fixed.jpg files in dir
const files = fs.readdirSync(args.dir).filter(f => f.endsWith('_fixed.jpg'));
console.log(`Found files.length fixed images. Use --sku + --slots or --from-report for targeted push.`);
server.close();
process.exit(0);
}
console.log(`Pushing fixes.length image(s)...\n`);
let success = 0;
for (const fix of fixes) {
process.stdout.write(` fix.sku [fix.slot] fix.file... `);
try {
const r = await patchImage(sp, fix.sku, fix.slot, `baseUrl/fix.file`);
console.log(`✅ r?.status || 'ACCEPTED'`);
success++;
} catch (e) {
console.log(`❌ e.message.slice(0, 80)`);
}
await new Promise(r => setTimeout(r, 600));
}
console.log(`\n✅ success/fixes.length patches accepted`);
console.log(`⏳ Keeping server alive 15 mins for Amazon to crawl...`);
setTimeout(() => {
server.close();
console.log('🔒 Server closed. Done.');
process.exit(0);
}, 15 * 60 * 1000);
}
main().catch(e => { console.error(e.message); process.exit(1); });
Amazon Ads API v3 skill for OpenClaw agents. List profiles, manage Sponsored Products campaigns, view budgets and performance. Works with any advertiser acco...
---
name: skill-amazon-ads
description: "Amazon Ads API v3 skill for OpenClaw agents. List profiles, manage Sponsored Products campaigns, view budgets and performance. Works with any advertiser account."
metadata:
openclaw:
requires: { bins: ["node"] }
---
# Amazon Ads API Skill
Manage Amazon Sponsored Products campaigns from your OpenClaw agent — list profiles, view campaigns, check budgets, and pull performance data.
---
## Setup
### 1. Create credentials file
```json
{
"lwaClientId": "amzn1.application-oa2-client.YOUR_CLIENT_ID",
"lwaClientSecret": "YOUR_CLIENT_SECRET",
"refreshToken": "Atzr|YOUR_REFRESH_TOKEN",
"profileId": "YOUR_ADS_PROFILE_ID",
"region": "eu"
}
```
Save as `amazon-ads-api.json`. Set `AMAZON_ADS_PATH` env var to point to it (default: `./amazon-ads-api.json`).
> **Regions & endpoints:**
> - `na` → `advertising-api.amazon.com`
> - `eu` → `advertising-api-eu.amazon.com`
> - `fe` → `advertising-api-fe.amazon.com`
### 2. Get your Profile ID
```bash
node scripts/ads.js --profiles
```
Copy the `profileId` for your brand/marketplace and add it to the credentials file.
---
## Scripts
### `ads.js` — Campaigns & Summary
```bash
node scripts/ads.js --profiles # list all advertiser profiles
node scripts/ads.js --campaigns # list all SP campaigns
node scripts/ads.js --summary # active campaigns + budgets summary
node scripts/ads.js --campaigns --out c.json # save to file
```
---
## Credentials Schema
| Field | Description |
|-------|-------------|
| `lwaClientId` | Ads app client ID (separate from SP-API) |
| `lwaClientSecret` | Ads app client secret |
| `refreshToken` | LWA refresh token |
| `profileId` | Advertising profile ID (from `--profiles`) |
| `region` | `na`, `eu`, or `fe` |
---
## Notes
- Ads API uses a **separate LWA app** from SP-API — different client ID/secret
- Profile ID is required for all campaign operations
- Tokens are fetched fresh per request (no caching overhead for CLI use)
- For production/high-frequency use, add token caching
## Related
- [skill-amazon-spapi](https://github.com/Zero2Ai-hub/skill-amazon-spapi) — Orders, inventory & listings
FILE:README.md
# skill-amazon-ads
OpenClaw Agent Skill — Amazon Ads API v3: manage campaigns, keywords, bids, and run daily optimization. Plug-and-play for any OpenClaw agent.
FILE:scripts/ads.js
#!/usr/bin/env node
/**
* Amazon Ads API v3 — Campaigns & Performance
* Usage:
* node ads.js --profiles
* node ads.js --campaigns [--out file.json]
* node ads.js --summary
*/
const fs = require('fs');
const CREDS_PATH = process.env.AMAZON_ADS_PATH || './amazon-ads-api.json';
const ENDPOINTS = { na: 'advertising-api.amazon.com', eu: 'advertising-api-eu.amazon.com', fe: 'advertising-api-fe.amazon.com' };
function getCreds() { return JSON.parse(fs.readFileSync(CREDS_PATH, 'utf8')); }
function getApiBase() {
const creds = getCreds();
return 'https://' + (ENDPOINTS[creds.region] || ENDPOINTS.eu);
}
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: {
'Amazon-Advertising-API-ClientId': creds.lwaClientId,
'Amazon-Advertising-API-Scope': creds.profileId,
'Authorization': 'Bearer ' + token,
'Content-Type': contentType,
'Accept': contentType,
},
};
if (body) opts.body = JSON.stringify(body);
const res = await fetch(getApiBase() + path, opts);
return res.json();
}
async function getProfiles() {
const creds = getCreds();
const token = await getAccessToken();
const res = await fetch(getApiBase() + '/v2/profiles', {
headers: {
'Amazon-Advertising-API-ClientId': creds.lwaClientId,
'Authorization': 'Bearer ' + token,
},
});
return res.json();
}
async function getCampaigns() {
return apiCall('/sp/campaigns/list', 'POST', {}, 'application/vnd.spcampaign.v3+json');
}
function parseArgs() {
const a = process.argv.slice(2);
const out = { command: null, out: null };
for (let i = 0; i < a.length; i++) {
if (a[i] === '--profiles') out.command = 'profiles';
else if (a[i] === '--campaigns') out.command = 'campaigns';
else if (a[i] === '--summary') out.command = 'summary';
else if (a[i] === '--out') out.out = a[++i];
}
return out;
}
async function main() {
const args = parseArgs();
if (!args.command) {
console.log('Usage:');
console.log(' node ads.js --profiles');
console.log(' node ads.js --campaigns [--out file.json]');
console.log(' node ads.js --summary');
process.exit(0);
}
if (args.command === 'profiles') {
const profiles = await getProfiles();
console.log(JSON.stringify(profiles, null, 2));
return;
}
if (args.command === 'campaigns' || args.command === 'summary') {
const data = await getCampaigns();
const campaigns = data.campaigns || [];
const enabled = campaigns.filter(c => c.state === 'ENABLED');
const paused = campaigns.filter(c => c.state === 'PAUSED');
const totalBudget = enabled.reduce((sum, c) => sum + (c.budget?.budget || 0), 0);
console.log(`\n📊 Amazon Ads Summary\n`);
console.log(`Active campaigns : enabled.length`);
console.log(`Paused campaigns : paused.length`);
console.log(`Total daily budget: totalBudget.toFixed(2)\n`);
campaigns.forEach(c => {
console.log(` [c.state] c.name — c.budget?.budget/day (c.targetingType)`);
});
if (args.out) {
fs.writeFileSync(args.out, JSON.stringify({ fetchedAt: new Date().toISOString(), totalResults: data.totalResults, campaigns }, null, 2));
console.log(`\nSaved to args.out`);
}
return campaigns;
}
}
main().catch(e => { console.error(e.message || e); process.exit(1); });
module.exports = { getProfiles, getCampaigns, getAccessToken };
Amazon SP-API skill for OpenClaw agents. Fetch orders, check FBA inventory, manage listings and pricing. Works with any marketplace and seller account.
---
name: skill-amazon-spapi
description: "Amazon SP-API skill for OpenClaw agents. Fetch orders, check FBA inventory, manage listings and pricing. Works with any marketplace and seller account."
metadata:
openclaw:
requires: { bins: ["node"] }
---
# Amazon SP-API Skill
Fetch orders, check FBA inventory, and manage listings — plug-and-play for any OpenClaw agent.
---
## Setup
### 1. Install dependency
```bash
npm install amazon-sp-api
```
### 2. Create credentials file
```json
{
"lwaClientId": "amzn1.application-oa2-client.YOUR_CLIENT_ID",
"lwaClientSecret": "YOUR_CLIENT_SECRET",
"refreshToken": "Atzr|YOUR_REFRESH_TOKEN",
"region": "eu",
"marketplace": "YOUR_MARKETPLACE_ID",
"sellerId": "YOUR_SELLER_ID"
}
```
Save as `amazon-sp-api.json`. Set `AMAZON_SPAPI_PATH` env var to point to it (default: `./amazon-sp-api.json`).
> **Regions:** `na` | `eu` | `fe`
> **Marketplace IDs:** [Full list](https://developer-docs.amazon.com/sp-api/docs/marketplace-ids)
---
## Scripts
### `auth.js` — Test Connection
```bash
node scripts/auth.js
```
### `orders.js` — Orders
```bash
node scripts/orders.js --list # last 7 days
node scripts/orders.js --list --days 30
node scripts/orders.js --list --status Unshipped
node scripts/orders.js --list --out orders.json
node scripts/orders.js --get ORDER-ID
```
### `inventory.js` — FBA Inventory
```bash
node scripts/inventory.js
node scripts/inventory.js --sku "MY-SKU"
node scripts/inventory.js --out inventory.json
```
### `listings.js` — Listings & Pricing
```bash
node scripts/listings.js --get "MY-SKU"
node scripts/listings.js --update "MY-SKU" --price 99.00
node scripts/listings.js --update "MY-SKU" --price 99.00 --currency USD
```
---
## Notes
- Tokens auto-refresh via LWA — no manual management
- Keep credential files at `chmod 600`
- Respect SP-API rate limits per endpoint
## Related
- [skill-amazon-ads](https://github.com/Zero2Ai-hub/skill-amazon-ads) — Campaign & bid management
FILE:README.md
# skill-amazon-spapi
> **OpenClaw Agent Skill** — Full Amazon SP-API integration. Fetch orders, check FBA inventory, manage listings and pricing — all from your AI agent, zero manual Seller Central work.
---
## What It Does
| Script | What it does |
|--------|-------------|
| `auth.js` | Test SP-API connection, list marketplace participations |
| `orders.js` | Fetch recent orders, filter by status/date, export to JSON |
| `inventory.js` | Check FBA fulfillable stock per SKU |
| `listings.js` | Get listing details, update pricing |
---
## Quick Start
```bash
# 1. Install dependency
npm install amazon-sp-api
# 2. Create credentials file: amazon-sp-api.json
{
"lwaClientId": "amzn1.application-oa2-client.YOUR_ID",
"lwaClientSecret": "YOUR_SECRET",
"refreshToken": "Atzr|YOUR_TOKEN",
"region": "eu",
"marketplace": "YOUR_MARKETPLACE_ID",
"sellerId": "YOUR_SELLER_ID"
}
# 3. Test connection
node scripts/auth.js
# 4. Pull today's orders
node scripts/orders.js --list --days 1
# 5. Check inventory
node scripts/inventory.js
```
---
## Usage Examples
```bash
# Orders
node scripts/orders.js --list --days 7
node scripts/orders.js --list --days 30 --status Unshipped --out unshipped.json
node scripts/orders.js --get 123-4567890-1234567
# Inventory
node scripts/inventory.js --sku "MY-SKU"
node scripts/inventory.js --out inventory.json
# Listings
node scripts/listings.js --get "MY-SKU"
node scripts/listings.js --update "MY-SKU" --price 49.99 --currency USD
```
---
## Marketplace IDs (Common)
| Country | Marketplace ID |
|---------|---------------|
| US | `ATVPDKIKX0DER` |
| UAE | `A2VIGQ35RCS4UG` |
| UK | `A1F83G8C2ARO7P` |
| DE | `A1PA6795UKMFR9` |
| SA | `A17E79C6D8DWNP` |
> Full list: [Amazon Marketplace IDs](https://developer-docs.amazon.com/sp-api/docs/marketplace-ids)
---
## Credentials Setup
1. Go to [Seller Central > Apps & Services > Develop Apps](https://sellercentral.amazon.com/apps/develop)
2. Create a new SP-API application
3. Generate LWA credentials (Client ID + Secret)
4. Authorize and get your Refresh Token
5. Find your Seller ID under Account Info
---
## Part of the Zero2AI Skill Library
Built and battle-tested in production. Part of a growing open-source library of AI agent skills for e-commerce automation.
- 🔗 [skill-amazon-ads-optimizer](https://github.com/Zero2Ai-hub/skill-amazon-ads-optimizer) — Campaign & bid management
- 🔗 [skill-amazon-listing-optimizer](https://github.com/Zero2Ai-hub/skill-amazon-listing-optimizer) — Image audit & fix
---
**Built by [Zero2AI](https://zeerotoai.com) · Published on [ClawHub](https://clawhub.ai)**
FILE:scripts/auth.js
#!/usr/bin/env node
/**
* Amazon SP-API Auth
* Tests connection and lists marketplace participations.
*/
const SellingPartnerAPI = require('amazon-sp-api');
const fs = require('fs');
const CREDS_PATH = process.env.AMAZON_SPAPI_PATH || './amazon-sp-api.json';
function getCfg() { return JSON.parse(fs.readFileSync(CREDS_PATH, 'utf8')); }
function getClient() {
const creds = getCfg();
return new SellingPartnerAPI({
region: creds.region || 'eu',
refresh_token: creds.refreshToken,
credentials: {
SELLING_PARTNER_APP_CLIENT_ID: creds.lwaClientId,
SELLING_PARTNER_APP_CLIENT_SECRET: creds.lwaClientSecret,
}
});
}
module.exports = { getClient, getCfg };
if (require.main === module) {
(async () => {
try {
const sp = getClient();
const res = await sp.callAPI({ operation: 'getMarketplaceParticipations', endpoint: 'sellers' });
console.log('✅ SP-API Connected');
const marketplaces = res.map(p => ({
id: p.marketplace?.id,
name: p.marketplace?.name,
country: p.marketplace?.countryCode,
}));
console.log(JSON.stringify(marketplaces, null, 2));
} catch (e) {
console.error('❌ Auth failed:', e.message);
process.exit(1);
}
})();
}
FILE:scripts/inventory.js
#!/usr/bin/env node
/**
* Amazon SP-API — FBA Inventory
* Usage:
* node inventory.js [--sku "MY-SKU"] [--out inventory.json]
*/
const fs = require('fs');
const auth = require('./auth');
function parseArgs() {
const a = process.argv.slice(2);
const out = { sku: null, out: null };
for (let i = 0; i < a.length; i++) {
if (a[i] === '--sku') out.sku = a[++i];
else if (a[i] === '--out') out.out = a[++i];
}
return out;
}
async function getInventory(sellerSku = null) {
const sp = auth.getClient();
const cfg = auth.getCfg();
const marketplace = cfg.marketplace;
const query = {
details: true,
marketplaceIds: marketplace,
granularityType: 'Marketplace',
granularityId: marketplace,
};
if (sellerSku) query.sellerSkus = sellerSku;
const res = await sp.callAPI({ operation: 'getInventorySummaries', endpoint: 'fbaInventory', query });
return res.inventorySummaries || [];
}
async function main() {
const args = parseArgs();
const inventory = await getInventory(args.sku);
console.log(`\nInventory (inventory.length SKUs):\n`);
inventory.forEach(item => {
const qty = item.inventoryDetails?.fulfillableQuantity ?? item.totalQuantity ?? 'N/A';
console.log(` item.sellerSku | ASIN: item.asin | Fulfillable: qty | Condition: item.condition`);
});
if (args.out) {
fs.writeFileSync(args.out, JSON.stringify({ fetchedAt: new Date().toISOString(), count: inventory.length, inventory }, null, 2));
console.log(`\nSaved to args.out`);
}
return inventory;
}
main().catch(e => { console.error(e?.response?.data || e.message); process.exit(1); });
module.exports = { getInventory };
FILE:scripts/listings.js
#!/usr/bin/env node
/**
* Amazon SP-API — Listings & Pricing
* Usage:
* node listings.js --get <sku>
* node listings.js --update <sku> --price <amount> [--currency USD]
*/
const fs = require('fs');
const auth = require('./auth');
function parseArgs() {
const a = process.argv.slice(2);
const out = { command: null, sku: null, price: null, currency: 'USD', out: null };
for (let i = 0; i < a.length; i++) {
if (a[i] === '--get') { out.command = 'get'; out.sku = a[++i]; }
else if (a[i] === '--update') { out.command = 'update'; out.sku = a[++i]; }
else if (a[i] === '--price') out.price = a[++i];
else if (a[i] === '--currency') out.currency = a[++i];
else if (a[i] === '--out') out.out = a[++i];
}
return out;
}
async function getListing(sku) {
const sp = auth.getClient();
const cfg = auth.getCfg();
return sp.callAPI({
operation: 'getListingsItem',
endpoint: 'listingsItems',
path: { sellerId: cfg.sellerId, sku: encodeURIComponent(sku) },
query: { marketplaceIds: cfg.marketplace, includedData: 'summaries,attributes,issues,offers,fulfillmentAvailability' },
});
}
async function updatePrice(sku, price, currency) {
const sp = auth.getClient();
const cfg = auth.getCfg();
return sp.callAPI({
operation: 'patchListingsItem',
endpoint: 'listingsItems',
path: { sellerId: cfg.sellerId, sku: encodeURIComponent(sku) },
query: { marketplaceIds: cfg.marketplace },
body: {
productType: 'PRODUCT',
patches: [{
op: 'replace',
path: '/attributes/purchasable_offer',
value: [{
marketplace_id: cfg.marketplace,
currency,
our_price: [{ schedule: [{ value_with_tax: parseFloat(price) }] }],
}],
}],
},
});
}
async function main() {
const args = parseArgs();
if (!args.command) {
console.log('Usage:');
console.log(' node listings.js --get <sku>');
console.log(' node listings.js --update <sku> --price <amount> [--currency USD]');
process.exit(0);
}
if (args.command === 'get') {
const listing = await getListing(args.sku);
console.log(JSON.stringify(listing, null, 2));
if (args.out) fs.writeFileSync(args.out, JSON.stringify(listing, null, 2));
}
if (args.command === 'update') {
if (!args.price) { console.error('--price required'); process.exit(1); }
const result = await updatePrice(args.sku, args.price, args.currency);
console.log(JSON.stringify(result, null, 2));
}
}
main().catch(e => { console.error(e?.response?.data || e.message); process.exit(1); });
module.exports = { getListing, updatePrice };
FILE:scripts/orders.js
#!/usr/bin/env node
/**
* Amazon SP-API — Orders
* Usage:
* node orders.js --list [--days 7] [--status Unshipped] [--out orders.json]
* node orders.js --get <orderId>
*/
const fs = require('fs');
const auth = require('./auth');
function parseArgs() {
const a = process.argv.slice(2);
const out = { command: null, orderId: null, days: 7, status: null, out: null };
for (let i = 0; i < a.length; i++) {
if (a[i] === '--list') out.command = 'list';
else if (a[i] === '--get') { out.command = 'get'; out.orderId = a[++i]; }
else if (a[i] === '--days') out.days = Number(a[++i]);
else if (a[i] === '--status') out.status = a[++i];
else if (a[i] === '--out') out.out = a[++i];
}
return out;
}
async function listOrders(days = 7, status = null) {
const sp = auth.getClient();
const cfg = auth.getCfg();
const createdAfter = new Date(Date.now() - days * 24 * 3600 * 1000).toISOString();
const query = { MarketplaceIds: [cfg.marketplace], CreatedAfter: createdAfter };
if (status) query.OrderStatuses = [status];
const res = await sp.callAPI({ operation: 'getOrders', endpoint: 'orders', query });
return res.Orders || [];
}
async function getOrder(orderId) {
const sp = auth.getClient();
return sp.callAPI({ operation: 'getOrder', endpoint: 'orders', path: { orderId } });
}
async function getOrderItems(orderId) {
const sp = auth.getClient();
const res = await sp.callAPI({ operation: 'getOrderItems', endpoint: 'orders', path: { orderId } });
return res.OrderItems || [];
}
async function main() {
const args = parseArgs();
if (!args.command) {
console.log('Usage: node orders.js --list [--days 7] [--status Unshipped] [--out file.json]');
console.log(' node orders.js --get <orderId>');
process.exit(0);
}
if (args.command === 'list') {
const orders = await listOrders(args.days, args.status);
const summary = orders.map(o => ({
orderId: o.AmazonOrderId,
status: o.OrderStatus,
total: `o.OrderTotal?.Amount o.OrderTotal?.CurrencyCode`,
date: o.PurchaseDate,
}));
console.log(`\nFound summary.length orders (last args.days days):\n`);
summary.forEach(o => console.log(` o.orderId | o.status | o.total | o.date?.slice(0, 10)`));
if (args.out) fs.writeFileSync(args.out, JSON.stringify({ fetchedAt: new Date().toISOString(), count: summary.length, orders: summary }, null, 2));
return summary;
}
if (args.command === 'get') {
const order = await getOrder(args.orderId);
const items = await getOrderItems(args.orderId);
const result = { order, items };
console.log(JSON.stringify(result, null, 2));
if (args.out) fs.writeFileSync(args.out, JSON.stringify(result, null, 2));
return result;
}
}
main().catch(e => { console.error(e?.response?.data || e.message || e); process.exit(1); });
module.exports = { listOrders, getOrder, getOrderItems };