seo-intel 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/.env.example +41 -0
- package/LICENSE +75 -0
- package/README.md +243 -0
- package/Start SEO Intel.bat +9 -0
- package/Start SEO Intel.command +8 -0
- package/cli.js +3727 -0
- package/config/example.json +29 -0
- package/config/setup-wizard.js +522 -0
- package/crawler/index.js +566 -0
- package/crawler/robots.js +103 -0
- package/crawler/sanitize.js +124 -0
- package/crawler/schema-parser.js +168 -0
- package/crawler/sitemap.js +103 -0
- package/crawler/stealth.js +393 -0
- package/crawler/subdomain-discovery.js +341 -0
- package/db/db.js +213 -0
- package/db/schema.sql +120 -0
- package/exports/competitive.js +186 -0
- package/exports/heuristics.js +67 -0
- package/exports/queries.js +197 -0
- package/exports/suggestive.js +230 -0
- package/exports/technical.js +180 -0
- package/exports/templates.js +77 -0
- package/lib/gate.js +204 -0
- package/lib/license.js +369 -0
- package/lib/oauth.js +432 -0
- package/lib/updater.js +324 -0
- package/package.json +68 -0
- package/reports/generate-html.js +6194 -0
- package/reports/generate-site-graph.js +949 -0
- package/reports/gsc-loader.js +190 -0
- package/scheduler.js +142 -0
- package/seo-audit.js +619 -0
- package/seo-intel.png +0 -0
- package/server.js +602 -0
- package/setup/ROADMAP.md +109 -0
- package/setup/checks.js +483 -0
- package/setup/config-builder.js +227 -0
- package/setup/engine.js +65 -0
- package/setup/installers.js +197 -0
- package/setup/models.js +328 -0
- package/setup/openclaw-bridge.js +329 -0
- package/setup/validator.js +395 -0
- package/setup/web-routes.js +688 -0
- package/setup/wizard.html +2920 -0
- package/start-seo-intel.sh +8 -0
package/lib/license.js
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEO Intel — License System
|
|
3
|
+
*
|
|
4
|
+
* Validation priority:
|
|
5
|
+
* 1. FROGGO_TOKEN → validate against Froggo API
|
|
6
|
+
* 2. SEO_INTEL_LICENSE → validate against Lemon Squeezy API
|
|
7
|
+
* 3. No key → Free tier
|
|
8
|
+
*
|
|
9
|
+
* Local cache: ~/.seo-intel/license-cache.json
|
|
10
|
+
* - LS keys: cache 24h, stale up to 7 days if API unreachable
|
|
11
|
+
* - Froggo tokens: cache 24h, stale up to 24h if API unreachable
|
|
12
|
+
* - Beyond stale limit → degrade to free tier + warn
|
|
13
|
+
*
|
|
14
|
+
* Free tier: crawl + raw HTML export only. No AI extraction or analysis.
|
|
15
|
+
* Solo (€19.99/mo or €199/yr via LS, $9.99/mo via Froggo): Full AI extraction + analysis, all commands.
|
|
16
|
+
* Agency: Later phase — not sold yet.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
20
|
+
import { join, dirname } from 'path';
|
|
21
|
+
import { fileURLToPath } from 'url';
|
|
22
|
+
import { hostname, userInfo, platform } from 'os';
|
|
23
|
+
import { createHash } from 'crypto';
|
|
24
|
+
|
|
25
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const ROOT = join(__dirname, '..');
|
|
27
|
+
|
|
28
|
+
// Cache location: ~/.seo-intel/license-cache.json
|
|
29
|
+
const CACHE_DIR = join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.seo-intel');
|
|
30
|
+
const CACHE_PATH = join(CACHE_DIR, 'license-cache.json');
|
|
31
|
+
|
|
32
|
+
// Stale limits (ms)
|
|
33
|
+
const LS_CACHE_TTL = 24 * 60 * 60 * 1000; // 24h fresh
|
|
34
|
+
const LS_STALE_LIMIT = 7 * 24 * 60 * 60 * 1000; // 7 days stale max
|
|
35
|
+
const FROGGO_CACHE_TTL = 24 * 60 * 60 * 1000; // 24h fresh
|
|
36
|
+
const FROGGO_STALE_LIMIT = 24 * 60 * 60 * 1000; // 24h stale max
|
|
37
|
+
|
|
38
|
+
// ── Tiers ──────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export const TIERS = {
|
|
41
|
+
free: {
|
|
42
|
+
name: 'Free',
|
|
43
|
+
maxProjects: Infinity,
|
|
44
|
+
maxPagesPerDomain: Infinity,
|
|
45
|
+
features: [
|
|
46
|
+
'crawl', 'setup', 'serve', 'status',
|
|
47
|
+
'report', 'html', 'guide', 'schemas', 'schemas-backfill',
|
|
48
|
+
'competitors', 'update',
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
solo: {
|
|
52
|
+
name: 'Solo',
|
|
53
|
+
maxProjects: Infinity,
|
|
54
|
+
maxPagesPerDomain: Infinity,
|
|
55
|
+
features: 'all',
|
|
56
|
+
},
|
|
57
|
+
agency: {
|
|
58
|
+
name: 'Agency',
|
|
59
|
+
maxProjects: Infinity,
|
|
60
|
+
maxPagesPerDomain: Infinity,
|
|
61
|
+
features: 'all',
|
|
62
|
+
whiteLabel: true,
|
|
63
|
+
teamAccess: true,
|
|
64
|
+
dockerDeployment: true,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// ── Machine ID ─────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
function getMachineId() {
|
|
71
|
+
try {
|
|
72
|
+
const data = `${hostname()}:${userInfo().username}:${platform()}`;
|
|
73
|
+
return createHash('sha256').update(data).digest('hex').slice(0, 16);
|
|
74
|
+
} catch {
|
|
75
|
+
return 'unknown';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Local Cache ────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
function readCache() {
|
|
82
|
+
try {
|
|
83
|
+
if (!existsSync(CACHE_PATH)) return null;
|
|
84
|
+
return JSON.parse(readFileSync(CACHE_PATH, 'utf8'));
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function writeCache(data) {
|
|
91
|
+
try {
|
|
92
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
93
|
+
writeFileSync(CACHE_PATH, JSON.stringify(data, null, 2), 'utf8');
|
|
94
|
+
} catch { /* best-effort */ }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Check if cached validation is still usable.
|
|
99
|
+
* Returns { valid, tier, stale } or null if cache is expired/missing.
|
|
100
|
+
*/
|
|
101
|
+
function checkCache(key) {
|
|
102
|
+
const cache = readCache();
|
|
103
|
+
if (!cache || cache.key !== key) return null;
|
|
104
|
+
|
|
105
|
+
const age = Date.now() - (cache.validatedAt || 0);
|
|
106
|
+
const ttl = cache.source === 'froggo' ? FROGGO_CACHE_TTL : LS_CACHE_TTL;
|
|
107
|
+
const staleLimit = cache.source === 'froggo' ? FROGGO_STALE_LIMIT : LS_STALE_LIMIT;
|
|
108
|
+
|
|
109
|
+
if (age < ttl) {
|
|
110
|
+
return { valid: true, tier: cache.tier, stale: false, source: cache.source };
|
|
111
|
+
}
|
|
112
|
+
if (age < staleLimit) {
|
|
113
|
+
return { valid: true, tier: cache.tier, stale: true, source: cache.source };
|
|
114
|
+
}
|
|
115
|
+
return null; // Expired beyond stale limit
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Lemon Squeezy Validation ───────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Validate a license key against Lemon Squeezy API.
|
|
122
|
+
* Returns { valid, tier, error? } — never throws.
|
|
123
|
+
*/
|
|
124
|
+
async function validateWithLS(key) {
|
|
125
|
+
try {
|
|
126
|
+
const controller = new AbortController();
|
|
127
|
+
const timeout = setTimeout(() => controller.abort(), 8000);
|
|
128
|
+
|
|
129
|
+
const res = await fetch('https://api.lemonsqueezy.com/v1/licenses/validate', {
|
|
130
|
+
signal: controller.signal,
|
|
131
|
+
method: 'POST',
|
|
132
|
+
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
133
|
+
body: JSON.stringify({
|
|
134
|
+
license_key: key,
|
|
135
|
+
instance_name: `seo-intel-${getMachineId()}`,
|
|
136
|
+
}),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
clearTimeout(timeout);
|
|
140
|
+
const data = await res.json();
|
|
141
|
+
|
|
142
|
+
if (data.valid) {
|
|
143
|
+
// Determine tier from LS metadata
|
|
144
|
+
// Convention: product variant name contains "solo" or "agency"
|
|
145
|
+
const variantName = (data.meta?.variant_name || data.license_key?.key_data?.variant || '').toLowerCase();
|
|
146
|
+
const tier = variantName.includes('agency') ? 'agency' : 'solo';
|
|
147
|
+
return { valid: true, tier };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { valid: false, error: data.error || 'License key not valid' };
|
|
151
|
+
} catch (err) {
|
|
152
|
+
return { valid: false, error: `Network error: ${err.message}`, offline: true };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Froggo Token Validation ────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Validate a Froggo marketplace token.
|
|
160
|
+
* Returns { valid, tier, error? } — never throws.
|
|
161
|
+
*/
|
|
162
|
+
async function validateWithFroggo(token) {
|
|
163
|
+
try {
|
|
164
|
+
const controller = new AbortController();
|
|
165
|
+
const timeout = setTimeout(() => controller.abort(), 8000);
|
|
166
|
+
|
|
167
|
+
const res = await fetch('https://api.froggo.pro/v1/licenses/validate', {
|
|
168
|
+
signal: controller.signal,
|
|
169
|
+
method: 'POST',
|
|
170
|
+
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
171
|
+
body: JSON.stringify({
|
|
172
|
+
token,
|
|
173
|
+
product: 'seo-intel',
|
|
174
|
+
}),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
clearTimeout(timeout);
|
|
178
|
+
const data = await res.json();
|
|
179
|
+
|
|
180
|
+
if (data.valid) {
|
|
181
|
+
const tier = (data.tier || 'solo').toLowerCase();
|
|
182
|
+
return { valid: true, tier: tier === 'agency' ? 'agency' : 'solo' };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { valid: false, error: data.error || 'Token not valid' };
|
|
186
|
+
} catch (err) {
|
|
187
|
+
return { valid: false, error: `Network error: ${err.message}`, offline: true };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── License Loading ─────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
let _cachedLicense = undefined;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Read the license key / token from environment or .env file.
|
|
197
|
+
* Returns { type, value } or null.
|
|
198
|
+
*/
|
|
199
|
+
function readKeyFromEnv() {
|
|
200
|
+
// 1. Check Froggo token first (marketplace priority)
|
|
201
|
+
let froggoToken = process.env.FROGGO_TOKEN;
|
|
202
|
+
let lsKey = process.env.SEO_INTEL_LICENSE;
|
|
203
|
+
|
|
204
|
+
// 2. Check .env file
|
|
205
|
+
if (!froggoToken || !lsKey) {
|
|
206
|
+
const envPath = join(ROOT, '.env');
|
|
207
|
+
if (existsSync(envPath)) {
|
|
208
|
+
try {
|
|
209
|
+
const content = readFileSync(envPath, 'utf8');
|
|
210
|
+
if (!froggoToken) {
|
|
211
|
+
const match = content.match(/^FROGGO_TOKEN=(.+)$/m);
|
|
212
|
+
if (match) froggoToken = match[1].trim().replace(/^["']|["']$/g, '');
|
|
213
|
+
}
|
|
214
|
+
if (!lsKey) {
|
|
215
|
+
const match = content.match(/^SEO_INTEL_LICENSE=(.+)$/m);
|
|
216
|
+
if (match) lsKey = match[1].trim().replace(/^["']|["']$/g, '');
|
|
217
|
+
}
|
|
218
|
+
} catch { /* ok */ }
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (froggoToken) return { type: 'froggo', value: froggoToken };
|
|
223
|
+
if (lsKey) return { type: 'lemon-squeezy', value: lsKey };
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Load and validate the license.
|
|
229
|
+
* Synchronous — uses cache. Call activateLicense() for async network validation.
|
|
230
|
+
*/
|
|
231
|
+
export function loadLicense() {
|
|
232
|
+
if (_cachedLicense !== undefined) return _cachedLicense;
|
|
233
|
+
|
|
234
|
+
const keyInfo = readKeyFromEnv();
|
|
235
|
+
|
|
236
|
+
if (!keyInfo) {
|
|
237
|
+
_cachedLicense = { active: false, tier: 'free', ...TIERS.free };
|
|
238
|
+
return _cachedLicense;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Check local cache first (fast, offline)
|
|
242
|
+
const cached = checkCache(keyInfo.value);
|
|
243
|
+
if (cached && !cached.stale) {
|
|
244
|
+
const tierData = TIERS[cached.tier] || TIERS.solo;
|
|
245
|
+
_cachedLicense = { active: true, tier: cached.tier, key: keyInfo.value, source: cached.source, ...tierData };
|
|
246
|
+
return _cachedLicense;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (cached && cached.stale) {
|
|
250
|
+
// Stale but within limit — use it but flag for re-validation
|
|
251
|
+
const tierData = TIERS[cached.tier] || TIERS.solo;
|
|
252
|
+
_cachedLicense = { active: true, tier: cached.tier, key: keyInfo.value, source: cached.source, stale: true, ...tierData };
|
|
253
|
+
return _cachedLicense;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// No valid cache — need network validation
|
|
257
|
+
// For synchronous loadLicense(), degrade to free with a flag
|
|
258
|
+
_cachedLicense = { active: false, tier: 'free', needsActivation: true, key: keyInfo.value, keyType: keyInfo.type, ...TIERS.free };
|
|
259
|
+
return _cachedLicense;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Async license activation — validates against remote API and caches result.
|
|
264
|
+
* Call this at startup or when loadLicense() returns needsActivation: true.
|
|
265
|
+
* Returns the validated license object.
|
|
266
|
+
*/
|
|
267
|
+
export async function activateLicense() {
|
|
268
|
+
clearLicenseCache();
|
|
269
|
+
|
|
270
|
+
const keyInfo = readKeyFromEnv();
|
|
271
|
+
if (!keyInfo) {
|
|
272
|
+
_cachedLicense = { active: false, tier: 'free', ...TIERS.free };
|
|
273
|
+
return _cachedLicense;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
let result;
|
|
277
|
+
if (keyInfo.type === 'froggo') {
|
|
278
|
+
result = await validateWithFroggo(keyInfo.value);
|
|
279
|
+
} else {
|
|
280
|
+
result = await validateWithLS(keyInfo.value);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (result.valid) {
|
|
284
|
+
// Cache the successful validation
|
|
285
|
+
writeCache({
|
|
286
|
+
key: keyInfo.value,
|
|
287
|
+
tier: result.tier,
|
|
288
|
+
validatedAt: Date.now(),
|
|
289
|
+
source: keyInfo.type,
|
|
290
|
+
machineId: getMachineId(),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const tierData = TIERS[result.tier] || TIERS.solo;
|
|
294
|
+
_cachedLicense = { active: true, tier: result.tier, key: keyInfo.value, source: keyInfo.type, ...tierData };
|
|
295
|
+
return _cachedLicense;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (result.offline) {
|
|
299
|
+
// Network error — check if we have any stale cache at all
|
|
300
|
+
const cache = readCache();
|
|
301
|
+
if (cache && cache.key === keyInfo.value) {
|
|
302
|
+
const tierData = TIERS[cache.tier] || TIERS.solo;
|
|
303
|
+
_cachedLicense = { active: true, tier: cache.tier, key: keyInfo.value, source: cache.source, stale: true, ...tierData };
|
|
304
|
+
return _cachedLicense;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Validation failed
|
|
309
|
+
_cachedLicense = {
|
|
310
|
+
active: false,
|
|
311
|
+
tier: 'free',
|
|
312
|
+
invalidKey: true,
|
|
313
|
+
reason: result.error || 'License validation failed',
|
|
314
|
+
...TIERS.free,
|
|
315
|
+
};
|
|
316
|
+
return _cachedLicense;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Clear cached license (for testing or after key changes).
|
|
321
|
+
*/
|
|
322
|
+
export function clearLicenseCache() {
|
|
323
|
+
_cachedLicense = undefined;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── Tier Queries ────────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
/** Returns true for any paid tier (solo or agency). */
|
|
329
|
+
export function isPro() {
|
|
330
|
+
// Debug: SEO_INTEL_FORCE_FREE=1 simulates free tier for dashboard preview
|
|
331
|
+
if (process.env.SEO_INTEL_FORCE_FREE === '1') return false;
|
|
332
|
+
return loadLicense().tier !== 'free';
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Reset cached license — used by debug tier override */
|
|
336
|
+
export function _resetLicenseCache() { _cachedLicense = undefined; }
|
|
337
|
+
|
|
338
|
+
export function isFree() {
|
|
339
|
+
return loadLicense().tier === 'free';
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function isSolo() {
|
|
343
|
+
return loadLicense().tier === 'solo';
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function isAgency() {
|
|
347
|
+
return loadLicense().tier === 'agency';
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function getTier() {
|
|
351
|
+
return loadLicense();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function getMaxProjects() {
|
|
355
|
+
return loadLicense().maxProjects;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function getMaxPages() {
|
|
359
|
+
return loadLicense().maxPagesPerDomain;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Check if a specific feature/command is available on the current tier.
|
|
364
|
+
*/
|
|
365
|
+
export function isFeatureAvailable(featureName) {
|
|
366
|
+
const license = loadLicense();
|
|
367
|
+
if (license.features === 'all') return true;
|
|
368
|
+
return license.features.includes(featureName);
|
|
369
|
+
}
|