@lobu/cli 6.0.1 → 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.
- package/README.md +20 -27
- package/dist/bundled-skills/lobu/SKILL.md +11 -11
- package/dist/commands/_lib/apply/apply-cmd.d.ts +2 -0
- package/dist/commands/_lib/apply/apply-cmd.d.ts.map +1 -1
- package/dist/commands/_lib/apply/apply-cmd.js +26 -0
- package/dist/commands/_lib/apply/apply-cmd.js.map +1 -1
- package/dist/commands/_lib/apply/client.d.ts +1 -1
- package/dist/commands/_lib/apply/client.d.ts.map +1 -1
- package/dist/commands/_lib/apply/desired-state.js +4 -4
- package/dist/commands/_lib/apply/desired-state.js.map +1 -1
- package/dist/commands/agent.d.ts +7 -0
- package/dist/commands/agent.d.ts.map +1 -1
- package/dist/commands/agent.js +65 -1
- package/dist/commands/agent.js.map +1 -1
- package/dist/commands/chat.d.ts +12 -9
- package/dist/commands/chat.d.ts.map +1 -1
- package/dist/commands/chat.js +117 -56
- package/dist/commands/chat.js.map +1 -1
- package/dist/commands/dev.d.ts +15 -7
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +79 -44
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/doctor.d.ts +1 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +136 -0
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/eval.d.ts +8 -0
- package/dist/commands/eval.d.ts.map +1 -1
- package/dist/commands/eval.js +56 -1
- package/dist/commands/eval.js.map +1 -1
- package/dist/commands/init.d.ts +20 -5
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +332 -183
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/link.d.ts +11 -0
- package/dist/commands/link.d.ts.map +1 -0
- package/dist/commands/link.js +28 -0
- package/dist/commands/link.js.map +1 -0
- package/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +14 -2
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/memory/_lib/browser-auth-cmd.d.ts.map +1 -1
- package/dist/commands/memory/_lib/browser-auth-cmd.js +3 -3
- package/dist/commands/memory/_lib/browser-auth-cmd.js.map +1 -1
- package/dist/commands/memory/_lib/mcp.d.ts +2 -2
- package/dist/commands/memory/_lib/mcp.d.ts.map +1 -1
- package/dist/commands/memory/_lib/mcp.js +24 -12
- package/dist/commands/memory/_lib/mcp.js.map +1 -1
- package/dist/commands/memory/_lib/openclaw-auth.d.ts +1 -0
- package/dist/commands/memory/_lib/openclaw-auth.d.ts.map +1 -1
- package/dist/commands/memory/_lib/openclaw-auth.js +14 -3
- package/dist/commands/memory/_lib/openclaw-auth.js.map +1 -1
- package/dist/commands/memory/_lib/openclaw-cmd.js +1 -1
- package/dist/commands/memory/_lib/openclaw-cmd.js.map +1 -1
- package/dist/commands/memory/_lib/schema.d.ts +1 -1
- package/dist/commands/memory/_lib/schema.js +1 -1
- package/dist/commands/memory/_lib/seed-cmd.d.ts.map +1 -1
- package/dist/commands/memory/_lib/seed-cmd.js +5 -6
- package/dist/commands/memory/_lib/seed-cmd.js.map +1 -1
- package/dist/commands/memory/run.d.ts.map +1 -1
- package/dist/commands/memory/run.js +2 -2
- package/dist/commands/memory/run.js.map +1 -1
- package/dist/commands/platforms/platform-prompts.d.ts +0 -1
- package/dist/commands/platforms/platform-prompts.d.ts.map +1 -1
- package/dist/commands/platforms/platform-prompts.js +54 -8
- package/dist/commands/platforms/platform-prompts.js.map +1 -1
- package/dist/commands/telemetry.d.ts +10 -0
- package/dist/commands/telemetry.d.ts.map +1 -0
- package/dist/commands/telemetry.js +68 -0
- package/dist/commands/telemetry.js.map +1 -0
- package/dist/commands/whoami.d.ts.map +1 -1
- package/dist/commands/whoami.js +1 -1
- package/dist/commands/whoami.js.map +1 -1
- package/dist/connectors/README.md +534 -0
- package/dist/connectors/__tests__/browser-scraper-utils.test.ts +186 -0
- package/dist/connectors/browser-scraper-utils.ts +214 -0
- package/dist/connectors/capterra.ts +273 -0
- package/dist/connectors/g2.ts +286 -0
- package/dist/connectors/github.ts +1553 -0
- package/dist/connectors/glassdoor.ts +291 -0
- package/dist/connectors/gmaps.ts +197 -0
- package/dist/connectors/google_calendar.ts +631 -0
- package/dist/connectors/google_gmail.ts +751 -0
- package/dist/connectors/google_photos.ts +776 -0
- package/dist/connectors/google_play.ts +342 -0
- package/dist/connectors/hackernews.ts +471 -0
- package/dist/connectors/index.ts +23 -0
- package/dist/connectors/ios_appstore.ts +226 -0
- package/dist/connectors/linkedin.ts +471 -0
- package/dist/connectors/microsoft_outlook.ts +410 -0
- package/dist/connectors/producthunt.ts +471 -0
- package/dist/connectors/reddit.ts +600 -0
- package/dist/connectors/rss.ts +448 -0
- package/dist/connectors/spotify.ts +590 -0
- package/dist/connectors/trustpilot.ts +199 -0
- package/dist/connectors/website.ts +629 -0
- package/dist/connectors/whatsapp.ts +1073 -0
- package/dist/connectors/x.ts +526 -0
- package/dist/connectors/youtube.ts +666 -0
- package/dist/db/migrations/00000000000000_baseline.sql +4867 -0
- package/dist/db/migrations/20260405193000_add_mcp_sessions.sql +33 -0
- package/dist/db/migrations/20260408120000_remove_system_connectors.sql +48 -0
- package/dist/db/migrations/20260408120001_optional_compiled_code.sql +6 -0
- package/dist/db/migrations/20260409110000_add_active_watcher_run_index.sql +9 -0
- package/dist/db/migrations/20260409130000_connector_default_config.sql +5 -0
- package/dist/db/migrations/20260410120000_add_agent_secrets.sql +25 -0
- package/dist/db/migrations/20260413170000_add_watcher_group_id.sql +67 -0
- package/dist/db/migrations/20260416120000_add_entity_wa_jid_index.sql +14 -0
- package/dist/db/migrations/20260417100000_add_entity_identities.sql +77 -0
- package/dist/db/migrations/20260418100000_add_auth_runs.sql +83 -0
- package/dist/db/migrations/20260418110000_add_runs_created_by_user.sql +18 -0
- package/dist/db/migrations/20260419120000_add_event_identity_indexes.sql +56 -0
- package/dist/db/migrations/20260420120000_extend_reserved_org_slugs.sql +56 -0
- package/dist/db/migrations/20260424030000_add_watcher_run_correlation.sql +52 -0
- package/dist/db/migrations/20260424130000_relax_events_client_id_fk.sql +47 -0
- package/dist/db/migrations/20260425100000_normalize_watcher_feedback.sql +91 -0
- package/dist/db/migrations/20260425120000_add_run_diagnostics.sql +20 -0
- package/dist/db/migrations/20260425130000_add_repair_agent_plumbing.sql +46 -0
- package/dist/db/migrations/20260426120000_entities_entity_type_fk.sql +101 -0
- package/dist/db/migrations/20260426130000_db_integrity_cleanup.sql +104 -0
- package/dist/db/migrations/20260426130001_db_integrity_cleanup_concurrent.sql +187 -0
- package/dist/db/migrations/20260427133000_events_created_by_nullable.sql +74 -0
- package/dist/db/migrations/20260427140000_identity_engine_indexes.sql +140 -0
- package/dist/db/migrations/20260427150000_drop_events_source_id.sql +177 -0
- package/dist/db/migrations/20260427160000_drop_dead_schema.sql +76 -0
- package/dist/db/migrations/20260427170000_market_founder_to_member.sql +364 -0
- package/dist/db/migrations/20260428040000_cascade_events_watchers_org_fk.sql +66 -0
- package/dist/db/migrations/20260428050000_add_runs_approved_input.sql +9 -0
- package/dist/db/migrations/20260429010000_auth_profile_tenant_scoped_fk.sql +79 -0
- package/dist/db/migrations/20260429060000_extend_runs_for_lobu_queue.sql +108 -0
- package/dist/db/migrations/20260429120000_agent_changed_notify.sql +97 -0
- package/dist/db/migrations/20260429120100_user_auth_profiles_and_model_prefs.sql +36 -0
- package/dist/db/migrations/20260429120200_fix_notify_old_keys.sql +130 -0
- package/dist/db/migrations/20260429130000_oauth_states_cli_sessions_rate_limits.sql +83 -0
- package/dist/db/migrations/20260429140000_phase8_grants_chat_connections_mcp_sessions.sql +84 -0
- package/dist/db/migrations/20260429140100_runs_priority_expires_at_retry_delay.sql +44 -0
- package/dist/db/migrations/20260429180000_drop_invalidatable_cache_triggers.sql +25 -0
- package/dist/db/migrations/20260430005614_agents_apply_fields.sql +21 -0
- package/dist/db/migrations/20260430022231_fix_connection_config_encryption.sql +69 -0
- package/dist/db/migrations/20260430151215_add_task_run_type.sql +77 -0
- package/dist/db/migrations/20260501000000_drop_cli_sessions.sql +27 -0
- package/dist/db/migrations/20260501133000_lobu_memory_mcp_id.sql +117 -0
- package/dist/db/migrations/20260502000000_drop_chat_connections.sql +60 -0
- package/dist/db/migrations/20260503000000_agent_secrets_org_scope.sql +56 -0
- package/dist/db/migrations/20260504000000_flatten_agents_drop_sandbox_model.sql +48 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +147 -23
- package/dist/index.js.map +1 -1
- package/dist/internal/api-client.d.ts +4 -8
- package/dist/internal/api-client.d.ts.map +1 -1
- package/dist/internal/api-client.js +1 -1
- package/dist/internal/api-client.js.map +1 -1
- package/dist/internal/context.js +2 -2
- package/dist/internal/context.js.map +1 -1
- package/dist/internal/credentials.d.ts.map +1 -1
- package/dist/internal/credentials.js +6 -1
- package/dist/internal/credentials.js.map +1 -1
- package/dist/internal/index.d.ts +2 -3
- package/dist/internal/index.d.ts.map +1 -1
- package/dist/internal/index.js +2 -2
- package/dist/internal/index.js.map +1 -1
- package/dist/internal/oauth.d.ts +6 -5
- package/dist/internal/oauth.d.ts.map +1 -1
- package/dist/internal/oauth.js +2 -2
- package/dist/internal/project-link.d.ts +10 -0
- package/dist/internal/project-link.d.ts.map +1 -0
- package/dist/internal/project-link.js +48 -0
- package/dist/internal/project-link.js.map +1 -0
- package/dist/providers.json +2 -2
- package/dist/server.bundle.mjs +3090 -4321
- package/dist/start-local.bundle.mjs +71481 -0
- package/dist/templates/README.md.tmpl +10 -11
- package/package.json +14 -12
- package/dist/__tests__/chat.integration.test.d.ts +0 -2
- package/dist/__tests__/chat.integration.test.d.ts.map +0 -1
- package/dist/__tests__/chat.integration.test.js +0 -337
- package/dist/__tests__/chat.integration.test.js.map +0 -1
- package/dist/__tests__/dev.test.d.ts +0 -2
- package/dist/__tests__/dev.test.d.ts.map +0 -1
- package/dist/__tests__/dev.test.js +0 -25
- package/dist/__tests__/dev.test.js.map +0 -1
- package/dist/__tests__/init-memory.test.d.ts +0 -2
- package/dist/__tests__/init-memory.test.d.ts.map +0 -1
- package/dist/__tests__/init-memory.test.js +0 -45
- package/dist/__tests__/init-memory.test.js.map +0 -1
- package/dist/__tests__/token.test.d.ts +0 -2
- package/dist/__tests__/token.test.d.ts.map +0 -1
- package/dist/__tests__/token.test.js +0 -52
- package/dist/__tests__/token.test.js.map +0 -1
- package/dist/commands/_lib/apply/__tests__/client.test.d.ts +0 -2
- package/dist/commands/_lib/apply/__tests__/client.test.d.ts.map +0 -1
- package/dist/commands/_lib/apply/__tests__/client.test.js +0 -23
- package/dist/commands/_lib/apply/__tests__/client.test.js.map +0 -1
- package/dist/commands/_lib/apply/__tests__/desired-state.test.d.ts +0 -2
- package/dist/commands/_lib/apply/__tests__/desired-state.test.d.ts.map +0 -1
- package/dist/commands/_lib/apply/__tests__/desired-state.test.js +0 -140
- package/dist/commands/_lib/apply/__tests__/desired-state.test.js.map +0 -1
- package/dist/commands/_lib/apply/__tests__/diff.test.d.ts +0 -2
- package/dist/commands/_lib/apply/__tests__/diff.test.d.ts.map +0 -1
- package/dist/commands/_lib/apply/__tests__/diff.test.js +0 -378
- package/dist/commands/_lib/apply/__tests__/diff.test.js.map +0 -1
- package/dist/commands/apply.d.ts +0 -3
- package/dist/commands/apply.d.ts.map +0 -1
- package/dist/commands/apply.js +0 -5
- package/dist/commands/apply.js.map +0 -1
- package/dist/commands/memory/_lib/openclaw-auth.test.d.ts +0 -2
- package/dist/commands/memory/_lib/openclaw-auth.test.d.ts.map +0 -1
- package/dist/commands/memory/_lib/openclaw-auth.test.js +0 -9
- package/dist/commands/memory/_lib/openclaw-auth.test.js.map +0 -1
- package/dist/internal/__tests__/api-client.test.d.ts +0 -2
- package/dist/internal/__tests__/api-client.test.d.ts.map +0 -1
- package/dist/internal/__tests__/api-client.test.js +0 -95
- package/dist/internal/__tests__/api-client.test.js.map +0 -1
- package/dist/internal/__tests__/context.test.d.ts +0 -2
- package/dist/internal/__tests__/context.test.d.ts.map +0 -1
- package/dist/internal/__tests__/context.test.js +0 -77
- package/dist/internal/__tests__/context.test.js.map +0 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
// browser-scraper-utils.ts pulls runtime symbols (acquireBrowser,
|
|
4
|
+
// captureErrorArtifacts) from @lobu/connector-sdk, which itself pulls in
|
|
5
|
+
// playwright. Stub the SDK so the pure helpers (validateUrlDomain,
|
|
6
|
+
// getBrowserCookies, validateCookieNotExpired, filterByCheckpoint) can be
|
|
7
|
+
// imported without spinning up a real browser stack.
|
|
8
|
+
mock.module('@lobu/connector-sdk', () => ({
|
|
9
|
+
acquireBrowser: () => {
|
|
10
|
+
throw new Error('not used in unit tests');
|
|
11
|
+
},
|
|
12
|
+
captureErrorArtifacts: () => {
|
|
13
|
+
throw new Error('not used in unit tests');
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
const {
|
|
18
|
+
filterByCheckpoint,
|
|
19
|
+
getBrowserCookies,
|
|
20
|
+
validateCookieNotExpired,
|
|
21
|
+
validateUrlDomain,
|
|
22
|
+
} = await import('../browser-scraper-utils.ts');
|
|
23
|
+
|
|
24
|
+
describe('validateUrlDomain', () => {
|
|
25
|
+
test('accepts a well-formed https URL on the expected domain', () => {
|
|
26
|
+
expect(() =>
|
|
27
|
+
validateUrlDomain('https://www.trustpilot.com/review/foo', 'trustpilot.com')
|
|
28
|
+
).not.toThrow();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('accepts a subdomain of the expected domain', () => {
|
|
32
|
+
expect(() => validateUrlDomain('https://api.example.com/x', 'example.com')).not.toThrow();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('rejects a malformed URL', () => {
|
|
36
|
+
expect(() => validateUrlDomain('not a url', 'example.com')).toThrow(/Invalid example.com URL/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('rejects http (non-https) URLs', () => {
|
|
40
|
+
expect(() => validateUrlDomain('http://www.example.com/', 'example.com')).toThrow(
|
|
41
|
+
/must use https: protocol/
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('rejects a URL on a different domain', () => {
|
|
46
|
+
expect(() => validateUrlDomain('https://evil.com/foo', 'example.com')).toThrow(
|
|
47
|
+
/must be on example.com/
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('rejects a substring-match hostname (security: notexample.com is NOT example.com)', () => {
|
|
52
|
+
expect(() => validateUrlDomain('https://notexample.com/x', 'example.com')).toThrow(
|
|
53
|
+
/must be on example.com/
|
|
54
|
+
);
|
|
55
|
+
expect(() => validateUrlDomain('https://eviltrustpilot.com/x', 'trustpilot.com')).toThrow(
|
|
56
|
+
/must be on trustpilot.com/
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('accepts the apex domain itself (no subdomain)', () => {
|
|
61
|
+
expect(() => validateUrlDomain('https://example.com/x', 'example.com')).not.toThrow();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('getBrowserCookies', () => {
|
|
66
|
+
test('prefers checkpoint cookies over session-state cookies', () => {
|
|
67
|
+
const cookies = getBrowserCookies(
|
|
68
|
+
{ cookies: [{ name: 'checkpoint-cookie' }] },
|
|
69
|
+
{ cookies: [{ name: 'session-cookie' }] },
|
|
70
|
+
'connector.x'
|
|
71
|
+
);
|
|
72
|
+
expect(cookies).toEqual([{ name: 'checkpoint-cookie' }]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('falls back to session-state cookies when checkpoint has none', () => {
|
|
76
|
+
const cookies = getBrowserCookies(
|
|
77
|
+
null,
|
|
78
|
+
{ cookies: [{ name: 'session-cookie' }] },
|
|
79
|
+
'connector.x'
|
|
80
|
+
);
|
|
81
|
+
expect(cookies).toEqual([{ name: 'session-cookie' }]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('throws a descriptive error when no cookies are present anywhere', () => {
|
|
85
|
+
expect(() => getBrowserCookies(null, null, 'connector.x')).toThrow(
|
|
86
|
+
/No browser cookies found/
|
|
87
|
+
);
|
|
88
|
+
expect(() => getBrowserCookies(null, null, 'connector.x')).toThrow(/connector\.x/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('throws when checkpoint cookies array is empty and no session', () => {
|
|
92
|
+
expect(() => getBrowserCookies({ cookies: [] }, undefined, 'connector.x')).toThrow(
|
|
93
|
+
/No browser cookies found/
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('handles undefined sessionState explicitly', () => {
|
|
98
|
+
expect(() => getBrowserCookies(null, undefined, 'connector.x')).toThrow(
|
|
99
|
+
/No browser cookies found/
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('validateCookieNotExpired', () => {
|
|
105
|
+
test('does nothing when the cookie is missing entirely', () => {
|
|
106
|
+
expect(() =>
|
|
107
|
+
validateCookieNotExpired([{ name: 'other' }], 'session', 'connector.x')
|
|
108
|
+
).not.toThrow();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('does nothing when the cookie has no expires field', () => {
|
|
112
|
+
expect(() =>
|
|
113
|
+
validateCookieNotExpired([{ name: 'session' }], 'session', 'connector.x')
|
|
114
|
+
).not.toThrow();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('does nothing when expires is 0 (session cookie)', () => {
|
|
118
|
+
expect(() =>
|
|
119
|
+
validateCookieNotExpired([{ name: 'session', expires: 0 }], 'session', 'connector.x')
|
|
120
|
+
).not.toThrow();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('does nothing when the cookie expires in the future', () => {
|
|
124
|
+
const futureUnix = Math.floor(Date.now() / 1000) + 60 * 60;
|
|
125
|
+
expect(() =>
|
|
126
|
+
validateCookieNotExpired(
|
|
127
|
+
[{ name: 'session', expires: futureUnix }],
|
|
128
|
+
'session',
|
|
129
|
+
'connector.x'
|
|
130
|
+
)
|
|
131
|
+
).not.toThrow();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('throws when the cookie has expired', () => {
|
|
135
|
+
const pastUnix = Math.floor(Date.now() / 1000) - 60 * 60 * 24;
|
|
136
|
+
expect(() =>
|
|
137
|
+
validateCookieNotExpired(
|
|
138
|
+
[{ name: 'session', expires: pastUnix }],
|
|
139
|
+
'session',
|
|
140
|
+
'connector.x'
|
|
141
|
+
)
|
|
142
|
+
).toThrow(/session expired on/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('error message includes the connector slug for the re-auth hint', () => {
|
|
146
|
+
const pastUnix = Math.floor(Date.now() / 1000) - 60 * 60 * 24;
|
|
147
|
+
expect(() =>
|
|
148
|
+
validateCookieNotExpired(
|
|
149
|
+
[{ name: 'session', expires: pastUnix }],
|
|
150
|
+
'session',
|
|
151
|
+
'connector.x'
|
|
152
|
+
)
|
|
153
|
+
).toThrow(/--connector connector\.x/);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('filterByCheckpoint', () => {
|
|
158
|
+
const events = [
|
|
159
|
+
{ occurred_at: new Date('2024-01-01T00:00:00Z') } as any,
|
|
160
|
+
{ occurred_at: new Date('2024-06-01T00:00:00Z') } as any,
|
|
161
|
+
{ occurred_at: new Date('2024-12-31T00:00:00Z') } as any,
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
test('returns every event when no checkpoint is set', () => {
|
|
165
|
+
expect(filterByCheckpoint(events, null)).toEqual(events);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('returns every event when checkpoint has no last_timestamp', () => {
|
|
169
|
+
expect(filterByCheckpoint(events, {})).toEqual(events);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('keeps only events strictly newer than last_timestamp', () => {
|
|
173
|
+
const filtered = filterByCheckpoint(events, {
|
|
174
|
+
last_timestamp: '2024-06-01T00:00:00Z',
|
|
175
|
+
});
|
|
176
|
+
// strict `>` — event at exactly the cutoff is filtered out
|
|
177
|
+
expect(filtered).toHaveLength(1);
|
|
178
|
+
expect(filtered[0]).toBe(events[2]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('returns empty array when cutoff is past every event', () => {
|
|
182
|
+
expect(
|
|
183
|
+
filterByCheckpoint(events, { last_timestamp: '2099-01-01T00:00:00Z' })
|
|
184
|
+
).toEqual([]);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for browser-based scraper connectors.
|
|
3
|
+
*
|
|
4
|
+
* Provides common patterns used across Trustpilot, G2, Glassdoor, Capterra,
|
|
5
|
+
* and similar connectors that launch a stealth browser and scrape review pages.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
acquireBrowser,
|
|
10
|
+
type CdpPage,
|
|
11
|
+
captureErrorArtifacts,
|
|
12
|
+
type EventEnvelope,
|
|
13
|
+
} from '@lobu/connector-sdk';
|
|
14
|
+
import type { Browser, Cookie, Page } from 'playwright';
|
|
15
|
+
|
|
16
|
+
// -----------------------------------------------------------------------------
|
|
17
|
+
// Browser auth cookie helpers
|
|
18
|
+
// -----------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export function getBrowserCookies(
|
|
21
|
+
checkpoint: Record<string, unknown> | null,
|
|
22
|
+
sessionState: Record<string, unknown> | null | undefined,
|
|
23
|
+
connectorKey: string
|
|
24
|
+
): any[] {
|
|
25
|
+
const sessionCookies = (sessionState?.cookies as any[]) ?? [];
|
|
26
|
+
const cookies = (checkpoint as any)?.cookies ?? sessionCookies;
|
|
27
|
+
if (!cookies || cookies.length === 0) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`No browser cookies found. Run: lobu memory browser-auth --connector ${connectorKey} --auth-profile-slug <SLUG>`
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return cookies;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function validateCookieNotExpired(
|
|
36
|
+
cookies: any[],
|
|
37
|
+
cookieName: string,
|
|
38
|
+
connectorKey: string
|
|
39
|
+
): void {
|
|
40
|
+
const cookie = cookies.find((c: any) => c.name === cookieName);
|
|
41
|
+
if (cookie?.expires && cookie.expires > 0) {
|
|
42
|
+
const expiresAt = new Date(cookie.expires * 1000);
|
|
43
|
+
if (expiresAt < new Date()) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`${cookieName} expired on ${expiresAt.toISOString()}. Re-run: lobu memory browser-auth --connector ${connectorKey} --auth-profile-slug <SLUG>`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// -----------------------------------------------------------------------------
|
|
52
|
+
// URL validation
|
|
53
|
+
// -----------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Validate that a URL is well-formed, uses HTTPS, and belongs to the expected
|
|
57
|
+
* domain (hostname ends with `expectedDomain`).
|
|
58
|
+
*
|
|
59
|
+
* @throws If the URL is invalid, not HTTPS, or on the wrong domain.
|
|
60
|
+
*/
|
|
61
|
+
export function validateUrlDomain(url: string, expectedDomain: string): void {
|
|
62
|
+
let parsed: URL;
|
|
63
|
+
try {
|
|
64
|
+
parsed = new URL(url);
|
|
65
|
+
} catch {
|
|
66
|
+
throw new Error(`Invalid ${expectedDomain} URL: ${url}`);
|
|
67
|
+
}
|
|
68
|
+
if (parsed.protocol !== 'https:') {
|
|
69
|
+
throw new Error(`${expectedDomain} URL must use https: protocol, got ${parsed.protocol}`);
|
|
70
|
+
}
|
|
71
|
+
if (
|
|
72
|
+
parsed.hostname !== expectedDomain &&
|
|
73
|
+
!parsed.hostname.endsWith(`.${expectedDomain}`)
|
|
74
|
+
) {
|
|
75
|
+
throw new Error(`URL must be on ${expectedDomain}, got ${parsed.hostname}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// -----------------------------------------------------------------------------
|
|
80
|
+
// Browser lifecycle
|
|
81
|
+
// -----------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
export interface BrowserSession {
|
|
84
|
+
/** Playwright Browser (null when using raw CDP). */
|
|
85
|
+
browser: Browser | null;
|
|
86
|
+
/** Page handle — Playwright Page or CdpPage. Both support goto/evaluate/waitForSelector. */
|
|
87
|
+
page: Page | CdpPage;
|
|
88
|
+
screenshotDir: string;
|
|
89
|
+
/** Which backend was used ('cdp' or 'playwright'). */
|
|
90
|
+
backend: 'cdp' | 'playwright';
|
|
91
|
+
/** If false, don't close the browser (CDP — it's the user's Chrome). */
|
|
92
|
+
ownsBrowser: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Acquire a stealth browser session.
|
|
97
|
+
*
|
|
98
|
+
* By default launches a fresh Playwright browser (safe for DOM scraping).
|
|
99
|
+
* Pass `cdpUrl: 'auto'` to try CDP first — uses raw CDP protocol to avoid
|
|
100
|
+
* Playwright's connectOverCDP crash on browsers with many tabs.
|
|
101
|
+
*/
|
|
102
|
+
export async function openStealthBrowser(opts?: {
|
|
103
|
+
cdpUrl?: string | 'auto' | null;
|
|
104
|
+
cookies?: Cookie[];
|
|
105
|
+
authDomains?: string[];
|
|
106
|
+
}): Promise<BrowserSession> {
|
|
107
|
+
const acquired = await acquireBrowser({
|
|
108
|
+
cdpUrl: opts?.cdpUrl ?? null,
|
|
109
|
+
cookies: opts?.cookies ?? [],
|
|
110
|
+
authDomains: opts?.authDomains ?? [],
|
|
111
|
+
stealth: true,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const page = acquired.cdpPage ?? acquired.page;
|
|
115
|
+
if (!page) throw new Error('No page available from browser acquisition');
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
browser: acquired.browser,
|
|
119
|
+
page,
|
|
120
|
+
screenshotDir: acquired.screenshotDir,
|
|
121
|
+
backend: acquired.backend,
|
|
122
|
+
ownsBrowser: acquired.ownsBrowser,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// -----------------------------------------------------------------------------
|
|
127
|
+
// Cookie consent
|
|
128
|
+
// -----------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Attempt to dismiss a cookie consent banner by clicking an accept button.
|
|
132
|
+
*
|
|
133
|
+
* @param page - Playwright page instance
|
|
134
|
+
* @param selector - CSS selector for the accept/dismiss button
|
|
135
|
+
* @param timeout - How long to wait for the button to appear (ms, default 2000)
|
|
136
|
+
*/
|
|
137
|
+
export async function handleCookieConsent(
|
|
138
|
+
page: Page | CdpPage,
|
|
139
|
+
selector: string,
|
|
140
|
+
timeout = 2000
|
|
141
|
+
): Promise<void> {
|
|
142
|
+
try {
|
|
143
|
+
const found = await page.waitForSelector(selector, { timeout });
|
|
144
|
+
if (found) {
|
|
145
|
+
// CdpPage.waitForSelector returns boolean, Playwright returns ElementHandle
|
|
146
|
+
if (typeof found === 'boolean') {
|
|
147
|
+
await (page as CdpPage).click(selector);
|
|
148
|
+
} else {
|
|
149
|
+
await found.click();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
// No cookie banner found or already dismissed — continue
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// -----------------------------------------------------------------------------
|
|
158
|
+
// Checkpoint filtering
|
|
159
|
+
// -----------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Filter events that are newer than the checkpoint's `last_timestamp`.
|
|
163
|
+
* If no checkpoint is set, all events are returned.
|
|
164
|
+
*/
|
|
165
|
+
export function filterByCheckpoint(
|
|
166
|
+
events: EventEnvelope[],
|
|
167
|
+
checkpoint: Record<string, unknown> | null
|
|
168
|
+
): EventEnvelope[] {
|
|
169
|
+
const lastTimestamp = checkpoint?.last_timestamp as string | undefined;
|
|
170
|
+
if (!lastTimestamp) return events;
|
|
171
|
+
|
|
172
|
+
const cutoff = new Date(lastTimestamp);
|
|
173
|
+
return events.filter((e) => e.occurred_at > cutoff);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// -----------------------------------------------------------------------------
|
|
177
|
+
// Error handling with browser cleanup
|
|
178
|
+
// -----------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Run a scraper function inside a try/catch that captures error artifacts
|
|
182
|
+
* (screenshot + HTML snapshot) and ensures the browser is always closed.
|
|
183
|
+
*
|
|
184
|
+
* @param session - The browser session from `openStealthBrowser()`
|
|
185
|
+
* @param connectorName - Short name used for artifact filenames (e.g. "trustpilot-sync")
|
|
186
|
+
* @param fn - The async scraper logic receiving the page
|
|
187
|
+
* @returns - Whatever `fn` returns
|
|
188
|
+
*/
|
|
189
|
+
export async function withBrowserErrorCapture<T>(
|
|
190
|
+
session: BrowserSession,
|
|
191
|
+
connectorName: string,
|
|
192
|
+
fn: (page: Page | CdpPage) => Promise<T>
|
|
193
|
+
): Promise<T> {
|
|
194
|
+
try {
|
|
195
|
+
return await fn(session.page);
|
|
196
|
+
} catch (error: any) {
|
|
197
|
+
// captureErrorArtifacts only works with Playwright pages
|
|
198
|
+
if (session.backend === 'playwright' && session.page) {
|
|
199
|
+
await captureErrorArtifacts(
|
|
200
|
+
session.page as Page,
|
|
201
|
+
error,
|
|
202
|
+
connectorName,
|
|
203
|
+
session.screenshotDir
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
throw error;
|
|
207
|
+
} finally {
|
|
208
|
+
if (session.backend === 'cdp') {
|
|
209
|
+
await (session.page as CdpPage).close();
|
|
210
|
+
} else if (session.ownsBrowser && session.browser) {
|
|
211
|
+
await session.browser.close();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capterra Connector
|
|
3
|
+
* Scrapes software reviews from Capterra using browser rendering with stealth mode.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
type ActionContext,
|
|
8
|
+
type ActionResult,
|
|
9
|
+
type ConnectorDefinition,
|
|
10
|
+
ConnectorRuntime,
|
|
11
|
+
calculateEngagementScore,
|
|
12
|
+
type EventEnvelope,
|
|
13
|
+
type SyncContext,
|
|
14
|
+
type SyncResult,
|
|
15
|
+
} from '@lobu/connector-sdk';
|
|
16
|
+
import {
|
|
17
|
+
handleCookieConsent,
|
|
18
|
+
openStealthBrowser,
|
|
19
|
+
validateUrlDomain,
|
|
20
|
+
withBrowserErrorCapture,
|
|
21
|
+
} from './browser-scraper-utils.ts';
|
|
22
|
+
|
|
23
|
+
interface CapterraReview {
|
|
24
|
+
id: string;
|
|
25
|
+
rating: number;
|
|
26
|
+
title: string;
|
|
27
|
+
text: string;
|
|
28
|
+
date: string;
|
|
29
|
+
author: string;
|
|
30
|
+
helpfulCount: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default class CapterraConnector extends ConnectorRuntime {
|
|
34
|
+
readonly definition: ConnectorDefinition = {
|
|
35
|
+
key: 'capterra',
|
|
36
|
+
name: 'Capterra',
|
|
37
|
+
version: '1.0.0',
|
|
38
|
+
faviconDomain: 'capterra.com',
|
|
39
|
+
description: 'Scrapes software reviews from Capterra.',
|
|
40
|
+
authSchema: {
|
|
41
|
+
methods: [{ type: 'none' }],
|
|
42
|
+
},
|
|
43
|
+
feeds: {
|
|
44
|
+
reviews: {
|
|
45
|
+
key: 'reviews',
|
|
46
|
+
name: 'Reviews',
|
|
47
|
+
description: 'Capterra software reviews',
|
|
48
|
+
configSchema: {
|
|
49
|
+
type: 'object',
|
|
50
|
+
required: ['product_id'],
|
|
51
|
+
properties: {
|
|
52
|
+
product_id: {
|
|
53
|
+
type: 'string',
|
|
54
|
+
description: 'Capterra product ID (e.g., "12345")',
|
|
55
|
+
minLength: 1,
|
|
56
|
+
},
|
|
57
|
+
product_name: {
|
|
58
|
+
type: 'string',
|
|
59
|
+
description:
|
|
60
|
+
'Product name slug for URL (e.g., "spotify"). Optional - Capterra will redirect without it.',
|
|
61
|
+
minLength: 1,
|
|
62
|
+
},
|
|
63
|
+
vendor_name: {
|
|
64
|
+
type: 'string',
|
|
65
|
+
description:
|
|
66
|
+
'Vendor/company name (e.g., "Spotify AB"). Optional but recommended for disambiguation.',
|
|
67
|
+
minLength: 1,
|
|
68
|
+
},
|
|
69
|
+
lookback_days: {
|
|
70
|
+
type: 'integer',
|
|
71
|
+
description:
|
|
72
|
+
'Number of days to look back for historical data. Default: 365 (1 year). Maximum: 730 (2 years).',
|
|
73
|
+
minimum: 1,
|
|
74
|
+
maximum: 730,
|
|
75
|
+
default: 365,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
eventKinds: {
|
|
80
|
+
review: {
|
|
81
|
+
description: 'A Capterra software review',
|
|
82
|
+
metadataSchema: {
|
|
83
|
+
type: 'object',
|
|
84
|
+
properties: {
|
|
85
|
+
rating: { type: 'number', description: 'Star rating (0-5)' },
|
|
86
|
+
helpful_count: { type: 'number', description: 'Number of helpful votes' },
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
optionsSchema: {
|
|
94
|
+
type: 'object',
|
|
95
|
+
required: ['product_id'],
|
|
96
|
+
properties: {
|
|
97
|
+
product_id: {
|
|
98
|
+
type: 'string',
|
|
99
|
+
description: 'Capterra product ID (e.g., "12345")',
|
|
100
|
+
minLength: 1,
|
|
101
|
+
},
|
|
102
|
+
product_name: {
|
|
103
|
+
type: 'string',
|
|
104
|
+
description:
|
|
105
|
+
'Product name slug for URL (e.g., "spotify"). Optional - Capterra will redirect without it.',
|
|
106
|
+
minLength: 1,
|
|
107
|
+
},
|
|
108
|
+
vendor_name: {
|
|
109
|
+
type: 'string',
|
|
110
|
+
description:
|
|
111
|
+
'Vendor/company name (e.g., "Spotify AB"). Optional but recommended for disambiguation.',
|
|
112
|
+
minLength: 1,
|
|
113
|
+
},
|
|
114
|
+
lookback_days: {
|
|
115
|
+
type: 'integer',
|
|
116
|
+
description:
|
|
117
|
+
'Number of days to look back for historical data. Default: 365 (1 year). Maximum: 730 (2 years).',
|
|
118
|
+
minimum: 1,
|
|
119
|
+
maximum: 730,
|
|
120
|
+
default: 365,
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
async sync(ctx: SyncContext): Promise<SyncResult> {
|
|
127
|
+
const productId = ctx.config.product_id as string;
|
|
128
|
+
const productName = ctx.config.product_name as string | undefined;
|
|
129
|
+
|
|
130
|
+
const baseUrl = productName
|
|
131
|
+
? `https://www.capterra.com/p/${productId}/${productName}/reviews`
|
|
132
|
+
: `https://www.capterra.com/p/${productId}/reviews`;
|
|
133
|
+
validateUrlDomain(baseUrl, 'capterra.com');
|
|
134
|
+
|
|
135
|
+
const session = await openStealthBrowser({ cdpUrl: 'auto' });
|
|
136
|
+
|
|
137
|
+
return withBrowserErrorCapture(session, 'capterra-sync', async (page) => {
|
|
138
|
+
await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
139
|
+
|
|
140
|
+
await handleCookieConsent(page, '[data-test="cookie-accept"], #onetrust-accept-btn-handler');
|
|
141
|
+
|
|
142
|
+
// Wait for review cards
|
|
143
|
+
try {
|
|
144
|
+
await page.waitForSelector('[data-test="review-card"], .review-card', { timeout: 10000 });
|
|
145
|
+
} catch {
|
|
146
|
+
// Review selectors not found - page structure may have changed
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Extract reviews using page.evaluate
|
|
150
|
+
const rawReviews: CapterraReview[] = await page.evaluate(() => {
|
|
151
|
+
const reviewElements = Array.from(
|
|
152
|
+
document.querySelectorAll('[data-test="review-card"], .review-card')
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
return reviewElements.map((el: Element, index: number) => {
|
|
156
|
+
// Extract rating (usually shown as stars)
|
|
157
|
+
const ratingElement = el.querySelector(
|
|
158
|
+
'[data-test="rating"], .rating, [aria-label*="star"]'
|
|
159
|
+
);
|
|
160
|
+
let rating = 0;
|
|
161
|
+
if (ratingElement) {
|
|
162
|
+
const ariaLabel = ratingElement.getAttribute('aria-label');
|
|
163
|
+
const ratingMatch = ariaLabel?.match(/(\d+(?:\.\d+)?)/);
|
|
164
|
+
rating = ratingMatch ? parseFloat(ratingMatch[1]) : 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Extract review title
|
|
168
|
+
const titleElement = el.querySelector(
|
|
169
|
+
'[data-test="review-title"], .review-title, h3, h4'
|
|
170
|
+
);
|
|
171
|
+
const title = titleElement?.textContent?.trim() || '';
|
|
172
|
+
|
|
173
|
+
// Extract review text
|
|
174
|
+
const textElement = el.querySelector(
|
|
175
|
+
'[data-test="review-body"], .review-body, .review-content, .review-text'
|
|
176
|
+
);
|
|
177
|
+
const text = textElement?.textContent?.trim() || '';
|
|
178
|
+
|
|
179
|
+
// Extract date
|
|
180
|
+
const dateElement = el.querySelector('[data-test="review-date"], .review-date, time');
|
|
181
|
+
const dateText =
|
|
182
|
+
dateElement?.textContent?.trim() || dateElement?.getAttribute('datetime') || '';
|
|
183
|
+
|
|
184
|
+
// Parse relative dates like "2 weeks ago"
|
|
185
|
+
let date = new Date();
|
|
186
|
+
if (dateText) {
|
|
187
|
+
const weeksMatch = dateText.match(/(\d+)\s+weeks?\s+ago/i);
|
|
188
|
+
const monthsMatch = dateText.match(/(\d+)\s+months?\s+ago/i);
|
|
189
|
+
const daysMatch = dateText.match(/(\d+)\s+days?\s+ago/i);
|
|
190
|
+
|
|
191
|
+
if (weeksMatch) {
|
|
192
|
+
date = new Date(Date.now() - parseInt(weeksMatch[1], 10) * 7 * 24 * 60 * 60 * 1000);
|
|
193
|
+
} else if (monthsMatch) {
|
|
194
|
+
date = new Date(Date.now() - parseInt(monthsMatch[1], 10) * 30 * 24 * 60 * 60 * 1000);
|
|
195
|
+
} else if (daysMatch) {
|
|
196
|
+
date = new Date(Date.now() - parseInt(daysMatch[1], 10) * 24 * 60 * 60 * 1000);
|
|
197
|
+
} else {
|
|
198
|
+
// Try parsing as date
|
|
199
|
+
const parsed = new Date(dateText);
|
|
200
|
+
if (!Number.isNaN(parsed.getTime())) {
|
|
201
|
+
date = parsed;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Extract author
|
|
207
|
+
const authorElement = el.querySelector(
|
|
208
|
+
'[data-test="reviewer-name"], .reviewer-name, .author'
|
|
209
|
+
);
|
|
210
|
+
const author = authorElement?.textContent?.trim() || 'Anonymous';
|
|
211
|
+
|
|
212
|
+
// Extract review ID from data attributes or generate
|
|
213
|
+
const reviewId =
|
|
214
|
+
(el as HTMLElement).getAttribute('data-review-id') ||
|
|
215
|
+
(el as HTMLElement).id ||
|
|
216
|
+
`${date.toISOString()}_${index}`.replace(/[^a-zA-Z0-9]/g, '_');
|
|
217
|
+
|
|
218
|
+
// Extract helpful count
|
|
219
|
+
const helpfulElement = el.querySelector('[data-test="helpful-count"], .helpful-count');
|
|
220
|
+
const helpfulCount = helpfulElement
|
|
221
|
+
? parseInt(helpfulElement.textContent?.replace(/\D/g, '') || '0', 10)
|
|
222
|
+
: 0;
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
id: reviewId,
|
|
226
|
+
rating,
|
|
227
|
+
title,
|
|
228
|
+
text,
|
|
229
|
+
date: date.toISOString(),
|
|
230
|
+
author,
|
|
231
|
+
helpfulCount,
|
|
232
|
+
};
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Filter reviews with content
|
|
237
|
+
const reviews = rawReviews.filter((r) => r.text.length > 0);
|
|
238
|
+
|
|
239
|
+
// Transform to EventEnvelope
|
|
240
|
+
const events: EventEnvelope[] = reviews.map((review) => {
|
|
241
|
+
const engagementData = {
|
|
242
|
+
rating: review.rating,
|
|
243
|
+
helpful_count: review.helpfulCount,
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
origin_id: review.id,
|
|
248
|
+
title: review.title,
|
|
249
|
+
payload_text: review.text,
|
|
250
|
+
author_name: review.author,
|
|
251
|
+
occurred_at: new Date(review.date),
|
|
252
|
+
origin_type: 'review',
|
|
253
|
+
score: calculateEngagementScore('capterra', engagementData),
|
|
254
|
+
source_url: baseUrl,
|
|
255
|
+
metadata: engagementData,
|
|
256
|
+
};
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
events,
|
|
261
|
+
checkpoint: { last_sync_at: new Date().toISOString() },
|
|
262
|
+
metadata: {
|
|
263
|
+
items_found: rawReviews.length,
|
|
264
|
+
items_skipped: rawReviews.length - reviews.length,
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async execute(_ctx: ActionContext): Promise<ActionResult> {
|
|
271
|
+
return { success: false, error: 'Actions not supported' };
|
|
272
|
+
}
|
|
273
|
+
}
|