intelwatch 1.0.0

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.
@@ -0,0 +1,267 @@
1
+ import chalk from 'chalk';
2
+ import {
3
+ loadTrackers, updateTracker, saveSnapshot, loadLatestSnapshot
4
+ } from '../storage.js';
5
+ import { runCompetitorCheck, diffCompetitorSnapshots } from '../trackers/competitor.js';
6
+ import { runKeywordCheck, diffKeywordSnapshots } from '../trackers/keyword.js';
7
+ import { runBrandCheck, diffBrandSnapshots } from '../trackers/brand.js';
8
+ import { runPersonCheck, diffPersonSnapshots } from '../trackers/person.js';
9
+ import { header, section, diffLine, success, warn, error, trackerTypeIcon } from '../utils/display.js';
10
+
11
+ export async function runCheck(options) {
12
+ const trackers = loadTrackers();
13
+
14
+ if (trackers.length === 0) {
15
+ warn('No trackers configured. Use `intelwatch track` to add one.');
16
+ return;
17
+ }
18
+
19
+ const toCheck = options.tracker
20
+ ? trackers.filter(t => t.id === options.tracker)
21
+ : trackers;
22
+
23
+ if (options.tracker && toCheck.length === 0) {
24
+ error(`Tracker not found: ${options.tracker}`);
25
+ process.exit(1);
26
+ }
27
+
28
+ let totalChanges = 0;
29
+
30
+ for (const tracker of toCheck) {
31
+ header(`${trackerTypeIcon(tracker.type)} ${tracker.name || tracker.keyword || tracker.brandName} [${tracker.id}]`);
32
+
33
+ try {
34
+ const prevSnapshot = loadLatestSnapshot(tracker.id);
35
+
36
+ let snapshot;
37
+ let changes = [];
38
+
39
+ console.log(chalk.gray(` Checking ${tracker.url || tracker.keyword || tracker.brandName}...`));
40
+
41
+ if (tracker.type === 'competitor') {
42
+ snapshot = await runCompetitorCheck(tracker);
43
+ changes = diffCompetitorSnapshots(prevSnapshot, snapshot);
44
+ } else if (tracker.type === 'keyword') {
45
+ snapshot = await runKeywordCheck(tracker);
46
+ changes = diffKeywordSnapshots(prevSnapshot, snapshot);
47
+ } else if (tracker.type === 'brand') {
48
+ snapshot = await runBrandCheck(tracker);
49
+ changes = diffBrandSnapshots(prevSnapshot, snapshot);
50
+ } else if (tracker.type === 'person') {
51
+ snapshot = await runPersonCheck(tracker);
52
+ changes = diffPersonSnapshots(prevSnapshot, snapshot);
53
+ }
54
+
55
+ // Save snapshot
56
+ snapshot.changes = changes;
57
+ const filepath = saveSnapshot(tracker.id, snapshot);
58
+
59
+ updateTracker(tracker.id, {
60
+ lastCheckedAt: new Date().toISOString(),
61
+ lastSnapshotPath: filepath,
62
+ status: snapshot.error ? 'error' : 'active',
63
+ checkCount: (tracker.checkCount || 0) + 1,
64
+ });
65
+
66
+ if (snapshot.error) {
67
+ warn(` Error: ${snapshot.error}`);
68
+ } else {
69
+ success(` Check complete`);
70
+ }
71
+
72
+ if (changes.length === 0) {
73
+ console.log(chalk.gray(' No changes detected.'));
74
+ } else {
75
+ section(' Changes:');
76
+ for (const change of changes) {
77
+ console.log(' ' + diffLine(change.type, change.field, change.value));
78
+ }
79
+ totalChanges += changes.length;
80
+ }
81
+
82
+ // Show brief summary for keyword/brand
83
+ if (tracker.type === 'keyword' && snapshot.results?.length > 0) {
84
+ section(' Top 5 results:');
85
+ for (const r of snapshot.results.slice(0, 5)) {
86
+ const star = r.isFeaturedSnippet ? chalk.yellow(' ⭐') : '';
87
+ console.log(chalk.gray(` #${r.position}`), chalk.white(r.domain) + star);
88
+ }
89
+ }
90
+
91
+ if (tracker.type === 'brand') {
92
+ console.log(chalk.gray(` ${snapshot.mentionCount} mentions found`));
93
+ if (snapshot.socialMentions && Object.keys(snapshot.socialMentions).length > 0) {
94
+ const socialParts = Object.entries(snapshot.socialMentions)
95
+ .filter(([, arr]) => arr.length > 0)
96
+ .map(([platform, arr]) => {
97
+ const icon = platform === 'twitter' ? 'X' : platform === 'reddit' ? 'Reddit' : 'LinkedIn';
98
+ return `${icon}(${arr.length})`;
99
+ });
100
+ if (socialParts.length > 0) {
101
+ console.log(chalk.cyan(` šŸ“± Social: ${socialParts.join(' ')}`));
102
+ }
103
+ }
104
+ }
105
+
106
+ if (tracker.type === 'person') {
107
+ const s = snapshot;
108
+ const sent = s.sentimentBreakdown || {};
109
+ const sentStr = `šŸ‘${sent.positive || 0} 😐${sent.neutral || 0} šŸ‘Ž${sent.negative || 0}`;
110
+ console.log(chalk.magenta(` šŸ‘¤ ${s.personName}: ${s.mentionCount} mentions | ${sentStr}`));
111
+ if (s.org) console.log(chalk.gray(` Org filter: ${s.org}`));
112
+
113
+ // Social mentions summary
114
+ if (s.socialMentions && Object.keys(s.socialMentions).length > 0) {
115
+ const socialParts = Object.entries(s.socialMentions)
116
+ .filter(([, arr]) => arr.length > 0)
117
+ .map(([platform, arr]) => {
118
+ const icon = platform === 'twitter' ? 'X' : platform === 'reddit' ? 'Reddit' : 'LinkedIn';
119
+ return `${icon}(${arr.length})`;
120
+ });
121
+ if (socialParts.length > 0) {
122
+ console.log(chalk.cyan(` šŸ“± Social: ${socialParts.join(' ')}`));
123
+ }
124
+ }
125
+
126
+ // Top mentions
127
+ for (const m of (s.mentions || []).slice(0, 5)) {
128
+ const sentEmoji = m.sentiment === 'positive' || m.sentiment === 'slightly_positive' ? 'šŸ‘'
129
+ : m.sentiment === 'negative' || m.sentiment === 'slightly_negative' ? 'šŸ‘Ž' : '😐';
130
+ console.log(chalk.gray(` ${sentEmoji} [${m.category}] ${m.title?.substring(0, 80)} (${m.domain})`));
131
+ }
132
+ }
133
+
134
+ if (tracker.type === 'competitor') {
135
+ const techNames = (snapshot.techStack || []).map(t => t.name).join(', ') || 'none detected';
136
+ console.log(chalk.gray(` Tech: ${techNames}`));
137
+ console.log(chalk.gray(` Pages: ${snapshot.pageCount}`));
138
+
139
+ // Performance
140
+ if (snapshot.performance) {
141
+ const p = snapshot.performance;
142
+ console.log(chalk.gray(` Response: ${p.responseTimeMs}ms | HTML: ${p.htmlSizeKB}KB | Compressed: ${p.compressed ? 'yes' : 'no'}`));
143
+ }
144
+
145
+ // Security summary
146
+ if (snapshot.security) {
147
+ const s = snapshot.security;
148
+ const secScore = [s.https, s.hsts, s.xFrameOptions, s.csp, s.xContentType].filter(Boolean).length;
149
+ console.log(chalk.gray(` Security: ${secScore}/5 (HTTPS:${s.https ? 'āœ“' : 'āœ—'} HSTS:${s.hsts ? 'āœ“' : 'āœ—'} XFO:${s.xFrameOptions ? 'āœ“' : 'āœ—'} CSP:${s.csp ? 'āœ“' : 'āœ—'})`));
150
+ }
151
+
152
+ // SEO signals
153
+ if (snapshot.seoSignals) {
154
+ const seo = snapshot.seoSignals;
155
+ console.log(chalk.gray(` SEO: title=${seo.titleLength}ch | desc=${seo.descriptionLength}ch | H1:${seo.h1Count} H2:${seo.h2Count} | ${seo.imgCount} imgs (${seo.imgWithoutAlt} no alt) | ${seo.wordCount} words`));
156
+ }
157
+
158
+ // Key pages found
159
+ if (snapshot.keyPages && Object.keys(snapshot.keyPages).length > 0) {
160
+ const pageLabels = Object.keys(snapshot.keyPages).join(', ');
161
+ console.log(chalk.gray(` Key pages: ${pageLabels}`));
162
+ }
163
+
164
+ // Pricing
165
+ if (snapshot.pricing && snapshot.pricing.prices?.length > 0) {
166
+ console.log(chalk.cyan(` šŸ’° Pricing detected: ${snapshot.pricing.prices.slice(0, 5).join(', ')}`));
167
+ if (snapshot.pricing.plans?.length > 0) {
168
+ console.log(chalk.gray(` Plans: ${snapshot.pricing.plans.slice(0, 3).join(' | ')}`));
169
+ }
170
+ }
171
+
172
+ // Jobs
173
+ if (snapshot.jobs && snapshot.jobs.estimatedOpenings > 0) {
174
+ console.log(chalk.yellow(` šŸ‘„ Jobs: ~${snapshot.jobs.estimatedOpenings} openings detected`));
175
+ if (snapshot.jobs.titles?.length > 0) {
176
+ for (const t of snapshot.jobs.titles.slice(0, 5)) {
177
+ console.log(chalk.gray(` - ${t}`));
178
+ }
179
+ }
180
+ }
181
+
182
+ // Content/blog activity
183
+ if (snapshot.contentStats && snapshot.contentStats.recentArticles?.length > 0) {
184
+ console.log(chalk.green(` šŸ“ Blog: ${snapshot.contentStats.articleCount} recent articles`));
185
+ for (const a of snapshot.contentStats.recentArticles.slice(0, 3)) {
186
+ const dateStr = a.date ? ` (${a.date})` : '';
187
+ console.log(chalk.gray(` - ${a.title}${dateStr}`));
188
+ }
189
+ }
190
+
191
+ // Social links
192
+ if (snapshot.socialLinks && Object.keys(snapshot.socialLinks).length > 0) {
193
+ const socials = Object.entries(snapshot.socialLinks).map(([k, v]) => k).join(', ');
194
+ console.log(chalk.gray(` Social: ${socials}`));
195
+ }
196
+
197
+ // Press & mentions
198
+ if (snapshot.press && snapshot.press.totalCount > 0) {
199
+ const p = snapshot.press;
200
+ const sentStr = `šŸ‘${p.sentimentBreakdown?.positive || 0} 😐${p.sentimentBreakdown?.neutral || 0} šŸ‘Ž${p.sentimentBreakdown?.negative || 0}`;
201
+ console.log(chalk.magenta(` šŸ“° Press: ${p.totalCount} mentions (${p.pressCount || 0} press, ${p.forumCount || 0} forum/social) | ${sentStr}`));
202
+ for (const a of (p.articles || []).slice(0, 5)) {
203
+ const sentEmoji = a.sentiment === 'positive' || a.sentiment === 'slightly_positive' ? 'šŸ‘'
204
+ : a.sentiment === 'negative' || a.sentiment === 'slightly_negative' ? 'šŸ‘Ž' : '😐';
205
+ console.log(chalk.gray(` ${sentEmoji} [${a.category}] ${a.title.substring(0, 80)} (${a.domain})`));
206
+ }
207
+ } else if (snapshot.press) {
208
+ console.log(chalk.gray(` šŸ“° Press: no recent mentions found`));
209
+ }
210
+
211
+ // Reputation / reviews
212
+ if (snapshot.reputation) {
213
+ const r = snapshot.reputation;
214
+ const validPlatforms = (r.platforms || []).filter(p => p.rating);
215
+ if (validPlatforms.length > 0) {
216
+ const platStr = validPlatforms.map(p => {
217
+ const countStr = p.reviewCount ? ` (${p.reviewCount} avis)` : '';
218
+ return `${p.name}: ${p.rating}/5${countStr}`;
219
+ }).join(' | ');
220
+ console.log(chalk.yellow(` ⭐ ${platStr}`));
221
+ }
222
+ const validReviews = (r.reviews || []).filter(rev => rev.title && rev.title.length > 10);
223
+ if (validReviews.length > 0) {
224
+ for (const rev of validReviews.slice(0, 3)) {
225
+ const sentEmoji = rev.sentiment === 'positive' || rev.sentiment === 'slightly_positive' ? 'šŸ‘' : 'šŸ‘Ž';
226
+ console.log(chalk.gray(` ${sentEmoji} ${rev.title.substring(0, 80)}`));
227
+ }
228
+ }
229
+ if (!validPlatforms.length && !validReviews.length) {
230
+ console.log(chalk.gray(` ⭐ Reputation: no ratings found`));
231
+ }
232
+ }
233
+
234
+ // Pappers (French company data)
235
+ if (snapshot.pappers) {
236
+ const p = snapshot.pappers;
237
+ const parts = [`SIREN: ${p.siren || '?'}`];
238
+ if (p.formeJuridique) parts.push(p.formeJuridique);
239
+ if (p.dateCreation) parts.push(`crƩƩe ${p.dateCreation}`);
240
+ if (p.city) parts.push(p.city);
241
+ console.log(chalk.blue(` šŸ› Pappers: ${parts.join(' Ā· ')}`));
242
+ if (p.effectifs) console.log(chalk.gray(` Effectifs: ${p.effectifs}`));
243
+ if (p.ca) {
244
+ const caStr = p.ca >= 1_000_000 ? `${(p.ca / 1_000_000).toFixed(1)}M€` : `${(p.ca / 1000).toFixed(0)}K€`;
245
+ console.log(chalk.gray(` CA: ${caStr}${p.caYear ? ` (${p.caYear})` : ''}`));
246
+ }
247
+ if (p.nafCode) console.log(chalk.gray(` NAF: ${p.nafCode}${p.nafLabel ? ` — ${p.nafLabel}` : ''}`));
248
+ if (p.dirigeants?.length > 0) {
249
+ const dirs = p.dirigeants.slice(0, 3).map(d => `${d.prenom || ''} ${d.nom || ''} (${d.role || '?'})`).join(', ');
250
+ console.log(chalk.gray(` Dirigeants: ${dirs}`));
251
+ }
252
+ }
253
+ }
254
+
255
+ } catch (err) {
256
+ error(` Failed: ${err.message}`);
257
+ updateTracker(tracker.id, { status: 'error', lastError: err.message });
258
+ }
259
+ }
260
+
261
+ console.log('');
262
+ if (totalChanges > 0) {
263
+ success(`${totalChanges} total change(s) detected across ${toCheck.length} tracker(s).`);
264
+ } else {
265
+ console.log(chalk.gray('No changes detected.'));
266
+ }
267
+ }
@@ -0,0 +1,124 @@
1
+ import chalk from 'chalk';
2
+ import { getTracker, loadLatestSnapshot } from '../storage.js';
3
+ import { createTable, header, section, error, warn } from '../utils/display.js';
4
+
5
+ export function runCompare(id1, id2) {
6
+ const tracker1 = getTracker(id1);
7
+ const tracker2 = getTracker(id2);
8
+
9
+ if (!tracker1) { error(`Tracker not found: ${id1}`); process.exit(1); }
10
+ if (!tracker2) { error(`Tracker not found: ${id2}`); process.exit(1); }
11
+
12
+ if (tracker1.type !== 'competitor' || tracker2.type !== 'competitor') {
13
+ error('compare only works with competitor trackers.');
14
+ process.exit(1);
15
+ }
16
+
17
+ const snap1 = loadLatestSnapshot(id1);
18
+ const snap2 = loadLatestSnapshot(id2);
19
+
20
+ if (!snap1) { warn(`No snapshot for ${id1}. Run check first.`); return; }
21
+ if (!snap2) { warn(`No snapshot for ${id2}. Run check first.`); return; }
22
+
23
+ const name1 = tracker1.name || tracker1.url;
24
+ const name2 = tracker2.name || tracker2.url;
25
+
26
+ header(`šŸ”„ Compare: ${name1} vs ${name2}`);
27
+
28
+ // Overview table
29
+ section('\nOverview:');
30
+ const overviewTable = createTable(['Metric', name1.slice(0, 25), name2.slice(0, 25)]);
31
+
32
+ overviewTable.push([
33
+ 'Pages found',
34
+ String(snap1.pageCount || 0),
35
+ String(snap2.pageCount || 0),
36
+ ]);
37
+ overviewTable.push([
38
+ 'Tech stack',
39
+ String((snap1.techStack || []).length),
40
+ String((snap2.techStack || []).length),
41
+ ]);
42
+ overviewTable.push([
43
+ 'Social links',
44
+ String(Object.keys(snap1.socialLinks || {}).length),
45
+ String(Object.keys(snap2.socialLinks || {}).length),
46
+ ]);
47
+ overviewTable.push([
48
+ 'Open jobs',
49
+ snap1.jobs ? String(snap1.jobs.estimatedOpenings) : chalk.gray('?'),
50
+ snap2.jobs ? String(snap2.jobs.estimatedOpenings) : chalk.gray('?'),
51
+ ]);
52
+ overviewTable.push([
53
+ 'Pricing detected',
54
+ snap1.pricing ? chalk.green('yes') : chalk.gray('no'),
55
+ snap2.pricing ? chalk.green('yes') : chalk.gray('no'),
56
+ ]);
57
+ overviewTable.push([
58
+ 'Last checked',
59
+ snap1.checkedAt ? new Date(snap1.checkedAt).toLocaleDateString() : chalk.gray('?'),
60
+ snap2.checkedAt ? new Date(snap2.checkedAt).toLocaleDateString() : chalk.gray('?'),
61
+ ]);
62
+
63
+ console.log(overviewTable.toString());
64
+
65
+ // Tech stack diff
66
+ section('\nTech Stack:');
67
+ const techTable = createTable(['Technology', 'Category', name1.slice(0, 20), name2.slice(0, 20)]);
68
+
69
+ const tech1Names = new Set((snap1.techStack || []).map(t => t.name));
70
+ const tech2Names = new Set((snap2.techStack || []).map(t => t.name));
71
+ const allTechs = [...new Set([...(snap1.techStack || []), ...(snap2.techStack || [])].map(t => JSON.stringify(t)))].map(t => JSON.parse(t));
72
+
73
+ for (const tech of allTechs) {
74
+ techTable.push([
75
+ tech.name,
76
+ chalk.gray(tech.category),
77
+ tech1Names.has(tech.name) ? chalk.green('āœ“') : chalk.gray('—'),
78
+ tech2Names.has(tech.name) ? chalk.green('āœ“') : chalk.gray('—'),
79
+ ]);
80
+ }
81
+
82
+ if (allTechs.length === 0) {
83
+ console.log(chalk.gray(' No tech detected for either tracker yet.'));
84
+ } else {
85
+ console.log(techTable.toString());
86
+ }
87
+
88
+ // Social links diff
89
+ section('\nSocial Links:');
90
+ const allPlatforms = new Set([
91
+ ...Object.keys(snap1.socialLinks || {}),
92
+ ...Object.keys(snap2.socialLinks || {}),
93
+ ]);
94
+
95
+ if (allPlatforms.size > 0) {
96
+ const socialTable = createTable(['Platform', name1.slice(0, 25), name2.slice(0, 25)]);
97
+ for (const platform of allPlatforms) {
98
+ socialTable.push([
99
+ platform,
100
+ snap1.socialLinks?.[platform] ? chalk.green('āœ“') : chalk.gray('—'),
101
+ snap2.socialLinks?.[platform] ? chalk.green('āœ“') : chalk.gray('—'),
102
+ ]);
103
+ }
104
+ console.log(socialTable.toString());
105
+ } else {
106
+ console.log(chalk.gray(' No social links detected.'));
107
+ }
108
+
109
+ // Pricing comparison
110
+ section('\nPricing:');
111
+ if (snap1.pricing?.prices?.length || snap2.pricing?.prices?.length) {
112
+ console.log(chalk.bold(` ${name1}:`), (snap1.pricing?.prices || []).slice(0, 5).join(', ') || chalk.gray('none'));
113
+ console.log(chalk.bold(` ${name2}:`), (snap2.pricing?.prices || []).slice(0, 5).join(', ') || chalk.gray('none'));
114
+ } else {
115
+ console.log(chalk.gray(' No pricing data detected.'));
116
+ }
117
+
118
+ // Homepage meta
119
+ section('\nHomepage Meta:');
120
+ const meta1 = snap1.keyPages?.['/'];
121
+ const meta2 = snap2.keyPages?.['/'];
122
+ console.log(chalk.bold(` ${name1}: `) + chalk.gray((meta1?.title || '').slice(0, 70)));
123
+ console.log(chalk.bold(` ${name2}: `) + chalk.gray((meta2?.title || '').slice(0, 70)));
124
+ }
@@ -0,0 +1,118 @@
1
+ import chalk from 'chalk';
2
+ import { getTracker, listSnapshots, loadSnapshot } from '../storage.js';
3
+ import { diffCompetitorSnapshots } from '../trackers/competitor.js';
4
+ import { diffKeywordSnapshots } from '../trackers/keyword.js';
5
+ import { diffBrandSnapshots } from '../trackers/brand.js';
6
+ import { header, section, diffLine, warn, error, trackerTypeIcon } from '../utils/display.js';
7
+
8
+ export async function runDiff(trackerId, options) {
9
+ const tracker = getTracker(trackerId);
10
+
11
+ if (!tracker) {
12
+ error(`Tracker not found: ${trackerId}`);
13
+ console.log(chalk.gray('Use `intelwatch list` to see all tracker IDs.'));
14
+ process.exit(1);
15
+ }
16
+
17
+ const snapshots = listSnapshots(trackerId);
18
+
19
+ if (snapshots.length === 0) {
20
+ warn(`No snapshots for tracker: ${trackerId}`);
21
+ console.log(chalk.gray('Run `intelwatch check` first.'));
22
+ return;
23
+ }
24
+
25
+ let prevSnapshot, currSnapshot;
26
+
27
+ if (options.days) {
28
+ const daysMs = parseInt(options.days) * 24 * 60 * 60 * 1000;
29
+ const targetTime = Date.now() - daysMs;
30
+
31
+ // Find snapshot closest to targetTime
32
+ const targetSnap = snapshots.reduce((best, snap) => {
33
+ return Math.abs(snap.timestamp - targetTime) < Math.abs(best.timestamp - targetTime) ? snap : best;
34
+ });
35
+
36
+ prevSnapshot = loadSnapshot(targetSnap.filepath);
37
+ currSnapshot = loadSnapshot(snapshots[snapshots.length - 1].filepath);
38
+ } else {
39
+ currSnapshot = loadSnapshot(snapshots[snapshots.length - 1].filepath);
40
+ prevSnapshot = snapshots.length > 1
41
+ ? loadSnapshot(snapshots[snapshots.length - 2].filepath)
42
+ : null;
43
+ }
44
+
45
+ const target = tracker.name || tracker.keyword || tracker.brandName || trackerId;
46
+ header(`${trackerTypeIcon(tracker.type)} Diff: ${target}`);
47
+
48
+ if (options.days) {
49
+ console.log(chalk.gray(` Comparing with snapshot from ${options.days} day(s) ago`));
50
+ } else {
51
+ console.log(chalk.gray(` Comparing last 2 snapshots`));
52
+ }
53
+
54
+ const prevDate = prevSnapshot ? new Date(prevSnapshot.checkedAt).toLocaleString() : 'N/A';
55
+ const currDate = new Date(currSnapshot.checkedAt).toLocaleString();
56
+ console.log(chalk.gray(` Before: ${prevDate}`));
57
+ console.log(chalk.gray(` After: ${currDate}`));
58
+ console.log('');
59
+
60
+ let changes = [];
61
+ if (tracker.type === 'competitor') {
62
+ changes = diffCompetitorSnapshots(prevSnapshot, currSnapshot);
63
+ } else if (tracker.type === 'keyword') {
64
+ changes = diffKeywordSnapshots(prevSnapshot, currSnapshot);
65
+ } else if (tracker.type === 'brand') {
66
+ changes = diffBrandSnapshots(prevSnapshot, currSnapshot);
67
+ }
68
+
69
+ if (changes.length === 0) {
70
+ console.log(chalk.green('āœ“ No changes between these snapshots.'));
71
+ return;
72
+ }
73
+
74
+ // Group changes by type
75
+ const newChanges = changes.filter(c => c.type === 'new');
76
+ const changedChanges = changes.filter(c => c.type === 'changed');
77
+ const removedChanges = changes.filter(c => c.type === 'removed');
78
+
79
+ if (newChanges.length > 0) {
80
+ section(' New:');
81
+ for (const c of newChanges) {
82
+ console.log(' ' + diffLine('new', c.field, c.value));
83
+ }
84
+ }
85
+
86
+ if (changedChanges.length > 0) {
87
+ section(' Changed:');
88
+ for (const c of changedChanges) {
89
+ console.log(' ' + diffLine('changed', c.field, c.value));
90
+ }
91
+ }
92
+
93
+ if (removedChanges.length > 0) {
94
+ section(' Removed:');
95
+ for (const c of removedChanges) {
96
+ console.log(' ' + diffLine('removed', c.field, c.value));
97
+ }
98
+ }
99
+
100
+ console.log('');
101
+ console.log(chalk.gray(`Total: ${changes.length} change(s)`));
102
+
103
+ // Detailed output for specific types
104
+ if (tracker.type === 'keyword' && currSnapshot.results?.length > 0) {
105
+ section('\n Current Rankings:');
106
+ for (const r of currSnapshot.results.slice(0, 10)) {
107
+ const star = r.isFeaturedSnippet ? chalk.yellow(' ⭐ Featured') : '';
108
+ console.log(chalk.gray(` #${String(r.position).padEnd(3)}`), chalk.white(r.domain.padEnd(30)), chalk.gray(r.title?.slice(0, 40) || '') + star);
109
+ }
110
+ }
111
+
112
+ if (tracker.type === 'competitor' && currSnapshot.techStack?.length > 0) {
113
+ section('\n Current Tech Stack:');
114
+ for (const tech of currSnapshot.techStack) {
115
+ console.log(chalk.gray(` • ${tech.name}`), chalk.gray(`[${tech.category}]`));
116
+ }
117
+ }
118
+ }
@@ -0,0 +1,156 @@
1
+ import chalk from 'chalk';
2
+ import { loadTrackers, loadLatestSnapshot, listSnapshots, loadSnapshot } from '../storage.js';
3
+ import { diffCompetitorSnapshots } from '../trackers/competitor.js';
4
+ import { diffKeywordSnapshots } from '../trackers/keyword.js';
5
+ import { diffBrandSnapshots } from '../trackers/brand.js';
6
+ import { createTable, header, section, trackerTypeIcon, warn } from '../utils/display.js';
7
+ import { hasAIKey, callAI, getAIConfig } from '../ai/client.js';
8
+
9
+ export async function runDigest() {
10
+ const trackers = loadTrackers();
11
+
12
+ if (trackers.length === 0) {
13
+ warn('No trackers configured. Use `intelwatch track` to add one.');
14
+ return;
15
+ }
16
+
17
+ header('šŸ“Š Intelligence Digest');
18
+ console.log(chalk.gray(`Generated: ${new Date().toLocaleString()}\n`));
19
+
20
+ const table = createTable(['Tracker', 'Type', 'Changes', 'Summary']);
21
+ let totalChanges = 0;
22
+
23
+ for (const tracker of trackers) {
24
+ const snapshots = listSnapshots(tracker.id);
25
+
26
+ if (snapshots.length === 0) {
27
+ table.push([
28
+ chalk.cyan(tracker.id.slice(0, 25)),
29
+ trackerTypeIcon(tracker.type),
30
+ chalk.gray('—'),
31
+ chalk.gray('No snapshots yet'),
32
+ ]);
33
+ continue;
34
+ }
35
+
36
+ const latest = loadSnapshot(snapshots[snapshots.length - 1].filepath);
37
+ const prev = snapshots.length > 1 ? loadSnapshot(snapshots[snapshots.length - 2].filepath) : null;
38
+
39
+ let changes = [];
40
+ if (tracker.type === 'competitor') changes = diffCompetitorSnapshots(prev, latest);
41
+ else if (tracker.type === 'keyword') changes = diffKeywordSnapshots(prev, latest);
42
+ else if (tracker.type === 'brand') changes = diffBrandSnapshots(prev, latest);
43
+
44
+ // Use stored changes if available and no prev
45
+ if (!prev && latest.changes) {
46
+ changes = latest.changes;
47
+ }
48
+
49
+ const target = tracker.name || tracker.keyword || tracker.brandName || tracker.id;
50
+ const changeStr = changes.length > 0
51
+ ? chalk.yellow(String(changes.length))
52
+ : chalk.gray('0');
53
+
54
+ const summary = changes.length > 0
55
+ ? changes.slice(0, 2).map(c => c.value?.slice(0, 40) || c.field).join('; ')
56
+ : chalk.gray('no changes');
57
+
58
+ table.push([
59
+ chalk.cyan(target.slice(0, 25)),
60
+ trackerTypeIcon(tracker.type) + ' ' + tracker.type,
61
+ changeStr,
62
+ summary.slice(0, 50),
63
+ ]);
64
+
65
+ totalChanges += changes.length;
66
+ }
67
+
68
+ console.log(table.toString());
69
+ console.log('');
70
+
71
+ if (totalChanges > 0) {
72
+ console.log(chalk.yellow(`⚔ ${totalChanges} total change(s) across all trackers.`));
73
+ console.log(chalk.gray('Run `intelwatch diff <tracker-id>` for details on any tracker.'));
74
+ } else {
75
+ console.log(chalk.green('āœ“ No significant changes detected across all trackers.'));
76
+ }
77
+
78
+ console.log(chalk.gray('\nRun `intelwatch report --format html` for a full report.'));
79
+
80
+ // ── AI enhancement (optional) ─────────────────────────────────────────────
81
+ if (hasAIKey()) {
82
+ await runAIDigestSummary(trackers);
83
+ } else {
84
+ console.log(chalk.gray('\nTip: set OPENAI_API_KEY or ANTHROPIC_API_KEY for AI-powered digest analysis.'));
85
+ }
86
+ }
87
+
88
+ async function runAIDigestSummary(trackers) {
89
+ const competitors = trackers.filter(t => t.type === 'competitor');
90
+ if (competitors.length === 0) return;
91
+
92
+ const aiConfig = getAIConfig();
93
+ section('\nšŸ¤– AI Digest Analysis');
94
+ console.log(chalk.gray(`Provider: ${aiConfig.provider} / ${aiConfig.model}\n`));
95
+
96
+ // Build a compact snapshot of all competitors for a combined analysis
97
+ const competitorData = [];
98
+ for (const tracker of competitors) {
99
+ const snapshots = listSnapshots(tracker.id);
100
+ if (snapshots.length === 0) continue;
101
+
102
+ const latest = loadSnapshot(snapshots[snapshots.length - 1].filepath);
103
+ const prev = snapshots.length > 1 ? loadSnapshot(snapshots[snapshots.length - 2].filepath) : null;
104
+ const changes = diffCompetitorSnapshots(prev, latest);
105
+
106
+ competitorData.push({ tracker, latest, changes });
107
+ }
108
+
109
+ if (competitorData.length === 0) {
110
+ console.log(chalk.gray(' No competitor snapshots available for AI analysis.\n'));
111
+ return;
112
+ }
113
+
114
+ try {
115
+ const analysis = await generateDigestAnalysis(competitorData);
116
+ console.log(analysis + '\n');
117
+ } catch (err) {
118
+ console.log(chalk.red(` AI error: ${err.message}\n`));
119
+ }
120
+ }
121
+
122
+ async function generateDigestAnalysis(competitorData) {
123
+ const systemPrompt =
124
+ 'You are a competitive intelligence analyst producing a concise weekly digest. ' +
125
+ 'Be specific, actionable, and brief. Write in plain English prose. ' +
126
+ 'Focus on what changed and what it means strategically.';
127
+
128
+ const summaries = competitorData.map(({ tracker, latest, changes }) => {
129
+ const name = tracker.name || tracker.url;
130
+ const changesText = changes.length > 0
131
+ ? changes.slice(0, 5).map(c => `${c.type}: ${c.field} — ${c.value}`).join('; ')
132
+ : 'no changes detected';
133
+
134
+ const pressNote = latest.press?.totalCount
135
+ ? ` Press: ${latest.press.totalCount} mentions (${latest.press.sentimentBreakdown?.negative || 0} negative).`
136
+ : '';
137
+
138
+ const jobsNote = latest.jobs?.estimatedOpenings
139
+ ? ` Hiring: ~${latest.jobs.estimatedOpenings} openings.`
140
+ : '';
141
+
142
+ return `${name}: changes=[${changesText}]${pressNote}${jobsNote}`;
143
+ }).join('\n');
144
+
145
+ const userPrompt =
146
+ `Weekly competitive digest for ${competitorData.length} competitor(s):\n\n` +
147
+ `${summaries}\n\n` +
148
+ `Provide:\n` +
149
+ `1. **Overall Assessment** (2-3 sentences): What's the most important competitive development this week?\n` +
150
+ `2. **Threat Levels**: For each competitor, one line: "[Name]: [LOW/MEDIUM/HIGH] — [reason]"\n` +
151
+ `3. **Recommended Actions**: 2-3 bullet points of what to do right now\n` +
152
+ `4. **Team One-liner**: One sentence to forward to your team summarizing the week\n\n` +
153
+ `Be concise and specific.`;
154
+
155
+ return await callAI(systemPrompt, userPrompt, { maxTokens: 600 });
156
+ }