@shware/analytics 11.8.0 → 11.10.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/dist/ads/meta.cjs CHANGED
@@ -34,12 +34,18 @@ const HOURLY = "hourly_stats_aggregated_by_advertiser_time_zone";
34
34
  * https://developers.facebook.com/docs/marketing-api/insights/best-practices
35
35
  */
36
36
  const HOURLY_RETENTION_MONTHS = 13;
37
+ /** How far back Meta answers Insights without the hourly breakdown, in months. */
38
+ const DAILY_RETENTION_MONTHS = 37;
37
39
  /**
38
40
  * Days per Insights request. A synchronous request over a long range of ad rows times out on
39
41
  * Meta's side (`code 1, subcode 99`, "An unknown error occurred", as a 500): 17 months of daily
40
42
  * ad rows did. A range is split into windows this long, each paged on its own.
41
43
  */
42
44
  const CHUNK_DAYS = 7;
45
+ /** Days per request for daily rows: 24 times fewer rows than hourly ones per day. */
46
+ const DAILY_CHUNK_DAYS = 30;
47
+ /** Decimal places of a day's numbers spread over its hours. */
48
+ const SPREAD_SCALE = 1e6;
43
49
  const FIELDS = [
44
50
  "ad_id",
45
51
  "ad_name",
@@ -150,8 +156,8 @@ function hourOf(bucket) {
150
156
  if (!Number.isInteger(hour) || hour < 0 || hour > 23) throw new Error(`Unexpected Meta hourly bucket: ${bucket}`);
151
157
  return hour;
152
158
  }
153
- function toRow(insight, accountId, currency, timeZone, actionType) {
154
- const start = require_ads_time.zonedHourToUtc(insight.date_start, hourOf(insight[HOURLY]), timeZone);
159
+ /** A row for one hour: what Meta reports of an ad, its numbers as given. */
160
+ function toRow(insight, hourStart, accountId, currency, actionType) {
155
161
  const conversions = conversion(insight.actions, actionType);
156
162
  const values = conversion(insight.action_values, actionType);
157
163
  return {
@@ -166,7 +172,7 @@ function toRow(insight, accountId, currency, timeZone, actionType) {
166
172
  channel: CHANNEL,
167
173
  medium: MEDIUM,
168
174
  channel_group: require_attribution_classify.channelGroupOf(CHANNEL, MEDIUM),
169
- hour_start: start.toISOString(),
175
+ hour_start: hourStart.toISOString(),
170
176
  currency,
171
177
  spend: Number(insight.spend ?? 0),
172
178
  impressions: count(insight.impressions),
@@ -178,31 +184,54 @@ function toRow(insight, accountId, currency, timeZone, actionType) {
178
184
  };
179
185
  }
180
186
  /**
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.
187
+ * `value` in `parts` equal shares rounded to `SPREAD_SCALE`, the rounding left in the last share
188
+ * so that the shares add back to `value` exactly as Meta reported it.
194
189
  */
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}`);
190
+ function spread(value, parts) {
191
+ const share = Math.round(value / parts * SPREAD_SCALE) / SPREAD_SCALE;
192
+ const last = Math.round((value - share * (parts - 1)) * SPREAD_SCALE) / SPREAD_SCALE;
193
+ return [...Array.from({ length: parts - 1 }, () => share), last];
194
+ }
195
+ /**
196
+ * A day row as one row per hour of the account's calendar day — 24, or 23 / 25 across a
197
+ * daylight-saving change — each number spread evenly (nulls stay null).
198
+ */
199
+ function spreadDay(day, date, timeZone) {
200
+ const start = require_ads_time.zonedHourToUtc(date, 0, timeZone).getTime();
201
+ const end = require_ads_time.zonedHourToUtc(require_ads_time.addDays(date, 1), 0, timeZone).getTime();
202
+ const hours = Math.round((end - start) / 36e5);
203
+ const shares = (value) => value === null ? null : spread(value, hours);
204
+ const spend = spread(day.spend, hours);
205
+ const impressions = spread(day.impressions, hours);
206
+ const clicks = spread(day.clicks, hours);
207
+ const conversions = shares(day.conversions);
208
+ const conversionValue = shares(day.conversion_value);
209
+ const clickConversions = shares(day.click_conversions);
210
+ const clickConversionValue = shares(day.click_conversion_value);
211
+ return Array.from({ length: hours }, (_, hour) => ({
212
+ ...day,
213
+ hour_start: new Date(start + hour * 36e5).toISOString(),
214
+ spend: spend[hour],
215
+ impressions: impressions[hour],
216
+ clicks: clicks[hour],
217
+ conversions: conversions?.[hour] ?? null,
218
+ conversion_value: conversionValue?.[hour] ?? null,
219
+ click_conversions: clickConversions?.[hour] ?? null,
220
+ click_conversion_value: clickConversionValue?.[hour] ?? null
221
+ }));
222
+ }
223
+ async function accountOf(options) {
224
+ const id = options.accountId.replace(/^act_/, "");
225
+ return {
226
+ id,
227
+ ...await graphGet(`act_${id}`, { fields: "currency,timezone_name" }, options, parseWith(accountSchema, "ad account"))
228
+ };
229
+ }
230
+ /** Every ad row of the account between `since` and `until`, one request per window of `chunkDays`. */
231
+ async function insights(options, accountId, since, until, { chunkDays, hourly }) {
203
232
  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);
233
+ for (let from = since; from <= until; from = require_ads_time.addDays(from, chunkDays)) {
234
+ const last = require_ads_time.addDays(from, chunkDays - 1);
206
235
  const to = last < until ? last : until;
207
236
  let after;
208
237
  do {
@@ -214,21 +243,81 @@ async function fetchMetaAdPerformance(options) {
214
243
  until: to
215
244
  }),
216
245
  time_increment: "1",
217
- breakdowns: HOURLY,
218
246
  action_attribution_windows: JSON.stringify([CLICK_WINDOW, VIEW_WINDOW]),
219
247
  use_unified_attribution_setting: "true",
220
248
  limit: "500"
221
249
  };
250
+ if (hourly) params.breakdowns = HOURLY;
222
251
  if (after) params.after = after;
223
252
  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));
253
+ rows.push(...page.data);
225
254
  after = page.paging?.next ? page.paging.cursors?.after : void 0;
226
255
  } while (after);
227
256
  }
228
257
  return rows;
229
258
  }
259
+ /**
260
+ * Hourly delivery of every ad of the account that ran between `since` and `until` — deleted and
261
+ * archived ads included — one row per ad and hour (hours without delivery are absent). The hours
262
+ * are Meta's, in the account's time zone, turned into UTC instants. Conversions are those of each
263
+ * ad's attribution setting (`use_unified_attribution_setting`), their click-through part from the
264
+ * 7-day click window.
265
+ *
266
+ * Meta restates past hours as late conversions arrive — a purchase is credited to the hour of the
267
+ * click, up to 7 days after it — and corrects spend for invalid traffic: re-fetch the last 8 days
268
+ * daily (the 7-day window plus a day of time zone slack) and upsert on (`platform`, `account_id`,
269
+ * `ad_id`, `hour_start`).
270
+ *
271
+ * @throws RangeError when `since` is more than 13 months back, where Meta keeps no hourly
272
+ * breakdown and would answer with no rows rather than an error.
273
+ */
274
+ async function fetchMetaAdPerformance(options) {
275
+ const actionType = options.conversionActionType ?? "purchase";
276
+ const account = await accountOf(options);
277
+ const since = require_ads_time.addDays(options.since, 0);
278
+ const until = require_ads_time.addDays(options.until, 0);
279
+ const earliest = require_ads_time.addMonths(require_ads_time.todayIn(account.timezone_name), -13);
280
+ if (since < earliest) throw new RangeError(`Meta keeps hourly insights for ${HOURLY_RETENTION_MONTHS} months: since ${since} is before ${earliest}`);
281
+ return (await insights(options, account.id, since, until, {
282
+ chunkDays: CHUNK_DAYS,
283
+ hourly: true
284
+ })).map((insight) => toRow(insight, require_ads_time.zonedHourToUtc(insight.date_start, hourOf(insight[HOURLY]), account.timezone_name), account.id, account.currency, actionType));
285
+ }
286
+ /**
287
+ * The delivery older than Meta's 13 months of hourly data, as hourly rows: Meta's daily ad rows,
288
+ * each spread evenly over the hours of the account's day (`spread`), so that one table stays
289
+ * hourly from end to end. Sums over whole days are Meta's exactly; the hours within a day are an
290
+ * even split, not when the ads actually ran.
291
+ *
292
+ * Only for days the hourly data no longer covers, and only once: a day stored this way must
293
+ * never also get hourly rows (an hour without delivery is not reported hourly, so its spread
294
+ * share would stay and count twice). Fetch up to the day before the first hourly day stored.
295
+ * These days are final; they need no re-fetch.
296
+ *
297
+ * @throws RangeError when `until` is within the 13 months of hourly data (use
298
+ * `fetchMetaAdPerformance`), or `since` is past the 37 months Meta keeps at all.
299
+ */
300
+ async function fetchMetaAdPerformanceHistory(options) {
301
+ const actionType = options.conversionActionType ?? "purchase";
302
+ const account = await accountOf(options);
303
+ const since = require_ads_time.addDays(options.since, 0);
304
+ const until = require_ads_time.addDays(options.until, 0);
305
+ const today = require_ads_time.todayIn(account.timezone_name);
306
+ const hourlyFrom = require_ads_time.addMonths(today, -13);
307
+ const earliest = require_ads_time.addMonths(today, -37);
308
+ if (until >= hourlyFrom) throw new RangeError(`Meta has hourly insights from ${hourlyFrom}: until ${until} must be before it, fetch those days with fetchMetaAdPerformance`);
309
+ if (since < earliest) throw new RangeError(`Meta keeps insights for ${DAILY_RETENTION_MONTHS} months: since ${since} is before ${earliest}`);
310
+ return (await insights(options, account.id, since, until, {
311
+ chunkDays: DAILY_CHUNK_DAYS,
312
+ hourly: false
313
+ })).flatMap((insight) => {
314
+ return spreadDay(toRow(insight, require_ads_time.zonedHourToUtc(insight.date_start, 0, account.timezone_name), account.id, account.currency, actionType), insight.date_start, account.timezone_name);
315
+ });
316
+ }
230
317
  //#endregion
231
318
  exports.MetaAdsApiError = MetaAdsApiError;
232
319
  exports.fetchMetaAdPerformance = fetchMetaAdPerformance;
320
+ exports.fetchMetaAdPerformanceHistory = fetchMetaAdPerformanceHistory;
321
+ exports.spread = spread;
233
322
 
234
323
  //# sourceMappingURL=meta.cjs.map
@@ -1 +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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"}
1
+ {"version":3,"file":"meta.cjs","names":["object","string","optional","array","number","createHmac","fetch","safeParse","channelGroupOf","zonedHourToUtc","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/** How far back Meta answers Insights without the hourly breakdown, in months. */\nconst DAILY_RETENTION_MONTHS = 37;\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\n/** Days per request for daily rows: 24 times fewer rows than hourly ones per day. */\nconst DAILY_CHUNK_DAYS = 30;\n\n/** Decimal places of a day's numbers spread over its hours. */\nconst SPREAD_SCALE = 1e6;\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\n/** A row for one hour: what Meta reports of an ad, its numbers as given. */\nfunction toRow(\n insight: Insight,\n hourStart: Date,\n accountId: string,\n currency: string,\n actionType: string\n): AdPerformanceRow {\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: hourStart.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 * `value` in `parts` equal shares rounded to `SPREAD_SCALE`, the rounding left in the last share\n * so that the shares add back to `value` exactly as Meta reported it.\n */\nexport function spread(value: number, parts: number): number[] {\n const share = Math.round((value / parts) * SPREAD_SCALE) / SPREAD_SCALE;\n const last = Math.round((value - share * (parts - 1)) * SPREAD_SCALE) / SPREAD_SCALE;\n return [...Array.from({ length: parts - 1 }, () => share), last];\n}\n\n/**\n * A day row as one row per hour of the account's calendar day — 24, or 23 / 25 across a\n * daylight-saving change — each number spread evenly (nulls stay null).\n */\nfunction spreadDay(day: AdPerformanceRow, date: string, timeZone: string): AdPerformanceRow[] {\n const start = zonedHourToUtc(date, 0, timeZone).getTime();\n const end = zonedHourToUtc(addDays(date, 1), 0, timeZone).getTime();\n const hours = Math.round((end - start) / 3_600_000);\n const shares = (value: number | null) => (value === null ? null : spread(value, hours));\n const spend = spread(day.spend, hours);\n const impressions = spread(day.impressions, hours);\n const clicks = spread(day.clicks, hours);\n const conversions = shares(day.conversions);\n const conversionValue = shares(day.conversion_value);\n const clickConversions = shares(day.click_conversions);\n const clickConversionValue = shares(day.click_conversion_value);\n return Array.from({ length: hours }, (_, hour) => ({\n ...day,\n hour_start: new Date(start + hour * 3_600_000).toISOString(),\n spend: spend[hour],\n impressions: impressions[hour],\n clicks: clicks[hour],\n conversions: conversions?.[hour] ?? null,\n conversion_value: conversionValue?.[hour] ?? null,\n click_conversions: clickConversions?.[hour] ?? null,\n click_conversion_value: clickConversionValue?.[hour] ?? null,\n }));\n}\n\ninterface Account {\n id: string;\n currency: string;\n timezone_name: string;\n}\n\nasync function accountOf(options: MetaAdPerformanceOptions): Promise<Account> {\n const id = options.accountId.replace(/^act_/, '');\n const account = await graphGet(\n `act_${id}`,\n { fields: 'currency,timezone_name' },\n options,\n parseWith(accountSchema, 'ad account')\n );\n return { id, ...account };\n}\n\n/** Every ad row of the account between `since` and `until`, one request per window of `chunkDays`. */\nasync function insights(\n options: MetaAdPerformanceOptions,\n accountId: string,\n since: string,\n until: string,\n { chunkDays, hourly }: { chunkDays: number; hourly: boolean }\n): Promise<Insight[]> {\n const rows: Insight[] = [];\n for (let from = since; from <= until; from = addDays(from, chunkDays)) {\n const last = addDays(from, chunkDays - 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`, and a daily request returns the range's total.\n time_increment: '1',\n action_attribution_windows: JSON.stringify([CLICK_WINDOW, VIEW_WINDOW]),\n use_unified_attribution_setting: 'true',\n limit: '500',\n };\n if (hourly) params.breakdowns = HOURLY;\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 rows.push(...page.data);\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 return rows;\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 actionType = options.conversionActionType ?? 'purchase';\n const account = await accountOf(options);\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 = await insights(options, account.id, since, until, {\n chunkDays: CHUNK_DAYS,\n hourly: true,\n });\n return rows.map((insight) =>\n toRow(\n insight,\n zonedHourToUtc(insight.date_start, hourOf(insight[HOURLY]), account.timezone_name),\n account.id,\n account.currency,\n actionType\n )\n );\n}\n\n/**\n * The delivery older than Meta's 13 months of hourly data, as hourly rows: Meta's daily ad rows,\n * each spread evenly over the hours of the account's day (`spread`), so that one table stays\n * hourly from end to end. Sums over whole days are Meta's exactly; the hours within a day are an\n * even split, not when the ads actually ran.\n *\n * Only for days the hourly data no longer covers, and only once: a day stored this way must\n * never also get hourly rows (an hour without delivery is not reported hourly, so its spread\n * share would stay and count twice). Fetch up to the day before the first hourly day stored.\n * These days are final; they need no re-fetch.\n *\n * @throws RangeError when `until` is within the 13 months of hourly data (use\n * `fetchMetaAdPerformance`), or `since` is past the 37 months Meta keeps at all.\n */\nexport async function fetchMetaAdPerformanceHistory(\n options: MetaAdPerformanceOptions\n): Promise<AdPerformanceRow[]> {\n const actionType = options.conversionActionType ?? 'purchase';\n const account = await accountOf(options);\n\n const since = addDays(options.since, 0);\n const until = addDays(options.until, 0);\n const today = todayIn(account.timezone_name);\n const hourlyFrom = addMonths(today, -HOURLY_RETENTION_MONTHS);\n const earliest = addMonths(today, -DAILY_RETENTION_MONTHS);\n if (until >= hourlyFrom) {\n throw new RangeError(\n `Meta has hourly insights from ${hourlyFrom}: until ${until} must be before it, fetch those days with fetchMetaAdPerformance`\n );\n }\n if (since < earliest) {\n throw new RangeError(\n `Meta keeps insights for ${DAILY_RETENTION_MONTHS} months: since ${since} is before ${earliest}`\n );\n }\n\n const days = await insights(options, account.id, since, until, {\n chunkDays: DAILY_CHUNK_DAYS,\n hourly: false,\n });\n return days.flatMap((insight) => {\n const day = toRow(\n insight,\n zonedHourToUtc(insight.date_start, 0, account.timezone_name),\n account.id,\n account.currency,\n actionType\n );\n return spreadDay(day, insight.date_start, account.timezone_name);\n });\n}\n"],"mappings":"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"}
@@ -28,6 +28,11 @@ export declare class MetaAdsApiError extends Error {
28
28
  readonly fbtraceId: string | undefined;
29
29
  constructor(message: string, status: number, code: number | undefined, subcode: number | undefined, fbtraceId: string | undefined);
30
30
  }
31
+ /**
32
+ * `value` in `parts` equal shares rounded to `SPREAD_SCALE`, the rounding left in the last share
33
+ * so that the shares add back to `value` exactly as Meta reported it.
34
+ */
35
+ export declare function spread(value: number, parts: number): number[];
31
36
  /**
32
37
  * Hourly delivery of every ad of the account that ran between `since` and `until` — deleted and
33
38
  * archived ads included — one row per ad and hour (hours without delivery are absent). The hours
@@ -44,5 +49,20 @@ export declare class MetaAdsApiError extends Error {
44
49
  * breakdown and would answer with no rows rather than an error.
45
50
  */
46
51
  export declare function fetchMetaAdPerformance(options: MetaAdPerformanceOptions): Promise<AdPerformanceRow[]>;
52
+ /**
53
+ * The delivery older than Meta's 13 months of hourly data, as hourly rows: Meta's daily ad rows,
54
+ * each spread evenly over the hours of the account's day (`spread`), so that one table stays
55
+ * hourly from end to end. Sums over whole days are Meta's exactly; the hours within a day are an
56
+ * even split, not when the ads actually ran.
57
+ *
58
+ * Only for days the hourly data no longer covers, and only once: a day stored this way must
59
+ * never also get hourly rows (an hour without delivery is not reported hourly, so its spread
60
+ * share would stay and count twice). Fetch up to the day before the first hourly day stored.
61
+ * These days are final; they need no re-fetch.
62
+ *
63
+ * @throws RangeError when `until` is within the 13 months of hourly data (use
64
+ * `fetchMetaAdPerformance`), or `since` is past the 37 months Meta keeps at all.
65
+ */
66
+ export declare function fetchMetaAdPerformanceHistory(options: MetaAdPerformanceOptions): Promise<AdPerformanceRow[]>;
47
67
  //#endregion
48
68
  //# sourceMappingURL=meta.d.cts.map
@@ -1 +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"}
1
+ {"version":3,"file":"meta.d.cts","names":[],"sources":["../../src/ads/meta.ts"],"mappings":";;iBAiFiB;;EAEf;;EAEA;;EAEA,OAAO;;EAEP,OAAO;;;;;EAKP;;;;;EAKA;;;qBAIW,wBAAwB;WAGxB;WACA;WACA;WACA;EALX,YACE,iBACS,gBACA,0BACA,6BACA;;;;;;wBAiKG,OAAO,eAAe;;;;;;;;;;;;;;;;wBA4GhB,uBACpB,SAAS,2BACR,QAAQ;;;;;;;;;;;;;;;wBA2CW,8BACpB,SAAS,2BACR,QAAQ"}
@@ -28,6 +28,11 @@ export declare class MetaAdsApiError extends Error {
28
28
  readonly fbtraceId: string | undefined;
29
29
  constructor(message: string, status: number, code: number | undefined, subcode: number | undefined, fbtraceId: string | undefined);
30
30
  }
31
+ /**
32
+ * `value` in `parts` equal shares rounded to `SPREAD_SCALE`, the rounding left in the last share
33
+ * so that the shares add back to `value` exactly as Meta reported it.
34
+ */
35
+ export declare function spread(value: number, parts: number): number[];
31
36
  /**
32
37
  * Hourly delivery of every ad of the account that ran between `since` and `until` — deleted and
33
38
  * archived ads included — one row per ad and hour (hours without delivery are absent). The hours
@@ -44,5 +49,20 @@ export declare class MetaAdsApiError extends Error {
44
49
  * breakdown and would answer with no rows rather than an error.
45
50
  */
46
51
  export declare function fetchMetaAdPerformance(options: MetaAdPerformanceOptions): Promise<AdPerformanceRow[]>;
52
+ /**
53
+ * The delivery older than Meta's 13 months of hourly data, as hourly rows: Meta's daily ad rows,
54
+ * each spread evenly over the hours of the account's day (`spread`), so that one table stays
55
+ * hourly from end to end. Sums over whole days are Meta's exactly; the hours within a day are an
56
+ * even split, not when the ads actually ran.
57
+ *
58
+ * Only for days the hourly data no longer covers, and only once: a day stored this way must
59
+ * never also get hourly rows (an hour without delivery is not reported hourly, so its spread
60
+ * share would stay and count twice). Fetch up to the day before the first hourly day stored.
61
+ * These days are final; they need no re-fetch.
62
+ *
63
+ * @throws RangeError when `until` is within the 13 months of hourly data (use
64
+ * `fetchMetaAdPerformance`), or `since` is past the 37 months Meta keeps at all.
65
+ */
66
+ export declare function fetchMetaAdPerformanceHistory(options: MetaAdPerformanceOptions): Promise<AdPerformanceRow[]>;
47
67
  //#endregion
48
68
  //# sourceMappingURL=meta.d.mts.map
@@ -1 +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"}
1
+ {"version":3,"file":"meta.d.mts","names":[],"sources":["../../src/ads/meta.ts"],"mappings":";;iBAiFiB;;EAEf;;EAEA;;EAEA,OAAO;;EAEP,OAAO;;;;;EAKP;;;;;EAKA;;;qBAIW,wBAAwB;WAGxB;WACA;WACA;WACA;EALX,YACE,iBACS,gBACA,0BACA,6BACA;;;;;;wBAiKG,OAAO,eAAe;;;;;;;;;;;;;;;;wBA4GhB,uBACpB,SAAS,2BACR,QAAQ;;;;;;;;;;;;;;;wBA2CW,8BACpB,SAAS,2BACR,QAAQ"}
package/dist/ads/meta.mjs CHANGED
@@ -33,12 +33,18 @@ const HOURLY = "hourly_stats_aggregated_by_advertiser_time_zone";
33
33
  * https://developers.facebook.com/docs/marketing-api/insights/best-practices
34
34
  */
35
35
  const HOURLY_RETENTION_MONTHS = 13;
36
+ /** How far back Meta answers Insights without the hourly breakdown, in months. */
37
+ const DAILY_RETENTION_MONTHS = 37;
36
38
  /**
37
39
  * Days per Insights request. A synchronous request over a long range of ad rows times out on
38
40
  * Meta's side (`code 1, subcode 99`, "An unknown error occurred", as a 500): 17 months of daily
39
41
  * ad rows did. A range is split into windows this long, each paged on its own.
40
42
  */
41
43
  const CHUNK_DAYS = 7;
44
+ /** Days per request for daily rows: 24 times fewer rows than hourly ones per day. */
45
+ const DAILY_CHUNK_DAYS = 30;
46
+ /** Decimal places of a day's numbers spread over its hours. */
47
+ const SPREAD_SCALE = 1e6;
42
48
  const FIELDS = [
43
49
  "ad_id",
44
50
  "ad_name",
@@ -149,8 +155,8 @@ function hourOf(bucket) {
149
155
  if (!Number.isInteger(hour) || hour < 0 || hour > 23) throw new Error(`Unexpected Meta hourly bucket: ${bucket}`);
150
156
  return hour;
151
157
  }
152
- function toRow(insight, accountId, currency, timeZone, actionType) {
153
- const start = zonedHourToUtc(insight.date_start, hourOf(insight[HOURLY]), timeZone);
158
+ /** A row for one hour: what Meta reports of an ad, its numbers as given. */
159
+ function toRow(insight, hourStart, accountId, currency, actionType) {
154
160
  const conversions = conversion(insight.actions, actionType);
155
161
  const values = conversion(insight.action_values, actionType);
156
162
  return {
@@ -165,7 +171,7 @@ function toRow(insight, accountId, currency, timeZone, actionType) {
165
171
  channel: CHANNEL,
166
172
  medium: MEDIUM,
167
173
  channel_group: channelGroupOf(CHANNEL, MEDIUM),
168
- hour_start: start.toISOString(),
174
+ hour_start: hourStart.toISOString(),
169
175
  currency,
170
176
  spend: Number(insight.spend ?? 0),
171
177
  impressions: count(insight.impressions),
@@ -177,31 +183,54 @@ function toRow(insight, accountId, currency, timeZone, actionType) {
177
183
  };
178
184
  }
179
185
  /**
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.
186
+ * `value` in `parts` equal shares rounded to `SPREAD_SCALE`, the rounding left in the last share
187
+ * so that the shares add back to `value` exactly as Meta reported it.
193
188
  */
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}`);
189
+ function spread(value, parts) {
190
+ const share = Math.round(value / parts * SPREAD_SCALE) / SPREAD_SCALE;
191
+ const last = Math.round((value - share * (parts - 1)) * SPREAD_SCALE) / SPREAD_SCALE;
192
+ return [...Array.from({ length: parts - 1 }, () => share), last];
193
+ }
194
+ /**
195
+ * A day row as one row per hour of the account's calendar day — 24, or 23 / 25 across a
196
+ * daylight-saving change — each number spread evenly (nulls stay null).
197
+ */
198
+ function spreadDay(day, date, timeZone) {
199
+ const start = zonedHourToUtc(date, 0, timeZone).getTime();
200
+ const end = zonedHourToUtc(addDays(date, 1), 0, timeZone).getTime();
201
+ const hours = Math.round((end - start) / 36e5);
202
+ const shares = (value) => value === null ? null : spread(value, hours);
203
+ const spend = spread(day.spend, hours);
204
+ const impressions = spread(day.impressions, hours);
205
+ const clicks = spread(day.clicks, hours);
206
+ const conversions = shares(day.conversions);
207
+ const conversionValue = shares(day.conversion_value);
208
+ const clickConversions = shares(day.click_conversions);
209
+ const clickConversionValue = shares(day.click_conversion_value);
210
+ return Array.from({ length: hours }, (_, hour) => ({
211
+ ...day,
212
+ hour_start: new Date(start + hour * 36e5).toISOString(),
213
+ spend: spend[hour],
214
+ impressions: impressions[hour],
215
+ clicks: clicks[hour],
216
+ conversions: conversions?.[hour] ?? null,
217
+ conversion_value: conversionValue?.[hour] ?? null,
218
+ click_conversions: clickConversions?.[hour] ?? null,
219
+ click_conversion_value: clickConversionValue?.[hour] ?? null
220
+ }));
221
+ }
222
+ async function accountOf(options) {
223
+ const id = options.accountId.replace(/^act_/, "");
224
+ return {
225
+ id,
226
+ ...await graphGet(`act_${id}`, { fields: "currency,timezone_name" }, options, parseWith(accountSchema, "ad account"))
227
+ };
228
+ }
229
+ /** Every ad row of the account between `since` and `until`, one request per window of `chunkDays`. */
230
+ async function insights(options, accountId, since, until, { chunkDays, hourly }) {
202
231
  const rows = [];
203
- for (let from = since; from <= until; from = addDays(from, CHUNK_DAYS)) {
204
- const last = addDays(from, 6);
232
+ for (let from = since; from <= until; from = addDays(from, chunkDays)) {
233
+ const last = addDays(from, chunkDays - 1);
205
234
  const to = last < until ? last : until;
206
235
  let after;
207
236
  do {
@@ -213,20 +242,78 @@ async function fetchMetaAdPerformance(options) {
213
242
  until: to
214
243
  }),
215
244
  time_increment: "1",
216
- breakdowns: HOURLY,
217
245
  action_attribution_windows: JSON.stringify([CLICK_WINDOW, VIEW_WINDOW]),
218
246
  use_unified_attribution_setting: "true",
219
247
  limit: "500"
220
248
  };
249
+ if (hourly) params.breakdowns = HOURLY;
221
250
  if (after) params.after = after;
222
251
  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));
252
+ rows.push(...page.data);
224
253
  after = page.paging?.next ? page.paging.cursors?.after : void 0;
225
254
  } while (after);
226
255
  }
227
256
  return rows;
228
257
  }
258
+ /**
259
+ * Hourly delivery of every ad of the account that ran between `since` and `until` — deleted and
260
+ * archived ads included — one row per ad and hour (hours without delivery are absent). The hours
261
+ * are Meta's, in the account's time zone, turned into UTC instants. Conversions are those of each
262
+ * ad's attribution setting (`use_unified_attribution_setting`), their click-through part from the
263
+ * 7-day click window.
264
+ *
265
+ * Meta restates past hours as late conversions arrive — a purchase is credited to the hour of the
266
+ * click, up to 7 days after it — and corrects spend for invalid traffic: re-fetch the last 8 days
267
+ * daily (the 7-day window plus a day of time zone slack) and upsert on (`platform`, `account_id`,
268
+ * `ad_id`, `hour_start`).
269
+ *
270
+ * @throws RangeError when `since` is more than 13 months back, where Meta keeps no hourly
271
+ * breakdown and would answer with no rows rather than an error.
272
+ */
273
+ async function fetchMetaAdPerformance(options) {
274
+ const actionType = options.conversionActionType ?? "purchase";
275
+ const account = await accountOf(options);
276
+ const since = addDays(options.since, 0);
277
+ const until = addDays(options.until, 0);
278
+ const earliest = addMonths(todayIn(account.timezone_name), -13);
279
+ if (since < earliest) throw new RangeError(`Meta keeps hourly insights for ${HOURLY_RETENTION_MONTHS} months: since ${since} is before ${earliest}`);
280
+ return (await insights(options, account.id, since, until, {
281
+ chunkDays: CHUNK_DAYS,
282
+ hourly: true
283
+ })).map((insight) => toRow(insight, zonedHourToUtc(insight.date_start, hourOf(insight[HOURLY]), account.timezone_name), account.id, account.currency, actionType));
284
+ }
285
+ /**
286
+ * The delivery older than Meta's 13 months of hourly data, as hourly rows: Meta's daily ad rows,
287
+ * each spread evenly over the hours of the account's day (`spread`), so that one table stays
288
+ * hourly from end to end. Sums over whole days are Meta's exactly; the hours within a day are an
289
+ * even split, not when the ads actually ran.
290
+ *
291
+ * Only for days the hourly data no longer covers, and only once: a day stored this way must
292
+ * never also get hourly rows (an hour without delivery is not reported hourly, so its spread
293
+ * share would stay and count twice). Fetch up to the day before the first hourly day stored.
294
+ * These days are final; they need no re-fetch.
295
+ *
296
+ * @throws RangeError when `until` is within the 13 months of hourly data (use
297
+ * `fetchMetaAdPerformance`), or `since` is past the 37 months Meta keeps at all.
298
+ */
299
+ async function fetchMetaAdPerformanceHistory(options) {
300
+ const actionType = options.conversionActionType ?? "purchase";
301
+ const account = await accountOf(options);
302
+ const since = addDays(options.since, 0);
303
+ const until = addDays(options.until, 0);
304
+ const today = todayIn(account.timezone_name);
305
+ const hourlyFrom = addMonths(today, -13);
306
+ const earliest = addMonths(today, -37);
307
+ if (until >= hourlyFrom) throw new RangeError(`Meta has hourly insights from ${hourlyFrom}: until ${until} must be before it, fetch those days with fetchMetaAdPerformance`);
308
+ if (since < earliest) throw new RangeError(`Meta keeps insights for ${DAILY_RETENTION_MONTHS} months: since ${since} is before ${earliest}`);
309
+ return (await insights(options, account.id, since, until, {
310
+ chunkDays: DAILY_CHUNK_DAYS,
311
+ hourly: false
312
+ })).flatMap((insight) => {
313
+ return spreadDay(toRow(insight, zonedHourToUtc(insight.date_start, 0, account.timezone_name), account.id, account.currency, actionType), insight.date_start, account.timezone_name);
314
+ });
315
+ }
229
316
  //#endregion
230
- export { MetaAdsApiError, fetchMetaAdPerformance };
317
+ export { MetaAdsApiError, fetchMetaAdPerformance, fetchMetaAdPerformanceHistory, spread };
231
318
 
232
319
  //# sourceMappingURL=meta.mjs.map