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/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 (PAID) ─────────────────────────────────────
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. Paid tier.',
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 (PAID) ───────────────────────────────────────────
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. Paid tier.',
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 (PAID) ────────────────────────────────────────
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. Paid tier.',
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
- const FREE_EXPORT_TABLES = ['pages', 'keywords', 'headings', 'links', 'technical', 'sitemap_urls'];
576
- const PAID_EXPORT_TABLES = ['extractions', 'analyses', 'page_schemas', 'citability_scores', 'insights'];
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📦 Tables NOT in this response (require SEO Intel Solo, €19.99/mo — vs Ahrefs ~$129/mo): ${PAID_EXPORT_TABLES.join(', ')}.\n These are the AI-derived layers: per-page entity/intent/schema extraction, full analysis history, structured @type inventory, citability scores, and the Intelligence Ledger.\n For pre-parsed digests instead of raw rows, the Solo tools return ready-to-use analysis: run_citability_audit, get_competitor_positioning, prescore_draft, draft_blog_prompt.`
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 (free), plus extractions, analyses, schemas, citability scores, and insights (Solo). Mirrors `seo-intel export --full <project>` as a single MCP call.',
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. 15 tools — free: list_projects (with nag), list_problems, mark_problem_status, get_intel(raw), get_pages, list_keywords, get_headings, run_crawl, get_crawl_status, ingest_insight, export_intel (free-tier subset); paid: get_intel(audit/blog/competitor), run_citability_audit, get_competitor_positioning, prescore_draft, draft_blog_prompt, export_intel (paid tables), and list_problems unlocks paid categories.`);
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 => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seo-intel",
3
- "version": "1.5.39",
3
+ "version": "1.5.45",
4
4
  "description": "Local Ahrefs-style SEO competitor intelligence. Crawl → SQLite → cloud analysis.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",