openvolo 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +175 -0
- package/components.json +20 -0
- package/dist/cli.js +992 -0
- package/drizzle.config.ts +14 -0
- package/next.config.mjs +7 -0
- package/package.json +91 -0
- package/postcss.config.mjs +7 -0
- package/public/android-chrome-192x192.png +0 -0
- package/public/android-chrome-512x512.png +0 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/assets/openvolo-logo-black.png +0 -0
- package/public/assets/openvolo-logo-name.png +0 -0
- package/public/assets/openvolo-logo-transparent.png +0 -0
- package/public/favicon-16x16.png +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/site.webmanifest +19 -0
- package/src/app/api/analytics/agents/route.ts +30 -0
- package/src/app/api/analytics/content/route.ts +24 -0
- package/src/app/api/analytics/engagement/route.ts +24 -0
- package/src/app/api/analytics/overview/route.ts +22 -0
- package/src/app/api/analytics/sync-health/route.ts +22 -0
- package/src/app/api/contacts/[id]/identities/[identityId]/route.ts +24 -0
- package/src/app/api/contacts/[id]/identities/route.ts +61 -0
- package/src/app/api/contacts/[id]/route.ts +72 -0
- package/src/app/api/contacts/route.ts +91 -0
- package/src/app/api/content/[id]/route.ts +61 -0
- package/src/app/api/content/route.ts +48 -0
- package/src/app/api/platforms/gmail/auth/route.ts +50 -0
- package/src/app/api/platforms/gmail/callback/route.ts +126 -0
- package/src/app/api/platforms/gmail/route.ts +60 -0
- package/src/app/api/platforms/gmail/sync/route.ts +96 -0
- package/src/app/api/platforms/linkedin/auth/route.ts +40 -0
- package/src/app/api/platforms/linkedin/callback/route.ts +128 -0
- package/src/app/api/platforms/linkedin/import/route.ts +40 -0
- package/src/app/api/platforms/linkedin/route.ts +60 -0
- package/src/app/api/platforms/linkedin/sync/route.ts +85 -0
- package/src/app/api/platforms/x/auth/route.ts +52 -0
- package/src/app/api/platforms/x/browser-session/route.ts +79 -0
- package/src/app/api/platforms/x/callback/route.ts +130 -0
- package/src/app/api/platforms/x/compose/route.ts +247 -0
- package/src/app/api/platforms/x/engage/route.ts +113 -0
- package/src/app/api/platforms/x/enrich/route.ts +79 -0
- package/src/app/api/platforms/x/route.ts +63 -0
- package/src/app/api/platforms/x/sync/route.ts +142 -0
- package/src/app/api/settings/route.ts +43 -0
- package/src/app/api/settings/search-api/route.ts +180 -0
- package/src/app/api/tasks/[id]/route.ts +60 -0
- package/src/app/api/tasks/route.ts +39 -0
- package/src/app/api/workflows/[id]/progress/route.ts +45 -0
- package/src/app/api/workflows/[id]/route.ts +20 -0
- package/src/app/api/workflows/route.ts +30 -0
- package/src/app/api/workflows/run-agent/route.ts +44 -0
- package/src/app/api/workflows/templates/[id]/activate/route.ts +64 -0
- package/src/app/api/workflows/templates/[id]/route.ts +75 -0
- package/src/app/api/workflows/templates/route.ts +60 -0
- package/src/app/dashboard/analytics/analytics-dashboard.tsx +535 -0
- package/src/app/dashboard/analytics/page.tsx +15 -0
- package/src/app/dashboard/contacts/[id]/contact-detail-client.tsx +334 -0
- package/src/app/dashboard/contacts/[id]/page.tsx +21 -0
- package/src/app/dashboard/contacts/contact-list-client.tsx +213 -0
- package/src/app/dashboard/contacts/page.tsx +38 -0
- package/src/app/dashboard/content/[id]/engagement-actions.tsx +167 -0
- package/src/app/dashboard/content/[id]/page.tsx +253 -0
- package/src/app/dashboard/content/content-list-client.tsx +428 -0
- package/src/app/dashboard/content/page.tsx +39 -0
- package/src/app/dashboard/help/page.tsx +1247 -0
- package/src/app/dashboard/layout.tsx +19 -0
- package/src/app/dashboard/page.tsx +187 -0
- package/src/app/dashboard/settings/page.tsx +1664 -0
- package/src/app/dashboard/workflows/[id]/page.tsx +90 -0
- package/src/app/dashboard/workflows/[id]/workflow-detail-steps.tsx +55 -0
- package/src/app/dashboard/workflows/[id]/workflow-run-live.tsx +195 -0
- package/src/app/dashboard/workflows/activate-dialog.tsx +251 -0
- package/src/app/dashboard/workflows/page.tsx +41 -0
- package/src/app/dashboard/workflows/template-gallery.tsx +201 -0
- package/src/app/dashboard/workflows/workflow-quick-actions.tsx +121 -0
- package/src/app/dashboard/workflows/workflow-view-switcher.tsx +62 -0
- package/src/app/globals.css +232 -0
- package/src/app/layout.tsx +57 -0
- package/src/app/page.tsx +5 -0
- package/src/components/add-contact-dialog.tsx +74 -0
- package/src/components/add-task-dialog.tsx +153 -0
- package/src/components/animated-stat.tsx +53 -0
- package/src/components/app-sidebar.tsx +130 -0
- package/src/components/charts/area-chart-card.tsx +99 -0
- package/src/components/charts/bar-chart-card.tsx +128 -0
- package/src/components/charts/chart-skeleton.tsx +43 -0
- package/src/components/charts/donut-chart-card.tsx +100 -0
- package/src/components/charts/ranked-table-card.tsx +127 -0
- package/src/components/charts/stat-cards-row.tsx +45 -0
- package/src/components/compose-dialog.tsx +344 -0
- package/src/components/contact-form.tsx +218 -0
- package/src/components/dashboard-greeting.tsx +27 -0
- package/src/components/dashboard-header.tsx +87 -0
- package/src/components/empty-state.tsx +32 -0
- package/src/components/enrich-button.tsx +107 -0
- package/src/components/enrichment-score-badge.tsx +30 -0
- package/src/components/funnel-stage-badge.tsx +19 -0
- package/src/components/funnel-visualization.tsx +66 -0
- package/src/components/identities-section.tsx +219 -0
- package/src/components/pagination-controls.tsx +115 -0
- package/src/components/platform-connection-card.tsx +292 -0
- package/src/components/priority-badge.tsx +17 -0
- package/src/components/step-output-renderer.tsx +63 -0
- package/src/components/tweet-input.tsx +126 -0
- package/src/components/ui/alert-dialog.tsx +196 -0
- package/src/components/ui/avatar.tsx +109 -0
- package/src/components/ui/badge.tsx +48 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/chart.tsx +357 -0
- package/src/components/ui/dialog.tsx +158 -0
- package/src/components/ui/dropdown-menu.tsx +257 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +24 -0
- package/src/components/ui/progress.tsx +31 -0
- package/src/components/ui/scroll-area.tsx +58 -0
- package/src/components/ui/select.tsx +190 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +143 -0
- package/src/components/ui/sidebar.tsx +726 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/table.tsx +116 -0
- package/src/components/ui/tabs.tsx +91 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +57 -0
- package/src/components/workflow-graph-view.tsx +205 -0
- package/src/components/workflow-kanban-view.tsx +69 -0
- package/src/components/workflow-list-view.tsx +201 -0
- package/src/components/workflow-progress-card.tsx +150 -0
- package/src/components/workflow-run-card.tsx +144 -0
- package/src/components/workflow-step-timeline.tsx +173 -0
- package/src/components/workflow-swimlane-view.tsx +87 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/use-workflow-polling.ts +85 -0
- package/src/lib/agents/router.ts +79 -0
- package/src/lib/agents/run-agent-workflow.ts +605 -0
- package/src/lib/agents/tools/browser-scrape.ts +118 -0
- package/src/lib/agents/tools/enrich-contact.ts +128 -0
- package/src/lib/agents/tools/search-web.ts +473 -0
- package/src/lib/agents/tools/update-progress.ts +40 -0
- package/src/lib/agents/tools/url-fetch.ts +152 -0
- package/src/lib/agents/types.ts +79 -0
- package/src/lib/analytics/utils.ts +33 -0
- package/src/lib/auth/claude-auth.ts +134 -0
- package/src/lib/auth/crypto.ts +58 -0
- package/src/lib/browser/anti-detection.ts +79 -0
- package/src/lib/browser/extractors/profile-merger.ts +71 -0
- package/src/lib/browser/extractors/profile-parser.ts +133 -0
- package/src/lib/browser/platforms/x-scraper.ts +269 -0
- package/src/lib/browser/scraper.ts +92 -0
- package/src/lib/browser/session.ts +229 -0
- package/src/lib/browser/types.ts +80 -0
- package/src/lib/db/client.ts +24 -0
- package/src/lib/db/enrichment.ts +90 -0
- package/src/lib/db/migrate-identities.ts +95 -0
- package/src/lib/db/migrate.ts +33 -0
- package/src/lib/db/migrations/0000_tired_thanos.sql +296 -0
- package/src/lib/db/migrations/meta/0000_snapshot.json +2169 -0
- package/src/lib/db/migrations/meta/_journal.json +13 -0
- package/src/lib/db/queries/analytics.ts +449 -0
- package/src/lib/db/queries/contacts.ts +170 -0
- package/src/lib/db/queries/content.ts +215 -0
- package/src/lib/db/queries/dashboard.ts +79 -0
- package/src/lib/db/queries/engagements.ts +35 -0
- package/src/lib/db/queries/identities.ts +51 -0
- package/src/lib/db/queries/platform-accounts.ts +53 -0
- package/src/lib/db/queries/sync.ts +74 -0
- package/src/lib/db/queries/tasks.ts +88 -0
- package/src/lib/db/queries/workflow-templates.ts +213 -0
- package/src/lib/db/queries/workflows.ts +167 -0
- package/src/lib/db/schema.ts +437 -0
- package/src/lib/db/seed-templates.ts +221 -0
- package/src/lib/db/types.ts +78 -0
- package/src/lib/pagination.ts +12 -0
- package/src/lib/platforms/adapter.ts +75 -0
- package/src/lib/platforms/gmail/adapter.ts +112 -0
- package/src/lib/platforms/gmail/auth.ts +137 -0
- package/src/lib/platforms/gmail/client.ts +255 -0
- package/src/lib/platforms/gmail/mappers.ts +125 -0
- package/src/lib/platforms/gmail/oauth-state-store.ts +65 -0
- package/src/lib/platforms/index.ts +22 -0
- package/src/lib/platforms/linkedin/adapter.ts +164 -0
- package/src/lib/platforms/linkedin/auth.ts +124 -0
- package/src/lib/platforms/linkedin/client.ts +183 -0
- package/src/lib/platforms/linkedin/csv-import.ts +283 -0
- package/src/lib/platforms/linkedin/mappers.ts +123 -0
- package/src/lib/platforms/linkedin/oauth-state-store.ts +65 -0
- package/src/lib/platforms/rate-limiter.ts +88 -0
- package/src/lib/platforms/sync-contacts.ts +121 -0
- package/src/lib/platforms/sync-content.ts +225 -0
- package/src/lib/platforms/sync-gmail-contacts.ts +186 -0
- package/src/lib/platforms/sync-gmail-metadata.ts +158 -0
- package/src/lib/platforms/sync-linkedin-contacts.ts +148 -0
- package/src/lib/platforms/sync-x-profiles.ts +280 -0
- package/src/lib/platforms/x/adapter.ts +129 -0
- package/src/lib/platforms/x/auth.ts +165 -0
- package/src/lib/platforms/x/client.ts +390 -0
- package/src/lib/platforms/x/mappers.ts +134 -0
- package/src/lib/platforms/x/pkce-store.ts +67 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/workflows/format-error.test.ts +177 -0
- package/src/lib/workflows/format-error.ts +207 -0
- package/src/lib/workflows/run-sync-workflow.ts +141 -0
- package/src/lib/workflows/types.ts +71 -0
- package/tsconfig.json +42 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { eq, and } from "drizzle-orm";
|
|
2
|
+
import { db } from "@/lib/db/client";
|
|
3
|
+
import { contacts, contactIdentities } from "@/lib/db/schema";
|
|
4
|
+
import { createContact, updateContact, recalcEnrichment } from "@/lib/db/queries/contacts";
|
|
5
|
+
import { createIdentity } from "@/lib/db/queries/identities";
|
|
6
|
+
import { updatePlatformAccount } from "@/lib/db/queries/platform-accounts";
|
|
7
|
+
import { getSyncCursor, updateSyncCursor } from "@/lib/db/queries/sync";
|
|
8
|
+
import {
|
|
9
|
+
mapLinkedInConnectionToContact,
|
|
10
|
+
mapLinkedInConnectionToIdentity,
|
|
11
|
+
} from "@/lib/platforms/linkedin/mappers";
|
|
12
|
+
import { getConnections } from "@/lib/platforms/linkedin/client";
|
|
13
|
+
import type { LinkedInConnection } from "@/lib/platforms/linkedin/client";
|
|
14
|
+
import type { SyncResult } from "@/lib/platforms/adapter";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Sync contacts from LinkedIn connections into OpenVolo.
|
|
18
|
+
* Uses offset-based pagination (not cursor-based like X).
|
|
19
|
+
* Cross-platform dedup: matches by LinkedIn ID, then by email.
|
|
20
|
+
*/
|
|
21
|
+
export async function syncContactsFromLinkedIn(
|
|
22
|
+
accountId: string,
|
|
23
|
+
opts?: { maxPages?: number }
|
|
24
|
+
): Promise<SyncResult> {
|
|
25
|
+
const result: SyncResult = { added: 0, updated: 0, skipped: 0, errors: [] };
|
|
26
|
+
const maxPages = opts?.maxPages ?? 10; // Safety limit: 10 pages * 50 = 500 contacts max
|
|
27
|
+
|
|
28
|
+
// Get or create sync cursor for tracking
|
|
29
|
+
const cursor = getSyncCursor(accountId, "connections");
|
|
30
|
+
updateSyncCursor(cursor.id, {
|
|
31
|
+
syncStatus: "syncing",
|
|
32
|
+
lastSyncStartedAt: Math.floor(Date.now() / 1000),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
let start = 0;
|
|
37
|
+
let page = 0;
|
|
38
|
+
|
|
39
|
+
while (page < maxPages) {
|
|
40
|
+
const res = await getConnections(accountId, { start, count: 50 });
|
|
41
|
+
const connections = res.elements ?? [];
|
|
42
|
+
if (connections.length === 0) break;
|
|
43
|
+
|
|
44
|
+
// Process each connection
|
|
45
|
+
for (const connection of connections) {
|
|
46
|
+
try {
|
|
47
|
+
processLinkedInConnection(connection, result);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
const name = `${connection.localizedFirstName ?? ""} ${connection.localizedLastName ?? ""}`.trim();
|
|
50
|
+
result.errors.push(
|
|
51
|
+
`Failed to process ${name || connection.id}: ${err instanceof Error ? err.message : String(err)}`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Update cursor progress
|
|
57
|
+
updateSyncCursor(cursor.id, {
|
|
58
|
+
totalItemsSynced: (cursor.totalItemsSynced ?? 0) + connections.length,
|
|
59
|
+
cursor: String(start + connections.length),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Check if there are more pages
|
|
63
|
+
const total = res.paging?.total ?? 0;
|
|
64
|
+
start += res.paging?.count ?? 50;
|
|
65
|
+
if (start >= total) break;
|
|
66
|
+
page++;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Update sync timestamps — always set lastSyncCompletedAt (partial sync pattern)
|
|
70
|
+
const now = Math.floor(Date.now() / 1000);
|
|
71
|
+
updateSyncCursor(cursor.id, {
|
|
72
|
+
syncStatus: "completed",
|
|
73
|
+
lastSyncCompletedAt: now,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
updatePlatformAccount(accountId, {
|
|
77
|
+
lastSyncedAt: now,
|
|
78
|
+
});
|
|
79
|
+
} catch (err) {
|
|
80
|
+
result.errors.push(
|
|
81
|
+
`Sync failed: ${err instanceof Error ? err.message : String(err)}`
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Always set lastSyncCompletedAt even on error (partial sync pattern)
|
|
85
|
+
updateSyncCursor(cursor.id, {
|
|
86
|
+
syncStatus: "failed",
|
|
87
|
+
lastSyncCompletedAt: Math.floor(Date.now() / 1000),
|
|
88
|
+
lastError: err instanceof Error ? err.message : String(err),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Process a single LinkedIn connection — create or update contact + identity with cross-platform dedup. */
|
|
96
|
+
function processLinkedInConnection(connection: LinkedInConnection, result: SyncResult): void {
|
|
97
|
+
// 1. Check for existing identity by (platform="linkedin", platformUserId)
|
|
98
|
+
const existingIdentity = db
|
|
99
|
+
.select()
|
|
100
|
+
.from(contactIdentities)
|
|
101
|
+
.where(
|
|
102
|
+
and(
|
|
103
|
+
eq(contactIdentities.platform, "linkedin"),
|
|
104
|
+
eq(contactIdentities.platformUserId, connection.id)
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
.get();
|
|
108
|
+
|
|
109
|
+
if (existingIdentity) {
|
|
110
|
+
// Update existing contact with latest LinkedIn data
|
|
111
|
+
const contactData = mapLinkedInConnectionToContact(connection);
|
|
112
|
+
updateContact(existingIdentity.contactId, {
|
|
113
|
+
headline: contactData.headline,
|
|
114
|
+
photoUrl: contactData.photoUrl,
|
|
115
|
+
avatarUrl: contactData.avatarUrl,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Update identity with latest platform data
|
|
119
|
+
db.update(contactIdentities)
|
|
120
|
+
.set({
|
|
121
|
+
platformHandle: mapLinkedInConnectionToIdentity(connection, existingIdentity.contactId).platformHandle,
|
|
122
|
+
platformData: mapLinkedInConnectionToIdentity(connection, existingIdentity.contactId).platformData,
|
|
123
|
+
lastSyncedAt: Math.floor(Date.now() / 1000),
|
|
124
|
+
updatedAt: Math.floor(Date.now() / 1000),
|
|
125
|
+
})
|
|
126
|
+
.where(eq(contactIdentities.id, existingIdentity.id))
|
|
127
|
+
.run();
|
|
128
|
+
|
|
129
|
+
recalcEnrichment(existingIdentity.contactId);
|
|
130
|
+
result.updated++;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 2. Cross-platform dedup: check if we can match by name (first+last)
|
|
135
|
+
// Note: email is not available at the connection level without additional API scopes
|
|
136
|
+
const contactData = mapLinkedInConnectionToContact(connection);
|
|
137
|
+
|
|
138
|
+
// Create new contact
|
|
139
|
+
const contact = createContact(contactData);
|
|
140
|
+
|
|
141
|
+
// Create identity linking this contact to the LinkedIn connection
|
|
142
|
+
const identityData = mapLinkedInConnectionToIdentity(connection, contact.id);
|
|
143
|
+
createIdentity(identityData);
|
|
144
|
+
|
|
145
|
+
// Recompute enrichment with the new identity
|
|
146
|
+
recalcEnrichment(contact.id);
|
|
147
|
+
result.added++;
|
|
148
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { eq, and, asc, isNotNull, inArray } from "drizzle-orm";
|
|
2
|
+
import { db } from "@/lib/db/client";
|
|
3
|
+
import { contacts, contactIdentities } from "@/lib/db/schema";
|
|
4
|
+
import { updateContact } from "@/lib/db/queries/contacts";
|
|
5
|
+
import { updateIdentity } from "@/lib/db/queries/identities";
|
|
6
|
+
import { updatePlatformAccount } from "@/lib/db/queries/platform-accounts";
|
|
7
|
+
import { getSyncCursor, updateSyncCursor } from "@/lib/db/queries/sync";
|
|
8
|
+
import { XScraper } from "@/lib/browser/platforms/x-scraper";
|
|
9
|
+
import { parseProfile } from "@/lib/browser/extractors/profile-parser";
|
|
10
|
+
import {
|
|
11
|
+
mergeProfileData,
|
|
12
|
+
buildPlatformDataUpdate,
|
|
13
|
+
} from "@/lib/browser/extractors/profile-merger";
|
|
14
|
+
import type { SyncResult } from "@/lib/platforms/adapter";
|
|
15
|
+
|
|
16
|
+
/** Contact with its X identity info, ready for enrichment. */
|
|
17
|
+
interface EnrichableContact {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
company: string | null;
|
|
21
|
+
title: string | null;
|
|
22
|
+
headline: string | null;
|
|
23
|
+
email: string | null;
|
|
24
|
+
phone: string | null;
|
|
25
|
+
metadata: string | null;
|
|
26
|
+
enrichmentScore: number;
|
|
27
|
+
identityId: string;
|
|
28
|
+
platformHandle: string;
|
|
29
|
+
platformData: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Minimum days between re-scraping the same contact. */
|
|
33
|
+
const COOLDOWN_DAYS = 7;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Enrich X contacts by scraping their profile pages and extracting
|
|
37
|
+
* structured data via LLM. Follows the sync-gmail-metadata.ts pattern:
|
|
38
|
+
* sync cursor lifecycle, per-contact error isolation, partial failure.
|
|
39
|
+
*/
|
|
40
|
+
export async function syncXProfiles(
|
|
41
|
+
accountId: string,
|
|
42
|
+
opts?: {
|
|
43
|
+
maxProfiles?: number;
|
|
44
|
+
contactIds?: string[];
|
|
45
|
+
}
|
|
46
|
+
): Promise<SyncResult> {
|
|
47
|
+
const maxProfiles = opts?.maxProfiles ?? 15;
|
|
48
|
+
const result: SyncResult = { added: 0, updated: 0, skipped: 0, errors: [] };
|
|
49
|
+
|
|
50
|
+
// 1. Get or create sync cursor
|
|
51
|
+
const cursor = getSyncCursor(accountId, "x_profiles");
|
|
52
|
+
updateSyncCursor(cursor.id, {
|
|
53
|
+
syncStatus: "syncing",
|
|
54
|
+
lastSyncStartedAt: Math.floor(Date.now() / 1000),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
let scraper: XScraper | null = null;
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
// 2. Select contacts to enrich
|
|
61
|
+
const enrichable = selectContactsForEnrichment({
|
|
62
|
+
maxProfiles,
|
|
63
|
+
contactIds: opts?.contactIds,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (enrichable.length === 0) {
|
|
67
|
+
updateSyncCursor(cursor.id, {
|
|
68
|
+
syncStatus: "completed",
|
|
69
|
+
lastSyncCompletedAt: Math.floor(Date.now() / 1000),
|
|
70
|
+
});
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 3. Initialize scraper and validate session
|
|
75
|
+
scraper = new XScraper();
|
|
76
|
+
await scraper.init();
|
|
77
|
+
|
|
78
|
+
if (!(await scraper.validateSession())) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
"X browser session is invalid or expired. Please re-authenticate in Settings."
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let processed = 0;
|
|
85
|
+
|
|
86
|
+
// 4. Per-contact enrichment loop
|
|
87
|
+
for (const contact of enrichable) {
|
|
88
|
+
if (scraper.batchLimitReached) {
|
|
89
|
+
result.errors.push(
|
|
90
|
+
`Batch limit reached after ${processed} profiles. Remaining contacts will be enriched in the next batch.`
|
|
91
|
+
);
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
// 4a. Scrape profile page
|
|
97
|
+
const raw = await scraper.scrapeProfile(contact.platformHandle);
|
|
98
|
+
if (!raw) {
|
|
99
|
+
result.skipped++;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 4b. LLM extraction
|
|
104
|
+
const parsed = await parseProfile(raw);
|
|
105
|
+
|
|
106
|
+
// 4c. Merge into contact (fill gaps, don't overwrite)
|
|
107
|
+
// Build a minimal Contact-like object for the merger
|
|
108
|
+
const contactForMerge = {
|
|
109
|
+
id: contact.id,
|
|
110
|
+
name: contact.name,
|
|
111
|
+
company: contact.company,
|
|
112
|
+
title: contact.title,
|
|
113
|
+
headline: contact.headline,
|
|
114
|
+
email: contact.email,
|
|
115
|
+
phone: contact.phone,
|
|
116
|
+
metadata: contact.metadata,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const updates = mergeProfileData(
|
|
120
|
+
contactForMerge as Parameters<typeof mergeProfileData>[0],
|
|
121
|
+
parsed,
|
|
122
|
+
raw
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
if (Object.keys(updates).length > 0) {
|
|
126
|
+
// updateContact auto-calls recalcEnrichment(contactId)
|
|
127
|
+
updateContact(contact.id, updates);
|
|
128
|
+
result.updated++;
|
|
129
|
+
} else {
|
|
130
|
+
result.skipped++;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 4d. Update identity platformData for rich data scoring (+10 pts)
|
|
134
|
+
const updatedPlatformData = buildPlatformDataUpdate(
|
|
135
|
+
contact.platformData,
|
|
136
|
+
parsed
|
|
137
|
+
);
|
|
138
|
+
updateIdentity(contact.identityId, {
|
|
139
|
+
platformData: updatedPlatformData,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
processed++;
|
|
143
|
+
} catch (err) {
|
|
144
|
+
const message =
|
|
145
|
+
err instanceof Error ? err.message : String(err);
|
|
146
|
+
result.errors.push(
|
|
147
|
+
`Failed to enrich @${contact.platformHandle}: ${message}`
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// Stop batch on challenge/CAPTCHA detection
|
|
151
|
+
if (message.includes("Challenge/CAPTCHA")) {
|
|
152
|
+
result.errors.push("Stopping batch due to challenge detection.");
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 5. Mark sync completed
|
|
159
|
+
const now = Math.floor(Date.now() / 1000);
|
|
160
|
+
updateSyncCursor(cursor.id, {
|
|
161
|
+
syncStatus: "completed",
|
|
162
|
+
totalItemsSynced: (cursor.totalItemsSynced ?? 0) + processed,
|
|
163
|
+
lastSyncCompletedAt: now,
|
|
164
|
+
});
|
|
165
|
+
updatePlatformAccount(accountId, { lastSyncedAt: now });
|
|
166
|
+
} catch (err) {
|
|
167
|
+
// Partial sync pattern: ALWAYS set lastSyncCompletedAt even on error
|
|
168
|
+
result.errors.push(
|
|
169
|
+
`Profile enrichment failed: ${err instanceof Error ? err.message : String(err)}`
|
|
170
|
+
);
|
|
171
|
+
updateSyncCursor(cursor.id, {
|
|
172
|
+
syncStatus: "failed",
|
|
173
|
+
lastSyncCompletedAt: Math.floor(Date.now() / 1000),
|
|
174
|
+
lastError: err instanceof Error ? err.message : String(err),
|
|
175
|
+
});
|
|
176
|
+
} finally {
|
|
177
|
+
await scraper?.close();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Select contacts for browser enrichment.
|
|
185
|
+
* Prioritizes low enrichment scores, skips recently scraped (7-day cooldown).
|
|
186
|
+
*/
|
|
187
|
+
function selectContactsForEnrichment(opts: {
|
|
188
|
+
maxProfiles: number;
|
|
189
|
+
contactIds?: string[];
|
|
190
|
+
}): EnrichableContact[] {
|
|
191
|
+
const cooldownThreshold =
|
|
192
|
+
Math.floor(Date.now() / 1000) - COOLDOWN_DAYS * 86400;
|
|
193
|
+
|
|
194
|
+
// Base query: contacts with an X identity that has a handle
|
|
195
|
+
let rows;
|
|
196
|
+
if (opts.contactIds?.length) {
|
|
197
|
+
// Specific contacts requested
|
|
198
|
+
rows = db
|
|
199
|
+
.select({
|
|
200
|
+
id: contacts.id,
|
|
201
|
+
name: contacts.name,
|
|
202
|
+
company: contacts.company,
|
|
203
|
+
title: contacts.title,
|
|
204
|
+
headline: contacts.headline,
|
|
205
|
+
email: contacts.email,
|
|
206
|
+
phone: contacts.phone,
|
|
207
|
+
metadata: contacts.metadata,
|
|
208
|
+
enrichmentScore: contacts.enrichmentScore,
|
|
209
|
+
identityId: contactIdentities.id,
|
|
210
|
+
platformHandle: contactIdentities.platformHandle,
|
|
211
|
+
platformData: contactIdentities.platformData,
|
|
212
|
+
})
|
|
213
|
+
.from(contacts)
|
|
214
|
+
.innerJoin(
|
|
215
|
+
contactIdentities,
|
|
216
|
+
eq(contacts.id, contactIdentities.contactId)
|
|
217
|
+
)
|
|
218
|
+
.where(
|
|
219
|
+
and(
|
|
220
|
+
inArray(contacts.id, opts.contactIds),
|
|
221
|
+
eq(contactIdentities.platform, "x"),
|
|
222
|
+
isNotNull(contactIdentities.platformHandle)
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
.all();
|
|
226
|
+
} else {
|
|
227
|
+
// Auto-select: lowest enrichment scores first
|
|
228
|
+
// Fetch more than needed to account for cooldown filtering
|
|
229
|
+
rows = db
|
|
230
|
+
.select({
|
|
231
|
+
id: contacts.id,
|
|
232
|
+
name: contacts.name,
|
|
233
|
+
company: contacts.company,
|
|
234
|
+
title: contacts.title,
|
|
235
|
+
headline: contacts.headline,
|
|
236
|
+
email: contacts.email,
|
|
237
|
+
phone: contacts.phone,
|
|
238
|
+
metadata: contacts.metadata,
|
|
239
|
+
enrichmentScore: contacts.enrichmentScore,
|
|
240
|
+
identityId: contactIdentities.id,
|
|
241
|
+
platformHandle: contactIdentities.platformHandle,
|
|
242
|
+
platformData: contactIdentities.platformData,
|
|
243
|
+
})
|
|
244
|
+
.from(contacts)
|
|
245
|
+
.innerJoin(
|
|
246
|
+
contactIdentities,
|
|
247
|
+
eq(contacts.id, contactIdentities.contactId)
|
|
248
|
+
)
|
|
249
|
+
.where(
|
|
250
|
+
and(
|
|
251
|
+
eq(contactIdentities.platform, "x"),
|
|
252
|
+
isNotNull(contactIdentities.platformHandle)
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
.orderBy(asc(contacts.enrichmentScore))
|
|
256
|
+
.limit(opts.maxProfiles * 2) // over-fetch for cooldown filtering
|
|
257
|
+
.all();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Filter: skip recently scraped contacts (7-day cooldown)
|
|
261
|
+
const filtered = rows.filter((row) => {
|
|
262
|
+
if (!row.metadata) return true;
|
|
263
|
+
try {
|
|
264
|
+
const meta = JSON.parse(row.metadata);
|
|
265
|
+
const scrapedAt = meta?.browserEnrichment?.scrapedAt;
|
|
266
|
+
if (typeof scrapedAt === "number" && scrapedAt > cooldownThreshold) {
|
|
267
|
+
return false; // too recent
|
|
268
|
+
}
|
|
269
|
+
} catch {
|
|
270
|
+
// invalid metadata JSON — allow enrichment
|
|
271
|
+
}
|
|
272
|
+
return true;
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Cast platformHandle (Drizzle returns string | null from isNotNull)
|
|
276
|
+
return filtered.slice(0, opts.maxProfiles).map((row) => ({
|
|
277
|
+
...row,
|
|
278
|
+
platformHandle: row.platformHandle!,
|
|
279
|
+
}));
|
|
280
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PlatformAdapter,
|
|
3
|
+
PlatformCredentials,
|
|
4
|
+
PlatformUserProfile,
|
|
5
|
+
PaginatedResult,
|
|
6
|
+
RateLimitState,
|
|
7
|
+
} from "@/lib/platforms/adapter";
|
|
8
|
+
import { getXClientCredentials, refreshXTokenAsync, disconnectXAccount } from "@/lib/platforms/x/auth";
|
|
9
|
+
import { savePkceState } from "@/lib/platforms/x/pkce-store";
|
|
10
|
+
import {
|
|
11
|
+
getAuthenticatedUser,
|
|
12
|
+
getUserById as xGetUserById,
|
|
13
|
+
getFollowing,
|
|
14
|
+
} from "@/lib/platforms/x/client";
|
|
15
|
+
import { getRateLimitState } from "@/lib/platforms/rate-limiter";
|
|
16
|
+
import { getPlatformAccountByPlatform } from "@/lib/db/queries/platform-accounts";
|
|
17
|
+
import { randomBytes, createHash } from "crypto";
|
|
18
|
+
import type { XUser } from "@/lib/platforms/x/client";
|
|
19
|
+
|
|
20
|
+
/** Map an XUser to the normalized PlatformUserProfile. */
|
|
21
|
+
function toProfile(xUser: XUser): PlatformUserProfile {
|
|
22
|
+
return {
|
|
23
|
+
platformUserId: xUser.id,
|
|
24
|
+
platformHandle: `@${xUser.username}`,
|
|
25
|
+
displayName: xUser.name,
|
|
26
|
+
bio: xUser.description ?? null,
|
|
27
|
+
location: xUser.location ?? null,
|
|
28
|
+
website: xUser.url ?? null,
|
|
29
|
+
photoUrl: xUser.profile_image_url?.replace("_normal", "_400x400") ?? null,
|
|
30
|
+
followersCount: xUser.public_metrics?.followers_count ?? 0,
|
|
31
|
+
followingCount: xUser.public_metrics?.following_count ?? 0,
|
|
32
|
+
rawData: xUser as unknown as Record<string, unknown>,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class XPlatformAdapter implements PlatformAdapter {
|
|
37
|
+
readonly platform = "x" as const;
|
|
38
|
+
|
|
39
|
+
async getAuthorizationUrl(redirectUri: string, extended = false): Promise<{ authUrl: string; state: string }> {
|
|
40
|
+
const { clientId } = getXClientCredentials();
|
|
41
|
+
|
|
42
|
+
const codeVerifier = randomBytes(32).toString("base64url");
|
|
43
|
+
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
|
|
44
|
+
const state = randomBytes(16).toString("hex");
|
|
45
|
+
|
|
46
|
+
savePkceState(state, codeVerifier, extended);
|
|
47
|
+
|
|
48
|
+
// Free tier: CRM engagement + messaging; Extended (Basic+): adds contact management
|
|
49
|
+
const FREE_SCOPES = "tweet.read tweet.write tweet.moderate.write users.read like.read like.write bookmark.read bookmark.write dm.read dm.write offline.access";
|
|
50
|
+
const EXTENDED_SCOPES = "tweet.read tweet.write tweet.moderate.write users.read like.read like.write bookmark.read bookmark.write dm.read dm.write follows.read follows.write list.read list.write mute.read mute.write block.read block.write space.read offline.access";
|
|
51
|
+
|
|
52
|
+
const params = new URLSearchParams({
|
|
53
|
+
response_type: "code",
|
|
54
|
+
client_id: clientId,
|
|
55
|
+
redirect_uri: redirectUri,
|
|
56
|
+
scope: extended ? EXTENDED_SCOPES : FREE_SCOPES,
|
|
57
|
+
state,
|
|
58
|
+
code_challenge: codeChallenge,
|
|
59
|
+
code_challenge_method: "S256",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
authUrl: `https://x.com/i/oauth2/authorize?${params.toString()}`,
|
|
64
|
+
state,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async exchangeCode(
|
|
69
|
+
code: string,
|
|
70
|
+
_state: string,
|
|
71
|
+
redirectUri: string
|
|
72
|
+
): Promise<PlatformCredentials> {
|
|
73
|
+
// Code exchange is handled by the callback API route directly
|
|
74
|
+
// This is here to satisfy the interface
|
|
75
|
+
throw new Error("Use /api/platforms/x/callback for code exchange");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async refreshToken(accountId: string): Promise<PlatformCredentials> {
|
|
79
|
+
return refreshXTokenAsync(accountId);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async revokeToken(accountId: string): Promise<void> {
|
|
83
|
+
return disconnectXAccount(accountId);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async getProfile(accountId: string): Promise<PlatformUserProfile> {
|
|
87
|
+
const xUser = await getAuthenticatedUser(accountId);
|
|
88
|
+
return toProfile(xUser);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async getContacts(
|
|
92
|
+
accountId: string,
|
|
93
|
+
cursor?: string
|
|
94
|
+
): Promise<PaginatedResult<PlatformUserProfile>> {
|
|
95
|
+
// Get the authenticated user's ID to fetch their "following" list
|
|
96
|
+
const account = getPlatformAccountByPlatform("x");
|
|
97
|
+
if (!account) throw new Error("No X account connected");
|
|
98
|
+
|
|
99
|
+
const me = await getAuthenticatedUser(accountId);
|
|
100
|
+
const res = await getFollowing(accountId, me.id, {
|
|
101
|
+
maxResults: 100,
|
|
102
|
+
paginationToken: cursor || undefined,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const items = Array.isArray(res.data) ? res.data.map(toProfile) : [];
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
items,
|
|
109
|
+
nextCursor: res.meta?.next_token ?? null,
|
|
110
|
+
hasMore: !!res.meta?.next_token,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async getUserById(
|
|
115
|
+
accountId: string,
|
|
116
|
+
userId: string
|
|
117
|
+
): Promise<PlatformUserProfile | null> {
|
|
118
|
+
try {
|
|
119
|
+
const xUser = await xGetUserById(accountId, userId);
|
|
120
|
+
return toProfile(xUser);
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
getRateLimitState(accountId: string): RateLimitState {
|
|
127
|
+
return getRateLimitState(accountId);
|
|
128
|
+
}
|
|
129
|
+
}
|