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
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { collectTop, inferPriorityFromCount, makeAction, sortActions } from './heuristics.js';
|
|
2
|
+
import { getTechnicalDataset } from './queries.js';
|
|
3
|
+
|
|
4
|
+
export function buildTechnicalActions(db, project) {
|
|
5
|
+
const rows = getTechnicalDataset(db, project);
|
|
6
|
+
const actions = [];
|
|
7
|
+
|
|
8
|
+
const missingSchema = rows.filter(r => !r.schema_count && !r.has_schema);
|
|
9
|
+
if (missingSchema.length) {
|
|
10
|
+
actions.push(makeAction({
|
|
11
|
+
id: 'technical-missing-schema',
|
|
12
|
+
type: 'add_schema',
|
|
13
|
+
priority: inferPriorityFromCount(missingSchema.length, { critical: 25, high: 10, medium: 4 }),
|
|
14
|
+
area: 'schema',
|
|
15
|
+
title: `Add structured data to ${missingSchema.length} target pages`,
|
|
16
|
+
why: 'Pages without schema miss eligibility for rich results and machine-readable context.',
|
|
17
|
+
evidence: collectTop(missingSchema.map(r => `${r.url} (status ${r.status_code || 'n/a'})`), 8),
|
|
18
|
+
implementationHints: [
|
|
19
|
+
'Map page templates to schema types like Organization, Product, FAQ, Article, BreadcrumbList, and WebPage.',
|
|
20
|
+
'Generate JSON-LD server-side so it is present in raw HTML.',
|
|
21
|
+
'Prioritize money pages, docs hubs, and comparison pages first.',
|
|
22
|
+
],
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const brokenPages = rows.filter(r => Number(r.status_code) >= 400);
|
|
27
|
+
if (brokenPages.length) {
|
|
28
|
+
actions.push(makeAction({
|
|
29
|
+
id: 'technical-broken-pages',
|
|
30
|
+
type: 'fix',
|
|
31
|
+
priority: inferPriorityFromCount(brokenPages.length, { critical: 5, high: 3, medium: 1 }),
|
|
32
|
+
area: 'technical',
|
|
33
|
+
title: `Fix ${brokenPages.length} broken target pages returning 4xx/5xx`,
|
|
34
|
+
why: 'Broken pages waste crawl budget, lose link equity, and create dead ends in user journeys.',
|
|
35
|
+
evidence: collectTop(brokenPages.map(r => `${r.url} → ${r.status_code}`), 10),
|
|
36
|
+
implementationHints: [
|
|
37
|
+
'Restore the intended page or 301 it to the nearest equivalent live URL.',
|
|
38
|
+
'Update internal links pointing to these URLs after the redirect or fix.',
|
|
39
|
+
],
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const orphanPages = rows.filter(r => r.click_depth > 0 && r.inbound_internal_links === 0 && Number(r.status_code) < 400);
|
|
44
|
+
if (orphanPages.length) {
|
|
45
|
+
actions.push(makeAction({
|
|
46
|
+
id: 'technical-orphans',
|
|
47
|
+
type: 'fix',
|
|
48
|
+
priority: inferPriorityFromCount(orphanPages.length, { critical: 15, high: 7, medium: 3 }),
|
|
49
|
+
area: 'structure',
|
|
50
|
+
title: `Reconnect ${orphanPages.length} orphan pages to the internal link graph`,
|
|
51
|
+
why: 'Pages with no discovered internal links pointing at them are harder for crawlers and users to find.',
|
|
52
|
+
evidence: collectTop(orphanPages.map(r => `${r.url} (depth ${r.click_depth})`), 10),
|
|
53
|
+
implementationHints: [
|
|
54
|
+
'Add contextual links from hub pages, nav, docs indexes, and related content blocks.',
|
|
55
|
+
'Review sitemap inclusion for pages that should rank.',
|
|
56
|
+
],
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const thinPages = rows.filter(r => (r.word_count || 0) > 0 && r.word_count < 200 && Number(r.status_code) < 400);
|
|
61
|
+
if (thinPages.length) {
|
|
62
|
+
actions.push(makeAction({
|
|
63
|
+
id: 'technical-thin-pages',
|
|
64
|
+
type: 'improve',
|
|
65
|
+
priority: inferPriorityFromCount(thinPages.length, { critical: 30, high: 12, medium: 5 }),
|
|
66
|
+
area: 'content',
|
|
67
|
+
title: `Strengthen ${thinPages.length} thin pages under 200 words`,
|
|
68
|
+
why: 'Very short pages usually fail to satisfy intent unless they are utility endpoints or redirects.',
|
|
69
|
+
evidence: collectTop(thinPages.map(r => `${r.url} (${r.word_count} words)`), 10),
|
|
70
|
+
implementationHints: [
|
|
71
|
+
'Add clear H1/H2 structure, core benefit copy, FAQs, examples, and internal links.',
|
|
72
|
+
'Merge low-value pages into stronger canonical assets when intent overlaps.',
|
|
73
|
+
],
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const deepPages = rows.filter(r => (r.click_depth || 0) > 3 && Number(r.status_code) < 400);
|
|
78
|
+
if (deepPages.length) {
|
|
79
|
+
actions.push(makeAction({
|
|
80
|
+
id: 'technical-deep-pages',
|
|
81
|
+
type: 'fix',
|
|
82
|
+
priority: inferPriorityFromCount(deepPages.length, { critical: 20, high: 8, medium: 3 }),
|
|
83
|
+
area: 'structure',
|
|
84
|
+
title: `Pull ${deepPages.length} deep pages closer than 4 clicks from entry points`,
|
|
85
|
+
why: 'Important URLs buried deep in the crawl path tend to receive less internal authority and lower discovery frequency.',
|
|
86
|
+
evidence: collectTop(deepPages.map(r => `${r.url} (depth ${r.click_depth})`), 10),
|
|
87
|
+
implementationHints: [
|
|
88
|
+
'Promote high-value URLs into nav, footer, hub, or resource index pages.',
|
|
89
|
+
'Add breadcrumb trails and category pages to shorten crawl distance.',
|
|
90
|
+
],
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const missingH1 = rows.filter(r => !r.h1_count && !String(r.h1 || '').trim() && Number(r.status_code) < 400);
|
|
95
|
+
if (missingH1.length) {
|
|
96
|
+
actions.push(makeAction({
|
|
97
|
+
id: 'technical-missing-h1',
|
|
98
|
+
type: 'fix',
|
|
99
|
+
priority: inferPriorityFromCount(missingH1.length, { critical: 20, high: 8, medium: 3 }),
|
|
100
|
+
area: 'content',
|
|
101
|
+
title: `Add unique H1s to ${missingH1.length} pages`,
|
|
102
|
+
why: 'Missing H1s weaken topic clarity for both users and search engines.',
|
|
103
|
+
evidence: collectTop(missingH1.map(r => r.url), 10),
|
|
104
|
+
implementationHints: [
|
|
105
|
+
'Align the H1 with page intent and supporting title/meta copy.',
|
|
106
|
+
'Use one clear H1 per page instead of decorative empty hero copy.',
|
|
107
|
+
],
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const missingMeta = rows.filter(r => !String(r.meta_desc || '').trim() && Number(r.status_code) < 400);
|
|
112
|
+
if (missingMeta.length) {
|
|
113
|
+
actions.push(makeAction({
|
|
114
|
+
id: 'technical-missing-meta',
|
|
115
|
+
type: 'improve',
|
|
116
|
+
priority: inferPriorityFromCount(missingMeta.length, { critical: 25, high: 10, medium: 4 }),
|
|
117
|
+
area: 'content',
|
|
118
|
+
title: `Write meta descriptions for ${missingMeta.length} pages`,
|
|
119
|
+
why: 'Missing meta descriptions reduce control over SERP snippets and CTR messaging.',
|
|
120
|
+
evidence: collectTop(missingMeta.map(r => r.url), 10),
|
|
121
|
+
implementationHints: [
|
|
122
|
+
'Write intent-matched descriptions around 140–160 characters.',
|
|
123
|
+
'Highlight the core outcome, differentiator, and CTA.',
|
|
124
|
+
],
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const redirectChains = rows.filter(r => (r.redirects_linked_from_page || 0) > 0);
|
|
129
|
+
if (redirectChains.length) {
|
|
130
|
+
actions.push(makeAction({
|
|
131
|
+
id: 'technical-redirect-chains',
|
|
132
|
+
type: 'fix',
|
|
133
|
+
priority: inferPriorityFromCount(redirectChains.length, { critical: 12, high: 5, medium: 2 }),
|
|
134
|
+
area: 'technical',
|
|
135
|
+
title: `Update internal links on ${redirectChains.length} pages that point to redirects`,
|
|
136
|
+
why: 'Internal links that hit redirects waste crawl hops and can become redirect chains over time.',
|
|
137
|
+
evidence: collectTop(redirectChains.map(r => `${r.url} (${r.redirects_linked_from_page} redirecting links)`), 10),
|
|
138
|
+
implementationHints: [
|
|
139
|
+
'Replace redirecting targets with their final destination URLs in nav and body content.',
|
|
140
|
+
'Audit legacy paths generated by CMS migrations or product renames.',
|
|
141
|
+
],
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const missingCanonical = rows.filter(r => !r.has_canonical && Number(r.status_code) < 400);
|
|
146
|
+
if (missingCanonical.length) {
|
|
147
|
+
actions.push(makeAction({
|
|
148
|
+
id: 'technical-missing-canonical',
|
|
149
|
+
type: 'fix',
|
|
150
|
+
priority: inferPriorityFromCount(missingCanonical.length, { critical: 20, high: 10, medium: 4 }),
|
|
151
|
+
area: 'technical',
|
|
152
|
+
title: `Add canonical tags to ${missingCanonical.length} pages`,
|
|
153
|
+
why: 'Canonical tags help consolidate duplicate or near-duplicate URLs and reduce ambiguity.',
|
|
154
|
+
evidence: collectTop(missingCanonical.map(r => r.url), 10),
|
|
155
|
+
implementationHints: [
|
|
156
|
+
'Ensure every canonical points to the preferred self URL or the consolidated destination.',
|
|
157
|
+
'Keep canonicals consistent across parameterized, localized, and paginated pages.',
|
|
158
|
+
],
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const missingOg = rows.filter(r => !r.has_og_tags && Number(r.status_code) < 400);
|
|
163
|
+
if (missingOg.length) {
|
|
164
|
+
actions.push(makeAction({
|
|
165
|
+
id: 'technical-missing-og',
|
|
166
|
+
type: 'improve',
|
|
167
|
+
priority: inferPriorityFromCount(missingOg.length, { critical: 25, high: 10, medium: 4 }),
|
|
168
|
+
area: 'technical',
|
|
169
|
+
title: `Add Open Graph tags to ${missingOg.length} pages`,
|
|
170
|
+
why: 'OG tags improve share previews and often correlate with better metadata hygiene across templates.',
|
|
171
|
+
evidence: collectTop(missingOg.map(r => r.url), 10),
|
|
172
|
+
implementationHints: [
|
|
173
|
+
'Populate og:title, og:description, og:image, and og:url on every indexable template.',
|
|
174
|
+
'Generate reusable social preview images for docs, product, and comparison templates.',
|
|
175
|
+
],
|
|
176
|
+
}));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return sortActions(actions);
|
|
180
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { summarizeActions } from './heuristics.js';
|
|
2
|
+
|
|
3
|
+
export function buildExportPayload({ project, scope, actions }) {
|
|
4
|
+
return {
|
|
5
|
+
project,
|
|
6
|
+
generatedAt: new Date().toISOString(),
|
|
7
|
+
scope,
|
|
8
|
+
summary: summarizeActions(actions),
|
|
9
|
+
actions,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function formatActionsJson(payload) {
|
|
14
|
+
return JSON.stringify(payload, null, 2);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function formatActionsBrief(payload) {
|
|
18
|
+
const { project, scope, summary, actions } = payload;
|
|
19
|
+
const grouped = actions.reduce((acc, action) => {
|
|
20
|
+
if (!acc[action.area]) acc[action.area] = [];
|
|
21
|
+
acc[action.area].push(action);
|
|
22
|
+
return acc;
|
|
23
|
+
}, {});
|
|
24
|
+
|
|
25
|
+
const lines = [
|
|
26
|
+
`# SEO Intel Actions — ${project}`,
|
|
27
|
+
'',
|
|
28
|
+
`- Generated: ${payload.generatedAt}`,
|
|
29
|
+
`- Scope: ${scope}`,
|
|
30
|
+
`- Total actions: ${actions.length}`,
|
|
31
|
+
`- Priority mix: critical ${summary.critical}, high ${summary.high}, medium ${summary.medium}, low ${summary.low}`,
|
|
32
|
+
'',
|
|
33
|
+
'## Summary',
|
|
34
|
+
'',
|
|
35
|
+
`- Critical: ${summary.critical}`,
|
|
36
|
+
`- High: ${summary.high}`,
|
|
37
|
+
`- Medium: ${summary.medium}`,
|
|
38
|
+
`- Low: ${summary.low}`,
|
|
39
|
+
'',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
for (const area of ['technical', 'content', 'schema', 'structure']) {
|
|
43
|
+
const items = grouped[area] || [];
|
|
44
|
+
if (!items.length) continue;
|
|
45
|
+
lines.push(`## ${capitalize(area)}`);
|
|
46
|
+
lines.push('');
|
|
47
|
+
for (const action of items) {
|
|
48
|
+
lines.push(`### ${action.title}`);
|
|
49
|
+
lines.push(`- ID: ${action.id}`);
|
|
50
|
+
lines.push(`- Type: ${action.type}`);
|
|
51
|
+
lines.push(`- Priority: ${action.priority}`);
|
|
52
|
+
lines.push(`- Why: ${action.why}`);
|
|
53
|
+
if (action.evidence?.length) {
|
|
54
|
+
lines.push('- Evidence:');
|
|
55
|
+
for (const item of action.evidence) lines.push(` - ${item}`);
|
|
56
|
+
}
|
|
57
|
+
if (action.implementationHints?.length) {
|
|
58
|
+
lines.push('- Implementation hints:');
|
|
59
|
+
for (const item of action.implementationHints) lines.push(` - ${item}`);
|
|
60
|
+
}
|
|
61
|
+
lines.push('');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!actions.length) {
|
|
66
|
+
lines.push('## No actions found');
|
|
67
|
+
lines.push('');
|
|
68
|
+
lines.push('- The current dataset did not surface any qualifying actions for this scope.');
|
|
69
|
+
lines.push('');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return lines.join('\n');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function capitalize(value) {
|
|
76
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
77
|
+
}
|
package/lib/gate.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEO Intel — Feature Gates
|
|
3
|
+
*
|
|
4
|
+
* Friendly enforcement layer on top of lib/license.js.
|
|
5
|
+
* Premium features show clear upgrade messages instead of cryptic errors.
|
|
6
|
+
*
|
|
7
|
+
* Usage in CLI commands:
|
|
8
|
+
* import { requirePro, enforceLimits } from '../lib/gate.js';
|
|
9
|
+
* // At top of paid command:
|
|
10
|
+
* if (!requirePro('analyze')) return;
|
|
11
|
+
* // Before crawling:
|
|
12
|
+
* const maxPages = enforceLimits().maxPages;
|
|
13
|
+
*
|
|
14
|
+
* Usage in report generation:
|
|
15
|
+
* import { gateSection } from '../lib/gate.js';
|
|
16
|
+
* const insights = gateSection('gsc-insights') ? getGscInsights(...) : null;
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { loadLicense, isPro, isFree, getMaxPages, getMaxProjects } from './license.js';
|
|
20
|
+
|
|
21
|
+
// ── Styled console output ───────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const GOLD = '\x1b[38;5;214m';
|
|
24
|
+
const DIM = '\x1b[2m';
|
|
25
|
+
const BOLD = '\x1b[1m';
|
|
26
|
+
const RESET = '\x1b[0m';
|
|
27
|
+
const CYAN = '\x1b[36m';
|
|
28
|
+
|
|
29
|
+
function printUpgradeMessage(feature) {
|
|
30
|
+
console.log('');
|
|
31
|
+
console.log(`${GOLD}${BOLD} ⭐ Paid Feature: ${feature}${RESET}`);
|
|
32
|
+
console.log(`${DIM} This feature requires SEO Intel Solo (€19.99/mo).${RESET}`);
|
|
33
|
+
console.log('');
|
|
34
|
+
console.log(`${DIM} Get your license at ${CYAN}https://froggo.pro/seo-intel${RESET}`);
|
|
35
|
+
console.log(`${DIM} Then add your key: ${CYAN}SEO_INTEL_LICENSE=SI-xxxx-xxxx-xxxx-xxxx${RESET} ${DIM}in .env${RESET}`);
|
|
36
|
+
console.log('');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Feature name → display name map ─────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
const FEATURE_NAMES = {
|
|
42
|
+
'extract': 'AI Data Extraction (Ollama/Cloud)',
|
|
43
|
+
'analyze': 'Competitive Gap Analysis',
|
|
44
|
+
'keywords': 'AI Keyword Intelligence',
|
|
45
|
+
'run': 'Smart Scheduler',
|
|
46
|
+
'brief': 'Crawl Change Brief',
|
|
47
|
+
'velocity': 'Publishing Velocity',
|
|
48
|
+
'shallow': 'Shallow Content Audit',
|
|
49
|
+
'decay': 'Content Decay Detection',
|
|
50
|
+
'headings-audit': 'Heading Structure Audit',
|
|
51
|
+
'orphans': 'Orphan Page Detection',
|
|
52
|
+
'entities': 'Entity Coverage Analysis',
|
|
53
|
+
'friction': 'Friction Point Analysis',
|
|
54
|
+
'js-delta': 'JS Rendering Delta',
|
|
55
|
+
'html': 'HTML Dashboard',
|
|
56
|
+
'html-all': 'HTML Dashboard (All Projects)',
|
|
57
|
+
'gsc-insights': 'GSC Intelligence & Insights',
|
|
58
|
+
'competitive': 'Competitive Landscape Sections',
|
|
59
|
+
'unlimited-pages': 'Unlimited Crawl Pages',
|
|
60
|
+
'unlimited-projects': 'Unlimited Projects',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// ── CLI Gate — blocks command and shows upgrade message ──────────────────────
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Check if a pro feature is available. If not, print upgrade message.
|
|
67
|
+
* Returns true if allowed, false if blocked.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} feature - Feature key (e.g., 'analyze', 'keywords')
|
|
70
|
+
* @returns {boolean}
|
|
71
|
+
*/
|
|
72
|
+
export function requirePro(feature) {
|
|
73
|
+
if (isPro()) return true;
|
|
74
|
+
|
|
75
|
+
const displayName = FEATURE_NAMES[feature] || feature;
|
|
76
|
+
printUpgradeMessage(displayName);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Section Gate — for report/dashboard sections ────────────────────────────
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Check if a dashboard/report section should be rendered.
|
|
84
|
+
* Returns true if allowed (pro), false if should show upgrade placeholder.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} section - Section key (e.g., 'gsc-insights', 'competitive')
|
|
87
|
+
* @returns {boolean}
|
|
88
|
+
*/
|
|
89
|
+
export function gateSection(section) {
|
|
90
|
+
return isPro();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get HTML placeholder for a gated premium section in dashboards.
|
|
95
|
+
* Shows a tasteful "upgrade to unlock" card instead of the actual content.
|
|
96
|
+
*/
|
|
97
|
+
export function getPremiumPlaceholder(section) {
|
|
98
|
+
const displayName = FEATURE_NAMES[section] || section;
|
|
99
|
+
return `
|
|
100
|
+
<div class="card" style="text-align:center; padding: 32px 24px; opacity: 0.7;">
|
|
101
|
+
<div style="font-size: 1.5rem; margin-bottom: 8px;">⭐</div>
|
|
102
|
+
<h3 style="font-size: 0.85rem; margin-bottom: 6px;">${displayName}</h3>
|
|
103
|
+
<p style="font-size: 0.72rem; color: var(--text-muted); margin-bottom: 12px;">
|
|
104
|
+
This section requires SEO Intel Solo (€19.99/mo)
|
|
105
|
+
</p>
|
|
106
|
+
<a href="https://froggo.pro/seo-intel" target="_blank"
|
|
107
|
+
style="color: var(--accent-gold); font-size: 0.72rem; text-decoration: underline;">
|
|
108
|
+
Upgrade to unlock →
|
|
109
|
+
</a>
|
|
110
|
+
</div>
|
|
111
|
+
`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Limit Enforcement ───────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get enforced limits for the current tier.
|
|
118
|
+
* Use these values to cap crawl pages, project count, etc.
|
|
119
|
+
*/
|
|
120
|
+
export function enforceLimits() {
|
|
121
|
+
const license = loadLicense();
|
|
122
|
+
return {
|
|
123
|
+
maxPages: license.maxPagesPerDomain,
|
|
124
|
+
maxProjects: license.maxProjects,
|
|
125
|
+
tier: license.tier,
|
|
126
|
+
tierName: license.name,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Check if adding a new project would exceed the tier limit.
|
|
132
|
+
* Pass the current project count.
|
|
133
|
+
* Returns { allowed: boolean, limit: number, current: number }
|
|
134
|
+
*/
|
|
135
|
+
export function checkProjectLimit(currentCount) {
|
|
136
|
+
const max = getMaxProjects();
|
|
137
|
+
return {
|
|
138
|
+
allowed: currentCount < max,
|
|
139
|
+
limit: max,
|
|
140
|
+
current: currentCount,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Cap page count to tier limit.
|
|
146
|
+
* Returns the effective max pages.
|
|
147
|
+
*/
|
|
148
|
+
export function capPages(requestedPages) {
|
|
149
|
+
const max = getMaxPages();
|
|
150
|
+
if (!Number.isFinite(max)) return requestedPages;
|
|
151
|
+
return Math.min(requestedPages, max);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── License Status Display ──────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Print license status to console (for CLI status command / startup).
|
|
158
|
+
*/
|
|
159
|
+
export function printLicenseStatus() {
|
|
160
|
+
const license = loadLicense();
|
|
161
|
+
|
|
162
|
+
const sourceLabel = license.source === 'froggo' ? ' (Froggo)' : license.source === 'lemon-squeezy' ? ' (LS)' : '';
|
|
163
|
+
|
|
164
|
+
if (license.tier === 'agency') {
|
|
165
|
+
console.log(`${GOLD}${BOLD} ⭐ SEO Intel Agency${RESET}`);
|
|
166
|
+
console.log(`${DIM} License: ${license.key?.slice(0, 7)}...${license.key?.slice(-4)}${sourceLabel}${RESET}`);
|
|
167
|
+
console.log(`${DIM} All features + white-label + team access${RESET}`);
|
|
168
|
+
if (license.stale) console.log(`\x1b[33m ⚠ License cache stale — will re-validate on next network access${RESET}`);
|
|
169
|
+
} else if (license.tier === 'solo') {
|
|
170
|
+
console.log(`${GOLD}${BOLD} ⭐ SEO Intel Solo${RESET}`);
|
|
171
|
+
console.log(`${DIM} License: ${license.key?.slice(0, 7)}...${license.key?.slice(-4)}${sourceLabel}${RESET}`);
|
|
172
|
+
console.log(`${DIM} All features unlocked${RESET}`);
|
|
173
|
+
if (license.stale) console.log(`\x1b[33m ⚠ License cache stale — will re-validate on next network access${RESET}`);
|
|
174
|
+
} else {
|
|
175
|
+
console.log(`${DIM} SEO Intel Free${RESET}`);
|
|
176
|
+
console.log(`${DIM} Unlimited crawl · Raw SQLite data · No AI analysis · No dashboard${RESET}`);
|
|
177
|
+
if (license.invalidKey) {
|
|
178
|
+
console.log(`\x1b[33m ⚠ ${license.reason}${RESET}`);
|
|
179
|
+
}
|
|
180
|
+
if (license.needsActivation) {
|
|
181
|
+
console.log(`\x1b[33m ⚠ License key found but not yet validated — run any command to activate${RESET}`);
|
|
182
|
+
}
|
|
183
|
+
console.log(`${DIM} Upgrade: ${CYAN}https://froggo.pro/seo-intel${RESET} ${DIM}— Solo €19.99/mo · €199/yr${RESET}`);
|
|
184
|
+
}
|
|
185
|
+
console.log('');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Tier info for web/API ───────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Get tier info as JSON (for web wizard / API responses).
|
|
192
|
+
*/
|
|
193
|
+
export function getLicenseInfo() {
|
|
194
|
+
const license = loadLicense();
|
|
195
|
+
return {
|
|
196
|
+
tier: license.tier,
|
|
197
|
+
name: license.name,
|
|
198
|
+
active: license.active,
|
|
199
|
+
maxProjects: Number.isFinite(license.maxProjects) ? license.maxProjects : null,
|
|
200
|
+
maxPages: Number.isFinite(license.maxPagesPerDomain) ? license.maxPagesPerDomain : null,
|
|
201
|
+
features: license.features === 'all' ? 'all' : [...license.features],
|
|
202
|
+
upgradeUrl: 'https://froggo.pro/seo-intel',
|
|
203
|
+
};
|
|
204
|
+
}
|