@lobu/cli 6.0.0 → 6.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.
Files changed (222) hide show
  1. package/README.md +20 -27
  2. package/dist/bundled-skills/lobu/SKILL.md +12 -12
  3. package/dist/commands/_lib/apply/apply-cmd.d.ts +2 -0
  4. package/dist/commands/_lib/apply/apply-cmd.d.ts.map +1 -1
  5. package/dist/commands/_lib/apply/apply-cmd.js +26 -0
  6. package/dist/commands/_lib/apply/apply-cmd.js.map +1 -1
  7. package/dist/commands/_lib/apply/client.d.ts +1 -1
  8. package/dist/commands/_lib/apply/client.d.ts.map +1 -1
  9. package/dist/commands/_lib/apply/desired-state.js +6 -6
  10. package/dist/commands/_lib/apply/desired-state.js.map +1 -1
  11. package/dist/commands/agent.d.ts +7 -0
  12. package/dist/commands/agent.d.ts.map +1 -1
  13. package/dist/commands/agent.js +65 -1
  14. package/dist/commands/agent.js.map +1 -1
  15. package/dist/commands/chat.d.ts +12 -9
  16. package/dist/commands/chat.d.ts.map +1 -1
  17. package/dist/commands/chat.js +117 -56
  18. package/dist/commands/chat.js.map +1 -1
  19. package/dist/commands/dev.d.ts +15 -7
  20. package/dist/commands/dev.d.ts.map +1 -1
  21. package/dist/commands/dev.js +79 -44
  22. package/dist/commands/dev.js.map +1 -1
  23. package/dist/commands/doctor.d.ts +1 -0
  24. package/dist/commands/doctor.d.ts.map +1 -1
  25. package/dist/commands/doctor.js +136 -0
  26. package/dist/commands/doctor.js.map +1 -1
  27. package/dist/commands/eval.d.ts +8 -0
  28. package/dist/commands/eval.d.ts.map +1 -1
  29. package/dist/commands/eval.js +56 -1
  30. package/dist/commands/eval.js.map +1 -1
  31. package/dist/commands/init.d.ts +20 -5
  32. package/dist/commands/init.d.ts.map +1 -1
  33. package/dist/commands/init.js +332 -183
  34. package/dist/commands/init.js.map +1 -1
  35. package/dist/commands/link.d.ts +11 -0
  36. package/dist/commands/link.d.ts.map +1 -0
  37. package/dist/commands/link.js +28 -0
  38. package/dist/commands/link.js.map +1 -0
  39. package/dist/commands/login.d.ts.map +1 -1
  40. package/dist/commands/login.js +14 -2
  41. package/dist/commands/login.js.map +1 -1
  42. package/dist/commands/memory/_lib/browser-auth-cmd.d.ts.map +1 -1
  43. package/dist/commands/memory/_lib/browser-auth-cmd.js +4 -4
  44. package/dist/commands/memory/_lib/browser-auth-cmd.js.map +1 -1
  45. package/dist/commands/memory/_lib/install-targets.d.ts.map +1 -1
  46. package/dist/commands/memory/_lib/install-targets.js +1 -5
  47. package/dist/commands/memory/_lib/install-targets.js.map +1 -1
  48. package/dist/commands/memory/_lib/mcp.d.ts +2 -2
  49. package/dist/commands/memory/_lib/mcp.d.ts.map +1 -1
  50. package/dist/commands/memory/_lib/mcp.js +24 -12
  51. package/dist/commands/memory/_lib/mcp.js.map +1 -1
  52. package/dist/commands/memory/_lib/openclaw-auth.d.ts +1 -0
  53. package/dist/commands/memory/_lib/openclaw-auth.d.ts.map +1 -1
  54. package/dist/commands/memory/_lib/openclaw-auth.js +14 -3
  55. package/dist/commands/memory/_lib/openclaw-auth.js.map +1 -1
  56. package/dist/commands/memory/_lib/openclaw-cmd.js +1 -1
  57. package/dist/commands/memory/_lib/openclaw-cmd.js.map +1 -1
  58. package/dist/commands/memory/_lib/schema.d.ts +2 -2
  59. package/dist/commands/memory/_lib/schema.d.ts.map +1 -1
  60. package/dist/commands/memory/_lib/schema.js +3 -3
  61. package/dist/commands/memory/_lib/schema.js.map +1 -1
  62. package/dist/commands/memory/_lib/seed-cmd.d.ts.map +1 -1
  63. package/dist/commands/memory/_lib/seed-cmd.js +5 -6
  64. package/dist/commands/memory/_lib/seed-cmd.js.map +1 -1
  65. package/dist/commands/memory/run.d.ts.map +1 -1
  66. package/dist/commands/memory/run.js +2 -2
  67. package/dist/commands/memory/run.js.map +1 -1
  68. package/dist/commands/platforms/platform-prompts.d.ts +0 -1
  69. package/dist/commands/platforms/platform-prompts.d.ts.map +1 -1
  70. package/dist/commands/platforms/platform-prompts.js +54 -8
  71. package/dist/commands/platforms/platform-prompts.js.map +1 -1
  72. package/dist/commands/telemetry.d.ts +10 -0
  73. package/dist/commands/telemetry.d.ts.map +1 -0
  74. package/dist/commands/telemetry.js +68 -0
  75. package/dist/commands/telemetry.js.map +1 -0
  76. package/dist/commands/whoami.d.ts.map +1 -1
  77. package/dist/commands/whoami.js +1 -1
  78. package/dist/commands/whoami.js.map +1 -1
  79. package/dist/connectors/README.md +534 -0
  80. package/dist/connectors/__tests__/browser-scraper-utils.test.ts +186 -0
  81. package/dist/connectors/browser-scraper-utils.ts +214 -0
  82. package/dist/connectors/capterra.ts +273 -0
  83. package/dist/connectors/g2.ts +286 -0
  84. package/dist/connectors/github.ts +1553 -0
  85. package/dist/connectors/glassdoor.ts +291 -0
  86. package/dist/connectors/gmaps.ts +197 -0
  87. package/dist/connectors/google_calendar.ts +631 -0
  88. package/dist/connectors/google_gmail.ts +751 -0
  89. package/dist/connectors/google_photos.ts +776 -0
  90. package/dist/connectors/google_play.ts +342 -0
  91. package/dist/connectors/hackernews.ts +471 -0
  92. package/dist/connectors/index.ts +23 -0
  93. package/dist/connectors/ios_appstore.ts +226 -0
  94. package/dist/connectors/linkedin.ts +471 -0
  95. package/dist/connectors/microsoft_outlook.ts +410 -0
  96. package/dist/connectors/producthunt.ts +471 -0
  97. package/dist/connectors/reddit.ts +600 -0
  98. package/dist/connectors/rss.ts +448 -0
  99. package/dist/connectors/spotify.ts +590 -0
  100. package/dist/connectors/trustpilot.ts +199 -0
  101. package/dist/connectors/website.ts +629 -0
  102. package/dist/connectors/whatsapp.ts +1073 -0
  103. package/dist/connectors/x.ts +526 -0
  104. package/dist/connectors/youtube.ts +666 -0
  105. package/dist/db/migrations/00000000000000_baseline.sql +4867 -0
  106. package/dist/db/migrations/20260405193000_add_mcp_sessions.sql +33 -0
  107. package/dist/db/migrations/20260408120000_remove_system_connectors.sql +48 -0
  108. package/dist/db/migrations/20260408120001_optional_compiled_code.sql +6 -0
  109. package/dist/db/migrations/20260409110000_add_active_watcher_run_index.sql +9 -0
  110. package/dist/db/migrations/20260409130000_connector_default_config.sql +5 -0
  111. package/dist/db/migrations/20260410120000_add_agent_secrets.sql +25 -0
  112. package/dist/db/migrations/20260413170000_add_watcher_group_id.sql +67 -0
  113. package/dist/db/migrations/20260416120000_add_entity_wa_jid_index.sql +14 -0
  114. package/dist/db/migrations/20260417100000_add_entity_identities.sql +77 -0
  115. package/dist/db/migrations/20260418100000_add_auth_runs.sql +83 -0
  116. package/dist/db/migrations/20260418110000_add_runs_created_by_user.sql +18 -0
  117. package/dist/db/migrations/20260419120000_add_event_identity_indexes.sql +56 -0
  118. package/dist/db/migrations/20260420120000_extend_reserved_org_slugs.sql +56 -0
  119. package/dist/db/migrations/20260424030000_add_watcher_run_correlation.sql +52 -0
  120. package/dist/db/migrations/20260424130000_relax_events_client_id_fk.sql +47 -0
  121. package/dist/db/migrations/20260425100000_normalize_watcher_feedback.sql +91 -0
  122. package/dist/db/migrations/20260425120000_add_run_diagnostics.sql +20 -0
  123. package/dist/db/migrations/20260425130000_add_repair_agent_plumbing.sql +46 -0
  124. package/dist/db/migrations/20260426120000_entities_entity_type_fk.sql +101 -0
  125. package/dist/db/migrations/20260426130000_db_integrity_cleanup.sql +104 -0
  126. package/dist/db/migrations/20260426130001_db_integrity_cleanup_concurrent.sql +187 -0
  127. package/dist/db/migrations/20260427133000_events_created_by_nullable.sql +74 -0
  128. package/dist/db/migrations/20260427140000_identity_engine_indexes.sql +140 -0
  129. package/dist/db/migrations/20260427150000_drop_events_source_id.sql +177 -0
  130. package/dist/db/migrations/20260427160000_drop_dead_schema.sql +76 -0
  131. package/dist/db/migrations/20260427170000_market_founder_to_member.sql +364 -0
  132. package/dist/db/migrations/20260428040000_cascade_events_watchers_org_fk.sql +66 -0
  133. package/dist/db/migrations/20260428050000_add_runs_approved_input.sql +9 -0
  134. package/dist/db/migrations/20260429010000_auth_profile_tenant_scoped_fk.sql +79 -0
  135. package/dist/db/migrations/20260429060000_extend_runs_for_lobu_queue.sql +108 -0
  136. package/dist/db/migrations/20260429120000_agent_changed_notify.sql +97 -0
  137. package/dist/db/migrations/20260429120100_user_auth_profiles_and_model_prefs.sql +36 -0
  138. package/dist/db/migrations/20260429120200_fix_notify_old_keys.sql +130 -0
  139. package/dist/db/migrations/20260429130000_oauth_states_cli_sessions_rate_limits.sql +83 -0
  140. package/dist/db/migrations/20260429140000_phase8_grants_chat_connections_mcp_sessions.sql +84 -0
  141. package/dist/db/migrations/20260429140100_runs_priority_expires_at_retry_delay.sql +44 -0
  142. package/dist/db/migrations/20260429180000_drop_invalidatable_cache_triggers.sql +25 -0
  143. package/dist/db/migrations/20260430005614_agents_apply_fields.sql +21 -0
  144. package/dist/db/migrations/20260430022231_fix_connection_config_encryption.sql +69 -0
  145. package/dist/db/migrations/20260430151215_add_task_run_type.sql +77 -0
  146. package/dist/db/migrations/20260501000000_drop_cli_sessions.sql +27 -0
  147. package/dist/db/migrations/20260501133000_lobu_memory_mcp_id.sql +117 -0
  148. package/dist/db/migrations/20260502000000_drop_chat_connections.sql +60 -0
  149. package/dist/db/migrations/20260503000000_agent_secrets_org_scope.sql +56 -0
  150. package/dist/db/migrations/20260504000000_flatten_agents_drop_sandbox_model.sql +48 -0
  151. package/dist/index.d.ts.map +1 -1
  152. package/dist/index.js +147 -23
  153. package/dist/index.js.map +1 -1
  154. package/dist/internal/api-client.d.ts +4 -8
  155. package/dist/internal/api-client.d.ts.map +1 -1
  156. package/dist/internal/api-client.js +1 -1
  157. package/dist/internal/api-client.js.map +1 -1
  158. package/dist/internal/context.js +2 -2
  159. package/dist/internal/context.js.map +1 -1
  160. package/dist/internal/credentials.d.ts.map +1 -1
  161. package/dist/internal/credentials.js +6 -1
  162. package/dist/internal/credentials.js.map +1 -1
  163. package/dist/internal/index.d.ts +2 -3
  164. package/dist/internal/index.d.ts.map +1 -1
  165. package/dist/internal/index.js +2 -2
  166. package/dist/internal/index.js.map +1 -1
  167. package/dist/internal/oauth.d.ts +7 -6
  168. package/dist/internal/oauth.d.ts.map +1 -1
  169. package/dist/internal/oauth.js +3 -3
  170. package/dist/internal/project-link.d.ts +10 -0
  171. package/dist/internal/project-link.d.ts.map +1 -0
  172. package/dist/internal/project-link.js +48 -0
  173. package/dist/internal/project-link.js.map +1 -0
  174. package/dist/providers.json +2 -2
  175. package/dist/server.bundle.mjs +3173 -4404
  176. package/dist/start-local.bundle.mjs +71481 -0
  177. package/dist/templates/README.md.tmpl +10 -11
  178. package/package.json +14 -12
  179. package/dist/__tests__/chat.integration.test.d.ts +0 -2
  180. package/dist/__tests__/chat.integration.test.d.ts.map +0 -1
  181. package/dist/__tests__/chat.integration.test.js +0 -337
  182. package/dist/__tests__/chat.integration.test.js.map +0 -1
  183. package/dist/__tests__/dev.test.d.ts +0 -2
  184. package/dist/__tests__/dev.test.d.ts.map +0 -1
  185. package/dist/__tests__/dev.test.js +0 -25
  186. package/dist/__tests__/dev.test.js.map +0 -1
  187. package/dist/__tests__/init-memory.test.d.ts +0 -2
  188. package/dist/__tests__/init-memory.test.d.ts.map +0 -1
  189. package/dist/__tests__/init-memory.test.js +0 -45
  190. package/dist/__tests__/init-memory.test.js.map +0 -1
  191. package/dist/__tests__/token.test.d.ts +0 -2
  192. package/dist/__tests__/token.test.d.ts.map +0 -1
  193. package/dist/__tests__/token.test.js +0 -52
  194. package/dist/__tests__/token.test.js.map +0 -1
  195. package/dist/commands/_lib/apply/__tests__/client.test.d.ts +0 -2
  196. package/dist/commands/_lib/apply/__tests__/client.test.d.ts.map +0 -1
  197. package/dist/commands/_lib/apply/__tests__/client.test.js +0 -23
  198. package/dist/commands/_lib/apply/__tests__/client.test.js.map +0 -1
  199. package/dist/commands/_lib/apply/__tests__/desired-state.test.d.ts +0 -2
  200. package/dist/commands/_lib/apply/__tests__/desired-state.test.d.ts.map +0 -1
  201. package/dist/commands/_lib/apply/__tests__/desired-state.test.js +0 -140
  202. package/dist/commands/_lib/apply/__tests__/desired-state.test.js.map +0 -1
  203. package/dist/commands/_lib/apply/__tests__/diff.test.d.ts +0 -2
  204. package/dist/commands/_lib/apply/__tests__/diff.test.d.ts.map +0 -1
  205. package/dist/commands/_lib/apply/__tests__/diff.test.js +0 -378
  206. package/dist/commands/_lib/apply/__tests__/diff.test.js.map +0 -1
  207. package/dist/commands/apply.d.ts +0 -3
  208. package/dist/commands/apply.d.ts.map +0 -1
  209. package/dist/commands/apply.js +0 -5
  210. package/dist/commands/apply.js.map +0 -1
  211. package/dist/commands/memory/_lib/openclaw-auth.test.d.ts +0 -2
  212. package/dist/commands/memory/_lib/openclaw-auth.test.d.ts.map +0 -1
  213. package/dist/commands/memory/_lib/openclaw-auth.test.js +0 -9
  214. package/dist/commands/memory/_lib/openclaw-auth.test.js.map +0 -1
  215. package/dist/internal/__tests__/api-client.test.d.ts +0 -2
  216. package/dist/internal/__tests__/api-client.test.d.ts.map +0 -1
  217. package/dist/internal/__tests__/api-client.test.js +0 -95
  218. package/dist/internal/__tests__/api-client.test.js.map +0 -1
  219. package/dist/internal/__tests__/context.test.d.ts +0 -2
  220. package/dist/internal/__tests__/context.test.d.ts.map +0 -1
  221. package/dist/internal/__tests__/context.test.js +0 -77
  222. package/dist/internal/__tests__/context.test.js.map +0 -1
