@yourbright/emdash-analytics-plugin 0.1.1
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 +151 -0
- package/package.json +57 -0
- package/src/admin.tsx +1138 -0
- package/src/config-validation.ts +153 -0
- package/src/config.ts +90 -0
- package/src/constants.ts +55 -0
- package/src/content.ts +133 -0
- package/src/google.ts +518 -0
- package/src/index.ts +270 -0
- package/src/scoring.ts +83 -0
- package/src/sync.ts +749 -0
- package/src/types.ts +193 -0
package/src/google.ts
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import type { HttpAccess } from "emdash";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
GA_SCOPE,
|
|
5
|
+
GOOGLE_GA_BASE_URL,
|
|
6
|
+
GOOGLE_GSC_BASE_URL,
|
|
7
|
+
GOOGLE_TOKEN_URL,
|
|
8
|
+
GSC_DATA_DELAY_DAYS,
|
|
9
|
+
GSC_SCOPE
|
|
10
|
+
} from "./constants.js";
|
|
11
|
+
import { buildContentUrl, normalizePath } from "./content.js";
|
|
12
|
+
import type { GoogleServiceAccount, SavedPluginConfig } from "./types.js";
|
|
13
|
+
|
|
14
|
+
const accessTokenCache = new Map<string, { token: string; expiresAt: number }>();
|
|
15
|
+
|
|
16
|
+
interface DateWindow {
|
|
17
|
+
startDate: string;
|
|
18
|
+
endDate: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Windows {
|
|
22
|
+
gscCurrent: DateWindow;
|
|
23
|
+
gscPrevious: DateWindow;
|
|
24
|
+
gaCurrent: DateWindow;
|
|
25
|
+
gaPrevious: DateWindow;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface GscPageMetric {
|
|
29
|
+
urlPath: string;
|
|
30
|
+
clicks: number;
|
|
31
|
+
impressions: number;
|
|
32
|
+
ctr: number;
|
|
33
|
+
position: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface GscQueryMetric {
|
|
37
|
+
query: string;
|
|
38
|
+
clicks: number;
|
|
39
|
+
impressions: number;
|
|
40
|
+
ctr: number;
|
|
41
|
+
position: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface GaPageMetric {
|
|
45
|
+
urlPath: string;
|
|
46
|
+
views: number;
|
|
47
|
+
users: number;
|
|
48
|
+
sessions: number;
|
|
49
|
+
engagementRate: number;
|
|
50
|
+
bounceRate: number;
|
|
51
|
+
averageSessionDuration: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface TrendMetric {
|
|
55
|
+
date: string;
|
|
56
|
+
clicks: number;
|
|
57
|
+
impressions: number;
|
|
58
|
+
views: number;
|
|
59
|
+
sessions: number;
|
|
60
|
+
users: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function buildWindows(now = new Date()): Windows {
|
|
64
|
+
const gaCurrentEnd = addDaysUtc(now, -1);
|
|
65
|
+
const gaCurrentStart = addDaysUtc(gaCurrentEnd, -27);
|
|
66
|
+
const gaPreviousEnd = addDaysUtc(gaCurrentStart, -1);
|
|
67
|
+
const gaPreviousStart = addDaysUtc(gaPreviousEnd, -27);
|
|
68
|
+
|
|
69
|
+
const gscCurrentEnd = addDaysUtc(now, -GSC_DATA_DELAY_DAYS);
|
|
70
|
+
const gscCurrentStart = addDaysUtc(gscCurrentEnd, -27);
|
|
71
|
+
const gscPreviousEnd = addDaysUtc(gscCurrentStart, -1);
|
|
72
|
+
const gscPreviousStart = addDaysUtc(gscPreviousEnd, -27);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
gscCurrent: { startDate: formatDate(gscCurrentStart), endDate: formatDate(gscCurrentEnd) },
|
|
76
|
+
gscPrevious: { startDate: formatDate(gscPreviousStart), endDate: formatDate(gscPreviousEnd) },
|
|
77
|
+
gaCurrent: { startDate: formatDate(gaCurrentStart), endDate: formatDate(gaCurrentEnd) },
|
|
78
|
+
gaPrevious: { startDate: formatDate(gaPreviousStart), endDate: formatDate(gaPreviousEnd) }
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function runConnectionTest(
|
|
83
|
+
http: HttpAccess,
|
|
84
|
+
config: SavedPluginConfig,
|
|
85
|
+
serviceAccount: GoogleServiceAccount
|
|
86
|
+
): Promise<{ ga: Record<string, unknown>; gsc: Record<string, unknown> }> {
|
|
87
|
+
const gaToken = await getGoogleAccessToken(http, serviceAccount, [GA_SCOPE]);
|
|
88
|
+
const gscToken = await getGoogleAccessToken(http, serviceAccount, [GSC_SCOPE]);
|
|
89
|
+
const windows = buildWindows();
|
|
90
|
+
|
|
91
|
+
const ga = await postJson(
|
|
92
|
+
http,
|
|
93
|
+
`${GOOGLE_GA_BASE_URL}/properties/${config.ga4PropertyId}:runReport`,
|
|
94
|
+
gaToken,
|
|
95
|
+
{
|
|
96
|
+
dateRanges: [windows.gaCurrent],
|
|
97
|
+
dimensions: [{ name: "date" }],
|
|
98
|
+
metrics: [{ name: "sessions" }],
|
|
99
|
+
limit: 1
|
|
100
|
+
}
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const gsc = await postJson(
|
|
104
|
+
http,
|
|
105
|
+
`${GOOGLE_GSC_BASE_URL}/sites/${encodeURIComponent(config.gscSiteUrl)}/searchAnalytics/query`,
|
|
106
|
+
gscToken,
|
|
107
|
+
{
|
|
108
|
+
startDate: windows.gscCurrent.startDate,
|
|
109
|
+
endDate: windows.gscCurrent.endDate,
|
|
110
|
+
dimensions: ["date"],
|
|
111
|
+
rowLimit: 1,
|
|
112
|
+
startRow: 0
|
|
113
|
+
}
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
ga: {
|
|
118
|
+
rowCount: numberOrZero(ga.rowCount),
|
|
119
|
+
sample: Array.isArray(ga.rows) ? ga.rows[0] ?? null : null
|
|
120
|
+
},
|
|
121
|
+
gsc: {
|
|
122
|
+
rowCount: Array.isArray(gsc.rows) ? gsc.rows.length : 0,
|
|
123
|
+
sample: Array.isArray(gsc.rows) ? gsc.rows[0] ?? null : null
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function fetchGscPageMetrics(
|
|
129
|
+
http: HttpAccess,
|
|
130
|
+
config: SavedPluginConfig,
|
|
131
|
+
serviceAccount: GoogleServiceAccount,
|
|
132
|
+
window: DateWindow
|
|
133
|
+
): Promise<GscPageMetric[]> {
|
|
134
|
+
const token = await getGoogleAccessToken(http, serviceAccount, [GSC_SCOPE]);
|
|
135
|
+
const rows: GscPageMetric[] = [];
|
|
136
|
+
const canonicalHost = new URL(config.siteOrigin).hostname;
|
|
137
|
+
|
|
138
|
+
let startRow = 0;
|
|
139
|
+
while (true) {
|
|
140
|
+
const body = await postJson(
|
|
141
|
+
http,
|
|
142
|
+
`${GOOGLE_GSC_BASE_URL}/sites/${encodeURIComponent(config.gscSiteUrl)}/searchAnalytics/query`,
|
|
143
|
+
token,
|
|
144
|
+
{
|
|
145
|
+
startDate: window.startDate,
|
|
146
|
+
endDate: window.endDate,
|
|
147
|
+
dimensions: ["page"],
|
|
148
|
+
rowLimit: 25000,
|
|
149
|
+
startRow,
|
|
150
|
+
type: "web"
|
|
151
|
+
}
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const pageRows = Array.isArray(body.rows) ? body.rows : [];
|
|
155
|
+
for (const row of pageRows) {
|
|
156
|
+
const rawUrl = stringValue(row.keys?.[0]);
|
|
157
|
+
if (!rawUrl) continue;
|
|
158
|
+
const urlPath = normalizePath(rawUrl, canonicalHost);
|
|
159
|
+
if (!urlPath) continue;
|
|
160
|
+
rows.push({
|
|
161
|
+
urlPath,
|
|
162
|
+
clicks: numberOrZero(row.clicks),
|
|
163
|
+
impressions: numberOrZero(row.impressions),
|
|
164
|
+
ctr: numberOrZero(row.ctr),
|
|
165
|
+
position: numberOrZero(row.position)
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (pageRows.length < 25000) break;
|
|
170
|
+
startRow += 25000;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return rows;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function fetchGscDailyTrend(
|
|
177
|
+
http: HttpAccess,
|
|
178
|
+
config: SavedPluginConfig,
|
|
179
|
+
serviceAccount: GoogleServiceAccount,
|
|
180
|
+
window: DateWindow
|
|
181
|
+
): Promise<TrendMetric[]> {
|
|
182
|
+
const token = await getGoogleAccessToken(http, serviceAccount, [GSC_SCOPE]);
|
|
183
|
+
const body = await postJson(
|
|
184
|
+
http,
|
|
185
|
+
`${GOOGLE_GSC_BASE_URL}/sites/${encodeURIComponent(config.gscSiteUrl)}/searchAnalytics/query`,
|
|
186
|
+
token,
|
|
187
|
+
{
|
|
188
|
+
startDate: window.startDate,
|
|
189
|
+
endDate: window.endDate,
|
|
190
|
+
dimensions: ["date"],
|
|
191
|
+
rowLimit: 1000,
|
|
192
|
+
startRow: 0,
|
|
193
|
+
type: "web"
|
|
194
|
+
}
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
return (Array.isArray(body.rows) ? body.rows : []).map((row) => ({
|
|
198
|
+
date: stringValue(row.keys?.[0]) || window.startDate,
|
|
199
|
+
clicks: numberOrZero(row.clicks),
|
|
200
|
+
impressions: numberOrZero(row.impressions),
|
|
201
|
+
views: 0,
|
|
202
|
+
sessions: 0,
|
|
203
|
+
users: 0
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function fetchGscPageQueries(
|
|
208
|
+
http: HttpAccess,
|
|
209
|
+
config: SavedPluginConfig,
|
|
210
|
+
serviceAccount: GoogleServiceAccount,
|
|
211
|
+
urlPath: string,
|
|
212
|
+
window: DateWindow,
|
|
213
|
+
limit: number
|
|
214
|
+
): Promise<GscQueryMetric[]> {
|
|
215
|
+
const token = await getGoogleAccessToken(http, serviceAccount, [GSC_SCOPE]);
|
|
216
|
+
const pageUrl = buildContentUrl(config.siteOrigin, urlPath);
|
|
217
|
+
const body = await postJson(
|
|
218
|
+
http,
|
|
219
|
+
`${GOOGLE_GSC_BASE_URL}/sites/${encodeURIComponent(config.gscSiteUrl)}/searchAnalytics/query`,
|
|
220
|
+
token,
|
|
221
|
+
{
|
|
222
|
+
startDate: window.startDate,
|
|
223
|
+
endDate: window.endDate,
|
|
224
|
+
dimensions: ["query"],
|
|
225
|
+
rowLimit: limit,
|
|
226
|
+
startRow: 0,
|
|
227
|
+
type: "web",
|
|
228
|
+
dimensionFilterGroups: [
|
|
229
|
+
{
|
|
230
|
+
filters: [
|
|
231
|
+
{
|
|
232
|
+
dimension: "page",
|
|
233
|
+
operator: "equals",
|
|
234
|
+
expression: pageUrl
|
|
235
|
+
}
|
|
236
|
+
]
|
|
237
|
+
}
|
|
238
|
+
]
|
|
239
|
+
}
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
return (Array.isArray(body.rows) ? body.rows : []).map((row) => ({
|
|
243
|
+
query: stringValue(row.keys?.[0]) || "",
|
|
244
|
+
clicks: numberOrZero(row.clicks),
|
|
245
|
+
impressions: numberOrZero(row.impressions),
|
|
246
|
+
ctr: numberOrZero(row.ctr),
|
|
247
|
+
position: numberOrZero(row.position)
|
|
248
|
+
})).filter((row) => row.query.length > 0);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function fetchGaPageMetrics(
|
|
252
|
+
http: HttpAccess,
|
|
253
|
+
config: SavedPluginConfig,
|
|
254
|
+
serviceAccount: GoogleServiceAccount,
|
|
255
|
+
window: DateWindow
|
|
256
|
+
): Promise<GaPageMetric[]> {
|
|
257
|
+
const token = await getGoogleAccessToken(http, serviceAccount, [GA_SCOPE]);
|
|
258
|
+
const rows: GaPageMetric[] = [];
|
|
259
|
+
let offset = 0;
|
|
260
|
+
const limit = 10000;
|
|
261
|
+
|
|
262
|
+
while (true) {
|
|
263
|
+
const body = await postJson(
|
|
264
|
+
http,
|
|
265
|
+
`${GOOGLE_GA_BASE_URL}/properties/${config.ga4PropertyId}:runReport`,
|
|
266
|
+
token,
|
|
267
|
+
{
|
|
268
|
+
dateRanges: [window],
|
|
269
|
+
dimensions: [{ name: "pagePath" }],
|
|
270
|
+
metrics: [
|
|
271
|
+
{ name: "screenPageViews" },
|
|
272
|
+
{ name: "activeUsers" },
|
|
273
|
+
{ name: "sessions" },
|
|
274
|
+
{ name: "engagementRate" },
|
|
275
|
+
{ name: "bounceRate" },
|
|
276
|
+
{ name: "averageSessionDuration" }
|
|
277
|
+
],
|
|
278
|
+
dimensionFilter: {
|
|
279
|
+
filter: {
|
|
280
|
+
fieldName: "hostName",
|
|
281
|
+
stringFilter: {
|
|
282
|
+
matchType: "EXACT",
|
|
283
|
+
value: new URL(config.siteOrigin).hostname
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
limit,
|
|
288
|
+
offset
|
|
289
|
+
}
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const pageRows = Array.isArray(body.rows) ? body.rows : [];
|
|
293
|
+
for (const row of pageRows) {
|
|
294
|
+
const urlPath = normalizePath(stringValue(row.dimensionValues?.[0]?.value) || "/");
|
|
295
|
+
if (!urlPath) continue;
|
|
296
|
+
rows.push({
|
|
297
|
+
urlPath,
|
|
298
|
+
views: numberOrZero(row.metricValues?.[0]?.value),
|
|
299
|
+
users: numberOrZero(row.metricValues?.[1]?.value),
|
|
300
|
+
sessions: numberOrZero(row.metricValues?.[2]?.value),
|
|
301
|
+
engagementRate: numberOrZero(row.metricValues?.[3]?.value),
|
|
302
|
+
bounceRate: numberOrZero(row.metricValues?.[4]?.value),
|
|
303
|
+
averageSessionDuration: numberOrZero(row.metricValues?.[5]?.value)
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (pageRows.length < limit) break;
|
|
308
|
+
offset += limit;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return rows;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export async function fetchGaDailyTrend(
|
|
315
|
+
http: HttpAccess,
|
|
316
|
+
config: SavedPluginConfig,
|
|
317
|
+
serviceAccount: GoogleServiceAccount,
|
|
318
|
+
window: DateWindow
|
|
319
|
+
): Promise<TrendMetric[]> {
|
|
320
|
+
const token = await getGoogleAccessToken(http, serviceAccount, [GA_SCOPE]);
|
|
321
|
+
const body = await postJson(
|
|
322
|
+
http,
|
|
323
|
+
`${GOOGLE_GA_BASE_URL}/properties/${config.ga4PropertyId}:runReport`,
|
|
324
|
+
token,
|
|
325
|
+
{
|
|
326
|
+
dateRanges: [window],
|
|
327
|
+
dimensions: [{ name: "date" }],
|
|
328
|
+
metrics: [
|
|
329
|
+
{ name: "screenPageViews" },
|
|
330
|
+
{ name: "activeUsers" },
|
|
331
|
+
{ name: "sessions" }
|
|
332
|
+
],
|
|
333
|
+
dimensionFilter: {
|
|
334
|
+
filter: {
|
|
335
|
+
fieldName: "hostName",
|
|
336
|
+
stringFilter: {
|
|
337
|
+
matchType: "EXACT",
|
|
338
|
+
value: new URL(config.siteOrigin).hostname
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
limit: 1000
|
|
343
|
+
}
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
return (Array.isArray(body.rows) ? body.rows : []).map((row) => ({
|
|
347
|
+
date: parseGaDate(stringValue(row.dimensionValues?.[0]?.value) || window.startDate),
|
|
348
|
+
clicks: 0,
|
|
349
|
+
impressions: 0,
|
|
350
|
+
views: numberOrZero(row.metricValues?.[0]?.value),
|
|
351
|
+
users: numberOrZero(row.metricValues?.[1]?.value),
|
|
352
|
+
sessions: numberOrZero(row.metricValues?.[2]?.value)
|
|
353
|
+
}));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function getGoogleAccessToken(
|
|
357
|
+
http: HttpAccess,
|
|
358
|
+
serviceAccount: GoogleServiceAccount,
|
|
359
|
+
scopes: string[]
|
|
360
|
+
): Promise<string> {
|
|
361
|
+
const cacheKey = `${serviceAccount.client_email}:${scopes.join(" ")}`;
|
|
362
|
+
const cached = accessTokenCache.get(cacheKey);
|
|
363
|
+
if (cached && cached.expiresAt > Date.now() + 60_000) {
|
|
364
|
+
return cached.token;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const assertion = await createJwtAssertion(serviceAccount, scopes);
|
|
368
|
+
const response = await http.fetch(GOOGLE_TOKEN_URL, {
|
|
369
|
+
method: "POST",
|
|
370
|
+
headers: {
|
|
371
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
372
|
+
},
|
|
373
|
+
body: new URLSearchParams({
|
|
374
|
+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
375
|
+
assertion
|
|
376
|
+
}).toString()
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const body = await parseJson(response);
|
|
380
|
+
if (!response.ok) {
|
|
381
|
+
throw new Error(`Failed to obtain Google access token: ${body.error_description || body.error || response.status}`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const token = stringValue(body.access_token);
|
|
385
|
+
const expiresIn = numberOrZero(body.expires_in) || 3600;
|
|
386
|
+
if (!token) {
|
|
387
|
+
throw new Error("Google token response did not include access_token");
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
accessTokenCache.set(cacheKey, {
|
|
391
|
+
token,
|
|
392
|
+
expiresAt: Date.now() + expiresIn * 1000
|
|
393
|
+
});
|
|
394
|
+
return token;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function createJwtAssertion(
|
|
398
|
+
serviceAccount: GoogleServiceAccount,
|
|
399
|
+
scopes: string[]
|
|
400
|
+
): Promise<string> {
|
|
401
|
+
const now = Math.floor(Date.now() / 1000);
|
|
402
|
+
const header = { alg: "RS256", typ: "JWT" };
|
|
403
|
+
const claims = {
|
|
404
|
+
iss: serviceAccount.client_email,
|
|
405
|
+
scope: scopes.join(" "),
|
|
406
|
+
aud: serviceAccount.token_uri || GOOGLE_TOKEN_URL,
|
|
407
|
+
exp: now + 3600,
|
|
408
|
+
iat: now
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
const encodedHeader = base64url(JSON.stringify(header));
|
|
412
|
+
const encodedClaims = base64url(JSON.stringify(claims));
|
|
413
|
+
const payload = `${encodedHeader}.${encodedClaims}`;
|
|
414
|
+
|
|
415
|
+
const key = await crypto.subtle.importKey(
|
|
416
|
+
"pkcs8",
|
|
417
|
+
pemToArrayBuffer(serviceAccount.private_key),
|
|
418
|
+
{
|
|
419
|
+
name: "RSASSA-PKCS1-v1_5",
|
|
420
|
+
hash: "SHA-256"
|
|
421
|
+
},
|
|
422
|
+
false,
|
|
423
|
+
["sign"]
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
const signature = await crypto.subtle.sign(
|
|
427
|
+
"RSASSA-PKCS1-v1_5",
|
|
428
|
+
key,
|
|
429
|
+
new TextEncoder().encode(payload)
|
|
430
|
+
);
|
|
431
|
+
return `${payload}.${base64urlBytes(new Uint8Array(signature))}`;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function postJson(
|
|
435
|
+
http: HttpAccess,
|
|
436
|
+
url: string,
|
|
437
|
+
accessToken: string,
|
|
438
|
+
payload: Record<string, unknown>
|
|
439
|
+
): Promise<Record<string, any>> {
|
|
440
|
+
const response = await http.fetch(url, {
|
|
441
|
+
method: "POST",
|
|
442
|
+
headers: {
|
|
443
|
+
Authorization: `Bearer ${accessToken}`,
|
|
444
|
+
"Content-Type": "application/json"
|
|
445
|
+
},
|
|
446
|
+
body: JSON.stringify(payload)
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
const body = await parseJson(response);
|
|
450
|
+
if (!response.ok) {
|
|
451
|
+
const message = body.error?.message || body.error_description || response.statusText;
|
|
452
|
+
throw new Error(`Google API request failed: ${message}`);
|
|
453
|
+
}
|
|
454
|
+
return body;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function parseJson(response: Response): Promise<Record<string, any>> {
|
|
458
|
+
const text = await response.text();
|
|
459
|
+
if (!text) return {};
|
|
460
|
+
try {
|
|
461
|
+
return JSON.parse(text) as Record<string, any>;
|
|
462
|
+
} catch {
|
|
463
|
+
return { rawText: text };
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function pemToArrayBuffer(pem: string): ArrayBuffer {
|
|
468
|
+
const normalized = pem
|
|
469
|
+
.replace(/-----BEGIN PRIVATE KEY-----/g, "")
|
|
470
|
+
.replace(/-----END PRIVATE KEY-----/g, "")
|
|
471
|
+
.replace(/\s+/g, "");
|
|
472
|
+
const binary = atob(normalized);
|
|
473
|
+
const bytes = new Uint8Array(binary.length);
|
|
474
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
475
|
+
bytes[index] = binary.charCodeAt(index);
|
|
476
|
+
}
|
|
477
|
+
return bytes.buffer;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function base64url(value: string): string {
|
|
481
|
+
return base64urlBytes(new TextEncoder().encode(value));
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function base64urlBytes(value: Uint8Array): string {
|
|
485
|
+
let binary = "";
|
|
486
|
+
for (const byte of value) binary += String.fromCharCode(byte);
|
|
487
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function parseGaDate(value: string): string {
|
|
491
|
+
if (/^\d{8}$/.test(value)) {
|
|
492
|
+
return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`;
|
|
493
|
+
}
|
|
494
|
+
return value;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function addDaysUtc(date: Date, delta: number): Date {
|
|
498
|
+
const next = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
499
|
+
next.setUTCDate(next.getUTCDate() + delta);
|
|
500
|
+
return next;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function formatDate(date: Date): string {
|
|
504
|
+
return date.toISOString().slice(0, 10);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function stringValue(value: unknown): string | undefined {
|
|
508
|
+
return typeof value === "string" ? value : undefined;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function numberOrZero(value: unknown): number {
|
|
512
|
+
if (typeof value === "number") return value;
|
|
513
|
+
if (typeof value === "string" && value.length > 0) {
|
|
514
|
+
const parsed = Number(value);
|
|
515
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
516
|
+
}
|
|
517
|
+
return 0;
|
|
518
|
+
}
|