@oxygen-agent/cli 1.1003.12 → 1.1010.644
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 +1 -1
- package/dist/column-decision-options.d.ts +20 -0
- package/dist/column-decision-options.js +54 -0
- package/dist/command-manifest.js +15 -2
- package/dist/functions-commands.js +11 -11
- package/dist/index.js +1222 -159
- package/dist/search-ai-filter-notice.d.ts +17 -0
- package/dist/search-ai-filter-notice.js +38 -0
- package/dist/skills.js +34 -10
- package/dist/util.d.ts +9 -0
- package/dist/util.js +14 -0
- package/node_modules/@oxygen/cli-ugc/dist/commands.js +296 -140
- package/node_modules/@oxygen/cli-ugc/dist/field-parser.d.ts +9 -0
- package/node_modules/@oxygen/cli-ugc/dist/field-parser.js +34 -0
- package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +13 -0
- package/node_modules/@oxygen/shared/dist/billing.d.ts +21 -0
- package/node_modules/@oxygen/shared/dist/billing.js +45 -0
- package/node_modules/@oxygen/shared/dist/byok-connect.js +5 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +223 -13
- package/node_modules/@oxygen/shared/dist/cli-http-error.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/cli-http-error.js +8 -0
- package/node_modules/@oxygen/shared/dist/cli-result.js +1 -0
- package/node_modules/@oxygen/shared/dist/column-autofill.js +5 -23
- package/node_modules/@oxygen/shared/dist/column-decision.d.ts +50 -0
- package/node_modules/@oxygen/shared/dist/column-decision.js +228 -0
- package/node_modules/@oxygen/shared/dist/company-enrichment-fields.d.ts +9 -4
- package/node_modules/@oxygen/shared/dist/company-enrichment-fields.js +11 -8
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +4 -4
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +4 -4
- package/node_modules/@oxygen/shared/dist/cutover-freeze.d.ts +26 -0
- package/node_modules/@oxygen/shared/dist/cutover-freeze.js +52 -0
- package/node_modules/@oxygen/shared/dist/data-suppliers.d.ts +57 -0
- package/node_modules/@oxygen/shared/dist/data-suppliers.js +59 -0
- package/node_modules/@oxygen/shared/dist/enrichment-intents.d.ts +6 -2
- package/node_modules/@oxygen/shared/dist/enrichment-intents.js +13 -23
- package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +60 -4
- package/node_modules/@oxygen/shared/dist/hosted-ai.js +125 -10
- package/node_modules/@oxygen/shared/dist/index.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/index.js +2 -0
- package/node_modules/@oxygen/shared/dist/langfuse.d.ts +44 -1
- package/node_modules/@oxygen/shared/dist/langfuse.js +407 -14
- package/node_modules/@oxygen/shared/dist/linkedin-countries.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/linkedin-countries.js +2 -0
- package/node_modules/@oxygen/shared/dist/linkedin-country-timezones.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/linkedin-country-timezones.js +276 -0
- package/node_modules/@oxygen/shared/dist/linkedin-message-deletion.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/linkedin-message-deletion.js +5 -0
- package/node_modules/@oxygen/shared/dist/linkedin-post-keywords.d.ts +44 -0
- package/node_modules/@oxygen/shared/dist/linkedin-post-keywords.js +116 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +96 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +123 -0
- package/node_modules/@oxygen/shared/dist/llm-durable-capture.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/llm-durable-capture.js +89 -0
- package/node_modules/@oxygen/shared/dist/llm-prompts.d.ts +75 -0
- package/node_modules/@oxygen/shared/dist/llm-prompts.js +161 -0
- package/node_modules/@oxygen/shared/dist/mailbox-egress-ownership.d.ts +90 -0
- package/node_modules/@oxygen/shared/dist/mailbox-egress-ownership.js +130 -0
- package/node_modules/@oxygen/shared/dist/operational-telemetry.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/operational-telemetry.js +73 -0
- package/node_modules/@oxygen/shared/dist/otlp-log-sink.d.ts +29 -4
- package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +189 -36
- package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +21 -2
- package/node_modules/@oxygen/shared/dist/product-analytics-events.js +21 -1
- package/node_modules/@oxygen/shared/dist/redaction.js +4 -1
- package/node_modules/@oxygen/shared/dist/scraper-lane-credential.d.ts +18 -0
- package/node_modules/@oxygen/shared/dist/scraper-lane-credential.js +23 -0
- package/node_modules/@oxygen/shared/dist/sequences.js +5 -1
- package/node_modules/@oxygen/shared/dist/signup-lead-payload.d.ts +80 -0
- package/node_modules/@oxygen/shared/dist/signup-lead-payload.js +198 -0
- package/node_modules/@oxygen/shared/dist/social-capabilities.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/social-capabilities.js +25 -16
- package/node_modules/@oxygen/shared/dist/social-post-metrics-core.d.ts +32 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-core.js +32 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-linkedin.d.ts +31 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-linkedin.js +103 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-series.d.ts +96 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-series.js +213 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-x.d.ts +13 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-x.js +78 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics.d.ts +36 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics.js +51 -0
- package/node_modules/@oxygen/shared/dist/stripe-price-catalog.d.ts +36 -0
- package/node_modules/@oxygen/shared/dist/stripe-price-catalog.js +184 -0
- package/node_modules/@oxygen/shared/dist/stripe-subscription-kind.d.ts +41 -0
- package/node_modules/@oxygen/shared/dist/stripe-subscription-kind.js +44 -0
- package/node_modules/@oxygen/shared/dist/table-limits.d.ts +3 -0
- package/node_modules/@oxygen/shared/dist/table-limits.js +3 -0
- package/node_modules/@oxygen/shared/dist/telemetry-export-observer.d.ts +94 -0
- package/node_modules/@oxygen/shared/dist/telemetry-export-observer.js +298 -0
- package/node_modules/@oxygen/shared/dist/telemetry.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/telemetry.js +19 -1
- package/node_modules/@oxygen/shared/dist/ugc.d.ts +22 -11
- package/node_modules/@oxygen/shared/dist/ugc.js +10 -0
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/version.generated.js +2 -0
- package/node_modules/@oxygen/shared/dist/version.js +8 -1
- package/node_modules/@oxygen/shared/dist/workspace-event-catalog.js +0 -23
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +29 -0
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +31 -0
- package/node_modules/@oxygen/shared/package.json +9 -0
- package/package.json +2 -1
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { isPersonalEmailDomain } from "./freemail-domains.js";
|
|
2
|
+
import { readCoercedString as normalizeString } from "./type-guards.js";
|
|
3
|
+
/**
|
|
4
|
+
* Classifies an email address into a (likely) company work-email domain.
|
|
5
|
+
* Consumer/generic mailboxes return `{ domain: null, isGenericMailbox: true }`.
|
|
6
|
+
*/
|
|
7
|
+
export function classifyWorkEmail(email) {
|
|
8
|
+
if (!email)
|
|
9
|
+
return { domain: null, isGenericMailbox: false };
|
|
10
|
+
const at = email.lastIndexOf("@");
|
|
11
|
+
if (at < 0 || at >= email.length - 1)
|
|
12
|
+
return { domain: null, isGenericMailbox: false };
|
|
13
|
+
const domain = email.slice(at + 1).trim().toLowerCase();
|
|
14
|
+
if (!domain || domain.indexOf(".") < 0)
|
|
15
|
+
return { domain: null, isGenericMailbox: false };
|
|
16
|
+
if (isPersonalEmailDomain(domain)) {
|
|
17
|
+
return { domain: null, isGenericMailbox: true };
|
|
18
|
+
}
|
|
19
|
+
return { domain, isGenericMailbox: false };
|
|
20
|
+
}
|
|
21
|
+
export function isGenericMailboxDomain(domain) {
|
|
22
|
+
if (!domain)
|
|
23
|
+
return false;
|
|
24
|
+
return isPersonalEmailDomain(domain);
|
|
25
|
+
}
|
|
26
|
+
export function buildSignupLeadWebhookPayload(input) {
|
|
27
|
+
const type = normalizeString(input.type) ?? "signup_lead.event";
|
|
28
|
+
const occurredAt = normalizeDate(input.occurredAt) ?? new Date().toISOString();
|
|
29
|
+
const eventId = normalizeString(input.eventId) ?? buildFallbackEventId(input, occurredAt);
|
|
30
|
+
const providerEventId = normalizeString(input.providerEventId) ?? eventId;
|
|
31
|
+
const eventType = input.lifecycleEvent ?? type;
|
|
32
|
+
const clerkUserId = normalizeString(input.user?.clerkUserId);
|
|
33
|
+
const email = normalizeString(input.user?.email)?.toLowerCase() ?? null;
|
|
34
|
+
const firstName = normalizeString(input.user?.firstName);
|
|
35
|
+
const lastName = normalizeString(input.user?.lastName);
|
|
36
|
+
const fullName = normalizeString([firstName, lastName].filter(Boolean).join(" "));
|
|
37
|
+
const clerkOrgId = normalizeString(input.organization?.clerkOrgId);
|
|
38
|
+
const companyName = normalizeString(input.organization?.name);
|
|
39
|
+
// Prefer the explicit Clerk org domain, but most product signups arrive with
|
|
40
|
+
// no org domain set. Fall back to the work-email domain so downstream
|
|
41
|
+
// company enrichment (domain -> LinkedIn -> profile) and the Attio company
|
|
42
|
+
// upsert have a key to work with. Generic mailboxes (gmail, …) stay null.
|
|
43
|
+
const companyDomain = normalizeString(input.organization?.domain)?.toLowerCase() ??
|
|
44
|
+
classifyWorkEmail(email).domain ??
|
|
45
|
+
null;
|
|
46
|
+
const role = normalizeString(input.membership?.role);
|
|
47
|
+
const organizationCreatedBy = normalizeString(input.organization?.createdBy);
|
|
48
|
+
const attribution = buildSignupAttribution(input.metadata);
|
|
49
|
+
const user = compactRecord({
|
|
50
|
+
clerk_user_id: clerkUserId,
|
|
51
|
+
email,
|
|
52
|
+
first_name: firstName,
|
|
53
|
+
last_name: lastName,
|
|
54
|
+
full_name: fullName,
|
|
55
|
+
created_at: normalizeDate(input.user?.createdAt),
|
|
56
|
+
linkedin_url: normalizeString(input.user?.linkedinUrl) ?? undefined,
|
|
57
|
+
});
|
|
58
|
+
const organization = compactRecord({
|
|
59
|
+
clerk_org_id: clerkOrgId,
|
|
60
|
+
name: companyName,
|
|
61
|
+
slug: normalizeString(input.organization?.slug),
|
|
62
|
+
domain: companyDomain,
|
|
63
|
+
created_at: normalizeDate(input.organization?.createdAt),
|
|
64
|
+
created_by: organizationCreatedBy,
|
|
65
|
+
});
|
|
66
|
+
const membership = compactRecord({ role });
|
|
67
|
+
const stripe = input.stripe
|
|
68
|
+
? compactRecord({
|
|
69
|
+
customer_id: normalizeString(input.stripe.customerId),
|
|
70
|
+
subscription_id: normalizeString(input.stripe.subscriptionId),
|
|
71
|
+
invoice_id: normalizeString(input.stripe.invoiceId),
|
|
72
|
+
price_id: normalizeString(input.stripe.priceId),
|
|
73
|
+
tier: normalizeString(input.stripe.tier),
|
|
74
|
+
status: normalizeString(input.stripe.status),
|
|
75
|
+
trial_start: normalizeDate(input.stripe.trialStart),
|
|
76
|
+
trial_end: normalizeDate(input.stripe.trialEnd),
|
|
77
|
+
period_start: normalizeDate(input.stripe.periodStart),
|
|
78
|
+
period_end: normalizeDate(input.stripe.periodEnd),
|
|
79
|
+
amount_paid: input.stripe.amountPaid ?? undefined,
|
|
80
|
+
currency: normalizeString(input.stripe.currency)?.toLowerCase(),
|
|
81
|
+
billing_reason: normalizeString(input.stripe.billingReason),
|
|
82
|
+
})
|
|
83
|
+
: undefined;
|
|
84
|
+
const payload = compactRecord({
|
|
85
|
+
event_id: eventId,
|
|
86
|
+
event_type: eventType,
|
|
87
|
+
provider_event_id: providerEventId,
|
|
88
|
+
provider_event_type: type,
|
|
89
|
+
event: type,
|
|
90
|
+
type,
|
|
91
|
+
occurred_at: occurredAt,
|
|
92
|
+
source: input.source,
|
|
93
|
+
user,
|
|
94
|
+
person: user,
|
|
95
|
+
organization,
|
|
96
|
+
membership,
|
|
97
|
+
stripe,
|
|
98
|
+
metadata: input.metadata,
|
|
99
|
+
attribution,
|
|
100
|
+
qualification_evidence: input.qualificationEvidence,
|
|
101
|
+
research: input.research,
|
|
102
|
+
raw_event: input.rawEvent,
|
|
103
|
+
});
|
|
104
|
+
return compactRecord({
|
|
105
|
+
id: eventId,
|
|
106
|
+
event_id: eventId,
|
|
107
|
+
event_type: eventType,
|
|
108
|
+
provider_event_id: providerEventId,
|
|
109
|
+
provider_event_type: type,
|
|
110
|
+
event: type,
|
|
111
|
+
type,
|
|
112
|
+
occurred_at: occurredAt,
|
|
113
|
+
timestamp: occurredAt,
|
|
114
|
+
source: input.source,
|
|
115
|
+
provider: input.source,
|
|
116
|
+
clerk_user_id: clerkUserId,
|
|
117
|
+
clerk_org_id: clerkOrgId,
|
|
118
|
+
email,
|
|
119
|
+
first_name: firstName,
|
|
120
|
+
last_name: lastName,
|
|
121
|
+
full_name: fullName,
|
|
122
|
+
company_name: companyName,
|
|
123
|
+
company_domain: companyDomain,
|
|
124
|
+
organization_created_by: organizationCreatedBy,
|
|
125
|
+
person_linkedin_url: normalizeString(input.user?.linkedinUrl) ?? undefined,
|
|
126
|
+
role,
|
|
127
|
+
stripe_customer_id: normalizeString(input.stripe?.customerId),
|
|
128
|
+
stripe_subscription_id: normalizeString(input.stripe?.subscriptionId),
|
|
129
|
+
stripe_invoice_id: normalizeString(input.stripe?.invoiceId),
|
|
130
|
+
user,
|
|
131
|
+
person: user,
|
|
132
|
+
organization,
|
|
133
|
+
membership,
|
|
134
|
+
stripe,
|
|
135
|
+
metadata: input.metadata,
|
|
136
|
+
attribution,
|
|
137
|
+
qualification_evidence: input.qualificationEvidence,
|
|
138
|
+
research: input.research,
|
|
139
|
+
raw_event: input.rawEvent,
|
|
140
|
+
payload,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
function buildSignupAttribution(metadata) {
|
|
144
|
+
if (!metadata)
|
|
145
|
+
return undefined;
|
|
146
|
+
const publicMetadata = recordValue(metadata.clerk_public_metadata);
|
|
147
|
+
const unsafeMetadata = recordValue(metadata.clerk_unsafe_metadata);
|
|
148
|
+
const combined = { ...unsafeMetadata, ...publicMetadata, ...metadata };
|
|
149
|
+
const selfReported = Array.isArray(combined.attribution_sources)
|
|
150
|
+
? combined.attribution_sources.filter((value) => typeof value === "string" && value.trim().length > 0)
|
|
151
|
+
: [];
|
|
152
|
+
const result = compactRecord({
|
|
153
|
+
self_reported_sources: selfReported.length > 0 ? selfReported : undefined,
|
|
154
|
+
self_reported_other: normalizeString(combined.attribution_other) ?? undefined,
|
|
155
|
+
linkedin_url: firstStringValue(combined.linkedin_url, combined.linkedin_profile_url) ?? undefined,
|
|
156
|
+
landing_page: firstStringValue(combined.landing_page, combined.landing_page_url) ?? undefined,
|
|
157
|
+
referrer: firstStringValue(combined.referrer, combined.referrer_url) ?? undefined,
|
|
158
|
+
utm_source: normalizeString(combined.utm_source) ?? undefined,
|
|
159
|
+
utm_medium: normalizeString(combined.utm_medium) ?? undefined,
|
|
160
|
+
utm_campaign: normalizeString(combined.utm_campaign) ?? undefined,
|
|
161
|
+
utm_content: normalizeString(combined.utm_content) ?? undefined,
|
|
162
|
+
utm_term: normalizeString(combined.utm_term) ?? undefined,
|
|
163
|
+
});
|
|
164
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
165
|
+
}
|
|
166
|
+
function buildFallbackEventId(input, occurredAt) {
|
|
167
|
+
const identity = normalizeString(input.user?.clerkUserId) ??
|
|
168
|
+
normalizeString(input.organization?.clerkOrgId) ??
|
|
169
|
+
normalizeString(input.user?.email) ??
|
|
170
|
+
normalizeString(input.organization?.domain) ??
|
|
171
|
+
"unknown";
|
|
172
|
+
return `signup_lead:${input.source}:${input.type}:${identity}:${occurredAt}`;
|
|
173
|
+
}
|
|
174
|
+
function compactRecord(record) {
|
|
175
|
+
return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined));
|
|
176
|
+
}
|
|
177
|
+
function normalizeDate(value) {
|
|
178
|
+
if (value === null || value === undefined)
|
|
179
|
+
return null;
|
|
180
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
181
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
182
|
+
}
|
|
183
|
+
function stringValue(value) {
|
|
184
|
+
return normalizeString(value);
|
|
185
|
+
}
|
|
186
|
+
function firstStringValue(...values) {
|
|
187
|
+
for (const value of values) {
|
|
188
|
+
const normalized = stringValue(value);
|
|
189
|
+
if (normalized)
|
|
190
|
+
return normalized;
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
function recordValue(value) {
|
|
195
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
196
|
+
? value
|
|
197
|
+
: {};
|
|
198
|
+
}
|
|
@@ -26,6 +26,12 @@ export type SocialMetricCapability = {
|
|
|
26
26
|
status: SocialCapabilityStatus;
|
|
27
27
|
provider_field: string | null;
|
|
28
28
|
reason: string | null;
|
|
29
|
+
/**
|
|
30
|
+
* `author_private` metrics are shown by the network only to the post's author
|
|
31
|
+
* (LinkedIn impressions). They never leave the author's own workspace — a UGC
|
|
32
|
+
* program master sees public telemetry only.
|
|
33
|
+
*/
|
|
34
|
+
visibility: "public" | "author_private";
|
|
29
35
|
};
|
|
30
36
|
export type SocialCapabilityProfile = {
|
|
31
37
|
version: 1;
|
|
@@ -39,9 +39,11 @@ const LINKEDIN_UNIPILE_CAPABILITIES = {
|
|
|
39
39
|
read_post_metrics: {
|
|
40
40
|
status: "limited",
|
|
41
41
|
source: "provider",
|
|
42
|
-
|
|
42
|
+
// Owned-post discovery pages carry every post's counters; `linkedin.posts_get`
|
|
43
|
+
// is the per-post fallback for posts discovery has not seen recently.
|
|
44
|
+
provider_operation: "linkedin.users_posts",
|
|
43
45
|
external_write: false,
|
|
44
|
-
limitation: "
|
|
46
|
+
limitation: "owned_posts_author_analytics",
|
|
45
47
|
},
|
|
46
48
|
list_comments: {
|
|
47
49
|
status: "available",
|
|
@@ -59,14 +61,19 @@ const LINKEDIN_UNIPILE_CAPABILITIES = {
|
|
|
59
61
|
},
|
|
60
62
|
},
|
|
61
63
|
metrics: {
|
|
62
|
-
impressions: {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
64
|
+
impressions: {
|
|
65
|
+
status: "limited",
|
|
66
|
+
provider_field: "analytics.impressions_counter",
|
|
67
|
+
reason: "author_analytics_when_returned",
|
|
68
|
+
visibility: "author_private",
|
|
69
|
+
},
|
|
70
|
+
reactions: { status: "available", provider_field: "reactions_counter[].count", reason: null, visibility: "public" },
|
|
71
|
+
comments: { status: "available", provider_field: "comments_counter", reason: null, visibility: "public" },
|
|
72
|
+
reposts: { status: "available", provider_field: "reposts_counter", reason: null, visibility: "public" },
|
|
73
|
+
saves: { status: "unavailable", provider_field: null, reason: NOT_EXPOSED_BY_UNIPILE, visibility: "public" },
|
|
74
|
+
sends: { status: "unavailable", provider_field: null, reason: NOT_EXPOSED_BY_UNIPILE, visibility: "public" },
|
|
75
|
+
clicks: { status: "unavailable", provider_field: null, reason: NOT_EXPOSED_BY_UNIPILE, visibility: "author_private" },
|
|
76
|
+
video_views: { status: "unavailable", provider_field: null, reason: NOT_EXPOSED_BY_UNIPILE, visibility: "public" },
|
|
70
77
|
},
|
|
71
78
|
ingestion: {
|
|
72
79
|
polling: true,
|
|
@@ -108,21 +115,23 @@ const X_COMPOSIO_CAPABILITIES = {
|
|
|
108
115
|
},
|
|
109
116
|
},
|
|
110
117
|
metrics: {
|
|
111
|
-
impressions: { status: "available", provider_field: "public_metrics.impression_count", reason: null },
|
|
112
|
-
reactions: { status: "available", provider_field: "public_metrics.like_count", reason: null },
|
|
113
|
-
comments: { status: "available", provider_field: "public_metrics.reply_count", reason: null },
|
|
114
|
-
reposts: { status: "available", provider_field: "public_metrics.retweet_count", reason: null },
|
|
115
|
-
saves: { status: "available", provider_field: "public_metrics.bookmark_count", reason: null },
|
|
116
|
-
sends: { status: "unavailable", provider_field: null, reason: "not_exposed_by_x_post_lookup" },
|
|
118
|
+
impressions: { status: "available", provider_field: "public_metrics.impression_count", reason: null, visibility: "public" },
|
|
119
|
+
reactions: { status: "available", provider_field: "public_metrics.like_count", reason: null, visibility: "public" },
|
|
120
|
+
comments: { status: "available", provider_field: "public_metrics.reply_count", reason: null, visibility: "public" },
|
|
121
|
+
reposts: { status: "available", provider_field: "public_metrics.retweet_count", reason: null, visibility: "public" },
|
|
122
|
+
saves: { status: "available", provider_field: "public_metrics.bookmark_count", reason: null, visibility: "public" },
|
|
123
|
+
sends: { status: "unavailable", provider_field: null, reason: "not_exposed_by_x_post_lookup", visibility: "public" },
|
|
117
124
|
clicks: {
|
|
118
125
|
status: "limited",
|
|
119
126
|
provider_field: "non_public_metrics.url_link_clicks",
|
|
120
127
|
reason: "owned_posts_user_context_30_day_window",
|
|
128
|
+
visibility: "author_private",
|
|
121
129
|
},
|
|
122
130
|
video_views: {
|
|
123
131
|
status: "limited",
|
|
124
132
|
provider_field: "includes.media[].public_metrics.view_count",
|
|
125
133
|
reason: "video_media_only",
|
|
134
|
+
visibility: "public",
|
|
126
135
|
},
|
|
127
136
|
},
|
|
128
137
|
ingestion: {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { SocialPostMetric } from "./social-capabilities.js";
|
|
2
|
+
/**
|
|
3
|
+
* One provider read of a post's engagement, parsed into the registry vocabulary.
|
|
4
|
+
*
|
|
5
|
+
* Every value is a cumulative counter as of the read. An ABSENT key means the
|
|
6
|
+
* provider did not return that metric on this read; it is never a zero. A zero is
|
|
7
|
+
* only ever the provider saying "nobody did this".
|
|
8
|
+
*/
|
|
9
|
+
export type ParsedSocialPostMetrics = {
|
|
10
|
+
metrics: Partial<Record<SocialPostMetric, number>>;
|
|
11
|
+
/** Channel-specific counters outside the canonical registry (same absent-key rule). */
|
|
12
|
+
extended: Record<string, number>;
|
|
13
|
+
/** Which provider payload shape was recognised, e.g. `unipile_v2`. */
|
|
14
|
+
shape: string;
|
|
15
|
+
/**
|
|
16
|
+
* False when the counters describe someone else's post — a LinkedIn repost
|
|
17
|
+
* carries the ORIGINAL post's reactions and comments. Such a read must never be
|
|
18
|
+
* attributed to the reposting account's performance.
|
|
19
|
+
*/
|
|
20
|
+
attributable: boolean;
|
|
21
|
+
/** Metrics the provider returned but a plausibility guard refused. */
|
|
22
|
+
rejected: SocialPostMetric[];
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* A count the provider actually returned. Absent / null / non-numeric / negative
|
|
26
|
+
* all collapse to null — never to 0.
|
|
27
|
+
*/
|
|
28
|
+
export declare function readMetricCount(value: unknown): number | null;
|
|
29
|
+
/** First key that carries a usable count; null when none did. */
|
|
30
|
+
export declare function pickMetricCount(record: Record<string, unknown>, keys: readonly string[]): number | null;
|
|
31
|
+
/** Drop nulls so an absent metric stays an absent key. */
|
|
32
|
+
export declare function presentMetrics<K extends string>(values: Record<K, number | null>): Partial<Record<K, number>>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A count the provider actually returned. Absent / null / non-numeric / negative
|
|
3
|
+
* all collapse to null — never to 0.
|
|
4
|
+
*/
|
|
5
|
+
export function readMetricCount(value) {
|
|
6
|
+
if (typeof value === "number") {
|
|
7
|
+
return Number.isFinite(value) && value >= 0 ? Math.trunc(value) : null;
|
|
8
|
+
}
|
|
9
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
10
|
+
const parsed = Number(value);
|
|
11
|
+
return Number.isFinite(parsed) && parsed >= 0 ? Math.trunc(parsed) : null;
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
/** First key that carries a usable count; null when none did. */
|
|
16
|
+
export function pickMetricCount(record, keys) {
|
|
17
|
+
for (const key of keys) {
|
|
18
|
+
const value = readMetricCount(record[key]);
|
|
19
|
+
if (value !== null)
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
/** Drop nulls so an absent metric stays an absent key. */
|
|
25
|
+
export function presentMetrics(values) {
|
|
26
|
+
const present = {};
|
|
27
|
+
for (const [key, value] of Object.entries(values)) {
|
|
28
|
+
if (value !== null)
|
|
29
|
+
present[key] = value;
|
|
30
|
+
}
|
|
31
|
+
return present;
|
|
32
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type ParsedSocialPostMetrics } from "./social-post-metrics-core.js";
|
|
2
|
+
/**
|
|
3
|
+
* Bump when the parse of a stored LinkedIn payload changes meaning, so stored raw
|
|
4
|
+
* payloads can be re-derived.
|
|
5
|
+
*/
|
|
6
|
+
export declare const LINKEDIN_UNIPILE_POST_METRICS_PARSER_VERSION = 2;
|
|
7
|
+
/**
|
|
8
|
+
* Parse a LinkedIn post object returned by Unipile (`users_posts` list items and
|
|
9
|
+
* `posts_get` share the shape).
|
|
10
|
+
*
|
|
11
|
+
* Unipile's v1 DOCUMENTATION names the counters `reaction_counter`,
|
|
12
|
+
* `comment_counter` and `repost_counter`. The v2 API, which the v2 adapter passes
|
|
13
|
+
* through un-normalized, actually returns:
|
|
14
|
+
*
|
|
15
|
+
* reactions_counter [{ reaction, count }] one entry per reaction type
|
|
16
|
+
* comments_counter number
|
|
17
|
+
* reposts_counter number
|
|
18
|
+
* analytics { impressions_counter, page_viewers_from_this_post_counter,
|
|
19
|
+
* followers_gained_from_this_post_counter } — author-only
|
|
20
|
+
*
|
|
21
|
+
* Parsing the documented names against the real payload stored 5,172 production
|
|
22
|
+
* snapshots with every metric NULL (2026-09-23). Parsers here are pinned to
|
|
23
|
+
* redacted REAL payloads, never to vendor documentation.
|
|
24
|
+
*
|
|
25
|
+
* Impressions are read ONLY from the `analytics` object, which LinkedIn returns to
|
|
26
|
+
* the post's author. The v1 documentation's top-level `impressions_counter` is a
|
|
27
|
+
* placeholder zero and stays ignored. A repost's counters belong to the original
|
|
28
|
+
* post, so a repost is parsed but marked non-attributable.
|
|
29
|
+
*/
|
|
30
|
+
export declare function parseLinkedInUnipilePostMetrics(payload: Record<string, unknown>): ParsedSocialPostMetrics;
|
|
31
|
+
export declare function linkedInUnipileMetricEvidence(payload: Record<string, unknown>): Record<string, unknown>;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { pickMetricCount, presentMetrics, readMetricCount, } from "./social-post-metrics-core.js";
|
|
2
|
+
import { isRecord } from "./type-guards.js";
|
|
3
|
+
/**
|
|
4
|
+
* Bump when the parse of a stored LinkedIn payload changes meaning, so stored raw
|
|
5
|
+
* payloads can be re-derived.
|
|
6
|
+
*/
|
|
7
|
+
export const LINKEDIN_UNIPILE_POST_METRICS_PARSER_VERSION = 2;
|
|
8
|
+
/**
|
|
9
|
+
* Parse a LinkedIn post object returned by Unipile (`users_posts` list items and
|
|
10
|
+
* `posts_get` share the shape).
|
|
11
|
+
*
|
|
12
|
+
* Unipile's v1 DOCUMENTATION names the counters `reaction_counter`,
|
|
13
|
+
* `comment_counter` and `repost_counter`. The v2 API, which the v2 adapter passes
|
|
14
|
+
* through un-normalized, actually returns:
|
|
15
|
+
*
|
|
16
|
+
* reactions_counter [{ reaction, count }] one entry per reaction type
|
|
17
|
+
* comments_counter number
|
|
18
|
+
* reposts_counter number
|
|
19
|
+
* analytics { impressions_counter, page_viewers_from_this_post_counter,
|
|
20
|
+
* followers_gained_from_this_post_counter } — author-only
|
|
21
|
+
*
|
|
22
|
+
* Parsing the documented names against the real payload stored 5,172 production
|
|
23
|
+
* snapshots with every metric NULL (2026-09-23). Parsers here are pinned to
|
|
24
|
+
* redacted REAL payloads, never to vendor documentation.
|
|
25
|
+
*
|
|
26
|
+
* Impressions are read ONLY from the `analytics` object, which LinkedIn returns to
|
|
27
|
+
* the post's author. The v1 documentation's top-level `impressions_counter` is a
|
|
28
|
+
* placeholder zero and stays ignored. A repost's counters belong to the original
|
|
29
|
+
* post, so a repost is parsed but marked non-attributable.
|
|
30
|
+
*/
|
|
31
|
+
export function parseLinkedInUnipilePostMetrics(payload) {
|
|
32
|
+
const analytics = isRecord(payload.analytics) ? payload.analytics : null;
|
|
33
|
+
const shape = "reactions_counter" in payload || "comments_counter" in payload || "reposts_counter" in payload
|
|
34
|
+
? "unipile_v2"
|
|
35
|
+
: "reaction_counter" in payload || "comment_counter" in payload || "repost_counter" in payload
|
|
36
|
+
? "unipile_v1"
|
|
37
|
+
: "unknown";
|
|
38
|
+
const reactions = reactionTotal(payload.reactions_counter)
|
|
39
|
+
?? pickMetricCount(payload, ["reaction_counter", "reaction_count", "reactions_count", "likes_count"]);
|
|
40
|
+
const comments = pickMetricCount(payload, ["comments_counter", "comment_counter", "comment_count", "comments_count"]);
|
|
41
|
+
const reposts = pickMetricCount(payload, ["reposts_counter", "repost_counter", "repost_count", "share_count", "shares_count"]);
|
|
42
|
+
let impressions = analytics ? pickMetricCount(analytics, ["impressions_counter", "impressions"]) : null;
|
|
43
|
+
const rejected = [];
|
|
44
|
+
const engagements = (reactions ?? 0) + (comments ?? 0) + (reposts ?? 0);
|
|
45
|
+
// Every reaction, comment and repost came from a member who saw the post, so an
|
|
46
|
+
// impression count below them is not a measurement we can stand behind.
|
|
47
|
+
if (impressions !== null && impressions < engagements) {
|
|
48
|
+
impressions = null;
|
|
49
|
+
rejected.push("impressions");
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
metrics: presentMetrics({ reactions, comments, reposts, impressions }),
|
|
53
|
+
extended: presentMetrics({
|
|
54
|
+
followers_gained: analytics ? readMetricCount(analytics.followers_gained_from_this_post_counter) : null,
|
|
55
|
+
page_viewers: analytics ? readMetricCount(analytics.page_viewers_from_this_post_counter) : null,
|
|
56
|
+
}),
|
|
57
|
+
shape,
|
|
58
|
+
attributable: payload.is_repost !== true,
|
|
59
|
+
rejected,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Every field the LinkedIn parse reads, and nothing else: enough to re-derive the
|
|
64
|
+
* metrics after a parser fix, without storing the post's text, author or media.
|
|
65
|
+
*/
|
|
66
|
+
const LINKEDIN_METRIC_EVIDENCE_KEYS = [
|
|
67
|
+
"id", "social_id", "object", "is_repost", "created_at", "date",
|
|
68
|
+
"reactions_counter", "comments_counter", "reposts_counter", "analytics",
|
|
69
|
+
"reaction_counter", "comment_counter", "repost_counter",
|
|
70
|
+
"reaction_count", "reactions_count", "likes_count", "comment_count", "comments_count",
|
|
71
|
+
"repost_count", "share_count", "shares_count",
|
|
72
|
+
];
|
|
73
|
+
export function linkedInUnipileMetricEvidence(payload) {
|
|
74
|
+
const evidence = {};
|
|
75
|
+
for (const key of LINKEDIN_METRIC_EVIDENCE_KEYS) {
|
|
76
|
+
if (key in payload)
|
|
77
|
+
evidence[key] = payload[key];
|
|
78
|
+
}
|
|
79
|
+
return evidence;
|
|
80
|
+
}
|
|
81
|
+
/** v2 carries one `{reaction, count}` entry per reaction type; v1 a single number. */
|
|
82
|
+
function reactionTotal(value) {
|
|
83
|
+
const direct = readMetricCount(value);
|
|
84
|
+
if (direct !== null)
|
|
85
|
+
return direct;
|
|
86
|
+
if (!Array.isArray(value))
|
|
87
|
+
return null;
|
|
88
|
+
// An empty list is the provider stating "no reactions of any type".
|
|
89
|
+
if (value.length === 0)
|
|
90
|
+
return 0;
|
|
91
|
+
let total = 0;
|
|
92
|
+
let found = false;
|
|
93
|
+
for (const item of value) {
|
|
94
|
+
if (!isRecord(item))
|
|
95
|
+
continue;
|
|
96
|
+
const count = readMetricCount(item.count);
|
|
97
|
+
if (count === null)
|
|
98
|
+
continue;
|
|
99
|
+
total += count;
|
|
100
|
+
found = true;
|
|
101
|
+
}
|
|
102
|
+
return found ? total : null;
|
|
103
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { SocialPostMetric } from "./social-capabilities.js";
|
|
2
|
+
/**
|
|
3
|
+
* Engagement EARNED per day, derived from cumulative provider counters.
|
|
4
|
+
*
|
|
5
|
+
* A provider only ever reports a post's running total. What a founder asks —
|
|
6
|
+
* "how many people did my posts reach this week, and is that growing?" — is the
|
|
7
|
+
* DIFFERENCE between reads, attributed to the days it happened in. The rules:
|
|
8
|
+
*
|
|
9
|
+
* - Every counter is 0 at publish, so the publish instant is the first point.
|
|
10
|
+
* - An observation is flat from `observedAt` through `confirmedAt`; only the gap
|
|
11
|
+
* from one observation's `confirmedAt` to the next one's `observedAt` is unknown.
|
|
12
|
+
* - A delta whose unknown gap is at most `maxProrateMs` is spread across the days
|
|
13
|
+
* of that gap in proportion to time and flagged `estimated` when it spans more
|
|
14
|
+
* than one day. A longer gap (a post discovered weeks after publishing, a sync
|
|
15
|
+
* outage) is NOT smeared across days: its share inside the window is reported as
|
|
16
|
+
* `unattributed`, because pretending to know which day it happened on would be a
|
|
17
|
+
* confident-looking lie.
|
|
18
|
+
* - A negative delta (a deleted comment) is kept, so totals stay net-correct, and
|
|
19
|
+
* counted in `decreases`.
|
|
20
|
+
* - A day no post was tracked on is a gap (`null`), never a zero.
|
|
21
|
+
*/
|
|
22
|
+
export type MetricObservationPoint = {
|
|
23
|
+
observedAt: Date;
|
|
24
|
+
confirmedAt: Date;
|
|
25
|
+
metrics: Partial<Record<SocialPostMetric, number>>;
|
|
26
|
+
};
|
|
27
|
+
export type PostMetricTimeline = {
|
|
28
|
+
publishedAt: Date | null;
|
|
29
|
+
/** Ascending by observedAt. */
|
|
30
|
+
observations: MetricObservationPoint[];
|
|
31
|
+
};
|
|
32
|
+
export type EarnedDay = {
|
|
33
|
+
/** Local calendar date in the requested time zone. */
|
|
34
|
+
date: string;
|
|
35
|
+
value: number | null;
|
|
36
|
+
estimated: boolean;
|
|
37
|
+
postsTracked: number;
|
|
38
|
+
};
|
|
39
|
+
export type EarnedWindow = {
|
|
40
|
+
days: EarnedDay[];
|
|
41
|
+
/** Sum of the day values plus `unattributed`; null when nothing was tracked. */
|
|
42
|
+
total: number | null;
|
|
43
|
+
/** Earned inside the window across gaps too long to place on a day. */
|
|
44
|
+
unattributed: number;
|
|
45
|
+
decreases: number;
|
|
46
|
+
postsReporting: number;
|
|
47
|
+
};
|
|
48
|
+
export declare const EARNED_SERIES_MAX_PRORATE_MS: number;
|
|
49
|
+
/**
|
|
50
|
+
* Earned per day for one metric, or the sum of several (engagements), across a
|
|
51
|
+
* set of posts. `dayStarts` are the instants each local day begins; `windowEnd`
|
|
52
|
+
* closes the last day.
|
|
53
|
+
*/
|
|
54
|
+
export declare function computeEarnedWindow(input: {
|
|
55
|
+
timelines: readonly PostMetricTimeline[];
|
|
56
|
+
metrics: readonly SocialPostMetric[];
|
|
57
|
+
dayStarts: readonly {
|
|
58
|
+
date: string;
|
|
59
|
+
start: Date;
|
|
60
|
+
}[];
|
|
61
|
+
windowEnd: Date;
|
|
62
|
+
maxProrateMs?: number;
|
|
63
|
+
}): EarnedWindow;
|
|
64
|
+
export type ValueAtAge = {
|
|
65
|
+
value: number;
|
|
66
|
+
interpolated: boolean;
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* A post's cumulative metric (or sum of metrics) exactly `ageMs` after publishing,
|
|
70
|
+
* so posts can be compared at the same age ("impressions after 24 hours"). Exact
|
|
71
|
+
* inside a known-flat interval; interpolated linearly across a gap no longer than
|
|
72
|
+
* `maxProrateMs`; null when the age falls in a longer gap, before the first read,
|
|
73
|
+
* after the last confirmed read, or when the post has no publish time.
|
|
74
|
+
*/
|
|
75
|
+
export declare function valueAtAge(input: {
|
|
76
|
+
timeline: PostMetricTimeline;
|
|
77
|
+
metrics: readonly SocialPostMetric[];
|
|
78
|
+
ageMs: number;
|
|
79
|
+
maxProrateMs?: number;
|
|
80
|
+
}): ValueAtAge | null;
|
|
81
|
+
/** Median of the values, or null for none. */
|
|
82
|
+
export declare function medianOf(values: readonly number[]): number | null;
|
|
83
|
+
/**
|
|
84
|
+
* The instant each calendar day in [fromDate, toDate] (inclusive, YYYY-MM-DD)
|
|
85
|
+
* starts in `timeZone`, plus the instant after the last day. DST-safe: each start
|
|
86
|
+
* is resolved against that day's own offset.
|
|
87
|
+
*/
|
|
88
|
+
export declare function zonedDayStarts(fromDate: string, toDate: string, timeZone: string): {
|
|
89
|
+
days: {
|
|
90
|
+
date: string;
|
|
91
|
+
start: Date;
|
|
92
|
+
}[];
|
|
93
|
+
end: Date;
|
|
94
|
+
};
|
|
95
|
+
/** An IANA zone Intl accepts, or null. Validated here so Postgres never sees a bad one. */
|
|
96
|
+
export declare function validTimeZone(value: string | null | undefined): string | null;
|