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,119 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
|
|
3
|
+
const PAPPERS_API = 'https://api.pappers.fr/v1';
|
|
4
|
+
|
|
5
|
+
function getApiKey() {
|
|
6
|
+
return process.env.PAPPERS_API_KEY || null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function hasPappersKey() {
|
|
10
|
+
return !!getApiKey();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Search companies by name on Pappers
|
|
15
|
+
*/
|
|
16
|
+
export async function pappersSearchByName(name, options = {}) {
|
|
17
|
+
const apiKey = getApiKey();
|
|
18
|
+
if (!apiKey) return { results: [], error: 'No PAPPERS_API_KEY set' };
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const resp = await axios.get(`${PAPPERS_API}/recherche`, {
|
|
22
|
+
params: {
|
|
23
|
+
api_token: apiKey,
|
|
24
|
+
q: name,
|
|
25
|
+
par_page: options.count || 5,
|
|
26
|
+
},
|
|
27
|
+
timeout: 10000,
|
|
28
|
+
});
|
|
29
|
+
return { results: resp.data.resultats || [], error: null };
|
|
30
|
+
} catch (err) {
|
|
31
|
+
return { results: [], error: err.message };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get company details by SIREN
|
|
37
|
+
*/
|
|
38
|
+
export async function pappersGetBySiren(siren) {
|
|
39
|
+
const apiKey = getApiKey();
|
|
40
|
+
if (!apiKey) return { data: null, error: 'No PAPPERS_API_KEY set' };
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const resp = await axios.get(`${PAPPERS_API}/entreprise`, {
|
|
44
|
+
params: { api_token: apiKey, siren },
|
|
45
|
+
timeout: 10000,
|
|
46
|
+
});
|
|
47
|
+
return { data: resp.data, error: null };
|
|
48
|
+
} catch (err) {
|
|
49
|
+
return { data: null, error: err.message };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Main lookup: search by name, then fetch detail by SIREN.
|
|
55
|
+
* Returns null if no API key or no result found.
|
|
56
|
+
*/
|
|
57
|
+
export async function pappersLookup(companyName) {
|
|
58
|
+
if (!getApiKey()) return null;
|
|
59
|
+
|
|
60
|
+
const search = await pappersSearchByName(companyName);
|
|
61
|
+
if (search.error || search.results.length === 0) return null;
|
|
62
|
+
|
|
63
|
+
const top = search.results[0];
|
|
64
|
+
const siren = top.siren;
|
|
65
|
+
|
|
66
|
+
if (!siren) return formatPappersResult(top);
|
|
67
|
+
|
|
68
|
+
const detail = await pappersGetBySiren(siren);
|
|
69
|
+
if (detail.error || !detail.data) return formatPappersResult(top);
|
|
70
|
+
|
|
71
|
+
return formatPappersDetail(detail.data);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function formatPappersResult(r) {
|
|
75
|
+
return {
|
|
76
|
+
siren: r.siren,
|
|
77
|
+
siret: r.siret,
|
|
78
|
+
name: r.nom_entreprise || r.denomination,
|
|
79
|
+
dateCreation: r.date_creation,
|
|
80
|
+
nafCode: r.code_naf,
|
|
81
|
+
nafLabel: r.libelle_code_naf,
|
|
82
|
+
city: r.siege?.ville,
|
|
83
|
+
postalCode: r.siege?.code_postal,
|
|
84
|
+
effectifs: null,
|
|
85
|
+
ca: null,
|
|
86
|
+
caYear: null,
|
|
87
|
+
resultat: null,
|
|
88
|
+
dirigeants: [],
|
|
89
|
+
formeJuridique: r.forme_juridique || null,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function formatPappersDetail(d) {
|
|
94
|
+
const dirigeants = (d.dirigeants || []).slice(0, 5).map(p => ({
|
|
95
|
+
nom: p.nom,
|
|
96
|
+
prenom: p.prenom,
|
|
97
|
+
role: p.fonction,
|
|
98
|
+
dateNomination: p.date_prise_de_poste,
|
|
99
|
+
}));
|
|
100
|
+
|
|
101
|
+
const lastFin = (d.finances || [])[0] || {};
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
siren: d.siren,
|
|
105
|
+
siret: d.siege?.siret,
|
|
106
|
+
name: d.nom_entreprise || d.denomination,
|
|
107
|
+
dateCreation: d.date_creation,
|
|
108
|
+
nafCode: d.code_naf,
|
|
109
|
+
nafLabel: d.libelle_code_naf,
|
|
110
|
+
city: d.siege?.ville,
|
|
111
|
+
postalCode: d.siege?.code_postal,
|
|
112
|
+
effectifs: d.tranche_effectif || d.effectif || null,
|
|
113
|
+
ca: lastFin.chiffre_affaires || null,
|
|
114
|
+
caYear: lastFin.annee || null,
|
|
115
|
+
resultat: lastFin.resultat || null,
|
|
116
|
+
dirigeants,
|
|
117
|
+
formeJuridique: d.forme_juridique || null,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { fetch } from '../utils/fetcher.js';
|
|
2
|
+
import { load, extractLinks, extractMeta, extractSocialLinks, extractScripts, extractPricing, simpleHash } from '../utils/parser.js';
|
|
3
|
+
import { detectTechnologies } from '../utils/tech-detect.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Deep site analysis — captures maximum intelligence on first check.
|
|
7
|
+
*/
|
|
8
|
+
export async function analyzeSite(url) {
|
|
9
|
+
const result = {
|
|
10
|
+
url,
|
|
11
|
+
checkedAt: new Date().toISOString(),
|
|
12
|
+
status: 'ok',
|
|
13
|
+
error: null,
|
|
14
|
+
meta: {},
|
|
15
|
+
techStack: [],
|
|
16
|
+
socialLinks: {},
|
|
17
|
+
links: [],
|
|
18
|
+
pageCount: 0,
|
|
19
|
+
pricing: null,
|
|
20
|
+
jobs: null,
|
|
21
|
+
keyPages: {}, // title/desc snapshots of important pages
|
|
22
|
+
contentStats: null, // blog/content activity
|
|
23
|
+
seoSignals: null, // basic SEO health indicators
|
|
24
|
+
performance: null, // response time, size
|
|
25
|
+
security: null, // basic security signals
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const startTime = Date.now();
|
|
30
|
+
const response = await fetch(url, { retries: 3, delay: 1000 });
|
|
31
|
+
const loadTime = Date.now() - startTime;
|
|
32
|
+
const html = response.data;
|
|
33
|
+
const headers = response.headers || {};
|
|
34
|
+
|
|
35
|
+
const $ = load(html);
|
|
36
|
+
|
|
37
|
+
result.meta = extractMeta($);
|
|
38
|
+
result.techStack = detectTechnologies(html, headers, url);
|
|
39
|
+
result.socialLinks = extractSocialLinks($);
|
|
40
|
+
result.links = extractLinks($, url);
|
|
41
|
+
result.pageCount = result.links.length;
|
|
42
|
+
|
|
43
|
+
// --- Performance snapshot ---
|
|
44
|
+
result.performance = {
|
|
45
|
+
responseTimeMs: loadTime,
|
|
46
|
+
htmlSizeKB: Math.round(html.length / 1024),
|
|
47
|
+
compressed: !!(headers['content-encoding']),
|
|
48
|
+
http2: (headers[':status'] || headers['http-version'] || '').includes('2'),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// --- Security signals ---
|
|
52
|
+
result.security = {
|
|
53
|
+
https: url.startsWith('https'),
|
|
54
|
+
hsts: !!(headers['strict-transport-security']),
|
|
55
|
+
xFrameOptions: !!(headers['x-frame-options']),
|
|
56
|
+
csp: !!(headers['content-security-policy']),
|
|
57
|
+
xContentType: !!(headers['x-content-type-options']),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// --- SEO signals from homepage ---
|
|
61
|
+
result.seoSignals = {
|
|
62
|
+
hasTitle: !!result.meta.title,
|
|
63
|
+
titleLength: (result.meta.title || '').length,
|
|
64
|
+
hasDescription: !!result.meta.description,
|
|
65
|
+
descriptionLength: (result.meta.description || '').length,
|
|
66
|
+
hasCanonical: !!result.meta.canonical,
|
|
67
|
+
hasOgTags: !!(result.meta.ogTitle),
|
|
68
|
+
h1Count: $('h1').length,
|
|
69
|
+
h2Count: $('h2').length,
|
|
70
|
+
imgCount: $('img').length,
|
|
71
|
+
imgWithoutAlt: $('img:not([alt]), img[alt=""]').length,
|
|
72
|
+
wordCount: $.text().replace(/\s+/g, ' ').trim().split(/\s+/).length,
|
|
73
|
+
generator: result.meta.generator || null,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// --- Crawl key pages for deeper intel ---
|
|
77
|
+
const keyPagePaths = [
|
|
78
|
+
{ path: '/', label: 'homepage' },
|
|
79
|
+
{ path: '/pricing', label: 'pricing' },
|
|
80
|
+
{ path: '/tarifs', label: 'pricing' },
|
|
81
|
+
{ path: '/plans', label: 'pricing' },
|
|
82
|
+
{ path: '/about', label: 'about' },
|
|
83
|
+
{ path: '/a-propos', label: 'about' },
|
|
84
|
+
{ path: '/qui-sommes-nous', label: 'about' },
|
|
85
|
+
{ path: '/blog', label: 'blog' },
|
|
86
|
+
{ path: '/actualites', label: 'blog' },
|
|
87
|
+
{ path: '/news', label: 'blog' },
|
|
88
|
+
{ path: '/contact', label: 'contact' },
|
|
89
|
+
{ path: '/jobs', label: 'jobs' },
|
|
90
|
+
{ path: '/careers', label: 'jobs' },
|
|
91
|
+
{ path: '/recrutement', label: 'jobs' },
|
|
92
|
+
{ path: '/emploi', label: 'jobs' },
|
|
93
|
+
{ path: '/rejoignez-nous', label: 'jobs' },
|
|
94
|
+
{ path: '/produits', label: 'products' },
|
|
95
|
+
{ path: '/products', label: 'products' },
|
|
96
|
+
{ path: '/services', label: 'services' },
|
|
97
|
+
{ path: '/solutions', label: 'services' },
|
|
98
|
+
{ path: '/clients', label: 'clients' },
|
|
99
|
+
{ path: '/references', label: 'clients' },
|
|
100
|
+
{ path: '/temoignages', label: 'testimonials' },
|
|
101
|
+
{ path: '/testimonials', label: 'testimonials' },
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
const foundPages = {};
|
|
105
|
+
const jobKeywords = [
|
|
106
|
+
'engineer', 'developer', 'développeur', 'manager', 'designer',
|
|
107
|
+
'analyst', 'analyste', 'sales', 'commercial', 'marketing',
|
|
108
|
+
'support', 'consultant', 'chef de projet', 'product', 'data',
|
|
109
|
+
'devops', 'fullstack', 'frontend', 'backend', 'stage', 'alternance',
|
|
110
|
+
'cdi', 'cdd', 'freelance', 'ingénieur', 'responsable', 'directeur'
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
for (const { path, label } of keyPagePaths) {
|
|
114
|
+
const pageUrl = new URL(path, url).href;
|
|
115
|
+
// Check if this page exists in discovered links OR try it anyway for key pages
|
|
116
|
+
const isLinked = result.links.some(l => l === pageUrl || l.endsWith(path));
|
|
117
|
+
|
|
118
|
+
if (!isLinked && !['homepage', 'pricing', 'jobs', 'blog'].includes(label)) continue;
|
|
119
|
+
if (foundPages[label] && label !== 'jobs') continue; // already found one for this category
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
await new Promise(r => setTimeout(r, 800));
|
|
123
|
+
const pr = await fetch(pageUrl, { retries: 1, delay: 500 });
|
|
124
|
+
if (pr.status === 200 || pr.status === 301 || pr.status === 302) {
|
|
125
|
+
const p$ = load(pr.data);
|
|
126
|
+
const pageMeta = extractMeta(p$);
|
|
127
|
+
|
|
128
|
+
foundPages[label] = {
|
|
129
|
+
url: pageUrl,
|
|
130
|
+
title: pageMeta.title,
|
|
131
|
+
description: pageMeta.description,
|
|
132
|
+
hash: simpleHash(pageMeta.title + pageMeta.description + p$.text().substring(0, 500)),
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// --- Extract pricing info ---
|
|
136
|
+
if (label === 'pricing') {
|
|
137
|
+
result.pricing = extractPricing(p$, pr.data);
|
|
138
|
+
result.pricing.url = pageUrl;
|
|
139
|
+
result.pricing.pageTitle = pageMeta.title;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- Extract job listings ---
|
|
143
|
+
if (label === 'jobs') {
|
|
144
|
+
let jobCount = 0;
|
|
145
|
+
const jobTitles = [];
|
|
146
|
+
p$('h1,h2,h3,h4,li,article,.job,.position,.offer,.offre').each((_, el) => {
|
|
147
|
+
const text = p$(el).text().toLowerCase().trim();
|
|
148
|
+
if (text.length > 10 && text.length < 200 && jobKeywords.some(k => text.includes(k))) {
|
|
149
|
+
jobCount++;
|
|
150
|
+
if (jobTitles.length < 15) jobTitles.push(p$(el).text().trim().substring(0, 100));
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
result.jobs = {
|
|
154
|
+
url: pageUrl,
|
|
155
|
+
estimatedOpenings: jobCount,
|
|
156
|
+
titles: [...new Set(jobTitles)],
|
|
157
|
+
pageTitle: pageMeta.title,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// --- Extract blog/content activity ---
|
|
162
|
+
if (label === 'blog') {
|
|
163
|
+
const articles = [];
|
|
164
|
+
p$('article, .post, .entry, .blog-post, .article-item').each((_, el) => {
|
|
165
|
+
const title = p$(el).find('h2,h3,.title,.entry-title').first().text().trim();
|
|
166
|
+
const link = p$(el).find('a').first().attr('href');
|
|
167
|
+
const date = p$(el).find('time,.date,.published,.post-date').first().text().trim();
|
|
168
|
+
if (title) articles.push({ title: title.substring(0, 120), link, date });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Fallback: look for h2/h3 links in blog page
|
|
172
|
+
if (articles.length === 0) {
|
|
173
|
+
p$('h2 a, h3 a').each((_, el) => {
|
|
174
|
+
const title = p$(el).text().trim();
|
|
175
|
+
const link = p$(el).attr('href');
|
|
176
|
+
if (title && title.length > 10) articles.push({ title: title.substring(0, 120), link });
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
result.contentStats = {
|
|
181
|
+
url: pageUrl,
|
|
182
|
+
recentArticles: articles.slice(0, 10),
|
|
183
|
+
articleCount: articles.length,
|
|
184
|
+
pageTitle: pageMeta.title,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
} catch {}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
result.keyPages = foundPages;
|
|
192
|
+
|
|
193
|
+
// --- If no pricing found from key pages, check homepage ---
|
|
194
|
+
if (!result.pricing) {
|
|
195
|
+
const pageText = $.text();
|
|
196
|
+
if (/\$\d+|€\d+|£\d+/.test(pageText) && /plan|pricing|price|tarif/.test(pageText.toLowerCase())) {
|
|
197
|
+
result.pricing = extractPricing($, html);
|
|
198
|
+
result.pricing.url = url;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// --- Discover additional pages by crawling 1 level deep ---
|
|
203
|
+
// Pick up to 5 important-looking internal links to crawl
|
|
204
|
+
const importantPatterns = ['/produit', '/product', '/service', '/solution', '/offre', '/promo', '/categor'];
|
|
205
|
+
const extraPages = result.links
|
|
206
|
+
.filter(l => importantPatterns.some(p => l.toLowerCase().includes(p)))
|
|
207
|
+
.slice(0, 5);
|
|
208
|
+
|
|
209
|
+
for (const extraUrl of extraPages) {
|
|
210
|
+
try {
|
|
211
|
+
await new Promise(r => setTimeout(r, 600));
|
|
212
|
+
const er = await fetch(extraUrl, { retries: 1, delay: 300 });
|
|
213
|
+
if (er.status === 200) {
|
|
214
|
+
const e$ = load(er.data);
|
|
215
|
+
const extraLinks = extractLinks(e$, url);
|
|
216
|
+
for (const el of extraLinks) {
|
|
217
|
+
if (!result.links.includes(el)) result.links.push(el);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
} catch {}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
result.pageCount = result.links.length;
|
|
224
|
+
|
|
225
|
+
} catch (err) {
|
|
226
|
+
result.status = 'error';
|
|
227
|
+
result.error = err.message;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return result;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function analyzeKeyPages(url, pages = ['/', '/about', '/pricing']) {
|
|
234
|
+
const results = {};
|
|
235
|
+
for (const page of pages) {
|
|
236
|
+
try {
|
|
237
|
+
const pageUrl = new URL(page, url).href;
|
|
238
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
239
|
+
const response = await fetch(pageUrl);
|
|
240
|
+
if (response.status === 200) {
|
|
241
|
+
const $ = load(response.data);
|
|
242
|
+
const meta = extractMeta($);
|
|
243
|
+
results[page] = {
|
|
244
|
+
title: meta.title,
|
|
245
|
+
description: meta.description,
|
|
246
|
+
hash: simpleHash(meta.title + meta.description),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
} catch {}
|
|
250
|
+
}
|
|
251
|
+
return results;
|
|
252
|
+
}
|
package/src/storage.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, readdirSync, existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { randomUUID } from 'crypto';
|
|
5
|
+
|
|
6
|
+
const BASE_DIR = join(homedir(), '.intelwatch');
|
|
7
|
+
const TRACKERS_FILE = join(BASE_DIR, 'trackers.json');
|
|
8
|
+
const SNAPSHOTS_DIR = join(BASE_DIR, 'snapshots');
|
|
9
|
+
const REPORTS_DIR = join(BASE_DIR, 'reports');
|
|
10
|
+
|
|
11
|
+
export function ensureDirectories() {
|
|
12
|
+
for (const dir of [BASE_DIR, SNAPSHOTS_DIR, REPORTS_DIR]) {
|
|
13
|
+
if (!existsSync(dir)) {
|
|
14
|
+
mkdirSync(dir, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function loadTrackers() {
|
|
20
|
+
ensureDirectories();
|
|
21
|
+
if (!existsSync(TRACKERS_FILE)) {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(readFileSync(TRACKERS_FILE, 'utf8'));
|
|
26
|
+
} catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function saveTrackers(trackers) {
|
|
32
|
+
ensureDirectories();
|
|
33
|
+
writeFileSync(TRACKERS_FILE, JSON.stringify(trackers, null, 2), 'utf8');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createTracker(type, data) {
|
|
37
|
+
const trackers = loadTrackers();
|
|
38
|
+
const id = generateId(type, data);
|
|
39
|
+
|
|
40
|
+
const existing = trackers.find(t => t.id === id);
|
|
41
|
+
if (existing) {
|
|
42
|
+
return { tracker: existing, created: false };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const tracker = {
|
|
46
|
+
id,
|
|
47
|
+
type,
|
|
48
|
+
createdAt: new Date().toISOString(),
|
|
49
|
+
lastCheckedAt: null,
|
|
50
|
+
status: 'active',
|
|
51
|
+
checkCount: 0,
|
|
52
|
+
...data,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
trackers.push(tracker);
|
|
56
|
+
saveTrackers(trackers);
|
|
57
|
+
return { tracker, created: true };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getTracker(id) {
|
|
61
|
+
const trackers = loadTrackers();
|
|
62
|
+
return trackers.find(t => t.id === id) || null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function updateTracker(id, updates) {
|
|
66
|
+
const trackers = loadTrackers();
|
|
67
|
+
const idx = trackers.findIndex(t => t.id === id);
|
|
68
|
+
if (idx === -1) throw new Error(`Tracker not found: ${id}`);
|
|
69
|
+
trackers[idx] = { ...trackers[idx], ...updates };
|
|
70
|
+
saveTrackers(trackers);
|
|
71
|
+
return trackers[idx];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function removeTracker(id) {
|
|
75
|
+
const trackers = loadTrackers();
|
|
76
|
+
const idx = trackers.findIndex(t => t.id === id);
|
|
77
|
+
if (idx === -1) throw new Error(`Tracker not found: ${id}`);
|
|
78
|
+
const removed = trackers.splice(idx, 1)[0];
|
|
79
|
+
saveTrackers(trackers);
|
|
80
|
+
return removed;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function saveSnapshot(trackerId, snapshot) {
|
|
84
|
+
ensureDirectories();
|
|
85
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
86
|
+
const filename = `${trackerId}-${date}-${Date.now()}.json`;
|
|
87
|
+
const filepath = join(SNAPSHOTS_DIR, filename);
|
|
88
|
+
writeFileSync(filepath, JSON.stringify(snapshot, null, 2), 'utf8');
|
|
89
|
+
return filepath;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function loadLatestSnapshot(trackerId) {
|
|
93
|
+
const snapshots = listSnapshots(trackerId);
|
|
94
|
+
if (snapshots.length === 0) return null;
|
|
95
|
+
const latest = snapshots[snapshots.length - 1];
|
|
96
|
+
return loadSnapshot(latest.filepath);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function loadSnapshot(filepath) {
|
|
100
|
+
try {
|
|
101
|
+
return JSON.parse(readFileSync(filepath, 'utf8'));
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function listSnapshots(trackerId, limit = null) {
|
|
108
|
+
ensureDirectories();
|
|
109
|
+
const files = readdirSync(SNAPSHOTS_DIR)
|
|
110
|
+
.filter(f => f.startsWith(trackerId + '-') && f.endsWith('.json'))
|
|
111
|
+
.sort();
|
|
112
|
+
|
|
113
|
+
const snapshots = files.map(f => {
|
|
114
|
+
const filepath = join(SNAPSHOTS_DIR, f);
|
|
115
|
+
const parts = f.replace('.json', '').split('-');
|
|
116
|
+
// Format: trackerId-YYYY-MM-DD-timestamp.json
|
|
117
|
+
// trackerId may contain dashes, so parse from the end
|
|
118
|
+
const timestamp = parseInt(parts[parts.length - 1]);
|
|
119
|
+
return { filename: f, filepath, timestamp, date: parts.slice(-4, -1).join('-') };
|
|
120
|
+
}).sort((a, b) => a.timestamp - b.timestamp);
|
|
121
|
+
|
|
122
|
+
if (limit) return snapshots.slice(-limit);
|
|
123
|
+
return snapshots;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function loadSnapshotByDate(trackerId, targetDate) {
|
|
127
|
+
const snapshots = listSnapshots(trackerId);
|
|
128
|
+
// Find closest snapshot to targetDate
|
|
129
|
+
const target = new Date(targetDate).getTime();
|
|
130
|
+
let closest = null;
|
|
131
|
+
let minDiff = Infinity;
|
|
132
|
+
|
|
133
|
+
for (const s of snapshots) {
|
|
134
|
+
const diff = Math.abs(s.timestamp - target);
|
|
135
|
+
if (diff < minDiff) {
|
|
136
|
+
minDiff = diff;
|
|
137
|
+
closest = s;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!closest) return null;
|
|
142
|
+
return loadSnapshot(closest.filepath);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function saveReport(filename, content) {
|
|
146
|
+
ensureDirectories();
|
|
147
|
+
const filepath = join(REPORTS_DIR, filename);
|
|
148
|
+
writeFileSync(filepath, content, 'utf8');
|
|
149
|
+
return filepath;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function generateId(type, data) {
|
|
153
|
+
const base = type === 'competitor' ? slugify(data.url)
|
|
154
|
+
: type === 'keyword' ? 'kw-' + slugify(data.keyword)
|
|
155
|
+
: type === 'person' ? 'person-' + slugify(data.personName + (data.org ? '-' + data.org : ''))
|
|
156
|
+
: 'brand-' + slugify(data.brandName);
|
|
157
|
+
return base.slice(0, 50);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function slugify(str) {
|
|
161
|
+
return str
|
|
162
|
+
.toLowerCase()
|
|
163
|
+
.replace(/https?:\/\//g, '')
|
|
164
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
165
|
+
.replace(/^-+|-+$/g, '');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export { BASE_DIR, SNAPSHOTS_DIR, REPORTS_DIR };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { scrapeNewsMentions } from '../scrapers/google-news.js';
|
|
2
|
+
|
|
3
|
+
export async function runBrandCheck(tracker) {
|
|
4
|
+
const { brandName } = tracker;
|
|
5
|
+
|
|
6
|
+
const mentionData = await scrapeNewsMentions(brandName);
|
|
7
|
+
|
|
8
|
+
return {
|
|
9
|
+
type: 'brand',
|
|
10
|
+
trackerId: tracker.id,
|
|
11
|
+
brandName,
|
|
12
|
+
checkedAt: new Date().toISOString(),
|
|
13
|
+
mentions: mentionData.mentions,
|
|
14
|
+
mentionCount: mentionData.mentionCount,
|
|
15
|
+
error: mentionData.error || null,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function diffBrandSnapshots(prev, curr) {
|
|
20
|
+
const changes = [];
|
|
21
|
+
|
|
22
|
+
if (!prev) {
|
|
23
|
+
changes.push({
|
|
24
|
+
type: 'new',
|
|
25
|
+
field: 'tracker',
|
|
26
|
+
value: `Initial snapshot — ${curr.mentionCount} mentions found`,
|
|
27
|
+
});
|
|
28
|
+
return changes;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const prevUrls = new Set((prev.mentions || []).map(m => m.url));
|
|
32
|
+
|
|
33
|
+
for (const mention of (curr.mentions || [])) {
|
|
34
|
+
if (!prevUrls.has(mention.url)) {
|
|
35
|
+
const sentLabel = mention.sentiment === 'negative' || mention.sentiment === 'slightly_negative'
|
|
36
|
+
? '⚠ ' : '';
|
|
37
|
+
changes.push({
|
|
38
|
+
type: 'new',
|
|
39
|
+
field: 'mention',
|
|
40
|
+
value: `${sentLabel}[${mention.category}] ${mention.title?.slice(0, 80)} — ${mention.domain}`,
|
|
41
|
+
mention,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const prevCount = prev.mentionCount || 0;
|
|
47
|
+
const currCount = curr.mentionCount || 0;
|
|
48
|
+
if (currCount !== prevCount) {
|
|
49
|
+
changes.push({
|
|
50
|
+
type: 'changed',
|
|
51
|
+
field: 'mentionCount',
|
|
52
|
+
value: `Mention count: ${prevCount} → ${currCount}`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return changes;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getMentionSummary(snapshot) {
|
|
60
|
+
const mentions = snapshot.mentions || [];
|
|
61
|
+
const byCategory = {};
|
|
62
|
+
const bySentiment = {};
|
|
63
|
+
|
|
64
|
+
for (const m of mentions) {
|
|
65
|
+
byCategory[m.category] = (byCategory[m.category] || 0) + 1;
|
|
66
|
+
bySentiment[m.sentiment] = (bySentiment[m.sentiment] || 0) + 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
total: mentions.length,
|
|
71
|
+
byCategory,
|
|
72
|
+
bySentiment,
|
|
73
|
+
negativeMentions: mentions.filter(m => m.sentiment === 'negative' || m.sentiment === 'slightly_negative'),
|
|
74
|
+
positiveMentions: mentions.filter(m => m.sentiment === 'positive' || m.sentiment === 'slightly_positive'),
|
|
75
|
+
};
|
|
76
|
+
}
|