@shware/analytics 11.7.0 → 11.8.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/README.md +40 -0
- package/dist/ads/index.cjs +9 -0
- package/dist/ads/index.d.cts +4 -0
- package/dist/ads/index.d.mts +4 -0
- package/dist/ads/index.mjs +3 -0
- package/dist/ads/meta.cjs +234 -0
- package/dist/ads/meta.cjs.map +1 -0
- package/dist/ads/meta.d.cts +48 -0
- package/dist/ads/meta.d.cts.map +1 -0
- package/dist/ads/meta.d.mts +48 -0
- package/dist/ads/meta.d.mts.map +1 -0
- package/dist/ads/meta.mjs +232 -0
- package/dist/ads/meta.mjs.map +1 -0
- package/dist/ads/time.cjs +59 -0
- package/dist/ads/time.cjs.map +1 -0
- package/dist/ads/time.d.cts +17 -0
- package/dist/ads/time.d.cts.map +1 -0
- package/dist/ads/time.d.mts +17 -0
- package/dist/ads/time.d.mts.map +1 -0
- package/dist/ads/time.mjs +55 -0
- package/dist/ads/time.mjs.map +1 -0
- package/dist/ads/types.cjs +0 -0
- package/dist/ads/types.d.cts +57 -0
- package/dist/ads/types.d.cts.map +1 -0
- package/dist/ads/types.d.mts +57 -0
- package/dist/ads/types.d.mts.map +1 -0
- package/dist/ads/types.mjs +1 -0
- package/package.json +11 -1
package/README.md
CHANGED
|
@@ -341,6 +341,46 @@ What the SDK guarantees for this to hold:
|
|
|
341
341
|
`setVisitor` hands the server's value to the third-party user setters, and PostHog is identified
|
|
342
342
|
by it once a user is known.
|
|
343
343
|
|
|
344
|
+
## Ad performance (`@shware/analytics/ads`)
|
|
345
|
+
|
|
346
|
+
Spend, impressions, clicks and the platforms' own conversions, read from the ad platforms'
|
|
347
|
+
reporting APIs as typed `AdPerformanceRow`s — one per ad and hour — for a host to store next to its
|
|
348
|
+
sessions and compute ROAS, cost per session and the click → session landing rate.
|
|
349
|
+
|
|
350
|
+
```ts
|
|
351
|
+
import { fetchMetaAdPerformance } from '@shware/analytics/ads';
|
|
352
|
+
|
|
353
|
+
const rows = await fetchMetaAdPerformance({
|
|
354
|
+
accessToken: env.META_ADS_ACCESS_TOKEN, // a system user token with ads_read
|
|
355
|
+
accountId: '1234567890', // with or without act_
|
|
356
|
+
since: '2026-10-01', // days in the ad account's time zone
|
|
357
|
+
until: '2026-10-07',
|
|
358
|
+
});
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
- **Hourly rows only.** `hour_start` is a UTC instant, so a sum over any span in any time zone is
|
|
362
|
+
a sum of whole rows and lines up with the sessions grouped the same way, whatever time zone each
|
|
363
|
+
ad account reports in. A table never mixes a day row with the hours inside it: an upsert would
|
|
364
|
+
overwrite one with the other on their shared start, and the rest would count twice. A platform
|
|
365
|
+
that reports only days spreads each day evenly over its hours.
|
|
366
|
+
- **The attribution vocabulary.** `channel`, `medium` and `channel_group` are the values
|
|
367
|
+
`classifyTouch` gives the platform's ad clicks (`meta / cpc / paid_social`), so rows join
|
|
368
|
+
sessions and attribution with no mapping. Join on `channel` and `channel_group` together: the
|
|
369
|
+
same channel also brings organic traffic (Google search, ChatGPT answers).
|
|
370
|
+
- **Clicks are link clicks** to the site (Meta's `inline_link_clicks`), the number to compare
|
|
371
|
+
with sessions; Meta's `clicks` also counts likes and expands.
|
|
372
|
+
- **Two conversion counts.** `conversions` / `conversion_value` are the platform's under each ad's
|
|
373
|
+
attribution setting, view-through included — what its own dashboard shows.
|
|
374
|
+
`click_conversions` / `click_conversion_value` are the click-through part (Meta's 7-day click),
|
|
375
|
+
the one to set against a click-based attribution.
|
|
376
|
+
- **Re-fetch and upsert.** Platforms restate the past: a purchase is credited to the hour of the
|
|
377
|
+
click up to 7 days later, and spend is corrected for invalid traffic. Re-fetch today and
|
|
378
|
+
yesterday hourly, the last 8 days daily (the 7-day window plus a day of time zone slack) and the
|
|
379
|
+
last 30 weekly, upserting on (`platform`, `account_id`, `ad_id`, `hour_start`).
|
|
380
|
+
- **Retention.** Meta keeps the hourly breakdown for 13 months; `since` earlier than that throws
|
|
381
|
+
rather than returning the empty answer Meta gives. Long ranges are fetched in 7-day windows, as a
|
|
382
|
+
single request over months times out on Meta's side.
|
|
383
|
+
|
|
344
384
|
## UTM params
|
|
345
385
|
|
|
346
386
|
Typed as `UTMParams` (exported from `@shware/analytics`). Value unions follow the
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_ads_time = require("./time.cjs");
|
|
3
|
+
const require_ads_meta = require("./meta.cjs");
|
|
4
|
+
exports.MetaAdsApiError = require_ads_meta.MetaAdsApiError;
|
|
5
|
+
exports.addDays = require_ads_time.addDays;
|
|
6
|
+
exports.addMonths = require_ads_time.addMonths;
|
|
7
|
+
exports.fetchMetaAdPerformance = require_ads_meta.fetchMetaAdPerformance;
|
|
8
|
+
exports.todayIn = require_ads_time.todayIn;
|
|
9
|
+
exports.zonedHourToUtc = require_ads_time.zonedHourToUtc;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { AdPerformanceRow, AdPlatform, ReportDate } from "./types.cjs";
|
|
2
|
+
import { MetaAdPerformanceOptions, MetaAdsApiError, fetchMetaAdPerformance } from "./meta.cjs";
|
|
3
|
+
import { addDays, addMonths, todayIn, zonedHourToUtc } from "./time.cjs";
|
|
4
|
+
export { type AdPerformanceRow, type AdPlatform, MetaAdPerformanceOptions, MetaAdsApiError, type ReportDate, addDays, addMonths, fetchMetaAdPerformance, todayIn, zonedHourToUtc };
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { AdPerformanceRow, AdPlatform, ReportDate } from "./types.mjs";
|
|
2
|
+
import { MetaAdPerformanceOptions, MetaAdsApiError, fetchMetaAdPerformance } from "./meta.mjs";
|
|
3
|
+
import { addDays, addMonths, todayIn, zonedHourToUtc } from "./time.mjs";
|
|
4
|
+
export { type AdPerformanceRow, type AdPlatform, MetaAdPerformanceOptions, MetaAdsApiError, type ReportDate, addDays, addMonths, fetchMetaAdPerformance, todayIn, zonedHourToUtc };
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_attribution_classify = require("../attribution/classify.cjs");
|
|
3
|
+
require("../attribution/index.cjs");
|
|
4
|
+
const require_ads_time = require("./time.cjs");
|
|
5
|
+
let _shware_utils = require("@shware/utils");
|
|
6
|
+
let zod_mini = require("zod/mini");
|
|
7
|
+
let node_crypto = require("node:crypto");
|
|
8
|
+
//#region src/ads/meta.ts
|
|
9
|
+
/**
|
|
10
|
+
* Meta ad delivery through the Marketing API's Insights endpoint, over plain `fetch`: one row per
|
|
11
|
+
* ad and hour, in the attribution vocabulary (see `AdPerformanceRow`).
|
|
12
|
+
*
|
|
13
|
+
* The token needs `ads_read` on the ad account — a system user token with the account assigned
|
|
14
|
+
* at "View performance". It is sent in the `Authorization` header and never put in a URL: paging
|
|
15
|
+
* follows the `after` cursor rather than Meta's `paging.next`, which embeds the token.
|
|
16
|
+
*
|
|
17
|
+
* https://developers.facebook.com/docs/marketing-api/insights
|
|
18
|
+
* https://developers.facebook.com/docs/marketing-api/insights/breakdowns
|
|
19
|
+
*/
|
|
20
|
+
const GRAPH = `https://graph.facebook.com/v24.0`;
|
|
21
|
+
/** What an ad click is in the attribution vocabulary: `meta / cpc`, a paid social touch. */
|
|
22
|
+
const CHANNEL = "meta";
|
|
23
|
+
const MEDIUM = "cpc";
|
|
24
|
+
/**
|
|
25
|
+
* The click window of the click-through columns. Meta's longest since 2021, and the click part of
|
|
26
|
+
* its default setting (7-day click, 1-day view).
|
|
27
|
+
*/
|
|
28
|
+
const CLICK_WINDOW = "7d_click";
|
|
29
|
+
const VIEW_WINDOW = "1d_view";
|
|
30
|
+
const HOURLY = "hourly_stats_aggregated_by_advertiser_time_zone";
|
|
31
|
+
/**
|
|
32
|
+
* How far back Meta answers the hourly breakdown, in months. Past it Meta does not fail — it
|
|
33
|
+
* returns no rows — so a backfill would silently store a gap.
|
|
34
|
+
* https://developers.facebook.com/docs/marketing-api/insights/best-practices
|
|
35
|
+
*/
|
|
36
|
+
const HOURLY_RETENTION_MONTHS = 13;
|
|
37
|
+
/**
|
|
38
|
+
* Days per Insights request. A synchronous request over a long range of ad rows times out on
|
|
39
|
+
* Meta's side (`code 1, subcode 99`, "An unknown error occurred", as a 500): 17 months of daily
|
|
40
|
+
* ad rows did. A range is split into windows this long, each paged on its own.
|
|
41
|
+
*/
|
|
42
|
+
const CHUNK_DAYS = 7;
|
|
43
|
+
const FIELDS = [
|
|
44
|
+
"ad_id",
|
|
45
|
+
"ad_name",
|
|
46
|
+
"adset_id",
|
|
47
|
+
"adset_name",
|
|
48
|
+
"campaign_id",
|
|
49
|
+
"campaign_name",
|
|
50
|
+
"spend",
|
|
51
|
+
"impressions",
|
|
52
|
+
"inline_link_clicks",
|
|
53
|
+
"actions",
|
|
54
|
+
"action_values"
|
|
55
|
+
];
|
|
56
|
+
/** A Graph API error, with Meta's code, for a caller deciding whether to retry. */
|
|
57
|
+
var MetaAdsApiError = class extends Error {
|
|
58
|
+
constructor(message, status, code, subcode, fbtraceId) {
|
|
59
|
+
super(message);
|
|
60
|
+
this.status = status;
|
|
61
|
+
this.code = code;
|
|
62
|
+
this.subcode = subcode;
|
|
63
|
+
this.fbtraceId = fbtraceId;
|
|
64
|
+
this.name = "MetaAdsApiError";
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
const actionSchema = (0, zod_mini.object)({
|
|
68
|
+
action_type: (0, zod_mini.string)(),
|
|
69
|
+
/**
|
|
70
|
+
* The count under the ad's own attribution setting. Absent when the setting does not count any
|
|
71
|
+
* of it — a view-only conversion of an ad set to 1-day click — which only shows in the windows.
|
|
72
|
+
*/
|
|
73
|
+
value: (0, zod_mini.optional)((0, zod_mini.string)()),
|
|
74
|
+
[CLICK_WINDOW]: (0, zod_mini.optional)((0, zod_mini.string)()),
|
|
75
|
+
[VIEW_WINDOW]: (0, zod_mini.optional)((0, zod_mini.string)())
|
|
76
|
+
});
|
|
77
|
+
const insightSchema = (0, zod_mini.object)({
|
|
78
|
+
ad_id: (0, zod_mini.string)(),
|
|
79
|
+
ad_name: (0, zod_mini.optional)((0, zod_mini.string)()),
|
|
80
|
+
adset_id: (0, zod_mini.optional)((0, zod_mini.string)()),
|
|
81
|
+
adset_name: (0, zod_mini.optional)((0, zod_mini.string)()),
|
|
82
|
+
campaign_id: (0, zod_mini.string)(),
|
|
83
|
+
campaign_name: (0, zod_mini.optional)((0, zod_mini.string)()),
|
|
84
|
+
spend: (0, zod_mini.optional)((0, zod_mini.string)()),
|
|
85
|
+
impressions: (0, zod_mini.optional)((0, zod_mini.string)()),
|
|
86
|
+
inline_link_clicks: (0, zod_mini.optional)((0, zod_mini.string)()),
|
|
87
|
+
actions: (0, zod_mini.optional)((0, zod_mini.array)(actionSchema)),
|
|
88
|
+
action_values: (0, zod_mini.optional)((0, zod_mini.array)(actionSchema)),
|
|
89
|
+
date_start: (0, zod_mini.string)(),
|
|
90
|
+
[HOURLY]: (0, zod_mini.optional)((0, zod_mini.string)())
|
|
91
|
+
});
|
|
92
|
+
const pageSchema = (0, zod_mini.object)({
|
|
93
|
+
data: (0, zod_mini.array)(insightSchema),
|
|
94
|
+
paging: (0, zod_mini.optional)((0, zod_mini.object)({
|
|
95
|
+
cursors: (0, zod_mini.optional)((0, zod_mini.object)({ after: (0, zod_mini.optional)((0, zod_mini.string)()) })),
|
|
96
|
+
next: (0, zod_mini.optional)((0, zod_mini.string)())
|
|
97
|
+
}))
|
|
98
|
+
});
|
|
99
|
+
const accountSchema = (0, zod_mini.object)({
|
|
100
|
+
currency: (0, zod_mini.string)(),
|
|
101
|
+
timezone_name: (0, zod_mini.string)()
|
|
102
|
+
});
|
|
103
|
+
const errorSchema = (0, zod_mini.object)({ error: (0, zod_mini.object)({
|
|
104
|
+
message: (0, zod_mini.string)(),
|
|
105
|
+
code: (0, zod_mini.optional)((0, zod_mini.number)()),
|
|
106
|
+
error_subcode: (0, zod_mini.optional)((0, zod_mini.number)()),
|
|
107
|
+
fbtrace_id: (0, zod_mini.optional)((0, zod_mini.string)())
|
|
108
|
+
}) });
|
|
109
|
+
async function graphGet(path, params, options, parse) {
|
|
110
|
+
const query = new URLSearchParams(params);
|
|
111
|
+
if (options.appSecret) query.set("appsecret_proof", (0, node_crypto.createHmac)("sha256", options.appSecret).update(options.accessToken).digest("hex"));
|
|
112
|
+
const response = await (0, _shware_utils.fetch)(`${GRAPH}/${path}?${query}`, { headers: { authorization: `Bearer ${options.accessToken}` } });
|
|
113
|
+
const body = await response.json().catch(() => null);
|
|
114
|
+
if (!response.ok) {
|
|
115
|
+
const parsed = (0, zod_mini.safeParse)(errorSchema, body);
|
|
116
|
+
const error = parsed.success ? parsed.data.error : void 0;
|
|
117
|
+
throw new MetaAdsApiError(error?.message ?? `Meta Graph API responded ${response.status}`, response.status, error?.code, error?.error_subcode, error?.fbtrace_id);
|
|
118
|
+
}
|
|
119
|
+
return parse(body);
|
|
120
|
+
}
|
|
121
|
+
function parseWith(schema, what) {
|
|
122
|
+
return (body) => {
|
|
123
|
+
const parsed = (0, zod_mini.safeParse)(schema, body);
|
|
124
|
+
if (!parsed.success) {
|
|
125
|
+
const issues = parsed.error.issues;
|
|
126
|
+
const first = issues.slice(0, 3).map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
|
|
127
|
+
throw new Error(`Unexpected Meta ${what} response (${issues.length} issues): ${first}${issues.length > 3 ? "; …" : ""}`);
|
|
128
|
+
}
|
|
129
|
+
return parsed.data;
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function count(value) {
|
|
133
|
+
return value === void 0 ? 0 : Number(value);
|
|
134
|
+
}
|
|
135
|
+
/** The action's total and its click-through part; nulls when Meta reported no such action. */
|
|
136
|
+
function conversion(actions, actionType) {
|
|
137
|
+
const action = actions?.find((a) => a.action_type === actionType);
|
|
138
|
+
if (!action) return {
|
|
139
|
+
total: null,
|
|
140
|
+
click: null
|
|
141
|
+
};
|
|
142
|
+
return {
|
|
143
|
+
total: Number(action.value ?? 0),
|
|
144
|
+
click: Number(action[CLICK_WINDOW] ?? 0)
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/** `"13:00:00 - 13:59:59"` → 13. */
|
|
148
|
+
function hourOf(bucket) {
|
|
149
|
+
const hour = Number(bucket?.slice(0, 2));
|
|
150
|
+
if (!Number.isInteger(hour) || hour < 0 || hour > 23) throw new Error(`Unexpected Meta hourly bucket: ${bucket}`);
|
|
151
|
+
return hour;
|
|
152
|
+
}
|
|
153
|
+
function toRow(insight, accountId, currency, timeZone, actionType) {
|
|
154
|
+
const start = require_ads_time.zonedHourToUtc(insight.date_start, hourOf(insight[HOURLY]), timeZone);
|
|
155
|
+
const conversions = conversion(insight.actions, actionType);
|
|
156
|
+
const values = conversion(insight.action_values, actionType);
|
|
157
|
+
return {
|
|
158
|
+
platform: "meta",
|
|
159
|
+
account_id: accountId,
|
|
160
|
+
campaign_id: insight.campaign_id,
|
|
161
|
+
campaign_name: insight.campaign_name ?? null,
|
|
162
|
+
ad_group_id: insight.adset_id ?? null,
|
|
163
|
+
ad_group_name: insight.adset_name ?? null,
|
|
164
|
+
ad_id: insight.ad_id,
|
|
165
|
+
ad_name: insight.ad_name ?? null,
|
|
166
|
+
channel: CHANNEL,
|
|
167
|
+
medium: MEDIUM,
|
|
168
|
+
channel_group: require_attribution_classify.channelGroupOf(CHANNEL, MEDIUM),
|
|
169
|
+
hour_start: start.toISOString(),
|
|
170
|
+
currency,
|
|
171
|
+
spend: Number(insight.spend ?? 0),
|
|
172
|
+
impressions: count(insight.impressions),
|
|
173
|
+
clicks: count(insight.inline_link_clicks),
|
|
174
|
+
conversions: conversions.total,
|
|
175
|
+
conversion_value: values.total,
|
|
176
|
+
click_conversions: conversions.click,
|
|
177
|
+
click_conversion_value: values.click
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Hourly delivery of every ad of the account that ran between `since` and `until` — deleted and
|
|
182
|
+
* archived ads included — one row per ad and hour (hours without delivery are absent). The hours
|
|
183
|
+
* are Meta's, in the account's time zone, turned into UTC instants. Conversions are those of each
|
|
184
|
+
* ad's attribution setting (`use_unified_attribution_setting`), their click-through part from the
|
|
185
|
+
* 7-day click window.
|
|
186
|
+
*
|
|
187
|
+
* Meta restates past hours as late conversions arrive — a purchase is credited to the hour of the
|
|
188
|
+
* click, up to 7 days after it — and corrects spend for invalid traffic: re-fetch the last 8 days
|
|
189
|
+
* daily (the 7-day window plus a day of time zone slack) and upsert on (`platform`, `account_id`,
|
|
190
|
+
* `ad_id`, `hour_start`).
|
|
191
|
+
*
|
|
192
|
+
* @throws RangeError when `since` is more than 13 months back, where Meta keeps no hourly
|
|
193
|
+
* breakdown and would answer with no rows rather than an error.
|
|
194
|
+
*/
|
|
195
|
+
async function fetchMetaAdPerformance(options) {
|
|
196
|
+
const accountId = options.accountId.replace(/^act_/, "");
|
|
197
|
+
const actionType = options.conversionActionType ?? "purchase";
|
|
198
|
+
const account = await graphGet(`act_${accountId}`, { fields: "currency,timezone_name" }, options, parseWith(accountSchema, "ad account"));
|
|
199
|
+
const since = require_ads_time.addDays(options.since, 0);
|
|
200
|
+
const until = require_ads_time.addDays(options.until, 0);
|
|
201
|
+
const earliest = require_ads_time.addMonths(require_ads_time.todayIn(account.timezone_name), -13);
|
|
202
|
+
if (since < earliest) throw new RangeError(`Meta keeps hourly insights for ${HOURLY_RETENTION_MONTHS} months: since ${since} is before ${earliest}`);
|
|
203
|
+
const rows = [];
|
|
204
|
+
for (let from = since; from <= until; from = require_ads_time.addDays(from, CHUNK_DAYS)) {
|
|
205
|
+
const last = require_ads_time.addDays(from, 6);
|
|
206
|
+
const to = last < until ? last : until;
|
|
207
|
+
let after;
|
|
208
|
+
do {
|
|
209
|
+
const params = {
|
|
210
|
+
level: "ad",
|
|
211
|
+
fields: FIELDS.join(","),
|
|
212
|
+
time_range: JSON.stringify({
|
|
213
|
+
since: from,
|
|
214
|
+
until: to
|
|
215
|
+
}),
|
|
216
|
+
time_increment: "1",
|
|
217
|
+
breakdowns: HOURLY,
|
|
218
|
+
action_attribution_windows: JSON.stringify([CLICK_WINDOW, VIEW_WINDOW]),
|
|
219
|
+
use_unified_attribution_setting: "true",
|
|
220
|
+
limit: "500"
|
|
221
|
+
};
|
|
222
|
+
if (after) params.after = after;
|
|
223
|
+
const page = await graphGet(`act_${accountId}/insights`, params, options, parseWith(pageSchema, "insights"));
|
|
224
|
+
for (const insight of page.data) rows.push(toRow(insight, accountId, account.currency, account.timezone_name, actionType));
|
|
225
|
+
after = page.paging?.next ? page.paging.cursors?.after : void 0;
|
|
226
|
+
} while (after);
|
|
227
|
+
}
|
|
228
|
+
return rows;
|
|
229
|
+
}
|
|
230
|
+
//#endregion
|
|
231
|
+
exports.MetaAdsApiError = MetaAdsApiError;
|
|
232
|
+
exports.fetchMetaAdPerformance = fetchMetaAdPerformance;
|
|
233
|
+
|
|
234
|
+
//# sourceMappingURL=meta.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"meta.cjs","names":["object","string","optional","array","number","createHmac","fetch","safeParse","zonedHourToUtc","channelGroupOf","addDays","addMonths","todayIn"],"sources":["../../src/ads/meta.ts"],"sourcesContent":["/**\n * Meta ad delivery through the Marketing API's Insights endpoint, over plain `fetch`: one row per\n * ad and hour, in the attribution vocabulary (see `AdPerformanceRow`).\n *\n * The token needs `ads_read` on the ad account — a system user token with the account assigned\n * at \"View performance\". It is sent in the `Authorization` header and never put in a URL: paging\n * follows the `after` cursor rather than Meta's `paging.next`, which embeds the token.\n *\n * https://developers.facebook.com/docs/marketing-api/insights\n * https://developers.facebook.com/docs/marketing-api/insights/breakdowns\n */\nimport { createHmac } from 'node:crypto';\nimport { fetch } from '@shware/utils';\nimport {\n type infer as Infer,\n type ZodMiniType,\n array,\n number,\n object,\n optional,\n safeParse,\n string,\n} from 'zod/mini';\nimport { channelGroupOf } from '../attribution';\nimport { addDays, addMonths, todayIn, zonedHourToUtc } from './time';\nimport type { AdPerformanceRow, ReportDate } from './types';\n\n/** Same Graph API version as the Conversions API sender. */\nconst API_VERSION = 'v24.0';\nconst GRAPH = `https://graph.facebook.com/${API_VERSION}`;\n\n/** What an ad click is in the attribution vocabulary: `meta / cpc`, a paid social touch. */\nconst CHANNEL = 'meta';\nconst MEDIUM = 'cpc';\n\n/**\n * The click window of the click-through columns. Meta's longest since 2021, and the click part of\n * its default setting (7-day click, 1-day view).\n */\nconst CLICK_WINDOW = '7d_click';\nconst VIEW_WINDOW = '1d_view';\n\nconst HOURLY = 'hourly_stats_aggregated_by_advertiser_time_zone';\n\n/**\n * How far back Meta answers the hourly breakdown, in months. Past it Meta does not fail — it\n * returns no rows — so a backfill would silently store a gap.\n * https://developers.facebook.com/docs/marketing-api/insights/best-practices\n */\nconst HOURLY_RETENTION_MONTHS = 13;\n\n/**\n * Days per Insights request. A synchronous request over a long range of ad rows times out on\n * Meta's side (`code 1, subcode 99`, \"An unknown error occurred\", as a 500): 17 months of daily\n * ad rows did. A range is split into windows this long, each paged on its own.\n */\nconst CHUNK_DAYS = 7;\n\nconst FIELDS = [\n 'ad_id',\n 'ad_name',\n 'adset_id',\n 'adset_name',\n 'campaign_id',\n 'campaign_name',\n 'spend',\n 'impressions',\n 'inline_link_clicks',\n 'actions',\n 'action_values',\n];\n\nexport interface MetaAdPerformanceOptions {\n /** A token with `ads_read` on the account. */\n accessToken: string;\n /** The ad account id, with or without its `act_` prefix. */\n accountId: string;\n /** First day, in the ad account's time zone. */\n since: ReportDate;\n /** Last day, inclusive, in the ad account's time zone. */\n until: ReportDate;\n /**\n * The `action_type` counted as the conversion, default `purchase` (Meta's total of the pixel,\n * Conversions API and in-app purchases of an event, deduplicated).\n */\n conversionActionType?: string;\n /**\n * The app secret, when the app requires `appsecret_proof` (App settings → Advanced → \"Require\n * app secret\"). The proof is computed here; the secret itself is never sent.\n */\n appSecret?: string;\n}\n\n/** A Graph API error, with Meta's code, for a caller deciding whether to retry. */\nexport class MetaAdsApiError extends Error {\n constructor(\n message: string,\n readonly status: number,\n readonly code: number | undefined,\n readonly subcode: number | undefined,\n readonly fbtraceId: string | undefined\n ) {\n super(message);\n this.name = 'MetaAdsApiError';\n }\n}\n\nconst actionSchema = object({\n action_type: string(),\n /**\n * The count under the ad's own attribution setting. Absent when the setting does not count any\n * of it — a view-only conversion of an ad set to 1-day click — which only shows in the windows.\n */\n value: optional(string()),\n [CLICK_WINDOW]: optional(string()),\n [VIEW_WINDOW]: optional(string()),\n});\n\nconst insightSchema = object({\n ad_id: string(),\n ad_name: optional(string()),\n adset_id: optional(string()),\n adset_name: optional(string()),\n campaign_id: string(),\n campaign_name: optional(string()),\n spend: optional(string()),\n impressions: optional(string()),\n inline_link_clicks: optional(string()),\n actions: optional(array(actionSchema)),\n action_values: optional(array(actionSchema)),\n date_start: string(),\n [HOURLY]: optional(string()),\n});\n\nconst pageSchema = object({\n data: array(insightSchema),\n paging: optional(\n object({ cursors: optional(object({ after: optional(string()) })), next: optional(string()) })\n ),\n});\n\nconst accountSchema = object({ currency: string(), timezone_name: string() });\n\nconst errorSchema = object({\n error: object({\n message: string(),\n code: optional(number()),\n error_subcode: optional(number()),\n fbtrace_id: optional(string()),\n }),\n});\n\ntype Insight = Infer<typeof insightSchema>;\ntype Action = Infer<typeof actionSchema>;\n\nasync function graphGet<T>(\n path: string,\n params: Record<string, string>,\n options: MetaAdPerformanceOptions,\n parse: (body: unknown) => T\n): Promise<T> {\n const query = new URLSearchParams(params);\n if (options.appSecret) {\n query.set(\n 'appsecret_proof',\n createHmac('sha256', options.appSecret).update(options.accessToken).digest('hex')\n );\n }\n const response = await fetch(`${GRAPH}/${path}?${query}`, {\n headers: { authorization: `Bearer ${options.accessToken}` },\n });\n const body: unknown = await response.json().catch(() => null);\n if (!response.ok) {\n const parsed = safeParse(errorSchema, body);\n const error = parsed.success ? parsed.data.error : undefined;\n throw new MetaAdsApiError(\n error?.message ?? `Meta Graph API responded ${response.status}`,\n response.status,\n error?.code,\n error?.error_subcode,\n error?.fbtrace_id\n );\n }\n return parse(body);\n}\n\nfunction parseWith<T>(schema: ZodMiniType<T>, what: string) {\n return (body: unknown): T => {\n const parsed = safeParse(schema, body);\n if (!parsed.success) {\n const issues = parsed.error.issues;\n const first = issues\n .slice(0, 3)\n .map((issue) => `${issue.path.join('.')}: ${issue.message}`)\n .join('; ');\n throw new Error(\n `Unexpected Meta ${what} response (${issues.length} issues): ${first}${issues.length > 3 ? '; …' : ''}`\n );\n }\n return parsed.data;\n };\n}\n\nfunction count(value: string | undefined): number {\n return value === undefined ? 0 : Number(value);\n}\n\n/** The action's total and its click-through part; nulls when Meta reported no such action. */\nfunction conversion(actions: Action[] | undefined, actionType: string) {\n const action = actions?.find((a) => a.action_type === actionType);\n if (!action) return { total: null, click: null };\n return { total: Number(action.value ?? 0), click: Number(action[CLICK_WINDOW] ?? 0) };\n}\n\n/** `\"13:00:00 - 13:59:59\"` → 13. */\nfunction hourOf(bucket: string | undefined): number {\n const hour = Number(bucket?.slice(0, 2));\n if (!Number.isInteger(hour) || hour < 0 || hour > 23) {\n throw new Error(`Unexpected Meta hourly bucket: ${bucket}`);\n }\n return hour;\n}\n\nfunction toRow(\n insight: Insight,\n accountId: string,\n currency: string,\n timeZone: string,\n actionType: string\n): AdPerformanceRow {\n const start = zonedHourToUtc(insight.date_start, hourOf(insight[HOURLY]), timeZone);\n const conversions = conversion(insight.actions, actionType);\n const values = conversion(insight.action_values, actionType);\n return {\n platform: 'meta',\n account_id: accountId,\n campaign_id: insight.campaign_id,\n campaign_name: insight.campaign_name ?? null,\n ad_group_id: insight.adset_id ?? null,\n ad_group_name: insight.adset_name ?? null,\n ad_id: insight.ad_id,\n ad_name: insight.ad_name ?? null,\n channel: CHANNEL,\n medium: MEDIUM,\n channel_group: channelGroupOf(CHANNEL, MEDIUM),\n hour_start: start.toISOString(),\n currency,\n spend: Number(insight.spend ?? 0),\n impressions: count(insight.impressions),\n clicks: count(insight.inline_link_clicks),\n conversions: conversions.total,\n conversion_value: values.total,\n click_conversions: conversions.click,\n click_conversion_value: values.click,\n };\n}\n\n/**\n * Hourly delivery of every ad of the account that ran between `since` and `until` — deleted and\n * archived ads included — one row per ad and hour (hours without delivery are absent). The hours\n * are Meta's, in the account's time zone, turned into UTC instants. Conversions are those of each\n * ad's attribution setting (`use_unified_attribution_setting`), their click-through part from the\n * 7-day click window.\n *\n * Meta restates past hours as late conversions arrive — a purchase is credited to the hour of the\n * click, up to 7 days after it — and corrects spend for invalid traffic: re-fetch the last 8 days\n * daily (the 7-day window plus a day of time zone slack) and upsert on (`platform`, `account_id`,\n * `ad_id`, `hour_start`).\n *\n * @throws RangeError when `since` is more than 13 months back, where Meta keeps no hourly\n * breakdown and would answer with no rows rather than an error.\n */\nexport async function fetchMetaAdPerformance(\n options: MetaAdPerformanceOptions\n): Promise<AdPerformanceRow[]> {\n const accountId = options.accountId.replace(/^act_/, '');\n const actionType = options.conversionActionType ?? 'purchase';\n\n const account = await graphGet(\n `act_${accountId}`,\n { fields: 'currency,timezone_name' },\n options,\n parseWith(accountSchema, 'ad account')\n );\n\n // Normalized, so `2025-9-8` compares as `2025-09-08`.\n const since = addDays(options.since, 0);\n const until = addDays(options.until, 0);\n const earliest = addMonths(todayIn(account.timezone_name), -HOURLY_RETENTION_MONTHS);\n if (since < earliest) {\n throw new RangeError(\n `Meta keeps hourly insights for ${HOURLY_RETENTION_MONTHS} months: since ${since} is before ${earliest}`\n );\n }\n\n const rows: AdPerformanceRow[] = [];\n for (let from = since; from <= until; from = addDays(from, CHUNK_DAYS)) {\n const last = addDays(from, CHUNK_DAYS - 1);\n const to = last < until ? last : until;\n let after: string | undefined;\n do {\n const params: Record<string, string> = {\n level: 'ad',\n fields: FIELDS.join(','),\n time_range: JSON.stringify({ since: from, until: to }),\n // One row per day. Without it the hourly breakdown folds the whole range into 24\n // hour-of-day buckets, all dated `since`.\n time_increment: '1',\n breakdowns: HOURLY,\n action_attribution_windows: JSON.stringify([CLICK_WINDOW, VIEW_WINDOW]),\n use_unified_attribution_setting: 'true',\n limit: '500',\n };\n if (after) params.after = after;\n const page = await graphGet(\n `act_${accountId}/insights`,\n params,\n options,\n parseWith(pageSchema, 'insights')\n );\n for (const insight of page.data) {\n rows.push(toRow(insight, accountId, account.currency, account.timezone_name, actionType));\n }\n // `next` is absent on the last page; the cursor alone is not a signal, Meta returns it there too.\n after = page.paging?.next ? page.paging.cursors?.after : undefined;\n } while (after);\n }\n\n return rows;\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { AdPerformanceRow, ReportDate } from "./types.cjs";
|
|
2
|
+
//#region src/ads/meta.d.ts
|
|
3
|
+
export interface MetaAdPerformanceOptions {
|
|
4
|
+
/** A token with `ads_read` on the account. */
|
|
5
|
+
accessToken: string;
|
|
6
|
+
/** The ad account id, with or without its `act_` prefix. */
|
|
7
|
+
accountId: string;
|
|
8
|
+
/** First day, in the ad account's time zone. */
|
|
9
|
+
since: ReportDate;
|
|
10
|
+
/** Last day, inclusive, in the ad account's time zone. */
|
|
11
|
+
until: ReportDate;
|
|
12
|
+
/**
|
|
13
|
+
* The `action_type` counted as the conversion, default `purchase` (Meta's total of the pixel,
|
|
14
|
+
* Conversions API and in-app purchases of an event, deduplicated).
|
|
15
|
+
*/
|
|
16
|
+
conversionActionType?: string;
|
|
17
|
+
/**
|
|
18
|
+
* The app secret, when the app requires `appsecret_proof` (App settings → Advanced → "Require
|
|
19
|
+
* app secret"). The proof is computed here; the secret itself is never sent.
|
|
20
|
+
*/
|
|
21
|
+
appSecret?: string;
|
|
22
|
+
}
|
|
23
|
+
/** A Graph API error, with Meta's code, for a caller deciding whether to retry. */
|
|
24
|
+
export declare class MetaAdsApiError extends Error {
|
|
25
|
+
readonly status: number;
|
|
26
|
+
readonly code: number | undefined;
|
|
27
|
+
readonly subcode: number | undefined;
|
|
28
|
+
readonly fbtraceId: string | undefined;
|
|
29
|
+
constructor(message: string, status: number, code: number | undefined, subcode: number | undefined, fbtraceId: string | undefined);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Hourly delivery of every ad of the account that ran between `since` and `until` — deleted and
|
|
33
|
+
* archived ads included — one row per ad and hour (hours without delivery are absent). The hours
|
|
34
|
+
* are Meta's, in the account's time zone, turned into UTC instants. Conversions are those of each
|
|
35
|
+
* ad's attribution setting (`use_unified_attribution_setting`), their click-through part from the
|
|
36
|
+
* 7-day click window.
|
|
37
|
+
*
|
|
38
|
+
* Meta restates past hours as late conversions arrive — a purchase is credited to the hour of the
|
|
39
|
+
* click, up to 7 days after it — and corrects spend for invalid traffic: re-fetch the last 8 days
|
|
40
|
+
* daily (the 7-day window plus a day of time zone slack) and upsert on (`platform`, `account_id`,
|
|
41
|
+
* `ad_id`, `hour_start`).
|
|
42
|
+
*
|
|
43
|
+
* @throws RangeError when `since` is more than 13 months back, where Meta keeps no hourly
|
|
44
|
+
* breakdown and would answer with no rows rather than an error.
|
|
45
|
+
*/
|
|
46
|
+
export declare function fetchMetaAdPerformance(options: MetaAdPerformanceOptions): Promise<AdPerformanceRow[]>;
|
|
47
|
+
//#endregion
|
|
48
|
+
//# sourceMappingURL=meta.d.cts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"meta.d.cts","names":[],"sources":["../../src/ads/meta.ts"],"mappings":";;iBAwEiB;;EAEf;;EAEA;;EAEA,OAAO;;EAEP,OAAO;;;;;EAKP;;;;;EAKA;;;qBAIW,wBAAwB;WAGxB;WACA;WACA;WACA;EALX,YACE,iBACS,gBACA,0BACA,6BACA;;;;;;;;;;;;;;;;;wBA4KS,uBACpB,SAAS,2BACR,QAAQ"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { AdPerformanceRow, ReportDate } from "./types.mjs";
|
|
2
|
+
//#region src/ads/meta.d.ts
|
|
3
|
+
export interface MetaAdPerformanceOptions {
|
|
4
|
+
/** A token with `ads_read` on the account. */
|
|
5
|
+
accessToken: string;
|
|
6
|
+
/** The ad account id, with or without its `act_` prefix. */
|
|
7
|
+
accountId: string;
|
|
8
|
+
/** First day, in the ad account's time zone. */
|
|
9
|
+
since: ReportDate;
|
|
10
|
+
/** Last day, inclusive, in the ad account's time zone. */
|
|
11
|
+
until: ReportDate;
|
|
12
|
+
/**
|
|
13
|
+
* The `action_type` counted as the conversion, default `purchase` (Meta's total of the pixel,
|
|
14
|
+
* Conversions API and in-app purchases of an event, deduplicated).
|
|
15
|
+
*/
|
|
16
|
+
conversionActionType?: string;
|
|
17
|
+
/**
|
|
18
|
+
* The app secret, when the app requires `appsecret_proof` (App settings → Advanced → "Require
|
|
19
|
+
* app secret"). The proof is computed here; the secret itself is never sent.
|
|
20
|
+
*/
|
|
21
|
+
appSecret?: string;
|
|
22
|
+
}
|
|
23
|
+
/** A Graph API error, with Meta's code, for a caller deciding whether to retry. */
|
|
24
|
+
export declare class MetaAdsApiError extends Error {
|
|
25
|
+
readonly status: number;
|
|
26
|
+
readonly code: number | undefined;
|
|
27
|
+
readonly subcode: number | undefined;
|
|
28
|
+
readonly fbtraceId: string | undefined;
|
|
29
|
+
constructor(message: string, status: number, code: number | undefined, subcode: number | undefined, fbtraceId: string | undefined);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Hourly delivery of every ad of the account that ran between `since` and `until` — deleted and
|
|
33
|
+
* archived ads included — one row per ad and hour (hours without delivery are absent). The hours
|
|
34
|
+
* are Meta's, in the account's time zone, turned into UTC instants. Conversions are those of each
|
|
35
|
+
* ad's attribution setting (`use_unified_attribution_setting`), their click-through part from the
|
|
36
|
+
* 7-day click window.
|
|
37
|
+
*
|
|
38
|
+
* Meta restates past hours as late conversions arrive — a purchase is credited to the hour of the
|
|
39
|
+
* click, up to 7 days after it — and corrects spend for invalid traffic: re-fetch the last 8 days
|
|
40
|
+
* daily (the 7-day window plus a day of time zone slack) and upsert on (`platform`, `account_id`,
|
|
41
|
+
* `ad_id`, `hour_start`).
|
|
42
|
+
*
|
|
43
|
+
* @throws RangeError when `since` is more than 13 months back, where Meta keeps no hourly
|
|
44
|
+
* breakdown and would answer with no rows rather than an error.
|
|
45
|
+
*/
|
|
46
|
+
export declare function fetchMetaAdPerformance(options: MetaAdPerformanceOptions): Promise<AdPerformanceRow[]>;
|
|
47
|
+
//#endregion
|
|
48
|
+
//# sourceMappingURL=meta.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"meta.d.mts","names":[],"sources":["../../src/ads/meta.ts"],"mappings":";;iBAwEiB;;EAEf;;EAEA;;EAEA,OAAO;;EAEP,OAAO;;;;;EAKP;;;;;EAKA;;;qBAIW,wBAAwB;WAGxB;WACA;WACA;WACA;EALX,YACE,iBACS,gBACA,0BACA,6BACA;;;;;;;;;;;;;;;;;wBA4KS,uBACpB,SAAS,2BACR,QAAQ"}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { channelGroupOf } from "../attribution/classify.mjs";
|
|
2
|
+
import "../attribution/index.mjs";
|
|
3
|
+
import { addDays, addMonths, todayIn, zonedHourToUtc } from "./time.mjs";
|
|
4
|
+
import { fetch } from "@shware/utils";
|
|
5
|
+
import { array, number, object, optional, safeParse, string } from "zod/mini";
|
|
6
|
+
import { createHmac } from "node:crypto";
|
|
7
|
+
//#region src/ads/meta.ts
|
|
8
|
+
/**
|
|
9
|
+
* Meta ad delivery through the Marketing API's Insights endpoint, over plain `fetch`: one row per
|
|
10
|
+
* ad and hour, in the attribution vocabulary (see `AdPerformanceRow`).
|
|
11
|
+
*
|
|
12
|
+
* The token needs `ads_read` on the ad account — a system user token with the account assigned
|
|
13
|
+
* at "View performance". It is sent in the `Authorization` header and never put in a URL: paging
|
|
14
|
+
* follows the `after` cursor rather than Meta's `paging.next`, which embeds the token.
|
|
15
|
+
*
|
|
16
|
+
* https://developers.facebook.com/docs/marketing-api/insights
|
|
17
|
+
* https://developers.facebook.com/docs/marketing-api/insights/breakdowns
|
|
18
|
+
*/
|
|
19
|
+
const GRAPH = `https://graph.facebook.com/v24.0`;
|
|
20
|
+
/** What an ad click is in the attribution vocabulary: `meta / cpc`, a paid social touch. */
|
|
21
|
+
const CHANNEL = "meta";
|
|
22
|
+
const MEDIUM = "cpc";
|
|
23
|
+
/**
|
|
24
|
+
* The click window of the click-through columns. Meta's longest since 2021, and the click part of
|
|
25
|
+
* its default setting (7-day click, 1-day view).
|
|
26
|
+
*/
|
|
27
|
+
const CLICK_WINDOW = "7d_click";
|
|
28
|
+
const VIEW_WINDOW = "1d_view";
|
|
29
|
+
const HOURLY = "hourly_stats_aggregated_by_advertiser_time_zone";
|
|
30
|
+
/**
|
|
31
|
+
* How far back Meta answers the hourly breakdown, in months. Past it Meta does not fail — it
|
|
32
|
+
* returns no rows — so a backfill would silently store a gap.
|
|
33
|
+
* https://developers.facebook.com/docs/marketing-api/insights/best-practices
|
|
34
|
+
*/
|
|
35
|
+
const HOURLY_RETENTION_MONTHS = 13;
|
|
36
|
+
/**
|
|
37
|
+
* Days per Insights request. A synchronous request over a long range of ad rows times out on
|
|
38
|
+
* Meta's side (`code 1, subcode 99`, "An unknown error occurred", as a 500): 17 months of daily
|
|
39
|
+
* ad rows did. A range is split into windows this long, each paged on its own.
|
|
40
|
+
*/
|
|
41
|
+
const CHUNK_DAYS = 7;
|
|
42
|
+
const FIELDS = [
|
|
43
|
+
"ad_id",
|
|
44
|
+
"ad_name",
|
|
45
|
+
"adset_id",
|
|
46
|
+
"adset_name",
|
|
47
|
+
"campaign_id",
|
|
48
|
+
"campaign_name",
|
|
49
|
+
"spend",
|
|
50
|
+
"impressions",
|
|
51
|
+
"inline_link_clicks",
|
|
52
|
+
"actions",
|
|
53
|
+
"action_values"
|
|
54
|
+
];
|
|
55
|
+
/** A Graph API error, with Meta's code, for a caller deciding whether to retry. */
|
|
56
|
+
var MetaAdsApiError = class extends Error {
|
|
57
|
+
constructor(message, status, code, subcode, fbtraceId) {
|
|
58
|
+
super(message);
|
|
59
|
+
this.status = status;
|
|
60
|
+
this.code = code;
|
|
61
|
+
this.subcode = subcode;
|
|
62
|
+
this.fbtraceId = fbtraceId;
|
|
63
|
+
this.name = "MetaAdsApiError";
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const actionSchema = object({
|
|
67
|
+
action_type: string(),
|
|
68
|
+
/**
|
|
69
|
+
* The count under the ad's own attribution setting. Absent when the setting does not count any
|
|
70
|
+
* of it — a view-only conversion of an ad set to 1-day click — which only shows in the windows.
|
|
71
|
+
*/
|
|
72
|
+
value: optional(string()),
|
|
73
|
+
[CLICK_WINDOW]: optional(string()),
|
|
74
|
+
[VIEW_WINDOW]: optional(string())
|
|
75
|
+
});
|
|
76
|
+
const insightSchema = object({
|
|
77
|
+
ad_id: string(),
|
|
78
|
+
ad_name: optional(string()),
|
|
79
|
+
adset_id: optional(string()),
|
|
80
|
+
adset_name: optional(string()),
|
|
81
|
+
campaign_id: string(),
|
|
82
|
+
campaign_name: optional(string()),
|
|
83
|
+
spend: optional(string()),
|
|
84
|
+
impressions: optional(string()),
|
|
85
|
+
inline_link_clicks: optional(string()),
|
|
86
|
+
actions: optional(array(actionSchema)),
|
|
87
|
+
action_values: optional(array(actionSchema)),
|
|
88
|
+
date_start: string(),
|
|
89
|
+
[HOURLY]: optional(string())
|
|
90
|
+
});
|
|
91
|
+
const pageSchema = object({
|
|
92
|
+
data: array(insightSchema),
|
|
93
|
+
paging: optional(object({
|
|
94
|
+
cursors: optional(object({ after: optional(string()) })),
|
|
95
|
+
next: optional(string())
|
|
96
|
+
}))
|
|
97
|
+
});
|
|
98
|
+
const accountSchema = object({
|
|
99
|
+
currency: string(),
|
|
100
|
+
timezone_name: string()
|
|
101
|
+
});
|
|
102
|
+
const errorSchema = object({ error: object({
|
|
103
|
+
message: string(),
|
|
104
|
+
code: optional(number()),
|
|
105
|
+
error_subcode: optional(number()),
|
|
106
|
+
fbtrace_id: optional(string())
|
|
107
|
+
}) });
|
|
108
|
+
async function graphGet(path, params, options, parse) {
|
|
109
|
+
const query = new URLSearchParams(params);
|
|
110
|
+
if (options.appSecret) query.set("appsecret_proof", createHmac("sha256", options.appSecret).update(options.accessToken).digest("hex"));
|
|
111
|
+
const response = await fetch(`${GRAPH}/${path}?${query}`, { headers: { authorization: `Bearer ${options.accessToken}` } });
|
|
112
|
+
const body = await response.json().catch(() => null);
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
const parsed = safeParse(errorSchema, body);
|
|
115
|
+
const error = parsed.success ? parsed.data.error : void 0;
|
|
116
|
+
throw new MetaAdsApiError(error?.message ?? `Meta Graph API responded ${response.status}`, response.status, error?.code, error?.error_subcode, error?.fbtrace_id);
|
|
117
|
+
}
|
|
118
|
+
return parse(body);
|
|
119
|
+
}
|
|
120
|
+
function parseWith(schema, what) {
|
|
121
|
+
return (body) => {
|
|
122
|
+
const parsed = safeParse(schema, body);
|
|
123
|
+
if (!parsed.success) {
|
|
124
|
+
const issues = parsed.error.issues;
|
|
125
|
+
const first = issues.slice(0, 3).map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
|
|
126
|
+
throw new Error(`Unexpected Meta ${what} response (${issues.length} issues): ${first}${issues.length > 3 ? "; …" : ""}`);
|
|
127
|
+
}
|
|
128
|
+
return parsed.data;
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function count(value) {
|
|
132
|
+
return value === void 0 ? 0 : Number(value);
|
|
133
|
+
}
|
|
134
|
+
/** The action's total and its click-through part; nulls when Meta reported no such action. */
|
|
135
|
+
function conversion(actions, actionType) {
|
|
136
|
+
const action = actions?.find((a) => a.action_type === actionType);
|
|
137
|
+
if (!action) return {
|
|
138
|
+
total: null,
|
|
139
|
+
click: null
|
|
140
|
+
};
|
|
141
|
+
return {
|
|
142
|
+
total: Number(action.value ?? 0),
|
|
143
|
+
click: Number(action[CLICK_WINDOW] ?? 0)
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/** `"13:00:00 - 13:59:59"` → 13. */
|
|
147
|
+
function hourOf(bucket) {
|
|
148
|
+
const hour = Number(bucket?.slice(0, 2));
|
|
149
|
+
if (!Number.isInteger(hour) || hour < 0 || hour > 23) throw new Error(`Unexpected Meta hourly bucket: ${bucket}`);
|
|
150
|
+
return hour;
|
|
151
|
+
}
|
|
152
|
+
function toRow(insight, accountId, currency, timeZone, actionType) {
|
|
153
|
+
const start = zonedHourToUtc(insight.date_start, hourOf(insight[HOURLY]), timeZone);
|
|
154
|
+
const conversions = conversion(insight.actions, actionType);
|
|
155
|
+
const values = conversion(insight.action_values, actionType);
|
|
156
|
+
return {
|
|
157
|
+
platform: "meta",
|
|
158
|
+
account_id: accountId,
|
|
159
|
+
campaign_id: insight.campaign_id,
|
|
160
|
+
campaign_name: insight.campaign_name ?? null,
|
|
161
|
+
ad_group_id: insight.adset_id ?? null,
|
|
162
|
+
ad_group_name: insight.adset_name ?? null,
|
|
163
|
+
ad_id: insight.ad_id,
|
|
164
|
+
ad_name: insight.ad_name ?? null,
|
|
165
|
+
channel: CHANNEL,
|
|
166
|
+
medium: MEDIUM,
|
|
167
|
+
channel_group: channelGroupOf(CHANNEL, MEDIUM),
|
|
168
|
+
hour_start: start.toISOString(),
|
|
169
|
+
currency,
|
|
170
|
+
spend: Number(insight.spend ?? 0),
|
|
171
|
+
impressions: count(insight.impressions),
|
|
172
|
+
clicks: count(insight.inline_link_clicks),
|
|
173
|
+
conversions: conversions.total,
|
|
174
|
+
conversion_value: values.total,
|
|
175
|
+
click_conversions: conversions.click,
|
|
176
|
+
click_conversion_value: values.click
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Hourly delivery of every ad of the account that ran between `since` and `until` — deleted and
|
|
181
|
+
* archived ads included — one row per ad and hour (hours without delivery are absent). The hours
|
|
182
|
+
* are Meta's, in the account's time zone, turned into UTC instants. Conversions are those of each
|
|
183
|
+
* ad's attribution setting (`use_unified_attribution_setting`), their click-through part from the
|
|
184
|
+
* 7-day click window.
|
|
185
|
+
*
|
|
186
|
+
* Meta restates past hours as late conversions arrive — a purchase is credited to the hour of the
|
|
187
|
+
* click, up to 7 days after it — and corrects spend for invalid traffic: re-fetch the last 8 days
|
|
188
|
+
* daily (the 7-day window plus a day of time zone slack) and upsert on (`platform`, `account_id`,
|
|
189
|
+
* `ad_id`, `hour_start`).
|
|
190
|
+
*
|
|
191
|
+
* @throws RangeError when `since` is more than 13 months back, where Meta keeps no hourly
|
|
192
|
+
* breakdown and would answer with no rows rather than an error.
|
|
193
|
+
*/
|
|
194
|
+
async function fetchMetaAdPerformance(options) {
|
|
195
|
+
const accountId = options.accountId.replace(/^act_/, "");
|
|
196
|
+
const actionType = options.conversionActionType ?? "purchase";
|
|
197
|
+
const account = await graphGet(`act_${accountId}`, { fields: "currency,timezone_name" }, options, parseWith(accountSchema, "ad account"));
|
|
198
|
+
const since = addDays(options.since, 0);
|
|
199
|
+
const until = addDays(options.until, 0);
|
|
200
|
+
const earliest = addMonths(todayIn(account.timezone_name), -13);
|
|
201
|
+
if (since < earliest) throw new RangeError(`Meta keeps hourly insights for ${HOURLY_RETENTION_MONTHS} months: since ${since} is before ${earliest}`);
|
|
202
|
+
const rows = [];
|
|
203
|
+
for (let from = since; from <= until; from = addDays(from, CHUNK_DAYS)) {
|
|
204
|
+
const last = addDays(from, 6);
|
|
205
|
+
const to = last < until ? last : until;
|
|
206
|
+
let after;
|
|
207
|
+
do {
|
|
208
|
+
const params = {
|
|
209
|
+
level: "ad",
|
|
210
|
+
fields: FIELDS.join(","),
|
|
211
|
+
time_range: JSON.stringify({
|
|
212
|
+
since: from,
|
|
213
|
+
until: to
|
|
214
|
+
}),
|
|
215
|
+
time_increment: "1",
|
|
216
|
+
breakdowns: HOURLY,
|
|
217
|
+
action_attribution_windows: JSON.stringify([CLICK_WINDOW, VIEW_WINDOW]),
|
|
218
|
+
use_unified_attribution_setting: "true",
|
|
219
|
+
limit: "500"
|
|
220
|
+
};
|
|
221
|
+
if (after) params.after = after;
|
|
222
|
+
const page = await graphGet(`act_${accountId}/insights`, params, options, parseWith(pageSchema, "insights"));
|
|
223
|
+
for (const insight of page.data) rows.push(toRow(insight, accountId, account.currency, account.timezone_name, actionType));
|
|
224
|
+
after = page.paging?.next ? page.paging.cursors?.after : void 0;
|
|
225
|
+
} while (after);
|
|
226
|
+
}
|
|
227
|
+
return rows;
|
|
228
|
+
}
|
|
229
|
+
//#endregion
|
|
230
|
+
export { MetaAdsApiError, fetchMetaAdPerformance };
|
|
231
|
+
|
|
232
|
+
//# sourceMappingURL=meta.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"meta.mjs","names":[],"sources":["../../src/ads/meta.ts"],"sourcesContent":["/**\n * Meta ad delivery through the Marketing API's Insights endpoint, over plain `fetch`: one row per\n * ad and hour, in the attribution vocabulary (see `AdPerformanceRow`).\n *\n * The token needs `ads_read` on the ad account — a system user token with the account assigned\n * at \"View performance\". It is sent in the `Authorization` header and never put in a URL: paging\n * follows the `after` cursor rather than Meta's `paging.next`, which embeds the token.\n *\n * https://developers.facebook.com/docs/marketing-api/insights\n * https://developers.facebook.com/docs/marketing-api/insights/breakdowns\n */\nimport { createHmac } from 'node:crypto';\nimport { fetch } from '@shware/utils';\nimport {\n type infer as Infer,\n type ZodMiniType,\n array,\n number,\n object,\n optional,\n safeParse,\n string,\n} from 'zod/mini';\nimport { channelGroupOf } from '../attribution';\nimport { addDays, addMonths, todayIn, zonedHourToUtc } from './time';\nimport type { AdPerformanceRow, ReportDate } from './types';\n\n/** Same Graph API version as the Conversions API sender. */\nconst API_VERSION = 'v24.0';\nconst GRAPH = `https://graph.facebook.com/${API_VERSION}`;\n\n/** What an ad click is in the attribution vocabulary: `meta / cpc`, a paid social touch. */\nconst CHANNEL = 'meta';\nconst MEDIUM = 'cpc';\n\n/**\n * The click window of the click-through columns. Meta's longest since 2021, and the click part of\n * its default setting (7-day click, 1-day view).\n */\nconst CLICK_WINDOW = '7d_click';\nconst VIEW_WINDOW = '1d_view';\n\nconst HOURLY = 'hourly_stats_aggregated_by_advertiser_time_zone';\n\n/**\n * How far back Meta answers the hourly breakdown, in months. Past it Meta does not fail — it\n * returns no rows — so a backfill would silently store a gap.\n * https://developers.facebook.com/docs/marketing-api/insights/best-practices\n */\nconst HOURLY_RETENTION_MONTHS = 13;\n\n/**\n * Days per Insights request. A synchronous request over a long range of ad rows times out on\n * Meta's side (`code 1, subcode 99`, \"An unknown error occurred\", as a 500): 17 months of daily\n * ad rows did. A range is split into windows this long, each paged on its own.\n */\nconst CHUNK_DAYS = 7;\n\nconst FIELDS = [\n 'ad_id',\n 'ad_name',\n 'adset_id',\n 'adset_name',\n 'campaign_id',\n 'campaign_name',\n 'spend',\n 'impressions',\n 'inline_link_clicks',\n 'actions',\n 'action_values',\n];\n\nexport interface MetaAdPerformanceOptions {\n /** A token with `ads_read` on the account. */\n accessToken: string;\n /** The ad account id, with or without its `act_` prefix. */\n accountId: string;\n /** First day, in the ad account's time zone. */\n since: ReportDate;\n /** Last day, inclusive, in the ad account's time zone. */\n until: ReportDate;\n /**\n * The `action_type` counted as the conversion, default `purchase` (Meta's total of the pixel,\n * Conversions API and in-app purchases of an event, deduplicated).\n */\n conversionActionType?: string;\n /**\n * The app secret, when the app requires `appsecret_proof` (App settings → Advanced → \"Require\n * app secret\"). The proof is computed here; the secret itself is never sent.\n */\n appSecret?: string;\n}\n\n/** A Graph API error, with Meta's code, for a caller deciding whether to retry. */\nexport class MetaAdsApiError extends Error {\n constructor(\n message: string,\n readonly status: number,\n readonly code: number | undefined,\n readonly subcode: number | undefined,\n readonly fbtraceId: string | undefined\n ) {\n super(message);\n this.name = 'MetaAdsApiError';\n }\n}\n\nconst actionSchema = object({\n action_type: string(),\n /**\n * The count under the ad's own attribution setting. Absent when the setting does not count any\n * of it — a view-only conversion of an ad set to 1-day click — which only shows in the windows.\n */\n value: optional(string()),\n [CLICK_WINDOW]: optional(string()),\n [VIEW_WINDOW]: optional(string()),\n});\n\nconst insightSchema = object({\n ad_id: string(),\n ad_name: optional(string()),\n adset_id: optional(string()),\n adset_name: optional(string()),\n campaign_id: string(),\n campaign_name: optional(string()),\n spend: optional(string()),\n impressions: optional(string()),\n inline_link_clicks: optional(string()),\n actions: optional(array(actionSchema)),\n action_values: optional(array(actionSchema)),\n date_start: string(),\n [HOURLY]: optional(string()),\n});\n\nconst pageSchema = object({\n data: array(insightSchema),\n paging: optional(\n object({ cursors: optional(object({ after: optional(string()) })), next: optional(string()) })\n ),\n});\n\nconst accountSchema = object({ currency: string(), timezone_name: string() });\n\nconst errorSchema = object({\n error: object({\n message: string(),\n code: optional(number()),\n error_subcode: optional(number()),\n fbtrace_id: optional(string()),\n }),\n});\n\ntype Insight = Infer<typeof insightSchema>;\ntype Action = Infer<typeof actionSchema>;\n\nasync function graphGet<T>(\n path: string,\n params: Record<string, string>,\n options: MetaAdPerformanceOptions,\n parse: (body: unknown) => T\n): Promise<T> {\n const query = new URLSearchParams(params);\n if (options.appSecret) {\n query.set(\n 'appsecret_proof',\n createHmac('sha256', options.appSecret).update(options.accessToken).digest('hex')\n );\n }\n const response = await fetch(`${GRAPH}/${path}?${query}`, {\n headers: { authorization: `Bearer ${options.accessToken}` },\n });\n const body: unknown = await response.json().catch(() => null);\n if (!response.ok) {\n const parsed = safeParse(errorSchema, body);\n const error = parsed.success ? parsed.data.error : undefined;\n throw new MetaAdsApiError(\n error?.message ?? `Meta Graph API responded ${response.status}`,\n response.status,\n error?.code,\n error?.error_subcode,\n error?.fbtrace_id\n );\n }\n return parse(body);\n}\n\nfunction parseWith<T>(schema: ZodMiniType<T>, what: string) {\n return (body: unknown): T => {\n const parsed = safeParse(schema, body);\n if (!parsed.success) {\n const issues = parsed.error.issues;\n const first = issues\n .slice(0, 3)\n .map((issue) => `${issue.path.join('.')}: ${issue.message}`)\n .join('; ');\n throw new Error(\n `Unexpected Meta ${what} response (${issues.length} issues): ${first}${issues.length > 3 ? '; …' : ''}`\n );\n }\n return parsed.data;\n };\n}\n\nfunction count(value: string | undefined): number {\n return value === undefined ? 0 : Number(value);\n}\n\n/** The action's total and its click-through part; nulls when Meta reported no such action. */\nfunction conversion(actions: Action[] | undefined, actionType: string) {\n const action = actions?.find((a) => a.action_type === actionType);\n if (!action) return { total: null, click: null };\n return { total: Number(action.value ?? 0), click: Number(action[CLICK_WINDOW] ?? 0) };\n}\n\n/** `\"13:00:00 - 13:59:59\"` → 13. */\nfunction hourOf(bucket: string | undefined): number {\n const hour = Number(bucket?.slice(0, 2));\n if (!Number.isInteger(hour) || hour < 0 || hour > 23) {\n throw new Error(`Unexpected Meta hourly bucket: ${bucket}`);\n }\n return hour;\n}\n\nfunction toRow(\n insight: Insight,\n accountId: string,\n currency: string,\n timeZone: string,\n actionType: string\n): AdPerformanceRow {\n const start = zonedHourToUtc(insight.date_start, hourOf(insight[HOURLY]), timeZone);\n const conversions = conversion(insight.actions, actionType);\n const values = conversion(insight.action_values, actionType);\n return {\n platform: 'meta',\n account_id: accountId,\n campaign_id: insight.campaign_id,\n campaign_name: insight.campaign_name ?? null,\n ad_group_id: insight.adset_id ?? null,\n ad_group_name: insight.adset_name ?? null,\n ad_id: insight.ad_id,\n ad_name: insight.ad_name ?? null,\n channel: CHANNEL,\n medium: MEDIUM,\n channel_group: channelGroupOf(CHANNEL, MEDIUM),\n hour_start: start.toISOString(),\n currency,\n spend: Number(insight.spend ?? 0),\n impressions: count(insight.impressions),\n clicks: count(insight.inline_link_clicks),\n conversions: conversions.total,\n conversion_value: values.total,\n click_conversions: conversions.click,\n click_conversion_value: values.click,\n };\n}\n\n/**\n * Hourly delivery of every ad of the account that ran between `since` and `until` — deleted and\n * archived ads included — one row per ad and hour (hours without delivery are absent). The hours\n * are Meta's, in the account's time zone, turned into UTC instants. Conversions are those of each\n * ad's attribution setting (`use_unified_attribution_setting`), their click-through part from the\n * 7-day click window.\n *\n * Meta restates past hours as late conversions arrive — a purchase is credited to the hour of the\n * click, up to 7 days after it — and corrects spend for invalid traffic: re-fetch the last 8 days\n * daily (the 7-day window plus a day of time zone slack) and upsert on (`platform`, `account_id`,\n * `ad_id`, `hour_start`).\n *\n * @throws RangeError when `since` is more than 13 months back, where Meta keeps no hourly\n * breakdown and would answer with no rows rather than an error.\n */\nexport async function fetchMetaAdPerformance(\n options: MetaAdPerformanceOptions\n): Promise<AdPerformanceRow[]> {\n const accountId = options.accountId.replace(/^act_/, '');\n const actionType = options.conversionActionType ?? 'purchase';\n\n const account = await graphGet(\n `act_${accountId}`,\n { fields: 'currency,timezone_name' },\n options,\n parseWith(accountSchema, 'ad account')\n );\n\n // Normalized, so `2025-9-8` compares as `2025-09-08`.\n const since = addDays(options.since, 0);\n const until = addDays(options.until, 0);\n const earliest = addMonths(todayIn(account.timezone_name), -HOURLY_RETENTION_MONTHS);\n if (since < earliest) {\n throw new RangeError(\n `Meta keeps hourly insights for ${HOURLY_RETENTION_MONTHS} months: since ${since} is before ${earliest}`\n );\n }\n\n const rows: AdPerformanceRow[] = [];\n for (let from = since; from <= until; from = addDays(from, CHUNK_DAYS)) {\n const last = addDays(from, CHUNK_DAYS - 1);\n const to = last < until ? last : until;\n let after: string | undefined;\n do {\n const params: Record<string, string> = {\n level: 'ad',\n fields: FIELDS.join(','),\n time_range: JSON.stringify({ since: from, until: to }),\n // One row per day. Without it the hourly breakdown folds the whole range into 24\n // hour-of-day buckets, all dated `since`.\n time_increment: '1',\n breakdowns: HOURLY,\n action_attribution_windows: JSON.stringify([CLICK_WINDOW, VIEW_WINDOW]),\n use_unified_attribution_setting: 'true',\n limit: '500',\n };\n if (after) params.after = after;\n const page = await graphGet(\n `act_${accountId}/insights`,\n params,\n options,\n parseWith(pageSchema, 'insights')\n );\n for (const insight of page.data) {\n rows.push(toRow(insight, accountId, account.currency, account.timezone_name, actionType));\n }\n // `next` is absent on the last page; the cursor alone is not a signal, Meta returns it there too.\n after = page.paging?.next ? page.paging.cursors?.after : undefined;\n } while (after);\n }\n\n return rows;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AA6BA,MAAM,QAAQ;;AAGd,MAAM,UAAU;AAChB,MAAM,SAAS;;;;;AAMf,MAAM,eAAe;AACrB,MAAM,cAAc;AAEpB,MAAM,SAAS;;;;;;AAOf,MAAM,0BAA0B;;;;;;AAOhC,MAAM,aAAa;AAEnB,MAAM,SAAS;CACb;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF;;AAwBA,IAAa,kBAAb,cAAqC,MAAM;CACzC,YACE,SACA,QACA,MACA,SACA,WACA;EACA,MAAM,OAAO;EALJ,KAAA,SAAA;EACA,KAAA,OAAA;EACA,KAAA,UAAA;EACA,KAAA,YAAA;EAGT,KAAK,OAAO;CACd;AACF;AAEA,MAAM,eAAe,OAAO;CAC1B,aAAa,OAAO;;;;;CAKpB,OAAO,SAAS,OAAO,CAAC;EACvB,eAAe,SAAS,OAAO,CAAC;EAChC,cAAc,SAAS,OAAO,CAAC;AAClC,CAAC;AAED,MAAM,gBAAgB,OAAO;CAC3B,OAAO,OAAO;CACd,SAAS,SAAS,OAAO,CAAC;CAC1B,UAAU,SAAS,OAAO,CAAC;CAC3B,YAAY,SAAS,OAAO,CAAC;CAC7B,aAAa,OAAO;CACpB,eAAe,SAAS,OAAO,CAAC;CAChC,OAAO,SAAS,OAAO,CAAC;CACxB,aAAa,SAAS,OAAO,CAAC;CAC9B,oBAAoB,SAAS,OAAO,CAAC;CACrC,SAAS,SAAS,MAAM,YAAY,CAAC;CACrC,eAAe,SAAS,MAAM,YAAY,CAAC;CAC3C,YAAY,OAAO;EAClB,SAAS,SAAS,OAAO,CAAC;AAC7B,CAAC;AAED,MAAM,aAAa,OAAO;CACxB,MAAM,MAAM,aAAa;CACzB,QAAQ,SACN,OAAO;EAAE,SAAS,SAAS,OAAO,EAAE,OAAO,SAAS,OAAO,CAAC,EAAE,CAAC,CAAC;EAAG,MAAM,SAAS,OAAO,CAAC;CAAE,CAAC,CAC/F;AACF,CAAC;AAED,MAAM,gBAAgB,OAAO;CAAE,UAAU,OAAO;CAAG,eAAe,OAAO;AAAE,CAAC;AAE5E,MAAM,cAAc,OAAO,EACzB,OAAO,OAAO;CACZ,SAAS,OAAO;CAChB,MAAM,SAAS,OAAO,CAAC;CACvB,eAAe,SAAS,OAAO,CAAC;CAChC,YAAY,SAAS,OAAO,CAAC;AAC/B,CAAC,EACH,CAAC;AAKD,eAAe,SACb,MACA,QACA,SACA,OACY;CACZ,MAAM,QAAQ,IAAI,gBAAgB,MAAM;CACxC,IAAI,QAAQ,WACV,MAAM,IACJ,mBACA,WAAW,UAAU,QAAQ,SAAS,CAAC,CAAC,OAAO,QAAQ,WAAW,CAAC,CAAC,OAAO,KAAK,CAClF;CAEF,MAAM,WAAW,MAAM,MAAM,GAAG,MAAM,GAAG,KAAK,GAAG,SAAS,EACxD,SAAS,EAAE,eAAe,UAAU,QAAQ,cAAc,EAC5D,CAAC;CACD,MAAM,OAAgB,MAAM,SAAS,KAAK,CAAC,CAAC,YAAY,IAAI;CAC5D,IAAI,CAAC,SAAS,IAAI;EAChB,MAAM,SAAS,UAAU,aAAa,IAAI;EAC1C,MAAM,QAAQ,OAAO,UAAU,OAAO,KAAK,QAAQ,KAAA;EACnD,MAAM,IAAI,gBACR,OAAO,WAAW,4BAA4B,SAAS,UACvD,SAAS,QACT,OAAO,MACP,OAAO,eACP,OAAO,UACT;CACF;CACA,OAAO,MAAM,IAAI;AACnB;AAEA,SAAS,UAAa,QAAwB,MAAc;CAC1D,QAAQ,SAAqB;EAC3B,MAAM,SAAS,UAAU,QAAQ,IAAI;EACrC,IAAI,CAAC,OAAO,SAAS;GACnB,MAAM,SAAS,OAAO,MAAM;GAC5B,MAAM,QAAQ,OACX,MAAM,GAAG,CAAC,CAAC,CACX,KAAK,UAAU,GAAG,MAAM,KAAK,KAAK,GAAG,EAAE,IAAI,MAAM,SAAS,CAAC,CAC3D,KAAK,IAAI;GACZ,MAAM,IAAI,MACR,mBAAmB,KAAK,aAAa,OAAO,OAAO,YAAY,QAAQ,OAAO,SAAS,IAAI,QAAQ,IACrG;EACF;EACA,OAAO,OAAO;CAChB;AACF;AAEA,SAAS,MAAM,OAAmC;CAChD,OAAO,UAAU,KAAA,IAAY,IAAI,OAAO,KAAK;AAC/C;;AAGA,SAAS,WAAW,SAA+B,YAAoB;CACrE,MAAM,SAAS,SAAS,MAAM,MAAM,EAAE,gBAAgB,UAAU;CAChE,IAAI,CAAC,QAAQ,OAAO;EAAE,OAAO;EAAM,OAAO;CAAK;CAC/C,OAAO;EAAE,OAAO,OAAO,OAAO,SAAS,CAAC;EAAG,OAAO,OAAO,OAAO,iBAAiB,CAAC;CAAE;AACtF;;AAGA,SAAS,OAAO,QAAoC;CAClD,MAAM,OAAO,OAAO,QAAQ,MAAM,GAAG,CAAC,CAAC;CACvC,IAAI,CAAC,OAAO,UAAU,IAAI,KAAK,OAAO,KAAK,OAAO,IAChD,MAAM,IAAI,MAAM,kCAAkC,QAAQ;CAE5D,OAAO;AACT;AAEA,SAAS,MACP,SACA,WACA,UACA,UACA,YACkB;CAClB,MAAM,QAAQ,eAAe,QAAQ,YAAY,OAAO,QAAQ,OAAO,GAAG,QAAQ;CAClF,MAAM,cAAc,WAAW,QAAQ,SAAS,UAAU;CAC1D,MAAM,SAAS,WAAW,QAAQ,eAAe,UAAU;CAC3D,OAAO;EACL,UAAU;EACV,YAAY;EACZ,aAAa,QAAQ;EACrB,eAAe,QAAQ,iBAAiB;EACxC,aAAa,QAAQ,YAAY;EACjC,eAAe,QAAQ,cAAc;EACrC,OAAO,QAAQ;EACf,SAAS,QAAQ,WAAW;EAC5B,SAAS;EACT,QAAQ;EACR,eAAe,eAAe,SAAS,MAAM;EAC7C,YAAY,MAAM,YAAY;EAC9B;EACA,OAAO,OAAO,QAAQ,SAAS,CAAC;EAChC,aAAa,MAAM,QAAQ,WAAW;EACtC,QAAQ,MAAM,QAAQ,kBAAkB;EACxC,aAAa,YAAY;EACzB,kBAAkB,OAAO;EACzB,mBAAmB,YAAY;EAC/B,wBAAwB,OAAO;CACjC;AACF;;;;;;;;;;;;;;;;AAiBA,eAAsB,uBACpB,SAC6B;CAC7B,MAAM,YAAY,QAAQ,UAAU,QAAQ,SAAS,EAAE;CACvD,MAAM,aAAa,QAAQ,wBAAwB;CAEnD,MAAM,UAAU,MAAM,SACpB,OAAO,aACP,EAAE,QAAQ,yBAAyB,GACnC,SACA,UAAU,eAAe,YAAY,CACvC;CAGA,MAAM,QAAQ,QAAQ,QAAQ,OAAO,CAAC;CACtC,MAAM,QAAQ,QAAQ,QAAQ,OAAO,CAAC;CACtC,MAAM,WAAW,UAAU,QAAQ,QAAQ,aAAa,GAAG,GAAwB;CACnF,IAAI,QAAQ,UACV,MAAM,IAAI,WACR,kCAAkC,wBAAwB,iBAAiB,MAAM,aAAa,UAChG;CAGF,MAAM,OAA2B,CAAC;CAClC,KAAK,IAAI,OAAO,OAAO,QAAQ,OAAO,OAAO,QAAQ,MAAM,UAAU,GAAG;EACtE,MAAM,OAAO,QAAQ,MAAM,CAAc;EACzC,MAAM,KAAK,OAAO,QAAQ,OAAO;EACjC,IAAI;EACJ,GAAG;GACD,MAAM,SAAiC;IACrC,OAAO;IACP,QAAQ,OAAO,KAAK,GAAG;IACvB,YAAY,KAAK,UAAU;KAAE,OAAO;KAAM,OAAO;IAAG,CAAC;IAGrD,gBAAgB;IAChB,YAAY;IACZ,4BAA4B,KAAK,UAAU,CAAC,cAAc,WAAW,CAAC;IACtE,iCAAiC;IACjC,OAAO;GACT;GACA,IAAI,OAAO,OAAO,QAAQ;GAC1B,MAAM,OAAO,MAAM,SACjB,OAAO,UAAU,YACjB,QACA,SACA,UAAU,YAAY,UAAU,CAClC;GACA,KAAK,MAAM,WAAW,KAAK,MACzB,KAAK,KAAK,MAAM,SAAS,WAAW,QAAQ,UAAU,QAAQ,eAAe,UAAU,CAAC;GAG1F,QAAQ,KAAK,QAAQ,OAAO,KAAK,OAAO,SAAS,QAAQ,KAAA;EAC3D,SAAS;CACX;CAEA,OAAO;AACT"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region src/ads/time.ts
|
|
3
|
+
/** The zone's offset from UTC at an instant, in milliseconds (local minus UTC). */
|
|
4
|
+
function offsetAt(instant, timeZone) {
|
|
5
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
6
|
+
timeZone,
|
|
7
|
+
hourCycle: "h23",
|
|
8
|
+
year: "numeric",
|
|
9
|
+
month: "2-digit",
|
|
10
|
+
day: "2-digit",
|
|
11
|
+
hour: "2-digit",
|
|
12
|
+
minute: "2-digit",
|
|
13
|
+
second: "2-digit"
|
|
14
|
+
}).formatToParts(new Date(instant));
|
|
15
|
+
const get = (type) => Number(parts.find((part) => part.type === type)?.value);
|
|
16
|
+
return Date.UTC(get("year"), get("month") - 1, get("day"), get("hour"), get("minute"), get("second")) - Math.floor(instant / 1e3) * 1e3;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The UTC instant of `hour:00` on `date` (`YYYY-MM-DD`) in an IANA time zone. An hour skipped by
|
|
20
|
+
* a daylight-saving jump resolves to the instant after the jump; a repeated hour to its first
|
|
21
|
+
* occurrence — no platform reports delivery in a skipped hour, and a repeated one comes as a
|
|
22
|
+
* single bucket.
|
|
23
|
+
*/
|
|
24
|
+
function zonedHourToUtc(date, hour, timeZone) {
|
|
25
|
+
const [year, month, day] = date.split("-").map(Number);
|
|
26
|
+
const wall = Date.UTC(year, month - 1, day, hour);
|
|
27
|
+
const first = wall - offsetAt(wall, timeZone);
|
|
28
|
+
const second = wall - offsetAt(first, timeZone);
|
|
29
|
+
const valid = [first, second].filter((c) => c + offsetAt(c, timeZone) === wall);
|
|
30
|
+
return new Date(valid.length > 0 ? Math.min(...valid) : Math.max(first, second));
|
|
31
|
+
}
|
|
32
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
33
|
+
function format(year, month, day) {
|
|
34
|
+
return `${year}-${pad(month)}-${pad(day)}`;
|
|
35
|
+
}
|
|
36
|
+
/** Today's date in an IANA time zone. */
|
|
37
|
+
function todayIn(timeZone, now = /* @__PURE__ */ new Date()) {
|
|
38
|
+
return new Intl.DateTimeFormat("en-CA", { timeZone }).format(now);
|
|
39
|
+
}
|
|
40
|
+
/** `date` moved by whole days. */
|
|
41
|
+
function addDays(date, days) {
|
|
42
|
+
const [year, month, day] = date.split("-").map(Number);
|
|
43
|
+
const moved = new Date(Date.UTC(year, month - 1, day + days));
|
|
44
|
+
return format(moved.getUTCFullYear(), moved.getUTCMonth() + 1, moved.getUTCDate());
|
|
45
|
+
}
|
|
46
|
+
/** `date` moved by whole months, the day clamped to the target month's last (03-31 − 1 → 02-28). */
|
|
47
|
+
function addMonths(date, months) {
|
|
48
|
+
const [year, month, day] = date.split("-").map(Number);
|
|
49
|
+
const target = new Date(Date.UTC(year, month - 1 + months, 1));
|
|
50
|
+
const last = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate();
|
|
51
|
+
return format(target.getUTCFullYear(), target.getUTCMonth() + 1, Math.min(day, last));
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
54
|
+
exports.addDays = addDays;
|
|
55
|
+
exports.addMonths = addMonths;
|
|
56
|
+
exports.todayIn = todayIn;
|
|
57
|
+
exports.zonedHourToUtc = zonedHourToUtc;
|
|
58
|
+
|
|
59
|
+
//# sourceMappingURL=time.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"time.cjs","names":[],"sources":["../../src/ads/time.ts"],"sourcesContent":["import type { ReportDate } from './types';\n\n/** The zone's offset from UTC at an instant, in milliseconds (local minus UTC). */\nfunction offsetAt(instant: number, timeZone: string): number {\n const parts = new Intl.DateTimeFormat('en-US', {\n timeZone,\n hourCycle: 'h23',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n }).formatToParts(new Date(instant));\n const get = (type: Intl.DateTimeFormatPartTypes) =>\n Number(parts.find((part) => part.type === type)?.value);\n const local = Date.UTC(\n get('year'),\n get('month') - 1,\n get('day'),\n get('hour'),\n get('minute'),\n get('second')\n );\n return local - Math.floor(instant / 1000) * 1000;\n}\n\n/**\n * The UTC instant of `hour:00` on `date` (`YYYY-MM-DD`) in an IANA time zone. An hour skipped by\n * a daylight-saving jump resolves to the instant after the jump; a repeated hour to its first\n * occurrence — no platform reports delivery in a skipped hour, and a repeated one comes as a\n * single bucket.\n */\nexport function zonedHourToUtc(date: string, hour: number, timeZone: string): Date {\n const [year, month, day] = date.split('-').map(Number) as [number, number, number];\n const wall = Date.UTC(year, month - 1, day, hour);\n // The offset at the wall time read as UTC is a guess that is off by the jump near a transition;\n // the offset at that first candidate gives the other. A candidate is right when it reads back\n // as the wall time in the zone.\n const first = wall - offsetAt(wall, timeZone);\n const second = wall - offsetAt(first, timeZone);\n const valid = [first, second].filter((c) => c + offsetAt(c, timeZone) === wall);\n return new Date(valid.length > 0 ? Math.min(...valid) : Math.max(first, second));\n}\n\nconst pad = (n: number) => String(n).padStart(2, '0');\n\nfunction format(year: number, month: number, day: number): ReportDate {\n return `${year}-${pad(month)}-${pad(day)}` as ReportDate;\n}\n\n/** Today's date in an IANA time zone. */\nexport function todayIn(timeZone: string, now = new Date()): ReportDate {\n // en-CA formats as YYYY-MM-DD.\n return new Intl.DateTimeFormat('en-CA', { timeZone }).format(now) as ReportDate;\n}\n\n/** `date` moved by whole days. */\nexport function addDays(date: string, days: number): ReportDate {\n const [year, month, day] = date.split('-').map(Number) as [number, number, number];\n const moved = new Date(Date.UTC(year, month - 1, day + days));\n return format(moved.getUTCFullYear(), moved.getUTCMonth() + 1, moved.getUTCDate());\n}\n\n/** `date` moved by whole months, the day clamped to the target month's last (03-31 − 1 → 02-28). */\nexport function addMonths(date: string, months: number): ReportDate {\n const [year, month, day] = date.split('-').map(Number) as [number, number, number];\n const target = new Date(Date.UTC(year, month - 1 + months, 1));\n const last = new Date(\n Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)\n ).getUTCDate();\n return format(target.getUTCFullYear(), target.getUTCMonth() + 1, Math.min(day, last));\n}\n"],"mappings":";;;AAGA,SAAS,SAAS,SAAiB,UAA0B;CAC3D,MAAM,QAAQ,IAAI,KAAK,eAAe,SAAS;EAC7C;EACA,WAAW;EACX,MAAM;EACN,OAAO;EACP,KAAK;EACL,MAAM;EACN,QAAQ;EACR,QAAQ;CACV,CAAC,CAAC,CAAC,cAAc,IAAI,KAAK,OAAO,CAAC;CAClC,MAAM,OAAO,SACX,OAAO,MAAM,MAAM,SAAS,KAAK,SAAS,IAAI,CAAC,EAAE,KAAK;CASxD,OARc,KAAK,IACjB,IAAI,MAAM,GACV,IAAI,OAAO,IAAI,GACf,IAAI,KAAK,GACT,IAAI,MAAM,GACV,IAAI,QAAQ,GACZ,IAAI,QAAQ,CAEH,IAAI,KAAK,MAAM,UAAU,GAAI,IAAI;AAC9C;;;;;;;AAQA,SAAgB,eAAe,MAAc,MAAc,UAAwB;CACjF,MAAM,CAAC,MAAM,OAAO,OAAO,KAAK,MAAM,GAAG,CAAC,CAAC,IAAI,MAAM;CACrD,MAAM,OAAO,KAAK,IAAI,MAAM,QAAQ,GAAG,KAAK,IAAI;CAIhD,MAAM,QAAQ,OAAO,SAAS,MAAM,QAAQ;CAC5C,MAAM,SAAS,OAAO,SAAS,OAAO,QAAQ;CAC9C,MAAM,QAAQ,CAAC,OAAO,MAAM,CAAC,CAAC,QAAQ,MAAM,IAAI,SAAS,GAAG,QAAQ,MAAM,IAAI;CAC9E,OAAO,IAAI,KAAK,MAAM,SAAS,IAAI,KAAK,IAAI,GAAG,KAAK,IAAI,KAAK,IAAI,OAAO,MAAM,CAAC;AACjF;AAEA,MAAM,OAAO,MAAc,OAAO,CAAC,CAAC,CAAC,SAAS,GAAG,GAAG;AAEpD,SAAS,OAAO,MAAc,OAAe,KAAyB;CACpE,OAAO,GAAG,KAAK,GAAG,IAAI,KAAK,EAAE,GAAG,IAAI,GAAG;AACzC;;AAGA,SAAgB,QAAQ,UAAkB,sBAAM,IAAI,KAAK,GAAe;CAEtE,OAAO,IAAI,KAAK,eAAe,SAAS,EAAE,SAAS,CAAC,CAAC,CAAC,OAAO,GAAG;AAClE;;AAGA,SAAgB,QAAQ,MAAc,MAA0B;CAC9D,MAAM,CAAC,MAAM,OAAO,OAAO,KAAK,MAAM,GAAG,CAAC,CAAC,IAAI,MAAM;CACrD,MAAM,QAAQ,IAAI,KAAK,KAAK,IAAI,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC;CAC5D,OAAO,OAAO,MAAM,eAAe,GAAG,MAAM,YAAY,IAAI,GAAG,MAAM,WAAW,CAAC;AACnF;;AAGA,SAAgB,UAAU,MAAc,QAA4B;CAClE,MAAM,CAAC,MAAM,OAAO,OAAO,KAAK,MAAM,GAAG,CAAC,CAAC,IAAI,MAAM;CACrD,MAAM,SAAS,IAAI,KAAK,KAAK,IAAI,MAAM,QAAQ,IAAI,QAAQ,CAAC,CAAC;CAC7D,MAAM,OAAO,IAAI,KACf,KAAK,IAAI,OAAO,eAAe,GAAG,OAAO,YAAY,IAAI,GAAG,CAAC,CAC/D,CAAC,CAAC,WAAW;CACb,OAAO,OAAO,OAAO,eAAe,GAAG,OAAO,YAAY,IAAI,GAAG,KAAK,IAAI,KAAK,IAAI,CAAC;AACtF"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ReportDate } from "./types.cjs";
|
|
2
|
+
//#region src/ads/time.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The UTC instant of `hour:00` on `date` (`YYYY-MM-DD`) in an IANA time zone. An hour skipped by
|
|
5
|
+
* a daylight-saving jump resolves to the instant after the jump; a repeated hour to its first
|
|
6
|
+
* occurrence — no platform reports delivery in a skipped hour, and a repeated one comes as a
|
|
7
|
+
* single bucket.
|
|
8
|
+
*/
|
|
9
|
+
export declare function zonedHourToUtc(date: string, hour: number, timeZone: string): Date;
|
|
10
|
+
/** Today's date in an IANA time zone. */
|
|
11
|
+
export declare function todayIn(timeZone: string, now?: Date): ReportDate;
|
|
12
|
+
/** `date` moved by whole days. */
|
|
13
|
+
export declare function addDays(date: string, days: number): ReportDate;
|
|
14
|
+
/** `date` moved by whole months, the day clamped to the target month's last (03-31 − 1 → 02-28). */
|
|
15
|
+
export declare function addMonths(date: string, months: number): ReportDate;
|
|
16
|
+
//#endregion
|
|
17
|
+
//# sourceMappingURL=time.d.cts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"time.d.cts","names":[],"sources":["../../src/ads/time.ts"],"mappings":";;;;;;;;wBAiCgB,eAAe,cAAc,cAAc,mBAAmB;;wBAmB9D,QAAQ,kBAAkB,MAAG,OAAgB;;wBAM7C,QAAQ,cAAc,eAAe;;wBAOrC,UAAU,cAAc,iBAAiB"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ReportDate } from "./types.mjs";
|
|
2
|
+
//#region src/ads/time.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The UTC instant of `hour:00` on `date` (`YYYY-MM-DD`) in an IANA time zone. An hour skipped by
|
|
5
|
+
* a daylight-saving jump resolves to the instant after the jump; a repeated hour to its first
|
|
6
|
+
* occurrence — no platform reports delivery in a skipped hour, and a repeated one comes as a
|
|
7
|
+
* single bucket.
|
|
8
|
+
*/
|
|
9
|
+
export declare function zonedHourToUtc(date: string, hour: number, timeZone: string): Date;
|
|
10
|
+
/** Today's date in an IANA time zone. */
|
|
11
|
+
export declare function todayIn(timeZone: string, now?: Date): ReportDate;
|
|
12
|
+
/** `date` moved by whole days. */
|
|
13
|
+
export declare function addDays(date: string, days: number): ReportDate;
|
|
14
|
+
/** `date` moved by whole months, the day clamped to the target month's last (03-31 − 1 → 02-28). */
|
|
15
|
+
export declare function addMonths(date: string, months: number): ReportDate;
|
|
16
|
+
//#endregion
|
|
17
|
+
//# sourceMappingURL=time.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"time.d.mts","names":[],"sources":["../../src/ads/time.ts"],"mappings":";;;;;;;;wBAiCgB,eAAe,cAAc,cAAc,mBAAmB;;wBAmB9D,QAAQ,kBAAkB,MAAG,OAAgB;;wBAM7C,QAAQ,cAAc,eAAe;;wBAOrC,UAAU,cAAc,iBAAiB"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
//#region src/ads/time.ts
|
|
2
|
+
/** The zone's offset from UTC at an instant, in milliseconds (local minus UTC). */
|
|
3
|
+
function offsetAt(instant, timeZone) {
|
|
4
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
5
|
+
timeZone,
|
|
6
|
+
hourCycle: "h23",
|
|
7
|
+
year: "numeric",
|
|
8
|
+
month: "2-digit",
|
|
9
|
+
day: "2-digit",
|
|
10
|
+
hour: "2-digit",
|
|
11
|
+
minute: "2-digit",
|
|
12
|
+
second: "2-digit"
|
|
13
|
+
}).formatToParts(new Date(instant));
|
|
14
|
+
const get = (type) => Number(parts.find((part) => part.type === type)?.value);
|
|
15
|
+
return Date.UTC(get("year"), get("month") - 1, get("day"), get("hour"), get("minute"), get("second")) - Math.floor(instant / 1e3) * 1e3;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The UTC instant of `hour:00` on `date` (`YYYY-MM-DD`) in an IANA time zone. An hour skipped by
|
|
19
|
+
* a daylight-saving jump resolves to the instant after the jump; a repeated hour to its first
|
|
20
|
+
* occurrence — no platform reports delivery in a skipped hour, and a repeated one comes as a
|
|
21
|
+
* single bucket.
|
|
22
|
+
*/
|
|
23
|
+
function zonedHourToUtc(date, hour, timeZone) {
|
|
24
|
+
const [year, month, day] = date.split("-").map(Number);
|
|
25
|
+
const wall = Date.UTC(year, month - 1, day, hour);
|
|
26
|
+
const first = wall - offsetAt(wall, timeZone);
|
|
27
|
+
const second = wall - offsetAt(first, timeZone);
|
|
28
|
+
const valid = [first, second].filter((c) => c + offsetAt(c, timeZone) === wall);
|
|
29
|
+
return new Date(valid.length > 0 ? Math.min(...valid) : Math.max(first, second));
|
|
30
|
+
}
|
|
31
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
32
|
+
function format(year, month, day) {
|
|
33
|
+
return `${year}-${pad(month)}-${pad(day)}`;
|
|
34
|
+
}
|
|
35
|
+
/** Today's date in an IANA time zone. */
|
|
36
|
+
function todayIn(timeZone, now = /* @__PURE__ */ new Date()) {
|
|
37
|
+
return new Intl.DateTimeFormat("en-CA", { timeZone }).format(now);
|
|
38
|
+
}
|
|
39
|
+
/** `date` moved by whole days. */
|
|
40
|
+
function addDays(date, days) {
|
|
41
|
+
const [year, month, day] = date.split("-").map(Number);
|
|
42
|
+
const moved = new Date(Date.UTC(year, month - 1, day + days));
|
|
43
|
+
return format(moved.getUTCFullYear(), moved.getUTCMonth() + 1, moved.getUTCDate());
|
|
44
|
+
}
|
|
45
|
+
/** `date` moved by whole months, the day clamped to the target month's last (03-31 − 1 → 02-28). */
|
|
46
|
+
function addMonths(date, months) {
|
|
47
|
+
const [year, month, day] = date.split("-").map(Number);
|
|
48
|
+
const target = new Date(Date.UTC(year, month - 1 + months, 1));
|
|
49
|
+
const last = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate();
|
|
50
|
+
return format(target.getUTCFullYear(), target.getUTCMonth() + 1, Math.min(day, last));
|
|
51
|
+
}
|
|
52
|
+
//#endregion
|
|
53
|
+
export { addDays, addMonths, todayIn, zonedHourToUtc };
|
|
54
|
+
|
|
55
|
+
//# sourceMappingURL=time.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"time.mjs","names":[],"sources":["../../src/ads/time.ts"],"sourcesContent":["import type { ReportDate } from './types';\n\n/** The zone's offset from UTC at an instant, in milliseconds (local minus UTC). */\nfunction offsetAt(instant: number, timeZone: string): number {\n const parts = new Intl.DateTimeFormat('en-US', {\n timeZone,\n hourCycle: 'h23',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n }).formatToParts(new Date(instant));\n const get = (type: Intl.DateTimeFormatPartTypes) =>\n Number(parts.find((part) => part.type === type)?.value);\n const local = Date.UTC(\n get('year'),\n get('month') - 1,\n get('day'),\n get('hour'),\n get('minute'),\n get('second')\n );\n return local - Math.floor(instant / 1000) * 1000;\n}\n\n/**\n * The UTC instant of `hour:00` on `date` (`YYYY-MM-DD`) in an IANA time zone. An hour skipped by\n * a daylight-saving jump resolves to the instant after the jump; a repeated hour to its first\n * occurrence — no platform reports delivery in a skipped hour, and a repeated one comes as a\n * single bucket.\n */\nexport function zonedHourToUtc(date: string, hour: number, timeZone: string): Date {\n const [year, month, day] = date.split('-').map(Number) as [number, number, number];\n const wall = Date.UTC(year, month - 1, day, hour);\n // The offset at the wall time read as UTC is a guess that is off by the jump near a transition;\n // the offset at that first candidate gives the other. A candidate is right when it reads back\n // as the wall time in the zone.\n const first = wall - offsetAt(wall, timeZone);\n const second = wall - offsetAt(first, timeZone);\n const valid = [first, second].filter((c) => c + offsetAt(c, timeZone) === wall);\n return new Date(valid.length > 0 ? Math.min(...valid) : Math.max(first, second));\n}\n\nconst pad = (n: number) => String(n).padStart(2, '0');\n\nfunction format(year: number, month: number, day: number): ReportDate {\n return `${year}-${pad(month)}-${pad(day)}` as ReportDate;\n}\n\n/** Today's date in an IANA time zone. */\nexport function todayIn(timeZone: string, now = new Date()): ReportDate {\n // en-CA formats as YYYY-MM-DD.\n return new Intl.DateTimeFormat('en-CA', { timeZone }).format(now) as ReportDate;\n}\n\n/** `date` moved by whole days. */\nexport function addDays(date: string, days: number): ReportDate {\n const [year, month, day] = date.split('-').map(Number) as [number, number, number];\n const moved = new Date(Date.UTC(year, month - 1, day + days));\n return format(moved.getUTCFullYear(), moved.getUTCMonth() + 1, moved.getUTCDate());\n}\n\n/** `date` moved by whole months, the day clamped to the target month's last (03-31 − 1 → 02-28). */\nexport function addMonths(date: string, months: number): ReportDate {\n const [year, month, day] = date.split('-').map(Number) as [number, number, number];\n const target = new Date(Date.UTC(year, month - 1 + months, 1));\n const last = new Date(\n Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)\n ).getUTCDate();\n return format(target.getUTCFullYear(), target.getUTCMonth() + 1, Math.min(day, last));\n}\n"],"mappings":";;AAGA,SAAS,SAAS,SAAiB,UAA0B;CAC3D,MAAM,QAAQ,IAAI,KAAK,eAAe,SAAS;EAC7C;EACA,WAAW;EACX,MAAM;EACN,OAAO;EACP,KAAK;EACL,MAAM;EACN,QAAQ;EACR,QAAQ;CACV,CAAC,CAAC,CAAC,cAAc,IAAI,KAAK,OAAO,CAAC;CAClC,MAAM,OAAO,SACX,OAAO,MAAM,MAAM,SAAS,KAAK,SAAS,IAAI,CAAC,EAAE,KAAK;CASxD,OARc,KAAK,IACjB,IAAI,MAAM,GACV,IAAI,OAAO,IAAI,GACf,IAAI,KAAK,GACT,IAAI,MAAM,GACV,IAAI,QAAQ,GACZ,IAAI,QAAQ,CAEH,IAAI,KAAK,MAAM,UAAU,GAAI,IAAI;AAC9C;;;;;;;AAQA,SAAgB,eAAe,MAAc,MAAc,UAAwB;CACjF,MAAM,CAAC,MAAM,OAAO,OAAO,KAAK,MAAM,GAAG,CAAC,CAAC,IAAI,MAAM;CACrD,MAAM,OAAO,KAAK,IAAI,MAAM,QAAQ,GAAG,KAAK,IAAI;CAIhD,MAAM,QAAQ,OAAO,SAAS,MAAM,QAAQ;CAC5C,MAAM,SAAS,OAAO,SAAS,OAAO,QAAQ;CAC9C,MAAM,QAAQ,CAAC,OAAO,MAAM,CAAC,CAAC,QAAQ,MAAM,IAAI,SAAS,GAAG,QAAQ,MAAM,IAAI;CAC9E,OAAO,IAAI,KAAK,MAAM,SAAS,IAAI,KAAK,IAAI,GAAG,KAAK,IAAI,KAAK,IAAI,OAAO,MAAM,CAAC;AACjF;AAEA,MAAM,OAAO,MAAc,OAAO,CAAC,CAAC,CAAC,SAAS,GAAG,GAAG;AAEpD,SAAS,OAAO,MAAc,OAAe,KAAyB;CACpE,OAAO,GAAG,KAAK,GAAG,IAAI,KAAK,EAAE,GAAG,IAAI,GAAG;AACzC;;AAGA,SAAgB,QAAQ,UAAkB,sBAAM,IAAI,KAAK,GAAe;CAEtE,OAAO,IAAI,KAAK,eAAe,SAAS,EAAE,SAAS,CAAC,CAAC,CAAC,OAAO,GAAG;AAClE;;AAGA,SAAgB,QAAQ,MAAc,MAA0B;CAC9D,MAAM,CAAC,MAAM,OAAO,OAAO,KAAK,MAAM,GAAG,CAAC,CAAC,IAAI,MAAM;CACrD,MAAM,QAAQ,IAAI,KAAK,KAAK,IAAI,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC;CAC5D,OAAO,OAAO,MAAM,eAAe,GAAG,MAAM,YAAY,IAAI,GAAG,MAAM,WAAW,CAAC;AACnF;;AAGA,SAAgB,UAAU,MAAc,QAA4B;CAClE,MAAM,CAAC,MAAM,OAAO,OAAO,KAAK,MAAM,GAAG,CAAC,CAAC,IAAI,MAAM;CACrD,MAAM,SAAS,IAAI,KAAK,KAAK,IAAI,MAAM,QAAQ,IAAI,QAAQ,CAAC,CAAC;CAC7D,MAAM,OAAO,IAAI,KACf,KAAK,IAAI,OAAO,eAAe,GAAG,OAAO,YAAY,IAAI,GAAG,CAAC,CAC/D,CAAC,CAAC,WAAW;CACb,OAAO,OAAO,OAAO,eAAe,GAAG,OAAO,YAAY,IAAI,GAAG,KAAK,IAAI,KAAK,IAAI,CAAC;AACtF"}
|
|
File without changes
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { ChannelGroup } from "../attribution/vocabulary.cjs";
|
|
2
|
+
//#region src/ads/types.d.ts
|
|
3
|
+
/** The ad platforms whose reporting APIs this module reads. */
|
|
4
|
+
export type AdPlatform = 'meta';
|
|
5
|
+
/**
|
|
6
|
+
* One row of ad delivery: one ad over one hour. The row a host stores in its `ad_performance`
|
|
7
|
+
* table, unique on (`platform`, `account_id`, `ad_id`, `hour_start`).
|
|
8
|
+
*
|
|
9
|
+
* Every platform yields hourly rows, so that a sum over any span in any time zone — the way the
|
|
10
|
+
* host's attribution is grouped — is a sum of whole rows, and a table never mixes a day row with
|
|
11
|
+
* the hour rows inside it (an upsert would overwrite one with the other on the shared start, and
|
|
12
|
+
* the rest would count twice). A platform that reports only days spreads each day evenly over its
|
|
13
|
+
* hours, the counts then fractional.
|
|
14
|
+
*
|
|
15
|
+
* `channel`, `medium` and `channel_group` use the attribution vocabulary (`classifyTouch`), so a
|
|
16
|
+
* row joins the host's sessions and attribution on the same values with no mapping in SQL.
|
|
17
|
+
*/
|
|
18
|
+
export interface AdPerformanceRow {
|
|
19
|
+
/** The reporting API the row came from; `channel` is what it joins attribution on. */
|
|
20
|
+
platform: AdPlatform;
|
|
21
|
+
/** The platform's account id, without a prefix (Meta's `act_` is stripped). */
|
|
22
|
+
account_id: string;
|
|
23
|
+
campaign_id: string;
|
|
24
|
+
campaign_name: string | null;
|
|
25
|
+
/** The level between campaign and ad: Meta's ad set. */
|
|
26
|
+
ad_group_id: string | null;
|
|
27
|
+
ad_group_name: string | null;
|
|
28
|
+
ad_id: string;
|
|
29
|
+
ad_name: string | null;
|
|
30
|
+
channel: string;
|
|
31
|
+
medium: string;
|
|
32
|
+
channel_group: ChannelGroup;
|
|
33
|
+
/** The start of the hour, an ISO 8601 UTC instant; the row covers the hour that follows. */
|
|
34
|
+
hour_start: string;
|
|
35
|
+
/** The account's currency, ISO 4217; every amount of the row is in it. */
|
|
36
|
+
currency: string;
|
|
37
|
+
spend: number;
|
|
38
|
+
impressions: number;
|
|
39
|
+
/**
|
|
40
|
+
* Clicks to the advertiser's site — Meta's `inline_link_clicks`, not `clicks`, which also counts
|
|
41
|
+
* likes, expands and profile taps. The number to compare with the sessions an ad brought.
|
|
42
|
+
*/
|
|
43
|
+
clicks: number;
|
|
44
|
+
/**
|
|
45
|
+
* The platform's own count of the conversion event (and its value) under the attribution
|
|
46
|
+
* setting of the ad — view-through included. Null when the platform reported none.
|
|
47
|
+
*/
|
|
48
|
+
conversions: number | null;
|
|
49
|
+
conversion_value: number | null;
|
|
50
|
+
/** The click-through part only: the platform's number to set against a click-based attribution. */
|
|
51
|
+
click_conversions: number | null;
|
|
52
|
+
click_conversion_value: number | null;
|
|
53
|
+
}
|
|
54
|
+
/** A calendar date, `YYYY-MM-DD`, in the ad account's own time zone. */
|
|
55
|
+
export type ReportDate = `${number}-${number}-${number}`;
|
|
56
|
+
//#endregion
|
|
57
|
+
//# sourceMappingURL=types.d.cts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.cts","names":[],"sources":["../../src/ads/types.ts"],"mappings":";;;YAGY;;;;;;;;;;;;;;iBAeK;;EAEf,UAAU;;EAEV;EACA;EACA;;EAEA;EACA;EACA;EACA;EACA;EACA;EACA,eAAe;;EAEf;;EAEA;EACA;EACA;;;;;EAKA;;;;;EAKA;EACA;;EAEA;EACA;;;YAIU"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { ChannelGroup } from "../attribution/vocabulary.mjs";
|
|
2
|
+
//#region src/ads/types.d.ts
|
|
3
|
+
/** The ad platforms whose reporting APIs this module reads. */
|
|
4
|
+
export type AdPlatform = 'meta';
|
|
5
|
+
/**
|
|
6
|
+
* One row of ad delivery: one ad over one hour. The row a host stores in its `ad_performance`
|
|
7
|
+
* table, unique on (`platform`, `account_id`, `ad_id`, `hour_start`).
|
|
8
|
+
*
|
|
9
|
+
* Every platform yields hourly rows, so that a sum over any span in any time zone — the way the
|
|
10
|
+
* host's attribution is grouped — is a sum of whole rows, and a table never mixes a day row with
|
|
11
|
+
* the hour rows inside it (an upsert would overwrite one with the other on the shared start, and
|
|
12
|
+
* the rest would count twice). A platform that reports only days spreads each day evenly over its
|
|
13
|
+
* hours, the counts then fractional.
|
|
14
|
+
*
|
|
15
|
+
* `channel`, `medium` and `channel_group` use the attribution vocabulary (`classifyTouch`), so a
|
|
16
|
+
* row joins the host's sessions and attribution on the same values with no mapping in SQL.
|
|
17
|
+
*/
|
|
18
|
+
export interface AdPerformanceRow {
|
|
19
|
+
/** The reporting API the row came from; `channel` is what it joins attribution on. */
|
|
20
|
+
platform: AdPlatform;
|
|
21
|
+
/** The platform's account id, without a prefix (Meta's `act_` is stripped). */
|
|
22
|
+
account_id: string;
|
|
23
|
+
campaign_id: string;
|
|
24
|
+
campaign_name: string | null;
|
|
25
|
+
/** The level between campaign and ad: Meta's ad set. */
|
|
26
|
+
ad_group_id: string | null;
|
|
27
|
+
ad_group_name: string | null;
|
|
28
|
+
ad_id: string;
|
|
29
|
+
ad_name: string | null;
|
|
30
|
+
channel: string;
|
|
31
|
+
medium: string;
|
|
32
|
+
channel_group: ChannelGroup;
|
|
33
|
+
/** The start of the hour, an ISO 8601 UTC instant; the row covers the hour that follows. */
|
|
34
|
+
hour_start: string;
|
|
35
|
+
/** The account's currency, ISO 4217; every amount of the row is in it. */
|
|
36
|
+
currency: string;
|
|
37
|
+
spend: number;
|
|
38
|
+
impressions: number;
|
|
39
|
+
/**
|
|
40
|
+
* Clicks to the advertiser's site — Meta's `inline_link_clicks`, not `clicks`, which also counts
|
|
41
|
+
* likes, expands and profile taps. The number to compare with the sessions an ad brought.
|
|
42
|
+
*/
|
|
43
|
+
clicks: number;
|
|
44
|
+
/**
|
|
45
|
+
* The platform's own count of the conversion event (and its value) under the attribution
|
|
46
|
+
* setting of the ad — view-through included. Null when the platform reported none.
|
|
47
|
+
*/
|
|
48
|
+
conversions: number | null;
|
|
49
|
+
conversion_value: number | null;
|
|
50
|
+
/** The click-through part only: the platform's number to set against a click-based attribution. */
|
|
51
|
+
click_conversions: number | null;
|
|
52
|
+
click_conversion_value: number | null;
|
|
53
|
+
}
|
|
54
|
+
/** A calendar date, `YYYY-MM-DD`, in the ad account's own time zone. */
|
|
55
|
+
export type ReportDate = `${number}-${number}-${number}`;
|
|
56
|
+
//#endregion
|
|
57
|
+
//# sourceMappingURL=types.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.mts","names":[],"sources":["../../src/ads/types.ts"],"mappings":";;;YAGY;;;;;;;;;;;;;;iBAeK;;EAEf,UAAU;;EAEV;EACA;EACA;;EAEA;EACA;EACA;EACA;EACA;EACA;EACA,eAAe;;EAEf;;EAEA;EACA;EACA;;;;;EAKA;;;;;EAKA;EACA;;EAEA;EACA;;;YAIU"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shware/analytics",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.8.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -21,6 +21,16 @@
|
|
|
21
21
|
"default": "./dist/index.cjs"
|
|
22
22
|
}
|
|
23
23
|
},
|
|
24
|
+
"./ads": {
|
|
25
|
+
"import": {
|
|
26
|
+
"types": "./dist/ads/index.d.mts",
|
|
27
|
+
"default": "./dist/ads/index.mjs"
|
|
28
|
+
},
|
|
29
|
+
"require": {
|
|
30
|
+
"types": "./dist/ads/index.d.cts",
|
|
31
|
+
"default": "./dist/ads/index.cjs"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
24
34
|
"./attribution": {
|
|
25
35
|
"import": {
|
|
26
36
|
"types": "./dist/attribution/index.d.mts",
|