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.
- package/CHANGELOG.md +39 -0
- package/README.md +175 -0
- package/bin/intelwatch.js +8 -0
- package/package.json +43 -0
- package/src/ai/client.js +130 -0
- package/src/commands/ai-summary.js +147 -0
- package/src/commands/check.js +267 -0
- package/src/commands/compare.js +124 -0
- package/src/commands/diff.js +118 -0
- package/src/commands/digest.js +156 -0
- package/src/commands/discover.js +301 -0
- package/src/commands/history.js +60 -0
- package/src/commands/list.js +43 -0
- package/src/commands/notify.js +121 -0
- package/src/commands/pitch.js +156 -0
- package/src/commands/report.js +82 -0
- package/src/commands/track.js +94 -0
- package/src/config.js +65 -0
- package/src/index.js +182 -0
- package/src/report/html.js +499 -0
- package/src/report/json.js +44 -0
- package/src/report/markdown.js +156 -0
- package/src/scrapers/brave-search.js +268 -0
- package/src/scrapers/google-news.js +111 -0
- package/src/scrapers/google.js +113 -0
- package/src/scrapers/pappers.js +119 -0
- package/src/scrapers/site-analyzer.js +252 -0
- package/src/storage.js +168 -0
- package/src/trackers/brand.js +76 -0
- package/src/trackers/competitor.js +268 -0
- package/src/trackers/keyword.js +121 -0
- package/src/trackers/person.js +132 -0
- package/src/utils/display.js +102 -0
- package/src/utils/fetcher.js +82 -0
- package/src/utils/parser.js +110 -0
- package/src/utils/sentiment.js +95 -0
- package/src/utils/tech-detect.js +94 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { fetch } from '../utils/fetcher.js';
|
|
3
|
+
import { load, extractMeta } from '../utils/parser.js';
|
|
4
|
+
import { braveWebSearch } from '../scrapers/brave-search.js';
|
|
5
|
+
import { callAI, hasAIKey } from '../ai/client.js';
|
|
6
|
+
import { createTracker } from '../storage.js';
|
|
7
|
+
import { header, section, success, warn, error } from '../utils/display.js';
|
|
8
|
+
|
|
9
|
+
export async function runDiscover(url, options) {
|
|
10
|
+
let normalizedUrl = url;
|
|
11
|
+
if (!normalizedUrl.startsWith('http')) normalizedUrl = 'https://' + normalizedUrl;
|
|
12
|
+
|
|
13
|
+
try { new URL(normalizedUrl); } catch {
|
|
14
|
+
error(`Invalid URL: ${url}`);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
header(`๐ Discovering competitors for: ${normalizedUrl}`);
|
|
19
|
+
|
|
20
|
+
// โโ Step 1: Analyze target site โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
21
|
+
console.log(chalk.gray(' Analyzing target site...'));
|
|
22
|
+
|
|
23
|
+
const siteInfo = { title: '', description: '', keywords: [], services: [], location: null };
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const response = await fetch(normalizedUrl, { retries: 2, delay: 1000 });
|
|
27
|
+
const $ = load(response.data);
|
|
28
|
+
const meta = extractMeta($);
|
|
29
|
+
|
|
30
|
+
siteInfo.title = meta.title || '';
|
|
31
|
+
siteInfo.description = meta.description || '';
|
|
32
|
+
|
|
33
|
+
const metaKeywords = $('meta[name="keywords"]').attr('content') || '';
|
|
34
|
+
siteInfo.keywords = metaKeywords.split(',').map(k => k.trim()).filter(Boolean).slice(0, 10);
|
|
35
|
+
|
|
36
|
+
$('h1, h2, h3').each((_, el) => {
|
|
37
|
+
const text = $(el).text().trim();
|
|
38
|
+
if (text.length > 3 && text.length < 100) siteInfo.services.push(text);
|
|
39
|
+
});
|
|
40
|
+
siteInfo.services = siteInfo.services.slice(0, 10);
|
|
41
|
+
|
|
42
|
+
const pageText = $.text();
|
|
43
|
+
const locationMatch = pageText.match(
|
|
44
|
+
/\b(Paris|Lyon|Marseille|Bordeaux|Toulouse|Nantes|Lille|Strasbourg|France|London|Berlin|New York|San Francisco|Amsterdam|Madrid|Barcelona)\b/i
|
|
45
|
+
);
|
|
46
|
+
if (locationMatch) siteInfo.location = locationMatch[0];
|
|
47
|
+
} catch (err) {
|
|
48
|
+
warn(`Could not fetch target site: ${err.message}. Proceeding with URL-based discovery...`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const hostname = new URL(normalizedUrl).hostname.replace('www.', '');
|
|
52
|
+
const brandName = siteInfo.title.split(/[-|:]/)[0].trim() || hostname.split('.')[0];
|
|
53
|
+
|
|
54
|
+
console.log(chalk.gray(` Brand : ${brandName}`));
|
|
55
|
+
if (siteInfo.description) console.log(chalk.gray(` Desc : ${siteInfo.description.substring(0, 100)}`));
|
|
56
|
+
if (siteInfo.location) console.log(chalk.gray(` Location: ${siteInfo.location}`));
|
|
57
|
+
|
|
58
|
+
// โโ Step 2: Generate search queries โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
59
|
+
let queries = buildQueries(brandName, siteInfo);
|
|
60
|
+
|
|
61
|
+
if (options.ai && hasAIKey()) {
|
|
62
|
+
console.log(chalk.gray(' Using AI to generate smarter queries...'));
|
|
63
|
+
try {
|
|
64
|
+
const aiQueries = await generateAIQueries(brandName, siteInfo);
|
|
65
|
+
if (aiQueries.length > 0) queries = aiQueries;
|
|
66
|
+
} catch {}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
section(' Search queries:');
|
|
70
|
+
for (const q of queries) console.log(chalk.gray(` ยท ${q}`));
|
|
71
|
+
|
|
72
|
+
// โโ Step 3: Run searches and collect candidates โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
73
|
+
console.log('\n' + chalk.gray(' Searching for competitors...'));
|
|
74
|
+
const candidates = new Map();
|
|
75
|
+
|
|
76
|
+
for (const query of queries) {
|
|
77
|
+
await new Promise(r => setTimeout(r, 600));
|
|
78
|
+
const { results, error: searchError } = await braveWebSearch(query, { count: 20 });
|
|
79
|
+
if (searchError) {
|
|
80
|
+
warn(` Search error for "${query}": ${searchError}`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const r of results) {
|
|
85
|
+
const domain = r.domain;
|
|
86
|
+
if (!domain || domain === hostname) continue;
|
|
87
|
+
// Skip aggregators, directories, and social networks
|
|
88
|
+
if (/wikipedia|linkedin|facebook|twitter|x\.com|youtube|yelp|trustpilot|pagesjaunes|societe\.com|pappers|capterra|g2\.com|glassdoor/i.test(domain)) continue;
|
|
89
|
+
|
|
90
|
+
if (!candidates.has(domain)) {
|
|
91
|
+
candidates.set(domain, {
|
|
92
|
+
domain,
|
|
93
|
+
url: r.url.match(/^https?:\/\/[^/]+/)?.[0] || `https://${domain}`,
|
|
94
|
+
name: r.title?.split(/[-|:]/)[0].trim() || domain,
|
|
95
|
+
snippet: r.snippet || '',
|
|
96
|
+
appearances: 0,
|
|
97
|
+
queries: [],
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const c = candidates.get(domain);
|
|
102
|
+
c.appearances++;
|
|
103
|
+
c.queries.push(query);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (candidates.size === 0) {
|
|
108
|
+
warn('No competitors found. Check that BRAVE_API_KEY is set and the URL is reachable.');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// โโ Step 4: Score candidates โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
113
|
+
let scored = [...candidates.values()].map(c => {
|
|
114
|
+
let score = 0;
|
|
115
|
+
|
|
116
|
+
// More query appearances = stronger signal
|
|
117
|
+
score += Math.min(c.appearances * 15, 45);
|
|
118
|
+
|
|
119
|
+
// Domain extension match (e.g., both .fr)
|
|
120
|
+
const targetExt = hostname.split('.').pop();
|
|
121
|
+
const candExt = c.domain.split('.').pop();
|
|
122
|
+
if (candExt === targetExt) score += 10;
|
|
123
|
+
|
|
124
|
+
// Location match in snippet
|
|
125
|
+
if (siteInfo.location && c.snippet.toLowerCase().includes(siteInfo.location.toLowerCase())) {
|
|
126
|
+
score += 10;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Service/keyword overlap in snippet
|
|
130
|
+
const relevantTerms = [
|
|
131
|
+
...siteInfo.keywords,
|
|
132
|
+
...siteInfo.services.map(s => s.toLowerCase().split(/\s+/).slice(0, 2).join(' ')),
|
|
133
|
+
];
|
|
134
|
+
for (const term of relevantTerms) {
|
|
135
|
+
if (term.length > 3 && c.snippet.toLowerCase().includes(term.toLowerCase())) {
|
|
136
|
+
score += 5;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
...c,
|
|
142
|
+
score: Math.min(score, 100),
|
|
143
|
+
whyMatch: buildWhyMatch(c, siteInfo, hostname),
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// โโ Step 5: Optional AI scoring โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
148
|
+
if (options.ai && hasAIKey() && scored.length > 0) {
|
|
149
|
+
console.log(chalk.gray(' Using AI for smarter relevance scoring...'));
|
|
150
|
+
try {
|
|
151
|
+
scored = await scoreWithAI(scored, siteInfo, brandName);
|
|
152
|
+
} catch {
|
|
153
|
+
scored = scored.sort((a, b) => b.score - a.score).slice(0, 15);
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
scored = scored.sort((a, b) => b.score - a.score).slice(0, 15);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// โโ Step 6: Display results โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
160
|
+
section(`\n ${scored.length} competitors discovered:`);
|
|
161
|
+
console.log('');
|
|
162
|
+
|
|
163
|
+
for (let i = 0; i < scored.length; i++) {
|
|
164
|
+
const c = scored[i];
|
|
165
|
+
const rank = chalk.cyan(`#${i + 1}`);
|
|
166
|
+
const bar = scoreBar(c.score);
|
|
167
|
+
console.log(` ${rank} ${chalk.white.bold(c.name)} ${chalk.gray(`(${c.domain})`)}`);
|
|
168
|
+
console.log(` ${bar} ${chalk.yellow(`${c.score}%`)} similarity`);
|
|
169
|
+
console.log(` ${chalk.gray(c.url)}`);
|
|
170
|
+
if (c.whyMatch) console.log(` ${chalk.gray('โ')} ${chalk.gray(c.whyMatch)}`);
|
|
171
|
+
console.log('');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// โโ Step 7: Auto-track top results โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
175
|
+
if (options.autoTrack) {
|
|
176
|
+
const topN = scored.slice(0, 5);
|
|
177
|
+
section(` Auto-tracking top ${topN.length} competitors...`);
|
|
178
|
+
for (const c of topN) {
|
|
179
|
+
try {
|
|
180
|
+
const { tracker, created } = createTracker('competitor', { url: c.url, name: c.name });
|
|
181
|
+
if (created) {
|
|
182
|
+
success(` Created tracker: ${tracker.id} (${c.name})`);
|
|
183
|
+
} else {
|
|
184
|
+
warn(` Already tracked: ${tracker.id} (${c.name})`);
|
|
185
|
+
}
|
|
186
|
+
} catch {}
|
|
187
|
+
}
|
|
188
|
+
console.log(chalk.gray('\n Run `intelwatch check` to fetch initial snapshots.'));
|
|
189
|
+
} else {
|
|
190
|
+
console.log(chalk.gray(' Tip: use --auto-track to automatically create trackers for the top 5.'));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// โโ Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
195
|
+
|
|
196
|
+
function buildQueries(brandName, siteInfo) {
|
|
197
|
+
const queries = [];
|
|
198
|
+
|
|
199
|
+
// Alternative / competitor framing
|
|
200
|
+
queries.push(`alternative to ${brandName}`);
|
|
201
|
+
|
|
202
|
+
// Description-based query
|
|
203
|
+
if (siteInfo.description) {
|
|
204
|
+
const shortDesc = siteInfo.description.split(/[.!?]/)[0].substring(0, 80);
|
|
205
|
+
queries.push(shortDesc);
|
|
206
|
+
} else {
|
|
207
|
+
queries.push(`${brandName} competitor`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Main service heading
|
|
211
|
+
if (siteInfo.services.length > 0) {
|
|
212
|
+
queries.push(siteInfo.services[0].substring(0, 60));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Location + service
|
|
216
|
+
if (siteInfo.location && siteInfo.services.length > 0) {
|
|
217
|
+
queries.push(`${siteInfo.services[0].substring(0, 40)} ${siteInfo.location}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Top keywords
|
|
221
|
+
if (siteInfo.keywords.length >= 2) {
|
|
222
|
+
queries.push(siteInfo.keywords.slice(0, 3).join(' '));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return queries.slice(0, 5);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function generateAIQueries(brandName, siteInfo) {
|
|
229
|
+
const systemPrompt = 'You are a competitive intelligence expert. Output only valid JSON.';
|
|
230
|
+
const userPrompt = `Generate 5 web search queries to find direct competitors of this business.
|
|
231
|
+
|
|
232
|
+
Brand: ${brandName}
|
|
233
|
+
Description: ${siteInfo.description || 'unknown'}
|
|
234
|
+
Top services/headings: ${siteInfo.services.slice(0, 5).join(', ') || 'unknown'}
|
|
235
|
+
Keywords: ${siteInfo.keywords.join(', ') || 'unknown'}
|
|
236
|
+
Location: ${siteInfo.location || 'unknown'}
|
|
237
|
+
|
|
238
|
+
Return ONLY a JSON array of 5 search query strings. Example: ["query one", "query two", ...]`;
|
|
239
|
+
|
|
240
|
+
const raw = await callAI(systemPrompt, userPrompt, { max_tokens: 300 });
|
|
241
|
+
const match = raw.match(/\[[\s\S]*?\]/);
|
|
242
|
+
if (!match) return [];
|
|
243
|
+
try {
|
|
244
|
+
return JSON.parse(match[0]).filter(q => typeof q === 'string').slice(0, 5);
|
|
245
|
+
} catch {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function scoreWithAI(candidates, siteInfo, brandName) {
|
|
251
|
+
const simplified = candidates.slice(0, 20).map(c => ({
|
|
252
|
+
domain: c.domain,
|
|
253
|
+
name: c.name,
|
|
254
|
+
snippet: c.snippet?.substring(0, 150),
|
|
255
|
+
appearances: c.appearances,
|
|
256
|
+
}));
|
|
257
|
+
|
|
258
|
+
const systemPrompt = 'You are a competitive intelligence analyst. Output only valid JSON.';
|
|
259
|
+
const userPrompt = `Score these companies as potential competitors to "${brandName}".
|
|
260
|
+
Target description: ${siteInfo.description?.substring(0, 200) || 'unknown'}
|
|
261
|
+
|
|
262
|
+
Candidates:
|
|
263
|
+
${JSON.stringify(simplified, null, 2)}
|
|
264
|
+
|
|
265
|
+
Return ONLY a JSON array: [{"domain": "...", "score": 0-100, "reason": "short reason"}]
|
|
266
|
+
Sort by score descending.`;
|
|
267
|
+
|
|
268
|
+
const raw = await callAI(systemPrompt, userPrompt, { max_tokens: 1000 });
|
|
269
|
+
const match = raw.match(/\[[\s\S]*?\]/);
|
|
270
|
+
if (!match) throw new Error('No JSON in AI response');
|
|
271
|
+
|
|
272
|
+
const aiScores = JSON.parse(match[0]);
|
|
273
|
+
return candidates
|
|
274
|
+
.map(c => {
|
|
275
|
+
const ai = aiScores.find(a => a.domain === c.domain);
|
|
276
|
+
return {
|
|
277
|
+
...c,
|
|
278
|
+
score: ai?.score ?? c.score,
|
|
279
|
+
whyMatch: ai?.reason || c.whyMatch,
|
|
280
|
+
};
|
|
281
|
+
})
|
|
282
|
+
.sort((a, b) => b.score - a.score)
|
|
283
|
+
.slice(0, 15);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function buildWhyMatch(candidate, siteInfo, targetHostname) {
|
|
287
|
+
const reasons = [];
|
|
288
|
+
if (candidate.appearances > 1) reasons.push(`appeared in ${candidate.appearances} queries`);
|
|
289
|
+
if (siteInfo.location && candidate.snippet.toLowerCase().includes(siteInfo.location.toLowerCase())) {
|
|
290
|
+
reasons.push(`same location (${siteInfo.location})`);
|
|
291
|
+
}
|
|
292
|
+
const targetExt = targetHostname.split('.').pop();
|
|
293
|
+
const candExt = candidate.domain.split('.').pop();
|
|
294
|
+
if (candExt === targetExt) reasons.push(`.${candExt} domain`);
|
|
295
|
+
return reasons.join(', ') || 'matched search queries';
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function scoreBar(score) {
|
|
299
|
+
const filled = Math.round(score / 10);
|
|
300
|
+
return chalk.green('โ'.repeat(filled)) + chalk.gray('โ'.repeat(10 - filled));
|
|
301
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { getTracker, listSnapshots, loadSnapshot } from '../storage.js';
|
|
3
|
+
import { createTable, header, error, warn, trackerTypeIcon } from '../utils/display.js';
|
|
4
|
+
|
|
5
|
+
export function runHistory(trackerId, options) {
|
|
6
|
+
const tracker = getTracker(trackerId);
|
|
7
|
+
|
|
8
|
+
if (!tracker) {
|
|
9
|
+
error(`Tracker not found: ${trackerId}`);
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const limit = parseInt(options.limit || '20');
|
|
14
|
+
const snapshots = listSnapshots(trackerId, limit);
|
|
15
|
+
|
|
16
|
+
if (snapshots.length === 0) {
|
|
17
|
+
warn(`No snapshots for tracker: ${trackerId}`);
|
|
18
|
+
console.log(chalk.gray('Run `intelwatch check` first.'));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const target = tracker.name || tracker.keyword || tracker.brandName || trackerId;
|
|
23
|
+
header(`${trackerTypeIcon(tracker.type)} History: ${target}`);
|
|
24
|
+
console.log(chalk.gray(` Showing last ${snapshots.length} snapshot(s)\n`));
|
|
25
|
+
|
|
26
|
+
const table = createTable(['Date', 'Time', 'Changes', 'Summary']);
|
|
27
|
+
|
|
28
|
+
for (const snap of snapshots.reverse()) {
|
|
29
|
+
const snapshot = loadSnapshot(snap.filepath);
|
|
30
|
+
if (!snapshot) continue;
|
|
31
|
+
|
|
32
|
+
const date = new Date(snapshot.checkedAt);
|
|
33
|
+
const dateStr = date.toLocaleDateString();
|
|
34
|
+
const timeStr = date.toLocaleTimeString();
|
|
35
|
+
|
|
36
|
+
const changes = snapshot.changes || [];
|
|
37
|
+
const changeCount = changes.length > 0 ? chalk.yellow(String(changes.length)) : chalk.gray('0');
|
|
38
|
+
const summary = changes.length > 0
|
|
39
|
+
? changes[0].value?.slice(0, 50) || changes[0].field
|
|
40
|
+
: chalk.gray('โ');
|
|
41
|
+
|
|
42
|
+
let extra = '';
|
|
43
|
+
if (tracker.type === 'competitor') {
|
|
44
|
+
extra = `${snapshot.pageCount || 0} pages`;
|
|
45
|
+
} else if (tracker.type === 'keyword') {
|
|
46
|
+
extra = `${snapshot.resultCount || 0} results`;
|
|
47
|
+
} else if (tracker.type === 'brand') {
|
|
48
|
+
extra = `${snapshot.mentionCount || 0} mentions`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
table.push([
|
|
52
|
+
dateStr,
|
|
53
|
+
timeStr,
|
|
54
|
+
changeCount,
|
|
55
|
+
(extra ? chalk.gray(extra + ' | ') : '') + summary.slice(0, 45),
|
|
56
|
+
]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.log(table.toString());
|
|
60
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { loadTrackers, removeTracker } from '../storage.js';
|
|
3
|
+
import { createTable, formatDate, statusBadge, trackerTypeIcon, success, error, warn } from '../utils/display.js';
|
|
4
|
+
|
|
5
|
+
export function listTrackers() {
|
|
6
|
+
const trackers = loadTrackers();
|
|
7
|
+
|
|
8
|
+
if (trackers.length === 0) {
|
|
9
|
+
warn('No trackers configured.');
|
|
10
|
+
console.log(chalk.gray(' Use `intelwatch track competitor <url>` to add your first tracker.'));
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const table = createTable(['ID', 'Type', 'Target', 'Status', 'Last Check', 'Checks']);
|
|
15
|
+
|
|
16
|
+
for (const t of trackers) {
|
|
17
|
+
const target = t.type === 'competitor' ? (t.name || t.url)
|
|
18
|
+
: t.type === 'keyword' ? `"${t.keyword}"`
|
|
19
|
+
: `"${t.brandName}"`;
|
|
20
|
+
|
|
21
|
+
table.push([
|
|
22
|
+
chalk.cyan(t.id.slice(0, 30)),
|
|
23
|
+
trackerTypeIcon(t.type) + ' ' + t.type,
|
|
24
|
+
target.slice(0, 40),
|
|
25
|
+
statusBadge(t.status || 'active'),
|
|
26
|
+
formatDate(t.lastCheckedAt),
|
|
27
|
+
String(t.checkCount || 0),
|
|
28
|
+
]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
console.log(table.toString());
|
|
32
|
+
console.log(chalk.gray(`\n${trackers.length} tracker(s) total.`));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function removeTrackerCmd(id) {
|
|
36
|
+
try {
|
|
37
|
+
const removed = removeTracker(id);
|
|
38
|
+
success(`Tracker removed: ${removed.id}`);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
error(err.message);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { loadConfig, saveConfig } from '../config.js';
|
|
2
|
+
import { success, info, header } from '../utils/display.js';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { fetch } from '../utils/fetcher.js';
|
|
5
|
+
|
|
6
|
+
export async function setupNotifications(options) {
|
|
7
|
+
header('๐ Notification Setup');
|
|
8
|
+
|
|
9
|
+
// Check if inquirer is available
|
|
10
|
+
let inquirer;
|
|
11
|
+
try {
|
|
12
|
+
const mod = await import('inquirer');
|
|
13
|
+
inquirer = mod.default;
|
|
14
|
+
} catch {
|
|
15
|
+
console.log(chalk.yellow('Interactive mode requires inquirer. Running in guided mode.\n'));
|
|
16
|
+
await guidedSetup();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const config = loadConfig();
|
|
21
|
+
|
|
22
|
+
const answers = await inquirer.prompt([
|
|
23
|
+
{
|
|
24
|
+
type: 'input',
|
|
25
|
+
name: 'webhook',
|
|
26
|
+
message: 'Webhook URL (Slack/Discord, leave empty to skip):',
|
|
27
|
+
default: config.notifications.webhook || '',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
type: 'checkbox',
|
|
31
|
+
name: 'events',
|
|
32
|
+
message: 'Which events should trigger notifications?',
|
|
33
|
+
choices: [
|
|
34
|
+
{ name: 'Competitor: new page detected', value: 'competitor.new_page', checked: true },
|
|
35
|
+
{ name: 'Competitor: pricing changed', value: 'competitor.price_change', checked: true },
|
|
36
|
+
{ name: 'Competitor: tech stack changed', value: 'competitor.tech_change', checked: false },
|
|
37
|
+
{ name: 'Keyword: position change', value: 'keyword.position_change', checked: true },
|
|
38
|
+
{ name: 'Brand: new mention', value: 'brand.new_mention', checked: true },
|
|
39
|
+
{ name: 'Brand: negative mention', value: 'brand.negative_mention', checked: true },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
const newConfig = {
|
|
45
|
+
...config,
|
|
46
|
+
notifications: {
|
|
47
|
+
...config.notifications,
|
|
48
|
+
webhook: answers.webhook || null,
|
|
49
|
+
events: answers.events,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
saveConfig(newConfig);
|
|
54
|
+
|
|
55
|
+
if (answers.webhook) {
|
|
56
|
+
console.log(chalk.gray('\nTesting webhook...'));
|
|
57
|
+
await testWebhook(answers.webhook);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
success('Notification settings saved!');
|
|
61
|
+
info(`Config file: ${(await import('../config.js')).CONFIG_FILE}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function guidedSetup() {
|
|
65
|
+
const config = loadConfig();
|
|
66
|
+
console.log('Current configuration:');
|
|
67
|
+
console.log(JSON.stringify(config.notifications, null, 2));
|
|
68
|
+
console.log(chalk.gray('\nTo configure notifications, edit ~/.intelwatch/config.yml directly.'));
|
|
69
|
+
console.log(chalk.gray('Example:'));
|
|
70
|
+
console.log(chalk.gray(`
|
|
71
|
+
notifications:
|
|
72
|
+
webhook: https://hooks.slack.com/services/xxx/yyy/zzz
|
|
73
|
+
events:
|
|
74
|
+
- competitor.new_page
|
|
75
|
+
- competitor.price_change
|
|
76
|
+
- keyword.position_change
|
|
77
|
+
- brand.new_mention
|
|
78
|
+
- brand.negative_mention
|
|
79
|
+
`));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function sendWebhookNotification(webhookUrl, event, data) {
|
|
83
|
+
if (!webhookUrl) return;
|
|
84
|
+
|
|
85
|
+
const payload = {
|
|
86
|
+
text: `*intelwatch alert*: ${event}`,
|
|
87
|
+
attachments: [
|
|
88
|
+
{
|
|
89
|
+
color: event.includes('negative') ? 'danger' : 'good',
|
|
90
|
+
fields: Object.entries(data).map(([title, value]) => ({
|
|
91
|
+
title,
|
|
92
|
+
value: String(value).slice(0, 200),
|
|
93
|
+
short: true,
|
|
94
|
+
})),
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
await fetch(webhookUrl, {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: { 'Content-Type': 'application/json' },
|
|
103
|
+
data: JSON.stringify(payload),
|
|
104
|
+
retries: 1,
|
|
105
|
+
});
|
|
106
|
+
} catch {
|
|
107
|
+
// Silently fail notifications
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function testWebhook(url) {
|
|
112
|
+
try {
|
|
113
|
+
await sendWebhookNotification(url, 'test', {
|
|
114
|
+
message: 'intelwatch notification test โ everything is working!',
|
|
115
|
+
time: new Date().toISOString(),
|
|
116
|
+
});
|
|
117
|
+
console.log(chalk.green(' โ Webhook test sent successfully'));
|
|
118
|
+
} catch (err) {
|
|
119
|
+
console.log(chalk.red(` โ Webhook test failed: ${err.message}`));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { writeFileSync } from 'fs';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { getTracker, loadLatestSnapshot } from '../storage.js';
|
|
4
|
+
import { header, error, warn } from '../utils/display.js';
|
|
5
|
+
import { callAI, hasAIKey, getAIConfig } from '../ai/client.js';
|
|
6
|
+
|
|
7
|
+
export async function runPitch(competitorId, options = {}) {
|
|
8
|
+
if (!hasAIKey()) {
|
|
9
|
+
error('No AI API key configured.');
|
|
10
|
+
console.log(chalk.gray('Set OPENAI_API_KEY or ANTHROPIC_API_KEY env var, or add to ~/.intelwatch/config.yml'));
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const tracker = getTracker(competitorId);
|
|
15
|
+
if (!tracker) {
|
|
16
|
+
error(`Tracker not found: ${competitorId}`);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (tracker.type !== 'competitor') {
|
|
21
|
+
error('pitch only works with competitor trackers.');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const snapshot = loadLatestSnapshot(competitorId);
|
|
26
|
+
if (!snapshot) {
|
|
27
|
+
warn(`No snapshot for ${competitorId}. Run \`intelwatch check\` first.`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const competitorName = tracker.name || tracker.url;
|
|
32
|
+
const yourSite = options.for || 'your product';
|
|
33
|
+
const format = options.format || 'md';
|
|
34
|
+
|
|
35
|
+
const aiConfig = getAIConfig();
|
|
36
|
+
header(`๐ Competitive Pitch: ${yourSite} vs ${competitorName}`);
|
|
37
|
+
console.log(chalk.gray(`Provider: ${aiConfig.provider} / ${aiConfig.model}\n`));
|
|
38
|
+
|
|
39
|
+
let pitch;
|
|
40
|
+
try {
|
|
41
|
+
pitch = await generatePitch(tracker, snapshot, yourSite, format);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
error(`AI error: ${err.message}`);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
console.log('\n' + pitch + '\n');
|
|
48
|
+
|
|
49
|
+
if (options.output) {
|
|
50
|
+
writeFileSync(options.output, pitch, 'utf8');
|
|
51
|
+
console.log(chalk.green(`โ Pitch saved to ${options.output}`));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function generatePitch(tracker, snapshot, yourSite, format) {
|
|
56
|
+
const competitorName = tracker.name || tracker.url;
|
|
57
|
+
|
|
58
|
+
const systemPrompt =
|
|
59
|
+
'You are a sales strategist and competitive intelligence expert. ' +
|
|
60
|
+
'Generate crisp, data-backed competitive pitch documents. ' +
|
|
61
|
+
'Be specific about weaknesses you can exploit. Use the actual data points provided. ' +
|
|
62
|
+
'Be direct and sales-ready โ this document may be shared with prospects or internal teams. ' +
|
|
63
|
+
'Only reference data that was actually provided. Do not invent statistics.';
|
|
64
|
+
|
|
65
|
+
const context = buildPitchContext(competitorName, snapshot);
|
|
66
|
+
const outputFormat = format === 'html' ? 'HTML' : 'Markdown';
|
|
67
|
+
|
|
68
|
+
const userPrompt =
|
|
69
|
+
`Generate a competitive pitch document: "${yourSite}" vs "${competitorName}".\n\n` +
|
|
70
|
+
`Competitor data:\n${context}\n\n` +
|
|
71
|
+
`Write in ${outputFormat} with these sections:\n\n` +
|
|
72
|
+
`1. Executive Summary โ 2-3 sentences on why ${yourSite} is the better choice\n` +
|
|
73
|
+
`2. Competitor Weaknesses โ specific, data-backed points from the data provided\n` +
|
|
74
|
+
`3. Your Advantages โ frame based on their weaknesses (say "where they struggle, you excel")\n` +
|
|
75
|
+
`4. Talking Points โ 3-5 bullet points to use with prospects\n` +
|
|
76
|
+
`5. Data Snapshot โ table of key metrics\n\n` +
|
|
77
|
+
`Use actual numbers from the data. Omit sections that have no supporting data.`;
|
|
78
|
+
|
|
79
|
+
return await callAI(systemPrompt, userPrompt, { maxTokens: 1200 });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function buildPitchContext(name, snap) {
|
|
83
|
+
const lines = [];
|
|
84
|
+
|
|
85
|
+
lines.push(`Competitor: ${name}`);
|
|
86
|
+
lines.push(`URL: ${snap.url}`);
|
|
87
|
+
lines.push(`Pages indexed: ${snap.pageCount || 0}`);
|
|
88
|
+
|
|
89
|
+
if (snap.techStack?.length) {
|
|
90
|
+
lines.push(`Tech stack: ${snap.techStack.map(t => `${t.name} (${t.category})`).join(', ')}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (snap.performance) {
|
|
94
|
+
const p = snap.performance;
|
|
95
|
+
lines.push(`Page speed: load ${p.loadTime}ms, TTFB ${p.ttfb}ms`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (snap.security) {
|
|
99
|
+
const s = snap.security;
|
|
100
|
+
const issues = [];
|
|
101
|
+
if (!s.hsts) issues.push('missing HSTS');
|
|
102
|
+
if (!s.httpsRedirect) issues.push('no HTTPS redirect');
|
|
103
|
+
if (!s.xFrameOptions) issues.push('no X-Frame-Options');
|
|
104
|
+
if (!s.contentSecurityPolicy) issues.push('no CSP header');
|
|
105
|
+
if (issues.length) lines.push(`Security issues: ${issues.join(', ')}`);
|
|
106
|
+
else lines.push('Security: all common headers present');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (snap.seoSignals) {
|
|
110
|
+
const seo = snap.seoSignals;
|
|
111
|
+
const signals = [];
|
|
112
|
+
if (seo.missingAlt > 0) signals.push(`${seo.missingAlt} images without alt text`);
|
|
113
|
+
if (seo.htmlSize) signals.push(`${Math.round(seo.htmlSize / 1024)}KB uncompressed HTML`);
|
|
114
|
+
if (seo.brokenLinks > 0) signals.push(`${seo.brokenLinks} broken links`);
|
|
115
|
+
if (signals.length) lines.push(`SEO weaknesses: ${signals.join(', ')}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (snap.pricing?.prices?.length) {
|
|
119
|
+
lines.push(`Pricing: ${snap.pricing.prices.slice(0, 8).join(', ')}`);
|
|
120
|
+
} else {
|
|
121
|
+
lines.push('Pricing: not publicly listed');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (snap.jobs?.estimatedOpenings) {
|
|
125
|
+
lines.push(`Hiring: ~${snap.jobs.estimatedOpenings} open positions`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (snap.press?.articles?.length) {
|
|
129
|
+
const p = snap.press;
|
|
130
|
+
lines.push(
|
|
131
|
+
`Press coverage: ${p.totalCount} mentions ` +
|
|
132
|
+
`(${p.sentimentBreakdown?.positive || 0} positive, ${p.sentimentBreakdown?.negative || 0} negative)`
|
|
133
|
+
);
|
|
134
|
+
const negatives = p.articles
|
|
135
|
+
.filter(a => a.sentiment === 'negative' || a.sentiment === 'slightly_negative')
|
|
136
|
+
.slice(0, 3);
|
|
137
|
+
if (negatives.length) {
|
|
138
|
+
lines.push(`Negative coverage: ${negatives.map(a => `"${a.title}"`).join('; ')}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (snap.reputation?.platforms?.length) {
|
|
143
|
+
lines.push(`Customer ratings: ${snap.reputation.platforms.map(p => `${p.platform} ${p.rating}/5`).join(', ')}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (snap.meta?.title) {
|
|
147
|
+
lines.push(`Their positioning: "${snap.meta.title}"`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (snap.socialLinks) {
|
|
151
|
+
const platforms = Object.keys(snap.socialLinks);
|
|
152
|
+
if (platforms.length) lines.push(`Social presence: ${platforms.join(', ')}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return lines.join('\n');
|
|
156
|
+
}
|