@@ -0,0 +1,226 @@
1
+ /**
2
+ * iOS App Store Connector (V1 runtime)
3
+ *
4
+ * Fetches app reviews from the Apple App Store via RSS feeds.
5
+ */
6
+
7
+ import {
8
+ type ActionContext,
9
+ type ActionResult,
10
+ type ConnectorDefinition,
11
+ ConnectorRuntime,
12
+ calculateEngagementScore,
13
+ type EventEnvelope,
14
+ type SyncContext,
15
+ type SyncResult,
16
+ } from '@lobu/connector-sdk';
17
+
18
+ const IOS_HEADERS: Record<string, string> = {
19
+ 'User-Agent':
20
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
21
+ Accept: 'application/json, text/plain, */*',
22
+ 'Accept-Language': 'en-US,en;q=0.9',
23
+ Referer: 'https://apps.apple.com/',
24
+ };
25
+
26
+ interface IOSCheckpoint {
27
+ last_timestamp?: string;
28
+ }
29
+
30
+ interface RSSFeed {
31
+ feed?: {
32
+ entry?: RSSEntry | RSSEntry[];
33
+ };
34
+ }
35
+
36
+ interface RSSEntry {
37
+ id?: { label?: string };
38
+ title?: { label?: string };
39
+ content?: { label?: string };
40
+ author?: { name?: { label?: string } };
41
+ updated?: { label?: string };
42
+ link?: { attributes?: { href?: string } };
43
+ 'im:rating'?: { label?: string };
44
+ 'im:voteSum'?: { label?: string };
45
+ 'im:voteCount'?: { label?: string };
46
+ 'im:version'?: { label?: string };
47
+ }
48
+
49
+ const FEED_CONFIG_SCHEMA = {
50
+ type: 'object',
51
+ required: ['app_id', 'country'],
52
+ properties: {
53
+ app_id: {
54
+ type: 'string',
55
+ minLength: 1,
56
+ description: 'iOS App Store ID (e.g., "324684580")',
57
+ },
58
+ country: {
59
+ type: 'string',
60
+ minLength: 2,
61
+ maxLength: 2,
62
+ pattern: '^[A-Z]{2}$',
63
+ description: 'ISO country code (e.g., "US")',
64
+ },
65
+ },
66
+ } as const;
67
+
68
+ export default class IOSAppStoreConnector extends ConnectorRuntime {
69
+ readonly definition: ConnectorDefinition = {
70
+ key: 'ios_appstore',
71
+ name: 'iOS App Store',
72
+ description: 'Fetches app reviews from the Apple App Store via RSS feeds.',
73
+ version: '1.0.0',
74
+ faviconDomain: 'apple.com',
75
+ authSchema: {
76
+ methods: [{ type: 'none' }],
77
+ },
78
+ feeds: {
79
+ reviews: {
80
+ key: 'reviews',
81
+ name: 'App Reviews',
82
+ description: 'Fetch reviews for an iOS app.',
83
+ configSchema: FEED_CONFIG_SCHEMA,
84
+ eventKinds: {
85
+ review: {
86
+ description: 'An iOS App Store review',
87
+ metadataSchema: {
88
+ type: 'object',
89
+ properties: {
90
+ rating: { type: 'number', description: 'Star rating (1-5)' },
91
+ vote_sum: { type: 'number', description: 'Net helpful votes' },
92
+ vote_count: { type: 'number', description: 'Total vote count' },
93
+ version: { type: 'string', description: 'App version reviewed' },
94
+ },
95
+ },
96
+ },
97
+ },
98
+ },
99
+ },
100
+ optionsSchema: FEED_CONFIG_SCHEMA,
101
+ };
102
+
103
+ async sync(ctx: SyncContext): Promise<SyncResult> {
104
+ const appId = ctx.config.app_id as string;
105
+ const country = ctx.config.country as string;
106
+ const checkpoint = (ctx.checkpoint ?? {}) as IOSCheckpoint;
107
+ const lastTimestamp = checkpoint.last_timestamp ? new Date(checkpoint.last_timestamp) : null;
108
+
109
+ const MAX_PAGES = 10;
110
+ const allReviews: RSSEntry[] = [];
111
+ let shouldContinue = true;
112
+
113
+ for (let page = 1; shouldContinue && page <= MAX_PAGES; page++) {
114
+ const rssUrl = `https://itunes.apple.com/${country}/rss/customerreviews/page=${page}/id=${appId}/sortby=mostrecent/json`;
115
+
116
+ const response = await fetch(rssUrl, { headers: IOS_HEADERS });
117
+ if (!response.ok) {
118
+ if (page === 1) {
119
+ throw new Error(`RSS feed returned ${response.status}: ${rssUrl}`);
120
+ }
121
+ break;
122
+ }
123
+
124
+ let rssData: RSSFeed;
125
+ try {
126
+ rssData = await response.json();
127
+ } catch {
128
+ if (page === 1) {
129
+ const text = await response.text();
130
+ throw new Error(`RSS feed returned invalid JSON: ${text.substring(0, 100)}`);
131
+ }
132
+ break;
133
+ }
134
+
135
+ const rawEntries = rssData.feed?.entry;
136
+ const feedEntries: RSSEntry[] = Array.isArray(rawEntries)
137
+ ? rawEntries
138
+ : rawEntries
139
+ ? [rawEntries]
140
+ : [];
141
+
142
+ if (feedEntries.length === 0) {
143
+ break;
144
+ }
145
+
146
+ // Filter out the first entry on page 1 if it lacks im:rating (app metadata entry)
147
+ const reviews = feedEntries.filter((entry, index) => {
148
+ if (page === 1 && index === 0 && !entry['im:rating']) {
149
+ return false;
150
+ }
151
+ return !!entry['im:rating'];
152
+ });
153
+
154
+ if (reviews.length === 0) {
155
+ break;
156
+ }
157
+
158
+ // Check if the oldest review on this page is older than or equal to the checkpoint
159
+ if (lastTimestamp) {
160
+ const oldestReviewDate = new Date(reviews[reviews.length - 1].updated?.label || Date.now());
161
+ if (oldestReviewDate <= lastTimestamp) {
162
+ // Add only reviews newer than the checkpoint and stop
163
+ allReviews.push(
164
+ ...reviews.filter((r) => new Date(r.updated?.label || Date.now()) > lastTimestamp)
165
+ );
166
+ shouldContinue = false;
167
+ break;
168
+ }
169
+ }
170
+
171
+ allReviews.push(...reviews);
172
+
173
+ // 1 second delay between pages
174
+ if (shouldContinue && page < MAX_PAGES) {
175
+ await new Promise((resolve) => setTimeout(resolve, 1000));
176
+ }
177
+ }
178
+
179
+ // Transform reviews to EventEnvelope format
180
+ const appUrl = `https://apps.apple.com/${country.toLowerCase()}/app/id${appId}`;
181
+
182
+ const events: EventEnvelope[] = allReviews.map((review) => {
183
+ const rating = parseInt(review['im:rating']?.label || '0', 10);
184
+ const title = review.title?.label || '';
185
+ const body = review.content?.label || '';
186
+ const content = title ? `${title}\n\n${body}` : body;
187
+
188
+ return {
189
+ origin_id: review.id?.label || '',
190
+ payload_text: content,
191
+ author_name: review.author?.name?.label || undefined,
192
+ occurred_at: new Date(review.updated?.label || Date.now()),
193
+ origin_type: 'review',
194
+ score: calculateEngagementScore('ios_appstore', { rating }),
195
+ source_url: review.link?.attributes?.href || appUrl,
196
+ metadata: {
197
+ rating,
198
+ vote_sum: parseInt(review['im:voteSum']?.label || '0', 10),
199
+ vote_count: parseInt(review['im:voteCount']?.label || '0', 10),
200
+ version: review['im:version']?.label,
201
+ },
202
+ };
203
+ });
204
+
205
+ // Sort descending by occurred_at
206
+ events.sort((a, b) => b.occurred_at.getTime() - a.occurred_at.getTime());
207
+
208
+ // Build new checkpoint from the most recent event
209
+ const newCheckpoint: IOSCheckpoint =
210
+ events.length > 0
211
+ ? { last_timestamp: events[0].occurred_at.toISOString() }
212
+ : { last_timestamp: checkpoint.last_timestamp };
213
+
214
+ return {
215
+ events,
216
+ checkpoint: newCheckpoint as Record<string, unknown>,
217
+ metadata: {
218
+ items_found: allReviews.length,
219
+ },
220
+ };
221
+ }
222
+
223
+ async execute(_ctx: ActionContext): Promise<ActionResult> {
224
+ return { success: false, error: 'Actions not supported' };
225
+ }
226
+ }
@@ -0,0 +1,471 @@
1
+ /**
2
+ * LinkedIn Connector
3
+ *
4
+ * Scrapes LinkedIn company pages via browser network interception.
5
+ * Uses Playwright to navigate company pages and intercept Voyager API responses.
6
+ * Auth via persisted browser cookies captured into a reusable browser auth profile.
7
+ *
8
+ * Follows the same pattern as the X (Twitter) connector.
9
+ */
10
+
11
+ import {
12
+ type ActionContext,
13
+ type ActionResult,
14
+ browserNetworkSync,
15
+ type ConnectorDefinition,
16
+ ConnectorRuntime,
17
+ calculateEngagementScore,
18
+ type EventEnvelope,
19
+ type SyncContext,
20
+ type SyncResult,
21
+ } from '@lobu/connector-sdk';
22
+ import { getBrowserCookies, validateCookieNotExpired } from './browser-scraper-utils';
23
+
24
+ // ── Types ──────────────────────────────────────────────────────
25
+
26
+ interface LinkedInCheckpoint {
27
+ last_post_id?: string;
28
+ last_job_id?: string;
29
+ last_timestamp?: string;
30
+ }
31
+
32
+ interface LinkedInPost {
33
+ id: string;
34
+ text: string;
35
+ author: string;
36
+ authorHeadline?: string;
37
+ likes: number;
38
+ comments: number;
39
+ shares: number;
40
+ publishedAt: Date;
41
+ }
42
+
43
+ interface LinkedInJob {
44
+ id: string;
45
+ title: string;
46
+ location: string;
47
+ postedAt: Date;
48
+ url: string;
49
+ description?: string;
50
+ }
51
+
52
+ function normalizeCheckpointPostId(postId?: string): string | undefined {
53
+ if (!postId) return undefined;
54
+ return postId.startsWith('li_post_') ? postId.slice('li_post_'.length) : postId;
55
+ }
56
+
57
+ export function filterPostsSinceCheckpoint(
58
+ posts: LinkedInPost[],
59
+ checkpoint: LinkedInCheckpoint
60
+ ): LinkedInPost[] {
61
+ const seenIds = new Set<string>();
62
+ const checkpointPostId = normalizeCheckpointPostId(checkpoint.last_post_id);
63
+ const checkpointTimestamp = checkpoint.last_timestamp
64
+ ? new Date(checkpoint.last_timestamp).getTime()
65
+ : null;
66
+
67
+ const filtered: LinkedInPost[] = [];
68
+ for (const post of posts) {
69
+ if (!post.id || !post.text || seenIds.has(post.id)) continue;
70
+ seenIds.add(post.id);
71
+
72
+ if (checkpointPostId && post.id === checkpointPostId) break;
73
+ if (checkpointTimestamp !== null && post.publishedAt.getTime() <= checkpointTimestamp) {
74
+ continue;
75
+ }
76
+
77
+ filtered.push(post);
78
+ }
79
+
80
+ return filtered;
81
+ }
82
+
83
+ // ── Voyager API Response Parsers ──────────────────────────────
84
+
85
+ function parseCompanyUpdates(_url: string, json: unknown): LinkedInPost[] {
86
+ const posts: LinkedInPost[] = [];
87
+ const data = json as any;
88
+
89
+ // Build URN lookup from `included` array (LinkedIn GraphQL uses references)
90
+ const included: any[] = data?.included ?? [];
91
+ const byUrn: Record<string, any> = {};
92
+ for (const item of included) {
93
+ const urn = item.entityUrn || item.$id;
94
+ if (urn) byUrn[urn] = item;
95
+ }
96
+
97
+ // Find feed elements - LinkedIn nests under data.data with a long key
98
+ const feedRoot = data?.data?.data ?? data?.data ?? data;
99
+ let elements: any[] = [];
100
+ for (const key of Object.keys(feedRoot)) {
101
+ const val = feedRoot[key];
102
+ if (val?.['*elements'] && Array.isArray(val['*elements'])) {
103
+ elements = val['*elements'];
104
+ break;
105
+ }
106
+ if (val?.elements && Array.isArray(val.elements)) {
107
+ elements = val.elements;
108
+ break;
109
+ }
110
+ }
111
+
112
+ const resolve = (ref: any) => (typeof ref === 'string' ? byUrn[ref] : ref) ?? {};
113
+
114
+ for (const ref of elements) {
115
+ const el = resolve(ref);
116
+
117
+ // Get commentary text (may be a reference)
118
+ const commentaryObj = resolve(el['*commentary'] ?? el.commentary);
119
+ const textObj = commentaryObj?.text ?? commentaryObj;
120
+ const text = textObj?.text ?? textObj?.attributedText?.text ?? '';
121
+ if (!text) continue;
122
+
123
+ // Get actor
124
+ const actorObj = resolve(el['*actor'] ?? el.actor);
125
+ const authorName = actorObj?.name?.text ?? actorObj?.name ?? 'Unknown';
126
+ const authorDesc = actorObj?.description?.text ?? actorObj?.description ?? undefined;
127
+
128
+ // Get social counts
129
+ const socialRef = el['*socialDetail'] ?? el.socialDetail;
130
+ const social = resolve(socialRef);
131
+ const counts =
132
+ social?.totalSocialActivityCounts ??
133
+ social?.socialActivityCountsInsight?.totalSocialActivityCounts ??
134
+ {};
135
+
136
+ // Get URN for ID
137
+ const urn = el.entityUrn ?? el['*backendUrn'] ?? '';
138
+ const urnParts = urn.split(':');
139
+ const id =
140
+ urnParts[urnParts.length - 1] || `li_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
141
+
142
+ // Get timestamp
143
+ const metadata = resolve(el['*metadata'] ?? el.metadata);
144
+ const publishedAt = metadata?.publishedAt ?? el.createdAt ?? Date.now();
145
+
146
+ posts.push({
147
+ id,
148
+ text,
149
+ author: authorName,
150
+ authorHeadline: typeof authorDesc === 'string' ? authorDesc : undefined,
151
+ likes: counts.numLikes ?? 0,
152
+ comments: counts.numComments ?? 0,
153
+ shares: counts.numShares ?? 0,
154
+ publishedAt: new Date(publishedAt),
155
+ });
156
+ }
157
+
158
+ return posts;
159
+ }
160
+
161
+ function parseJobListings(_url: string, json: unknown): LinkedInJob[] {
162
+ const jobs: LinkedInJob[] = [];
163
+ const data = json as any;
164
+
165
+ const elements = data?.elements ?? data?.data?.elements ?? [];
166
+
167
+ for (const element of elements) {
168
+ const jobPosting = element?.jobPosting ?? element;
169
+ const title = jobPosting?.title ?? element?.title ?? '';
170
+ if (!title) continue;
171
+
172
+ const urnParts = (jobPosting?.entityUrn ?? element?.dashEntityUrn ?? '').split(':');
173
+ const id =
174
+ urnParts[urnParts.length - 1] ||
175
+ `job_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
176
+
177
+ jobs.push({
178
+ id,
179
+ title,
180
+ location: jobPosting?.formattedLocation ?? jobPosting?.location ?? '',
181
+ postedAt: new Date(jobPosting?.listedAt ?? element?.createdAt ?? Date.now()),
182
+ url: `https://www.linkedin.com/jobs/view/${id}`,
183
+ description: jobPosting?.description?.text ?? undefined,
184
+ });
185
+ }
186
+
187
+ return jobs;
188
+ }
189
+
190
+ // ── Config Schemas ────────────────────────────────────────────
191
+
192
+ const companyUpdatesConfigSchema = {
193
+ type: 'object',
194
+ required: ['company_url'],
195
+ properties: {
196
+ company_url: {
197
+ type: 'string',
198
+ description: 'LinkedIn company page URL (e.g., "https://www.linkedin.com/company/openai")',
199
+ },
200
+ max_scrolls: {
201
+ type: 'integer',
202
+ minimum: 1,
203
+ maximum: 20,
204
+ default: 5,
205
+ description: 'Maximum scroll iterations for pagination (default: 5)',
206
+ },
207
+ },
208
+ };
209
+
210
+ const jobsConfigSchema = {
211
+ type: 'object',
212
+ required: ['company_url'],
213
+ properties: {
214
+ company_url: {
215
+ type: 'string',
216
+ description: 'LinkedIn company page URL (e.g., "https://www.linkedin.com/company/openai")',
217
+ },
218
+ max_scrolls: {
219
+ type: 'integer',
220
+ minimum: 1,
221
+ maximum: 10,
222
+ default: 3,
223
+ description: 'Maximum scroll iterations for job listings (default: 3)',
224
+ },
225
+ },
226
+ };
227
+
228
+ // ── Connector ─────────────────────────────────────────────────
229
+
230
+ export default class LinkedInConnector extends ConnectorRuntime {
231
+ readonly definition: ConnectorDefinition = {
232
+ key: 'linkedin',
233
+ name: 'LinkedIn',
234
+ description: 'Scrapes LinkedIn company pages for posts, hiring signals, and team data.',
235
+ version: '1.1.0',
236
+ faviconDomain: 'linkedin.com',
237
+ authSchema: {
238
+ methods: [
239
+ {
240
+ type: 'browser',
241
+ capture: 'cli',
242
+ requiredDomains: ['linkedin.com', '.linkedin.com'],
243
+ description:
244
+ 'Preferred auth mode for LinkedIn scraping. Captures your existing browser session cookies.',
245
+ },
246
+ {
247
+ type: 'oauth',
248
+ provider: 'linkedin',
249
+ requiredScopes: ['openid', 'profile', 'email'],
250
+ loginScopes: ['openid', 'profile', 'email'],
251
+ authorizationUrl: 'https://www.linkedin.com/oauth/v2/authorization',
252
+ tokenUrl: 'https://www.linkedin.com/oauth/v2/accessToken',
253
+ userinfoUrl: 'https://api.linkedin.com/v2/userinfo',
254
+ tokenEndpointAuthMethod: 'client_secret_post',
255
+ clientIdKey: 'LINKEDIN_CLIENT_ID',
256
+ clientSecretKey: 'LINKEDIN_CLIENT_SECRET',
257
+ description:
258
+ 'Optional LinkedIn OAuth app config for sign-in and future API-based access. Current company page and jobs feeds still scrape via browser session cookies.',
259
+ setupInstructions:
260
+ 'Create a LinkedIn OAuth app, add {{redirect_uri}} as the callback URL, then paste the client ID and client secret below.',
261
+ },
262
+ ],
263
+ },
264
+ feeds: {
265
+ company_updates: {
266
+ key: 'company_updates',
267
+ name: 'Company Updates',
268
+ description: 'Posts and updates from the company LinkedIn page.',
269
+ configSchema: companyUpdatesConfigSchema,
270
+ eventKinds: {
271
+ post: {
272
+ description: 'A company LinkedIn post',
273
+ metadataSchema: {
274
+ type: 'object',
275
+ properties: {
276
+ author_headline: { type: 'string' },
277
+ likes: { type: 'number' },
278
+ comments: { type: 'number' },
279
+ shares: { type: 'number' },
280
+ },
281
+ },
282
+ },
283
+ },
284
+ },
285
+ jobs: {
286
+ key: 'jobs',
287
+ name: 'Job Listings',
288
+ description: 'Open job positions (hiring velocity signal).',
289
+ configSchema: jobsConfigSchema,
290
+ eventKinds: {
291
+ job_posting: {
292
+ description: 'An open job listing',
293
+ metadataSchema: {
294
+ type: 'object',
295
+ properties: {
296
+ location: { type: 'string' },
297
+ },
298
+ },
299
+ },
300
+ },
301
+ },
302
+ },
303
+ optionsSchema: companyUpdatesConfigSchema,
304
+ };
305
+
306
+ async sync(ctx: SyncContext): Promise<SyncResult> {
307
+ const config = ctx.config as Record<string, unknown>;
308
+ const checkpoint = (ctx.checkpoint ?? {}) as LinkedInCheckpoint;
309
+ const feedKey = ctx.feedKey ?? 'company_updates';
310
+
311
+ const companyUrl = config.company_url as string;
312
+ if (!companyUrl) {
313
+ throw new Error('company_url is required');
314
+ }
315
+
316
+ // Normalize URL - remove trailing slash
317
+ const baseUrl = companyUrl.replace(/\/$/, '');
318
+
319
+ const cookies = getBrowserCookies(ctx.checkpoint as any, ctx.sessionState as any, 'linkedin');
320
+ validateCookieNotExpired(cookies, 'li_at', 'linkedin');
321
+
322
+ const maxScrolls = (config.max_scrolls as number) ?? (feedKey === 'jobs' ? 3 : 5);
323
+
324
+ if (feedKey === 'jobs') {
325
+ return this.syncJobs(baseUrl, cookies, maxScrolls, checkpoint);
326
+ }
327
+
328
+ return this.syncUpdates(baseUrl, cookies, maxScrolls, checkpoint);
329
+ }
330
+
331
+ private async syncUpdates(
332
+ baseUrl: string,
333
+ cookies: any[],
334
+ maxScrolls: number,
335
+ checkpoint: LinkedInCheckpoint
336
+ ): Promise<SyncResult> {
337
+ const postsUrl = `${baseUrl}/posts/`;
338
+
339
+ const result = await browserNetworkSync<LinkedInPost>({
340
+ config: {
341
+ interceptPatterns: [
342
+ /voyager\/api\/graphql\?variables=.*ORGANIZATION_MEMBER_FEED/,
343
+ /voyager\/api\/graphql\?variables=.*organizationalPageUrn/,
344
+ ],
345
+ authDomains: ['linkedin.com', '.linkedin.com', '.www.linkedin.com'],
346
+ stealth: true,
347
+ maxScrolls,
348
+ scrollDelayMs: 3000,
349
+ responseTimeoutMs: 8000,
350
+ navigationTimeoutMs: 20000,
351
+ },
352
+ url: postsUrl,
353
+ cdpUrl: 'auto',
354
+ cookies,
355
+ parseResponse: parseCompanyUpdates,
356
+ checkAuth: async (page) => {
357
+ const url = page.url();
358
+ return !url.includes('/login') && !url.includes('/authwall');
359
+ },
360
+ });
361
+
362
+ const posts = filterPostsSinceCheckpoint(result.items, checkpoint);
363
+
364
+ const events: EventEnvelope[] = posts.map((post) => ({
365
+ origin_id: `li_post_${post.id}`,
366
+ payload_text: post.text,
367
+ author_name: post.author,
368
+ occurred_at: post.publishedAt,
369
+ origin_type: 'post',
370
+ source_url: `https://www.linkedin.com/feed/update/urn:li:activity:${post.id}`,
371
+ score: calculateEngagementScore('linkedin', {
372
+ upvotes: post.likes,
373
+ reply_count: post.comments,
374
+ }),
375
+ metadata: {
376
+ author_headline: post.authorHeadline,
377
+ likes: post.likes,
378
+ comments: post.comments,
379
+ shares: post.shares,
380
+ },
381
+ }));
382
+
383
+ events.sort((a, b) => new Date(b.occurred_at).getTime() - new Date(a.occurred_at).getTime());
384
+
385
+ return {
386
+ events,
387
+ checkpoint: {
388
+ last_post_id: posts[0]?.id ?? checkpoint.last_post_id,
389
+ last_timestamp: events[0]?.occurred_at?.toISOString?.() ?? checkpoint.last_timestamp,
390
+ } as unknown as Record<string, unknown>,
391
+ auth_update: { cookies: result.cookies },
392
+ metadata: {
393
+ items_found: events.length,
394
+ items_skipped: result.items.length - posts.length,
395
+ api_calls: result.apiCallCount,
396
+ },
397
+ };
398
+ }
399
+
400
+ private async syncJobs(
401
+ baseUrl: string,
402
+ cookies: any[],
403
+ maxScrolls: number,
404
+ checkpoint: LinkedInCheckpoint
405
+ ): Promise<SyncResult> {
406
+ const jobsUrl = `${baseUrl}/jobs/`;
407
+
408
+ const result = await browserNetworkSync<LinkedInJob>({
409
+ config: {
410
+ interceptPatterns: [
411
+ /voyager\/api\/graphql.*jobPosting/i,
412
+ /voyager\/api\/search\/dash\/.*jobs/i,
413
+ /voyager\/api\/organization\/.*jobs/i,
414
+ ],
415
+ authDomains: ['linkedin.com', '.linkedin.com', '.www.linkedin.com'],
416
+ stealth: true,
417
+ maxScrolls,
418
+ scrollDelayMs: 3000,
419
+ responseTimeoutMs: 8000,
420
+ navigationTimeoutMs: 20000,
421
+ },
422
+ url: jobsUrl,
423
+ cdpUrl: 'auto',
424
+ cookies,
425
+ parseResponse: parseJobListings,
426
+ checkAuth: async (page) => {
427
+ const url = page.url();
428
+ return !url.includes('/login') && !url.includes('/authwall');
429
+ },
430
+ });
431
+
432
+ // Deduplicate
433
+ const seenIds = new Set<string>();
434
+ const jobs = result.items.filter((j) => {
435
+ if (!j.id || seenIds.has(j.id)) return false;
436
+ seenIds.add(j.id);
437
+ return true;
438
+ });
439
+
440
+ jobs.sort((a, b) => b.postedAt.getTime() - a.postedAt.getTime());
441
+
442
+ const events: EventEnvelope[] = jobs.map((job) => ({
443
+ origin_id: `li_job_${job.id}`,
444
+ payload_text: job.description ?? job.title,
445
+ title: job.title,
446
+ occurred_at: job.postedAt,
447
+ origin_type: 'job_posting',
448
+ source_url: job.url,
449
+ metadata: {
450
+ location: job.location,
451
+ },
452
+ }));
453
+
454
+ return {
455
+ events,
456
+ checkpoint: {
457
+ last_job_id: jobs[0]?.id ?? checkpoint.last_job_id,
458
+ last_timestamp: jobs[0]?.postedAt?.toISOString?.() ?? checkpoint.last_timestamp,
459
+ } as unknown as Record<string, unknown>,
460
+ auth_update: { cookies: result.cookies },
461
+ metadata: {
462
+ items_found: events.length,
463
+ api_calls: result.apiCallCount,
464
+ },
465
+ };
466
+ }
467
+
468
+ async execute(_ctx: ActionContext): Promise<ActionResult> {
469
+ return { success: false, error: 'Actions not supported' };
470
+ }
471
+ }