seo-intel 1.5.39 → 1.5.45
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 +76 -0
- package/analyses/blog-draft/prescorer.js +17 -0
- package/analyses/loop/orchestrator.js +179 -0
- package/cli.js +197 -6
- package/crawler/html-extract.js +127 -0
- package/crawler/light.js +169 -0
- package/db/db.js +66 -0
- package/lib/cron.js +108 -0
- package/lib/gate.js +33 -1
- package/lib/intel.js +9 -3
- package/mcp/server.js +172 -17
- package/package.json +1 -1
- package/reports/generate-html.js +42 -404
- package/setup/web-routes.js +39 -0
- package/setup/wizard.html +73 -0
package/mcp/server.js
CHANGED
|
@@ -25,14 +25,16 @@ import { spawn } from 'child_process';
|
|
|
25
25
|
import { dirname, join } from 'path';
|
|
26
26
|
import { fileURLToPath } from 'url';
|
|
27
27
|
|
|
28
|
-
import { getDb, insertAgentInsight, AGENT_INSIGHT_TYPES, getActiveInsights, getCompetitorSummary } from '../db/db.js';
|
|
28
|
+
import { getDb, insertAgentInsight, AGENT_INSIGHT_TYPES, getActiveInsights, getCompetitorSummary, recordDraftCreated, markGapsInProgress } from '../db/db.js';
|
|
29
29
|
import { getIntel, INTEL_SLICES, FREE_SLICES } from '../lib/intel.js';
|
|
30
30
|
import { isPro } from '../lib/license.js';
|
|
31
31
|
import { readProgress } from '../lib/progress.js';
|
|
32
32
|
import { getProblems, getProblemCounts, markProblemStatus, getActiveStatusMap, PROBLEM_CATEGORIES, PROBLEM_STATUSES } from '../lib/problems.js';
|
|
33
33
|
|
|
34
34
|
import { runAeoAnalysis, persistAeoScores, upsertCitabilityInsights } from '../analyses/aeo/index.js';
|
|
35
|
-
import { prescore } from '../analyses/blog-draft/prescorer.js';
|
|
35
|
+
import { prescore, extractDraftTopic } from '../analyses/blog-draft/prescorer.js';
|
|
36
|
+
import { lightCrawl } from '../crawler/light.js';
|
|
37
|
+
import { runContentLoop } from '../analyses/loop/orchestrator.js';
|
|
36
38
|
import { gatherBlogDraftContext, buildBlogDraftPrompt } from '../analyses/blog-draft/index.js';
|
|
37
39
|
|
|
38
40
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
@@ -357,6 +359,88 @@ server.registerTool(
|
|
|
357
359
|
}
|
|
358
360
|
);
|
|
359
361
|
|
|
362
|
+
// ── Tool: crawl_site (free — zero-config, zero-signup, local, lightweight) ──
|
|
363
|
+
// "Crawl for all Claude users": point it at a URL and it BFS-crawls same-origin
|
|
364
|
+
// pages with plain fetch (no browser, no project config, nothing persisted,
|
|
365
|
+
// nothing leaves the machine). For deep/JS-rendered/persistent crawls, the user
|
|
366
|
+
// installs seo-intel and runs `seo-intel crawl`.
|
|
367
|
+
server.registerTool(
|
|
368
|
+
'crawl_site',
|
|
369
|
+
{
|
|
370
|
+
description: [
|
|
371
|
+
'Crawl a website ad-hoc and return structured SEO/AEO data — no project setup, no account, no API key, nothing saved. Point it at any URL.',
|
|
372
|
+
'',
|
|
373
|
+
'Lightweight by design: plain HTTP fetch (no browser/JS rendering), same-origin BFS, honours robots.txt + crawl-delay, small page budget (default 10, hard cap 50). Returns title, meta, headings, links, JSON-LD schema types, word count, indexability — optionally a per-page AI-citability (AEO) score.',
|
|
374
|
+
'',
|
|
375
|
+
'Limits: JS-rendered/SPA pages under-report content (use the full `seo-intel crawl` with Playwright for those). Results are ephemeral — for persistent history, the Intelligence Ledger, and competitor analysis, install seo-intel (still local, own-site free). Free tier.',
|
|
376
|
+
].join('\n'),
|
|
377
|
+
inputSchema: {
|
|
378
|
+
url: z.string().describe('Start URL (scheme optional — "example.com" works). The crawl follows same-origin links from here.'),
|
|
379
|
+
max_pages: z.number().int().positive().optional().describe('Pages to fetch (default 10, hard cap 50).'),
|
|
380
|
+
include_citability: z.boolean().optional().describe('Run the AEO citability scorer per page (default false). Note: light mode does no entity extraction, so entity-authority is under-counted — run `seo-intel aeo` for the full score.'),
|
|
381
|
+
same_origin: z.boolean().optional().describe('Only follow links on the start site (default true). www/non-www and http/https are treated as the same site.'),
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
async ({ url, max_pages, include_citability, same_origin }) => {
|
|
385
|
+
try {
|
|
386
|
+
const r = await lightCrawl(url, {
|
|
387
|
+
maxPages: max_pages ?? 10,
|
|
388
|
+
includeCitability: include_citability ?? false,
|
|
389
|
+
sameOrigin: same_origin ?? true,
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// Compact, token-aware shape: drop body_text + the full per-page link lists
|
|
393
|
+
// (return counts + a deduped discovered-URL list instead).
|
|
394
|
+
const pages = r.pages.map(p => ({
|
|
395
|
+
url: p.url,
|
|
396
|
+
status_code: p.status_code,
|
|
397
|
+
title: p.title,
|
|
398
|
+
meta_desc: p.meta_desc,
|
|
399
|
+
canonical: p.canonical || null,
|
|
400
|
+
is_indexable: p.is_indexable,
|
|
401
|
+
word_count: p.word_count,
|
|
402
|
+
headings: p.headings.slice(0, 40),
|
|
403
|
+
schema_types: p.schema_types,
|
|
404
|
+
published_date: p.published_date,
|
|
405
|
+
modified_date: p.modified_date,
|
|
406
|
+
internal_links: p.links.filter(l => l.internal).length,
|
|
407
|
+
external_links: p.links.filter(l => !l.internal).length,
|
|
408
|
+
...(p.citability ? { citability: p.citability } : {}),
|
|
409
|
+
}));
|
|
410
|
+
|
|
411
|
+
// Deduped internal URLs discovered but not crawled (structure peek).
|
|
412
|
+
const crawled = new Set(r.pages.map(p => p.url));
|
|
413
|
+
const discovered = [];
|
|
414
|
+
const seen = new Set();
|
|
415
|
+
for (const p of r.pages) {
|
|
416
|
+
for (const l of p.links) {
|
|
417
|
+
if (l.internal && !crawled.has(l.href) && !seen.has(l.href)) {
|
|
418
|
+
seen.add(l.href); discovered.push(l.href);
|
|
419
|
+
if (discovered.length >= 50) break;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (discovered.length >= 50) break;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const out = {
|
|
426
|
+
start: r.start,
|
|
427
|
+
origin: r.origin,
|
|
428
|
+
stats: r.stats,
|
|
429
|
+
pages,
|
|
430
|
+
discovered_internal_urls: discovered,
|
|
431
|
+
skipped: r.skipped,
|
|
432
|
+
notice: 'Ephemeral + local — nothing was saved and nothing left this machine. Light mode does not render JavaScript, so SPA/JS-built pages under-report content; use `seo-intel crawl` (Playwright) for those. For persistent history, the Intelligence Ledger, AI-citability over time, and competitor analysis, install seo-intel — own-site stays free.',
|
|
433
|
+
};
|
|
434
|
+
return {
|
|
435
|
+
content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
|
|
436
|
+
structuredContent: out,
|
|
437
|
+
};
|
|
438
|
+
} catch (err) {
|
|
439
|
+
return { content: [{ type: 'text', text: `seo-intel crawl_site error: ${err.message}` }], isError: true };
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
);
|
|
443
|
+
|
|
360
444
|
// ── Tool: ingest_insight (free — write-back closes the loop) ──────────────
|
|
361
445
|
server.registerTool(
|
|
362
446
|
'ingest_insight',
|
|
@@ -414,18 +498,17 @@ server.registerTool(
|
|
|
414
498
|
}
|
|
415
499
|
);
|
|
416
500
|
|
|
417
|
-
// ── Tool: run_citability_audit (
|
|
501
|
+
// ── Tool: run_citability_audit (FREE) ─────────────────────────────────────
|
|
418
502
|
server.registerTool(
|
|
419
503
|
'run_citability_audit',
|
|
420
504
|
{
|
|
421
|
-
description: 'Run AEO citability scoring across all crawled pages (6 signals: entity authority, structured claims, answer density, Q&A proximity, freshness, schema coverage). Persists scores to citability_scores and upserts citability_gap insights into the ledger. Pure function — fast, no LLM calls.
|
|
505
|
+
description: 'Run AEO citability scoring across all crawled pages (6 signals: entity authority, structured claims, answer density, Q&A proximity, freshness, schema coverage). Persists scores to citability_scores and upserts citability_gap insights into the ledger. Pure function — fast, no LLM calls. Free tier — analysis of your own site is free.',
|
|
422
506
|
inputSchema: {
|
|
423
507
|
project: z.string(),
|
|
424
508
|
include_competitors: z.boolean().optional().describe('Score competitor pages too (default true)'),
|
|
425
509
|
},
|
|
426
510
|
},
|
|
427
511
|
async ({ project, include_competitors = true }) => {
|
|
428
|
-
if (!isPro()) return paidGate('run_citability_audit');
|
|
429
512
|
if (!loadProjectConfig(project)) {
|
|
430
513
|
return { content: [{ type: 'text', text: `Project "${project}" not found. Use list_projects to discover.` }], isError: true };
|
|
431
514
|
}
|
|
@@ -497,17 +580,18 @@ server.registerTool(
|
|
|
497
580
|
}
|
|
498
581
|
);
|
|
499
582
|
|
|
500
|
-
// ── Tool: prescore_draft (
|
|
583
|
+
// ── Tool: prescore_draft (FREE) ───────────────────────────────────────────
|
|
501
584
|
server.registerTool(
|
|
502
585
|
'prescore_draft',
|
|
503
586
|
{
|
|
504
|
-
description: 'Run the AEO scorer on a markdown draft before publishing. Returns the same 6-signal breakdown the dashboard uses (entity authority, structured claims, answer density, Q&A proximity, freshness, schema coverage) plus the overall 0-100 score and tier (excellent / good / fair / poor). Use this as a pre-publish gate when drafting via draft_blog_prompt — score < 60 means revise.
|
|
587
|
+
description: 'Run the AEO scorer on a markdown draft before publishing. Returns the same 6-signal breakdown the dashboard uses (entity authority, structured claims, answer density, Q&A proximity, freshness, schema coverage) plus the overall 0-100 score and tier (excellent / good / fair / poor). Use this as a pre-publish gate when drafting via draft_blog_prompt — score < 60 means revise. Free tier. Pass `project` (and optionally `topic`) to close the loop: the draft is recorded in the Ledger and matching gaps are marked in_progress so they stop resurfacing.',
|
|
505
588
|
inputSchema: {
|
|
506
589
|
draft_md: z.string().describe('Full markdown of the draft, including YAML frontmatter if present. The scorer extracts headings, word count, schema_type from frontmatter, etc.'),
|
|
590
|
+
project: z.string().optional().describe('If set, the scored draft is written back to this project\'s Intelligence Ledger (records a draft_created insight + marks matching gaps in_progress). Omit for a pure, stateless score.'),
|
|
591
|
+
topic: z.string().optional().describe('The topic/keyword this draft targets. Used to match gaps for the in_progress write-back. If omitted, recovered from the draft\'s frontmatter title or first H1.'),
|
|
507
592
|
},
|
|
508
593
|
},
|
|
509
|
-
async ({ draft_md }) => {
|
|
510
|
-
if (!isPro()) return paidGate('prescore_draft');
|
|
594
|
+
async ({ draft_md, project, topic }) => {
|
|
511
595
|
try {
|
|
512
596
|
const score = prescore(draft_md);
|
|
513
597
|
const out = {
|
|
@@ -520,6 +604,33 @@ server.registerTool(
|
|
|
520
604
|
? 'Draft scores well. Safe to publish.'
|
|
521
605
|
: 'Below 60 — consider strengthening: add FAQ schema for Q&A proximity, increase entity authority via named experts/citations, shorten paragraphs for answer density, add structured claims (numbers/dates).',
|
|
522
606
|
};
|
|
607
|
+
|
|
608
|
+
// F1 (v1.5.42): loop write-back — only when a project is supplied, and
|
|
609
|
+
// best-effort so a Ledger hiccup never fails the score.
|
|
610
|
+
if (project && loadProjectConfig(project)) {
|
|
611
|
+
try {
|
|
612
|
+
const db = getDb();
|
|
613
|
+
const effectiveTopic = topic || extractDraftTopic(draft_md);
|
|
614
|
+
recordDraftCreated(db, project, {
|
|
615
|
+
topic: effectiveTopic,
|
|
616
|
+
score: score.score,
|
|
617
|
+
tier: score.tier,
|
|
618
|
+
wordCount: score.wordCount,
|
|
619
|
+
});
|
|
620
|
+
const marked = markGapsInProgress(db, project, effectiveTopic);
|
|
621
|
+
out.ledger = {
|
|
622
|
+
recorded: true,
|
|
623
|
+
topic: effectiveTopic || '(auto)',
|
|
624
|
+
gaps_marked_in_progress: marked,
|
|
625
|
+
note: marked > 0
|
|
626
|
+
? `${marked} matching gap(s) marked in_progress — they stop resurfacing until a re-audit re-scores the published page.`
|
|
627
|
+
: 'Draft recorded; no active gaps matched the topic.',
|
|
628
|
+
};
|
|
629
|
+
} catch (e) {
|
|
630
|
+
out.ledger = { recorded: false, error: e.message };
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
523
634
|
return {
|
|
524
635
|
content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
|
|
525
636
|
structuredContent: out,
|
|
@@ -530,11 +641,11 @@ server.registerTool(
|
|
|
530
641
|
}
|
|
531
642
|
);
|
|
532
643
|
|
|
533
|
-
// ── Tool: draft_blog_prompt (
|
|
644
|
+
// ── Tool: draft_blog_prompt (FREE) ────────────────────────────────────────
|
|
534
645
|
server.registerTool(
|
|
535
646
|
'draft_blog_prompt',
|
|
536
647
|
{
|
|
537
|
-
description: 'Generate an AEO-aware blog draft prompt seeded with full project context — keyword gaps, citability gaps, top entities, brand voice notes, competitor heading patterns. The agent\'s own LLM writes the draft using this prompt. Pair with prescore_draft for a write→score→revise loop.
|
|
648
|
+
description: 'Generate an AEO-aware blog draft prompt seeded with full project context — keyword gaps, citability gaps, top entities, brand voice notes, competitor heading patterns. The agent\'s own LLM writes the draft using this prompt. Pair with prescore_draft for a write→score→revise loop. Free tier.',
|
|
538
649
|
inputSchema: {
|
|
539
650
|
project: z.string(),
|
|
540
651
|
topic: z.string().optional().describe('Specific topic to draft about. If omitted, the prompt asks the LLM to pick the highest-leverage topic from the gap data.'),
|
|
@@ -543,7 +654,6 @@ server.registerTool(
|
|
|
543
654
|
},
|
|
544
655
|
},
|
|
545
656
|
async ({ project, topic, lang = 'en', content_type = 'blog' }) => {
|
|
546
|
-
if (!isPro()) return paidGate('draft_blog_prompt');
|
|
547
657
|
const config = loadProjectConfig(project);
|
|
548
658
|
if (!config) {
|
|
549
659
|
return { content: [{ type: 'text', text: `Project "${project}" not found. Use list_projects to discover.` }], isError: true };
|
|
@@ -571,9 +681,54 @@ server.registerTool(
|
|
|
571
681
|
}
|
|
572
682
|
);
|
|
573
683
|
|
|
684
|
+
// ── Tool: run_content_loop (free — the one-call content loop) ─────────────
|
|
685
|
+
// Walks gap → draft → prescore → queue. In MCP the agent's own LLM is the
|
|
686
|
+
// writer, so this runs in HAND-BACK mode: it ranks the gaps, picks the highest-
|
|
687
|
+
// leverage one(s), and returns a seeded prompt per gap. The agent writes the
|
|
688
|
+
// draft, then calls prescore_draft(project, topic) to score + close the loop.
|
|
689
|
+
server.registerTool(
|
|
690
|
+
'run_content_loop',
|
|
691
|
+
{
|
|
692
|
+
description: [
|
|
693
|
+
'Run the content loop for a project in one call: ranks the open gaps in the Intelligence Ledger by leverage (priority × source × AI-intent), picks the highest, and returns an AEO-aware draft prompt seeded with full context.',
|
|
694
|
+
'',
|
|
695
|
+
'Hand-back by design — your own LLM writes the draft from the returned prompt, then you call prescore_draft(project, topic) to AEO-score it and close the loop (records the draft, marks the gap in_progress). Use dry_run to just see which gap it would target. Free tier.',
|
|
696
|
+
].join('\n'),
|
|
697
|
+
inputSchema: {
|
|
698
|
+
project: z.string(),
|
|
699
|
+
topic: z.string().optional().describe('Focus a specific topic instead of auto-picking the top gap.'),
|
|
700
|
+
count: z.number().int().positive().optional().describe('Return prompts for the top N gaps (default 1).'),
|
|
701
|
+
lang: z.enum(['en', 'fi']).optional(),
|
|
702
|
+
content_type: z.enum(['blog', 'article', 'guide', 'docs', 'social']).optional(),
|
|
703
|
+
dry_run: z.boolean().optional().describe('Only rank + select the gap(s); do not build prompts.'),
|
|
704
|
+
},
|
|
705
|
+
},
|
|
706
|
+
async ({ project, topic, count, lang = 'en', content_type = 'blog', dry_run }) => {
|
|
707
|
+
const config = loadProjectConfig(project);
|
|
708
|
+
if (!config) {
|
|
709
|
+
return { content: [{ type: 'text', text: `Project "${project}" not found. Use list_projects to discover.` }], isError: true };
|
|
710
|
+
}
|
|
711
|
+
try {
|
|
712
|
+
const db = getDb();
|
|
713
|
+
const result = await runContentLoop(db, project, {
|
|
714
|
+
config, topic: topic || null, count: count || 1, lang, contentType: content_type,
|
|
715
|
+
dryRun: !!dry_run, generate: null, // hand-back: the agent writes
|
|
716
|
+
});
|
|
717
|
+
return {
|
|
718
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
719
|
+
structuredContent: result,
|
|
720
|
+
};
|
|
721
|
+
} catch (err) {
|
|
722
|
+
return { content: [{ type: 'text', text: `seo-intel run_content_loop error: ${err.message}` }], isError: true };
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
);
|
|
726
|
+
|
|
574
727
|
// ── Tool: export_intel (firehose; free tables + paid tables) ──────────────
|
|
575
|
-
|
|
576
|
-
|
|
728
|
+
// v1.5.41: own-site derived data (extractions, schemas, citability, the
|
|
729
|
+
// ledger) is free — only the competitor gap analysis (`analyses`) is paid.
|
|
730
|
+
const FREE_EXPORT_TABLES = ['pages', 'keywords', 'headings', 'links', 'technical', 'sitemap_urls', 'extractions', 'page_schemas', 'citability_scores', 'insights'];
|
|
731
|
+
const PAID_EXPORT_TABLES = ['analyses'];
|
|
577
732
|
const ALL_EXPORT_TABLES = [...FREE_EXPORT_TABLES, ...PAID_EXPORT_TABLES];
|
|
578
733
|
|
|
579
734
|
const EXPORT_TABLE_QUERIES = {
|
|
@@ -597,7 +752,7 @@ const MAX_MAX_ROWS_PER_TABLE = 50000;
|
|
|
597
752
|
function buildExportNotice({ tokens, bytes, free, paidRequested, paidExcluded, anyTruncated, maxRowsPerTable }) {
|
|
598
753
|
const tooBig = tokens > 50000;
|
|
599
754
|
const upgradeBlurb = free
|
|
600
|
-
? `\n\n📦
|
|
755
|
+
? `\n\n📦 Table NOT in this response (requires SEO Intel Solo, €19.99/mo — vs Ahrefs ~$129/mo): ${PAID_EXPORT_TABLES.join(', ')}.\n That's the competitor gap-analysis history (keyword_gaps, content_gaps, positioning, quick_wins). Everything about YOUR OWN site — extractions, schemas, citability scores, and the Intelligence Ledger — is free.\n Free pre-parsed digests: get_intel(for=audit|blog), run_citability_audit, prescore_draft, draft_blog_prompt. Solo adds competitor synthesis: get_competitor_positioning + get_intel(for=competitor).`
|
|
601
756
|
: `\n\nYou have Solo. Paid tables in this export: ${(paidRequested || []).join(', ') || '(none requested)'}.`;
|
|
602
757
|
|
|
603
758
|
const sizeLine = tooBig
|
|
@@ -630,7 +785,7 @@ server.registerTool(
|
|
|
630
785
|
'export_intel',
|
|
631
786
|
{
|
|
632
787
|
description: [
|
|
633
|
-
'Bulk export of raw structured intelligence — pages, keywords, headings, links, technical, sitemap URLs
|
|
788
|
+
'Bulk export of raw structured intelligence — pages, keywords, headings, links, technical, sitemap URLs, extractions, schemas, citability scores, and the Intelligence Ledger (all free), plus the competitor gap-analysis history (Solo). Mirrors `seo-intel export --full <project>` as a single MCP call.',
|
|
634
789
|
'',
|
|
635
790
|
'⚠️ FIREHOSE WARNING: this is raw rows, not summaries. For carbium-sized projects it can be 5–10 MB / 200k+ tokens. The response includes a `notice` field telling the agent how to handle it (pipe to file, use other tools, or upgrade). Agents SHOULD NOT paste the response wholesale into their context — read the `notice` first, then either query selectively or save to a file.',
|
|
636
791
|
'',
|
|
@@ -834,7 +989,7 @@ async function main() {
|
|
|
834
989
|
const transport = new StdioServerTransport();
|
|
835
990
|
await server.connect(transport);
|
|
836
991
|
// stderr is fine; the host typically surfaces this in its MCP logs panel.
|
|
837
|
-
console.error(`[seo-intel-mcp] v${VERSION} ready on stdio.
|
|
992
|
+
console.error(`[seo-intel-mcp] v${VERSION} ready on stdio. 17 tools — free: crawl_site (ad-hoc, any URL, no config), run_content_loop (gap→draft→close), list_projects, list_problems, mark_problem_status, get_intel(raw/audit/blog), get_pages, list_keywords, get_headings, run_crawl, get_crawl_status, ingest_insight, run_citability_audit, prescore_draft, draft_blog_prompt, export_intel (own-site tables); Solo (competitor synthesis): get_competitor_positioning, get_intel(competitor), export_intel (analyses table).`);
|
|
838
993
|
}
|
|
839
994
|
|
|
840
995
|
main().catch(err => {
|