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.
Files changed (46) hide show
  1. package/.env.example +41 -0
  2. package/LICENSE +75 -0
  3. package/README.md +243 -0
  4. package/Start SEO Intel.bat +9 -0
  5. package/Start SEO Intel.command +8 -0
  6. package/cli.js +3727 -0
  7. package/config/example.json +29 -0
  8. package/config/setup-wizard.js +522 -0
  9. package/crawler/index.js +566 -0
  10. package/crawler/robots.js +103 -0
  11. package/crawler/sanitize.js +124 -0
  12. package/crawler/schema-parser.js +168 -0
  13. package/crawler/sitemap.js +103 -0
  14. package/crawler/stealth.js +393 -0
  15. package/crawler/subdomain-discovery.js +341 -0
  16. package/db/db.js +213 -0
  17. package/db/schema.sql +120 -0
  18. package/exports/competitive.js +186 -0
  19. package/exports/heuristics.js +67 -0
  20. package/exports/queries.js +197 -0
  21. package/exports/suggestive.js +230 -0
  22. package/exports/technical.js +180 -0
  23. package/exports/templates.js +77 -0
  24. package/lib/gate.js +204 -0
  25. package/lib/license.js +369 -0
  26. package/lib/oauth.js +432 -0
  27. package/lib/updater.js +324 -0
  28. package/package.json +68 -0
  29. package/reports/generate-html.js +6194 -0
  30. package/reports/generate-site-graph.js +949 -0
  31. package/reports/gsc-loader.js +190 -0
  32. package/scheduler.js +142 -0
  33. package/seo-audit.js +619 -0
  34. package/seo-intel.png +0 -0
  35. package/server.js +602 -0
  36. package/setup/ROADMAP.md +109 -0
  37. package/setup/checks.js +483 -0
  38. package/setup/config-builder.js +227 -0
  39. package/setup/engine.js +65 -0
  40. package/setup/installers.js +197 -0
  41. package/setup/models.js +328 -0
  42. package/setup/openclaw-bridge.js +329 -0
  43. package/setup/validator.js +395 -0
  44. package/setup/web-routes.js +688 -0
  45. package/setup/wizard.html +2920 -0
  46. 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
+ }