@channel47/pinterest-ads-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +194 -0
- package/package.json +47 -0
- package/server/auth.js +141 -0
- package/server/http.js +198 -0
- package/server/index.js +272 -0
- package/server/prompts/templates.js +130 -0
- package/server/resources/analytics-columns-reference.md +86 -0
- package/server/resources/api-reference.md +100 -0
- package/server/resources/index.js +65 -0
- package/server/resources/rate-limits-and-quotas.md +39 -0
- package/server/tools/analytics.js +102 -0
- package/server/tools/list-accounts.js +68 -0
- package/server/tools/mutate.js +134 -0
- package/server/tools/query.js +130 -0
- package/server/utils/analytics-params.js +106 -0
- package/server/utils/errors.js +12 -0
- package/server/utils/mutate-operations.js +174 -0
- package/server/utils/response-format.js +63 -0
- package/server/utils/validation.js +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 channel47
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# @channel47/pinterest-ads-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for Pinterest Ads using the Pinterest REST API (`v5`).
|
|
4
|
+
|
|
5
|
+
This server exposes four tools expected by channel47 Pinterest workflows:
|
|
6
|
+
|
|
7
|
+
- `list_accounts`
|
|
8
|
+
- `query`
|
|
9
|
+
- `analytics`
|
|
10
|
+
- `mutate`
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
### Standalone
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npx @channel47/pinterest-ads-mcp@latest
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### Monorepo Development
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
cd mcps
|
|
24
|
+
npm install
|
|
25
|
+
npm run test
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Configuration
|
|
29
|
+
|
|
30
|
+
### Getting Credentials
|
|
31
|
+
|
|
32
|
+
1. Create an app at [developers.pinterest.com/apps](https://developers.pinterest.com/apps/) and request Standard access for the `ads:read` (and `ads:write` for mutations) scopes.
|
|
33
|
+
2. Complete the OAuth authorization-code flow to obtain an access token and refresh token — see [Set up authentication and authorization](https://developers.pinterest.com/docs/getting-started/set-up-authentication-and-authorization/).
|
|
34
|
+
3. Either paste a valid access token into `PINTEREST_ADS_ACCESS_TOKEN`, or configure the client credentials + refresh token and let this server mint access tokens itself.
|
|
35
|
+
|
|
36
|
+
### Required (one of the two options)
|
|
37
|
+
|
|
38
|
+
| Variable | Description |
|
|
39
|
+
|----------|-------------|
|
|
40
|
+
| `PINTEREST_ADS_ACCESS_TOKEN` | Pinterest OAuth access token (sent as `Authorization: Bearer`) |
|
|
41
|
+
|
|
42
|
+
or all three of:
|
|
43
|
+
|
|
44
|
+
| Variable | Description |
|
|
45
|
+
|----------|-------------|
|
|
46
|
+
| `PINTEREST_ADS_CLIENT_ID` | Pinterest app ID |
|
|
47
|
+
| `PINTEREST_ADS_CLIENT_SECRET` | Pinterest app secret |
|
|
48
|
+
| `PINTEREST_ADS_REFRESH_TOKEN` | OAuth refresh token used to mint access tokens via `POST /v5/oauth/token` |
|
|
49
|
+
|
|
50
|
+
When both are configured, the refresh-token flow wins. Access tokens are cached in
|
|
51
|
+
memory and refreshed about 5 minutes before expiry. Pinterest uses continuous
|
|
52
|
+
refresh tokens and may rotate the refresh token on use — the server logs a stderr
|
|
53
|
+
warning when that happens because it cannot persist the new value; update
|
|
54
|
+
`PINTEREST_ADS_REFRESH_TOKEN` yourself when you see the warning.
|
|
55
|
+
|
|
56
|
+
### Optional
|
|
57
|
+
|
|
58
|
+
| Variable | Description |
|
|
59
|
+
|----------|-------------|
|
|
60
|
+
| `PINTEREST_ADS_AD_ACCOUNT_ID` | Default ad account ID used when `ad_account_id` is omitted |
|
|
61
|
+
| `PINTEREST_ADS_READ_ONLY` | Set to `true` to disable live mutations |
|
|
62
|
+
| `PINTEREST_ADS_REQUEST_TIMEOUT_MS` | HTTP request timeout in milliseconds (default `30000`) |
|
|
63
|
+
|
|
64
|
+
### Claude Code
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
claude mcp add pinterest-ads \
|
|
68
|
+
--env PINTEREST_ADS_ACCESS_TOKEN=<token> \
|
|
69
|
+
--env PINTEREST_ADS_AD_ACCOUNT_ID=<ad-account-id> \
|
|
70
|
+
-- npx @channel47/pinterest-ads-mcp@latest
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Or as JSON config:
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"mcpServers": {
|
|
78
|
+
"pinterest-ads": {
|
|
79
|
+
"command": "npx",
|
|
80
|
+
"args": ["@channel47/pinterest-ads-mcp@latest"],
|
|
81
|
+
"env": {
|
|
82
|
+
"PINTEREST_ADS_ACCESS_TOKEN": "<token>",
|
|
83
|
+
"PINTEREST_ADS_AD_ACCOUNT_ID": "<ad-account-id>"
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Tool Reference
|
|
91
|
+
|
|
92
|
+
### `list_accounts`
|
|
93
|
+
|
|
94
|
+
List accessible ad accounts from `GET /ad_accounts` with bookmark pagination.
|
|
95
|
+
|
|
96
|
+
**Params:**
|
|
97
|
+
- `include_shared_accounts` (optional): include accounts shared with you (API default `true`)
|
|
98
|
+
|
|
99
|
+
**Notes:**
|
|
100
|
+
- Returns `id`, `name`, `currency`, `country`, `owner_username`, `time_zone`, `permissions`
|
|
101
|
+
- Follows bookmark pagination to return all accessible accounts
|
|
102
|
+
|
|
103
|
+
### `query`
|
|
104
|
+
|
|
105
|
+
Structured entity reads over:
|
|
106
|
+
|
|
107
|
+
- `campaigns` — `GET /ad_accounts/{id}/campaigns`
|
|
108
|
+
- `ad_groups` — `GET /ad_accounts/{id}/ad_groups`
|
|
109
|
+
- `ads` — `GET /ad_accounts/{id}/ads`
|
|
110
|
+
|
|
111
|
+
**Params:**
|
|
112
|
+
- `entity` (required)
|
|
113
|
+
- `ad_account_id` (optional if `PINTEREST_ADS_AD_ACCOUNT_ID` exists)
|
|
114
|
+
- `entity_statuses`: `ACTIVE`, `PAUSED`, `ARCHIVED`, `DRAFT`, `DELETED_DRAFT` — the API defaults to `ACTIVE, PAUSED`, so pass this to see archived entities
|
|
115
|
+
- `campaign_ids` / `ad_group_ids` / `ad_ids`: id filters (each only where the endpoint supports it)
|
|
116
|
+
- `order`: `ASCENDING` | `DESCENDING` by ID
|
|
117
|
+
- `limit`: rows to return across pages (default `100`, max `1000`; API pages are max `250`)
|
|
118
|
+
|
|
119
|
+
### `analytics`
|
|
120
|
+
|
|
121
|
+
Metrics via the v5 analytics endpoints:
|
|
122
|
+
|
|
123
|
+
- `account` — `GET /ad_accounts/{id}/analytics`
|
|
124
|
+
- `campaign` — `GET /ad_accounts/{id}/campaigns/analytics` (requires `campaign_ids`)
|
|
125
|
+
- `ad_group` — `GET /ad_accounts/{id}/ad_groups/analytics` (requires `ad_group_ids`)
|
|
126
|
+
- `ad` — `GET /ad_accounts/{id}/ads/analytics` (requires `ad_ids`)
|
|
127
|
+
|
|
128
|
+
**Params:**
|
|
129
|
+
- `start_date` / `end_date` (required, `YYYY-MM-DD`): at most 90 days back and a 90-day range
|
|
130
|
+
- `level`: `account` (default), `campaign`, `ad_group`, `ad`
|
|
131
|
+
- `columns`: defaults to `SPEND_IN_DOLLAR, IMPRESSION_2, CLICKTHROUGH_2, CTR_2, TOTAL_CONVERSIONS` (see the `pinterestads://analytics-columns` resource)
|
|
132
|
+
- `granularity`: `TOTAL` (default), `DAY`, `WEEK`, `MONTH`, `HOUR` (`HOUR` no longer returns conversion metrics)
|
|
133
|
+
- `click_window_days` / `engagement_window_days` / `view_window_days`: `0, 1, 7, 14, 30, 60`
|
|
134
|
+
- `conversion_report_time`: `TIME_OF_AD_ACTION` | `TIME_OF_CONVERSION`
|
|
135
|
+
- `reporting_timezone`: `PINTEREST_TIME_ZONE` | `AD_ACCOUNT_TIME_ZONE`
|
|
136
|
+
|
|
137
|
+
### `mutate`
|
|
138
|
+
|
|
139
|
+
Mutation tool with dry-run safety by default.
|
|
140
|
+
|
|
141
|
+
**Operation format:**
|
|
142
|
+
|
|
143
|
+
```json
|
|
144
|
+
{
|
|
145
|
+
"entity": "campaign",
|
|
146
|
+
"action": "update",
|
|
147
|
+
"id": "626735565838",
|
|
148
|
+
"params": {
|
|
149
|
+
"daily_spend_cap": 25000000
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**Supported entities:** `campaign`, `ad_group`, `ad`
|
|
155
|
+
|
|
156
|
+
**Supported actions:** `create`, `update`, `pause`, `enable`, `archive`
|
|
157
|
+
|
|
158
|
+
**Top-level params:**
|
|
159
|
+
- `operations` (required)
|
|
160
|
+
- `dry_run` (default `true`)
|
|
161
|
+
- `partial_failure` (default `true`)
|
|
162
|
+
|
|
163
|
+
**Notes:**
|
|
164
|
+
- Pinterest v5 uses bulk-array bodies: creates are `POST /ad_accounts/{id}/{campaigns|ad_groups|ads}` with an array of creation objects; updates and status changes are `PATCH` with an array of `{ id, ...changes }`
|
|
165
|
+
- **There is no delete.** `archive` sets `status: ARCHIVED`, which is terminal — archived entities cannot be reactivated
|
|
166
|
+
- Pinterest has no server-side validate-only mode, so `dry_run` performs local validation and returns a preview of the exact requests (method, path, body) without calling the API
|
|
167
|
+
- Creates default to `status: PAUSED` (pass an explicit `status` to override)
|
|
168
|
+
- Required create fields — campaign: `name`, `objective_type`; ad_group: `name`, `campaign_id`, `billable_event`; ad: `ad_group_id`, `pin_id`, `creative_type`
|
|
169
|
+
- Money fields (spend caps, budgets, bids) are in micro-currency: `25000000` = 25.00 in the account currency
|
|
170
|
+
|
|
171
|
+
## Read-Only Mode
|
|
172
|
+
|
|
173
|
+
Set `PINTEREST_ADS_READ_ONLY=true` to remove the `mutate` tool from the tool list
|
|
174
|
+
and block mutate calls entirely. Recommended for reporting-only setups.
|
|
175
|
+
|
|
176
|
+
## Behavior Notes
|
|
177
|
+
|
|
178
|
+
- Auth is sent via `Authorization: Bearer <token>` request header
|
|
179
|
+
- Retries once on HTTP `429`, using `Retry-After` when available (fallback `60s`)
|
|
180
|
+
- Requests abort on timeout (default `30000ms`, configurable via `PINTEREST_ADS_REQUEST_TIMEOUT_MS`)
|
|
181
|
+
- Errors surface as `Pinterest Ads API request failed (<status>): <message> [code <n>]`
|
|
182
|
+
- Pagination uses `page_size` (max 250) + `bookmark` cursors; responses are `{ items, bookmark }`
|
|
183
|
+
|
|
184
|
+
## Development Commands
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
cd pinterest-ads
|
|
188
|
+
npm test
|
|
189
|
+
node server/index.js
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## License
|
|
193
|
+
|
|
194
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@channel47/pinterest-ads-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pinterest Ads MCP Server - Query and mutate Pinterest advertising data via REST API v5",
|
|
5
|
+
"main": "server/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"pinterest-ads-mcp": "server/index.js"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"files": [
|
|
11
|
+
"server/**/*.js",
|
|
12
|
+
"server/**/*.md",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"start": "node server/index.js",
|
|
18
|
+
"test": "node --test *.test.js test/*.test.js",
|
|
19
|
+
"prepublishOnly": "npm test"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"mcp",
|
|
23
|
+
"model-context-protocol",
|
|
24
|
+
"pinterest-ads",
|
|
25
|
+
"pinterest",
|
|
26
|
+
"analytics",
|
|
27
|
+
"advertising",
|
|
28
|
+
"claude"
|
|
29
|
+
],
|
|
30
|
+
"author": "channel47",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/channel47/mcps.git",
|
|
35
|
+
"directory": "pinterest-ads"
|
|
36
|
+
},
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/channel47/mcps/issues"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/channel47/mcps/tree/main/pinterest-ads#readme",
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=18.0.0"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/server/auth.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
const TOKEN_URL = 'https://api.pinterest.com/v5/oauth/token';
|
|
2
|
+
const REFRESH_ENV_VARS = [
|
|
3
|
+
'PINTEREST_ADS_CLIENT_ID',
|
|
4
|
+
'PINTEREST_ADS_CLIENT_SECRET',
|
|
5
|
+
'PINTEREST_ADS_REFRESH_TOKEN'
|
|
6
|
+
];
|
|
7
|
+
const REFRESH_MARGIN_MS = 5 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
let cachedAccessToken = null;
|
|
10
|
+
let cachedTokenExpiresAt = null;
|
|
11
|
+
|
|
12
|
+
function hasStaticToken() {
|
|
13
|
+
return Boolean(process.env.PINTEREST_ADS_ACCESS_TOKEN);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function missingRefreshVars() {
|
|
17
|
+
return REFRESH_ENV_VARS.filter((name) => !process.env[name]);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function hasRefreshCredentials() {
|
|
21
|
+
return missingRefreshVars().length === 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Validate required environment variables for Pinterest Ads auth.
|
|
26
|
+
* Valid when PINTEREST_ADS_ACCESS_TOKEN is set, or when all of
|
|
27
|
+
* PINTEREST_ADS_CLIENT_ID + PINTEREST_ADS_CLIENT_SECRET + PINTEREST_ADS_REFRESH_TOKEN are set.
|
|
28
|
+
* @returns {{ valid: boolean, missing: string[] }}
|
|
29
|
+
*/
|
|
30
|
+
export function validateEnvironment() {
|
|
31
|
+
if (hasStaticToken() || hasRefreshCredentials()) {
|
|
32
|
+
return { valid: true, missing: [] };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const partialRefresh = missingRefreshVars().length < REFRESH_ENV_VARS.length;
|
|
36
|
+
if (partialRefresh) {
|
|
37
|
+
return { valid: false, missing: missingRefreshVars() };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { valid: false, missing: ['PINTEREST_ADS_ACCESS_TOKEN'] };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function extractRefreshErrorMessage(payload) {
|
|
44
|
+
if (payload && typeof payload === 'object') {
|
|
45
|
+
return payload.message || payload.error_description || payload.error || 'Unknown OAuth error';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (typeof payload === 'string' && payload) {
|
|
49
|
+
return payload;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return 'Unknown OAuth error';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function refreshAccessToken(fetchImpl) {
|
|
56
|
+
const clientId = process.env.PINTEREST_ADS_CLIENT_ID;
|
|
57
|
+
const clientSecret = process.env.PINTEREST_ADS_CLIENT_SECRET;
|
|
58
|
+
const refreshToken = process.env.PINTEREST_ADS_REFRESH_TOKEN;
|
|
59
|
+
const basicAuth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
|
|
60
|
+
|
|
61
|
+
const body = new URLSearchParams({
|
|
62
|
+
grant_type: 'refresh_token',
|
|
63
|
+
refresh_token: refreshToken
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const response = await fetchImpl(TOKEN_URL, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: {
|
|
69
|
+
Authorization: `Basic ${basicAuth}`,
|
|
70
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
71
|
+
},
|
|
72
|
+
body: body.toString()
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
let payload;
|
|
76
|
+
try {
|
|
77
|
+
payload = await response.json();
|
|
78
|
+
} catch {
|
|
79
|
+
payload = null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!response.ok || !payload?.access_token) {
|
|
83
|
+
const message = extractRefreshErrorMessage(payload);
|
|
84
|
+
throw new Error(`Pinterest Ads token refresh failed (${response.status}): ${message}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (payload.refresh_token && payload.refresh_token !== refreshToken) {
|
|
88
|
+
console.error(
|
|
89
|
+
'Warning: Pinterest rotated the refresh token (continuous refresh). '
|
|
90
|
+
+ 'Update PINTEREST_ADS_REFRESH_TOKEN with the new value or future refreshes may fail.'
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const expiresInMs = Number(payload.expires_in || 0) * 1000;
|
|
95
|
+
cachedAccessToken = String(payload.access_token);
|
|
96
|
+
cachedTokenExpiresAt = expiresInMs > 0 ? Date.now() + expiresInMs - REFRESH_MARGIN_MS : null;
|
|
97
|
+
|
|
98
|
+
return cachedAccessToken;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Return a Pinterest Ads access token with in-memory caching.
|
|
103
|
+
* Prefers the refresh-token flow when full OAuth credentials are configured;
|
|
104
|
+
* otherwise falls back to the static PINTEREST_ADS_ACCESS_TOKEN value.
|
|
105
|
+
* @param {{ fetchImpl?: typeof fetch }} [dependencies]
|
|
106
|
+
* @returns {Promise<string>}
|
|
107
|
+
*/
|
|
108
|
+
export async function getAccessToken(dependencies = {}) {
|
|
109
|
+
const fetchImpl = dependencies.fetchImpl || fetch;
|
|
110
|
+
|
|
111
|
+
const { valid, missing } = validateEnvironment();
|
|
112
|
+
if (!valid) {
|
|
113
|
+
throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (hasRefreshCredentials()) {
|
|
117
|
+
const cacheIsFresh = cachedAccessToken
|
|
118
|
+
&& (cachedTokenExpiresAt === null || Date.now() < cachedTokenExpiresAt);
|
|
119
|
+
if (cacheIsFresh) {
|
|
120
|
+
return cachedAccessToken;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return refreshAccessToken(fetchImpl);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (cachedAccessToken) {
|
|
127
|
+
return cachedAccessToken;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
cachedAccessToken = String(process.env.PINTEREST_ADS_ACCESS_TOKEN);
|
|
131
|
+
cachedTokenExpiresAt = null;
|
|
132
|
+
return cachedAccessToken;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Clear the in-memory token cache (used by tests and local dev flows).
|
|
137
|
+
*/
|
|
138
|
+
export function clearAuthCache() {
|
|
139
|
+
cachedAccessToken = null;
|
|
140
|
+
cachedTokenExpiresAt = null;
|
|
141
|
+
}
|
package/server/http.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { getAccessToken } from './auth.js';
|
|
2
|
+
import { invalidParamsError } from './utils/errors.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Base URL for all Pinterest REST API v5 requests used by this server.
|
|
6
|
+
*/
|
|
7
|
+
export const PINTEREST_BASE_URL = 'https://api.pinterest.com/v5';
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
9
|
+
const DEFAULT_RATE_LIMIT_SLEEP_MS = 60_000;
|
|
10
|
+
|
|
11
|
+
function sleep(ms) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
setTimeout(resolve, ms);
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizePath(path) {
|
|
18
|
+
if (!path) {
|
|
19
|
+
throw new Error('API path is required');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return path.startsWith('/') ? path : `/${path}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function serializeParamValue(value) {
|
|
26
|
+
if (Array.isArray(value)) {
|
|
27
|
+
return value.map((entry) => String(entry).trim()).filter(Boolean).join(',');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return String(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function buildUrl(path, params = {}) {
|
|
34
|
+
const url = new URL(`${PINTEREST_BASE_URL}${normalizePath(path)}`);
|
|
35
|
+
|
|
36
|
+
for (const [key, value] of Object.entries(params)) {
|
|
37
|
+
if (value === undefined || value === null || value === '') {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const serialized = serializeParamValue(value);
|
|
42
|
+
if (serialized === '') {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
url.searchParams.set(key, serialized);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return url;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function parseResponse(response) {
|
|
52
|
+
const contentType = response.headers.get('content-type') || '';
|
|
53
|
+
|
|
54
|
+
if (contentType.includes('application/json')) {
|
|
55
|
+
return response.json();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const text = await response.text();
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(text);
|
|
61
|
+
} catch {
|
|
62
|
+
return text;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function extractErrorMessage(payload) {
|
|
67
|
+
if (payload?.message) {
|
|
68
|
+
return payload.message;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (typeof payload === 'string' && payload) {
|
|
72
|
+
return payload;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return 'Unknown Pinterest Ads API error';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function extractErrorCode(payload) {
|
|
79
|
+
return Number(payload?.code || 0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function resolveTimeoutMs(timeoutMs) {
|
|
83
|
+
const candidate = timeoutMs ?? process.env.PINTEREST_ADS_REQUEST_TIMEOUT_MS ?? DEFAULT_TIMEOUT_MS;
|
|
84
|
+
const parsed = Number(candidate);
|
|
85
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
86
|
+
return DEFAULT_TIMEOUT_MS;
|
|
87
|
+
}
|
|
88
|
+
return Math.floor(parsed);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getRateLimitSleepMs(response) {
|
|
92
|
+
const retryAfter = response?.headers?.get('retry-after');
|
|
93
|
+
if (!retryAfter) {
|
|
94
|
+
return DEFAULT_RATE_LIMIT_SLEEP_MS;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const seconds = Number(retryAfter);
|
|
98
|
+
if (Number.isFinite(seconds) && seconds > 0) {
|
|
99
|
+
return Math.floor(seconds * 1000);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const retryAtMs = Date.parse(retryAfter);
|
|
103
|
+
if (Number.isFinite(retryAtMs)) {
|
|
104
|
+
const delta = retryAtMs - Date.now();
|
|
105
|
+
if (delta > 0) {
|
|
106
|
+
return Math.floor(delta);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return DEFAULT_RATE_LIMIT_SLEEP_MS;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Execute an authenticated Pinterest REST API v5 request with timeout and retry handling.
|
|
115
|
+
* Array query parameter values are serialized as comma-separated strings per Pinterest conventions.
|
|
116
|
+
* @param {string} path
|
|
117
|
+
* @param {Record<string, unknown>} [params]
|
|
118
|
+
* @param {{
|
|
119
|
+
* method?: string,
|
|
120
|
+
* body?: unknown,
|
|
121
|
+
* timeoutMs?: number,
|
|
122
|
+
* retryThrottled?: boolean,
|
|
123
|
+
* sleep?: (ms: number) => Promise<void>
|
|
124
|
+
* }} [options]
|
|
125
|
+
* @returns {Promise<unknown>}
|
|
126
|
+
*/
|
|
127
|
+
export async function pinterestRequest(
|
|
128
|
+
path,
|
|
129
|
+
params = {},
|
|
130
|
+
{
|
|
131
|
+
method = 'GET',
|
|
132
|
+
body,
|
|
133
|
+
timeoutMs,
|
|
134
|
+
retryThrottled = true,
|
|
135
|
+
sleep: sleepFn = sleep
|
|
136
|
+
} = {}
|
|
137
|
+
) {
|
|
138
|
+
const token = await getAccessToken();
|
|
139
|
+
const url = buildUrl(path, params);
|
|
140
|
+
const resolvedTimeoutMs = resolveTimeoutMs(timeoutMs);
|
|
141
|
+
const controller = new AbortController();
|
|
142
|
+
const timeoutId = setTimeout(() => {
|
|
143
|
+
controller.abort();
|
|
144
|
+
}, resolvedTimeoutMs);
|
|
145
|
+
|
|
146
|
+
const requestOptions = {
|
|
147
|
+
method,
|
|
148
|
+
headers: {
|
|
149
|
+
'Content-Type': 'application/json',
|
|
150
|
+
Authorization: `Bearer ${token}`
|
|
151
|
+
},
|
|
152
|
+
signal: controller.signal
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
if (body !== undefined && body !== null) {
|
|
156
|
+
requestOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let response;
|
|
160
|
+
try {
|
|
161
|
+
response = await fetch(url, requestOptions);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (error?.name === 'AbortError') {
|
|
164
|
+
throw new Error(`Pinterest Ads API request timed out after ${resolvedTimeoutMs}ms`);
|
|
165
|
+
}
|
|
166
|
+
throw error;
|
|
167
|
+
} finally {
|
|
168
|
+
clearTimeout(timeoutId);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const payload = await parseResponse(response);
|
|
172
|
+
|
|
173
|
+
if (response.ok) {
|
|
174
|
+
return payload;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (response.status === 401) {
|
|
178
|
+
const message = extractErrorMessage(payload);
|
|
179
|
+
throw invalidParamsError(`Pinterest Ads API request failed (${response.status}): ${message}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (response.status === 429 && retryThrottled) {
|
|
183
|
+
await sleepFn(getRateLimitSleepMs(response));
|
|
184
|
+
|
|
185
|
+
return pinterestRequest(path, params, {
|
|
186
|
+
method,
|
|
187
|
+
body,
|
|
188
|
+
timeoutMs: resolvedTimeoutMs,
|
|
189
|
+
retryThrottled: false,
|
|
190
|
+
sleep: sleepFn
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const message = extractErrorMessage(payload);
|
|
195
|
+
const code = extractErrorCode(payload);
|
|
196
|
+
const codeSuffix = code ? ` [code ${code}]` : '';
|
|
197
|
+
throw new Error(`Pinterest Ads API request failed (${response.status}): ${message}${codeSuffix}`);
|
|
198
|
+
}
|