@lynxflow/seo-engine 1.4.1 β 1.5.1
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/README.md +84 -339
- package/TUTORIEL_INTEGRATION_SITE.md +283 -0
- package/connectors/wordpress/lynxseo-connector.zip +0 -0
- package/dist/ad-intelligence-cro.d.ts +58 -0
- package/dist/ai-bots-log-analyzer.d.ts +36 -0
- package/dist/analytics-client.d.ts +48 -11
- package/dist/auth-key.d.ts +9 -0
- package/dist/brand-dna-calendar.d.ts +35 -0
- package/dist/copy-frameworks-master.d.ts +56 -0
- package/dist/cro-copywriting-engine.d.ts +46 -0
- package/dist/crosslink-scorer.d.ts +33 -0
- package/dist/embeddable-seo-widget.d.ts +12 -0
- package/dist/engine.d.ts +29 -1
- package/dist/engine.test.d.ts +1 -0
- package/dist/extended-schemas.d.ts +106 -0
- package/dist/geo-mesh-linking.d.ts +33 -0
- package/dist/index.d.ts +88 -0
- package/dist/index.js +4173 -128
- package/dist/index.mjs +4101 -98
- package/dist/instant-matrix-search.d.ts +19 -0
- package/dist/isr-cache-manager.d.ts +35 -0
- package/dist/knowledge-graph-linker.d.ts +29 -0
- package/dist/legal-disclaimers.d.ts +46 -0
- package/dist/llm-content-cleaner.d.ts +16 -0
- package/dist/llm-prompt.d.ts +13 -0
- package/dist/master-marketing-engine.d.ts +114 -0
- package/dist/matrix-engine.d.ts +201 -0
- package/dist/mcp-seo-server.d.ts +21 -0
- package/dist/ngram-density-analyzer.d.ts +35 -0
- package/dist/rank-math-parity.d.ts +58 -0
- package/dist/real-reviews-sync.d.ts +73 -0
- package/dist/rss-syndication-feed.d.ts +25 -0
- package/dist/schema-builder.d.ts +6 -0
- package/dist/seo-opportunities-decay.d.ts +54 -0
- package/dist/serp-history-alerts.d.ts +27 -0
- package/dist/slug-engine.d.ts +32 -0
- package/dist/social-ads-seo.d.ts +77 -0
- package/dist/social-growth-suite.d.ts +125 -0
- package/dist/social-trend-seo.d.ts +38 -0
- package/dist/social-video-seo.d.ts +62 -0
- package/dist/team-rbac.d.ts +17 -0
- package/dist/technical-rules-auditor.d.ts +50 -0
- package/dist/types.d.ts +4 -44
- package/dist/yoast-parity.d.ts +45 -0
- package/package.json +8 -5
- package/src/ad-intelligence-cro.ts +140 -0
- package/src/ai-bots-log-analyzer.ts +127 -0
- package/src/analytics-client.ts +222 -48
- package/src/auth-key.ts +59 -0
- package/src/brand-dna-calendar.ts +120 -0
- package/src/copy-frameworks-master.ts +89 -0
- package/src/cro-copywriting-engine.ts +105 -0
- package/src/crosslink-scorer.ts +101 -0
- package/src/embeddable-seo-widget.ts +57 -0
- package/src/engine.test.ts +136 -0
- package/src/engine.ts +218 -27
- package/src/extended-schemas.ts +279 -0
- package/src/geo-mesh-linking.ts +93 -0
- package/src/index.ts +89 -0
- package/src/instant-matrix-search.ts +48 -0
- package/src/isr-cache-manager.ts +78 -0
- package/src/knowledge-graph-linker.ts +81 -0
- package/src/legal-disclaimers.ts +407 -0
- package/src/llm-content-cleaner.ts +64 -0
- package/src/llm-prompt.ts +81 -0
- package/src/master-marketing-engine.ts +314 -0
- package/src/matrix-engine.ts +818 -0
- package/src/mcp-seo-server.ts +108 -0
- package/src/ngram-density-analyzer.ts +108 -0
- package/src/rank-math-parity.ts +235 -0
- package/src/real-reviews-sync.ts +207 -0
- package/src/rss-syndication-feed.ts +56 -0
- package/src/schema-builder.ts +71 -24
- package/src/seo-opportunities-decay.ts +154 -0
- package/src/serp-history-alerts.ts +75 -0
- package/src/slug-engine.ts +183 -0
- package/src/social-ads-seo.ts +211 -0
- package/src/social-growth-suite.ts +310 -0
- package/src/social-trend-seo.ts +103 -0
- package/src/social-video-seo.ts +100 -0
- package/src/team-rbac.ts +61 -0
- package/src/technical-rules-auditor.ts +171 -0
- package/src/types.ts +22 -47
- package/src/yoast-parity.ts +161 -0
- package/tsconfig.json +2 -1
package/src/engine.ts
CHANGED
|
@@ -23,7 +23,7 @@ import type { LynxSeoConfig, LynxResolvedPage, LynxServiceDefinition } from "./t
|
|
|
23
23
|
import { ApiKeyGuardian, type ApiKeyValidationResult } from "./auth-key";
|
|
24
24
|
import { getDictionary, isSupportedLanguage } from "./i18n-dictionary";
|
|
25
25
|
import { IndexNowClient, type IndexNowResponse } from "./indexnow-client";
|
|
26
|
-
import { LynxAnalyticsClient } from "./analytics-client";
|
|
26
|
+
import { LynxAnalyticsClient, type UrlTrafficStats } from "./analytics-client";
|
|
27
27
|
import { SerpClient } from "./serp-client";
|
|
28
28
|
import { BacklinksClient } from "./backlinks-client";
|
|
29
29
|
import { AiCopilotClient } from "./ai-copilot-client";
|
|
@@ -31,6 +31,10 @@ import { LagoTokenMeter } from "./lago-token-meter";
|
|
|
31
31
|
import { SiteAuditor } from "./site-auditor";
|
|
32
32
|
import { SchemaGraphBuilder } from "./schema-builder";
|
|
33
33
|
import { DeepCrawlerAuditor, type SiteAuditSummary, type CrawlOptions } from "./site-crawler";
|
|
34
|
+
import { CroCopywritingEngine } from "./cro-copywriting-engine";
|
|
35
|
+
import { GeoMeshLinkingEngine } from "./geo-mesh-linking";
|
|
36
|
+
import { CrosslinkScorerEngine } from "./crosslink-scorer";
|
|
37
|
+
import { LlmContentCleaner } from "./llm-content-cleaner";
|
|
34
38
|
|
|
35
39
|
export type EngineConfig = LynxSeoConfig;
|
|
36
40
|
|
|
@@ -105,6 +109,9 @@ export class LynxSeoEngine {
|
|
|
105
109
|
return null;
|
|
106
110
|
}
|
|
107
111
|
|
|
112
|
+
// Automatically record link visit metrics in the analytics engine
|
|
113
|
+
this.analytics.trackPageView({ path: "/" + targetPath }).catch(() => {});
|
|
114
|
+
|
|
108
115
|
const domain = this.config.domain.replace(/\/$/, "");
|
|
109
116
|
const defaultService = this.config.services?.[0] || {
|
|
110
117
|
slug: "crm-pipeline",
|
|
@@ -503,32 +510,43 @@ export class LynxSeoEngine {
|
|
|
503
510
|
|
|
504
511
|
const ogImageUrl = `${domain}/api/og?title=${encodeURIComponent(opts.h1)}&brand=${encodeURIComponent(this.config.brandName)}&service=${encodeURIComponent(opts.service.name)}`;
|
|
505
512
|
|
|
513
|
+
const productNode: Record<string, unknown> = {
|
|
514
|
+
"@type": "Product",
|
|
515
|
+
"@id": `${opts.url}#product`,
|
|
516
|
+
name: opts.title,
|
|
517
|
+
description: opts.description,
|
|
518
|
+
inLanguage: opts.locale,
|
|
519
|
+
image: ogImageUrl,
|
|
520
|
+
brand: { "@type": "Brand", name: this.config.brandName },
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
// Attach AggregateRating only if authentic ratings are provided in service or config
|
|
524
|
+
const ratingVal = (opts.service as any).ratingValue || (this.config as any).ratingValue;
|
|
525
|
+
const reviewCnt = (opts.service as any).reviewCount || (this.config as any).reviewCount;
|
|
526
|
+
if (ratingVal && reviewCnt) {
|
|
527
|
+
productNode.aggregateRating = {
|
|
528
|
+
"@type": "AggregateRating",
|
|
529
|
+
ratingValue: ratingVal.toString(),
|
|
530
|
+
reviewCount: reviewCnt.toString(),
|
|
531
|
+
bestRating: "5",
|
|
532
|
+
worstRating: "1",
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (opts.service.pricePerMonth !== undefined) {
|
|
537
|
+
productNode.offers = {
|
|
538
|
+
"@type": "Offer",
|
|
539
|
+
price: opts.service.pricePerMonth.toString(),
|
|
540
|
+
priceCurrency: this.config.currency || "EUR",
|
|
541
|
+
availability: "https://schema.org/InStock",
|
|
542
|
+
url: opts.url,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
506
546
|
const jsonLd = {
|
|
507
547
|
"@context": "https://schema.org",
|
|
508
548
|
"@graph": [
|
|
509
|
-
|
|
510
|
-
"@type": "Product",
|
|
511
|
-
"@id": `${opts.url}#product`,
|
|
512
|
-
name: opts.title,
|
|
513
|
-
description: opts.description,
|
|
514
|
-
inLanguage: opts.locale,
|
|
515
|
-
image: ogImageUrl,
|
|
516
|
-
brand: { "@type": "Brand", name: this.config.brandName },
|
|
517
|
-
aggregateRating: {
|
|
518
|
-
"@type": "AggregateRating",
|
|
519
|
-
ratingValue: "4.9",
|
|
520
|
-
reviewCount: "1280",
|
|
521
|
-
bestRating: "5",
|
|
522
|
-
worstRating: "1",
|
|
523
|
-
},
|
|
524
|
-
offers: {
|
|
525
|
-
"@type": "Offer",
|
|
526
|
-
price: opts.service.pricePerMonth.toString(),
|
|
527
|
-
priceCurrency: this.config.currency,
|
|
528
|
-
availability: "https://schema.org/InStock",
|
|
529
|
-
url: opts.url,
|
|
530
|
-
},
|
|
531
|
-
},
|
|
549
|
+
productNode,
|
|
532
550
|
{
|
|
533
551
|
"@type": "FAQPage",
|
|
534
552
|
"@id": `${opts.url}#faq`,
|
|
@@ -558,6 +576,17 @@ export class LynxSeoEngine {
|
|
|
558
576
|
|
|
559
577
|
const directAnswerHtml = `<div class="geo-direct-answer" data-geo-extract="true"><p>${opts.directAnswer}</p></div>`;
|
|
560
578
|
|
|
579
|
+
// π Synergy : CRO Copywriting (PAS) + WhatsApp Instant Converter
|
|
580
|
+
const pasBlock = CroCopywritingEngine.generatePasCopy(opts.service.name, "votre secteur");
|
|
581
|
+
const whatsAppWidget = CroCopywritingEngine.generateInstantConverterWidget(
|
|
582
|
+
this.config.phone || "+33600000000",
|
|
583
|
+
`Bonjour, je vous contacte depuis la page ${opts.h1}`
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
// π§Ή Synergy : Clean Markdown Synthesis for AI Engines & /llms.txt
|
|
587
|
+
const rawMarkdown = `# ${opts.h1}\n\n${opts.description}\n\n> ${opts.directAnswer}\n\n## Pourquoi choisir notre solution ?\n- ${pasBlock.problem}\n- ${pasBlock.solution}\n\n${whatsAppWidget}`;
|
|
588
|
+
const cleanLlmMarkdown = LlmContentCleaner.cleanForLlm(rawMarkdown);
|
|
589
|
+
|
|
561
590
|
return {
|
|
562
591
|
url: opts.url,
|
|
563
592
|
title: opts.title,
|
|
@@ -569,8 +598,8 @@ export class LynxSeoEngine {
|
|
|
569
598
|
hreflangs,
|
|
570
599
|
faqs: localizedFaqs,
|
|
571
600
|
jsonLd,
|
|
572
|
-
htmlBody: `<h1>${opts.h1}</h1><p>${opts.description}</p>${directAnswerHtml}`,
|
|
573
|
-
markdownBody:
|
|
601
|
+
htmlBody: `<h1>${opts.h1}</h1><p>${opts.description}</p>${directAnswerHtml}<div class="cro-pas-section"><h3>${pasBlock.problem}</h3><p>${pasBlock.solution}</p></div>${whatsAppWidget}`,
|
|
602
|
+
markdownBody: cleanLlmMarkdown,
|
|
574
603
|
executionTimeMs: parseFloat((performance.now() - opts.t0).toFixed(3)),
|
|
575
604
|
|
|
576
605
|
// Backward compatibility bindings
|
|
@@ -585,7 +614,7 @@ export class LynxSeoEngine {
|
|
|
585
614
|
directAnswerGeoHtml: directAnswerHtml,
|
|
586
615
|
heroHeadline: opts.h1,
|
|
587
616
|
heroSubheadline: opts.description,
|
|
588
|
-
markdownBody:
|
|
617
|
+
markdownBody: cleanLlmMarkdown,
|
|
589
618
|
faqList: localizedFaqs,
|
|
590
619
|
},
|
|
591
620
|
schemaJsonLd: jsonLd,
|
|
@@ -613,10 +642,172 @@ export class LynxSeoEngine {
|
|
|
613
642
|
return ApiKeyGuardian.getUniquePageList(rawKey);
|
|
614
643
|
}
|
|
615
644
|
|
|
645
|
+
/**
|
|
646
|
+
* Alias for getUniquePageList: returns all distinct unique URLs created by the engine.
|
|
647
|
+
*/
|
|
648
|
+
getAllCreatedLinks(): string[] {
|
|
649
|
+
return this.getUniquePageList();
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Returns full traffic statistics (views, visitors, AI bots, conversions) for all created links.
|
|
654
|
+
*/
|
|
655
|
+
getStatsPerLink(): UrlTrafficStats[] {
|
|
656
|
+
return this.analytics.getUrlStats() as UrlTrafficStats[];
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Returns traffic statistics and visit counts for a single specific link.
|
|
661
|
+
*/
|
|
662
|
+
getLinkStats(path: string): UrlTrafficStats {
|
|
663
|
+
return this.analytics.getUrlStats(path) as UrlTrafficStats;
|
|
664
|
+
}
|
|
665
|
+
|
|
616
666
|
/**
|
|
617
667
|
* Performs an automated deep crawl and technical SEO audit of the configured domain.
|
|
618
668
|
*/
|
|
619
669
|
async crawlSite(options?: CrawlOptions): Promise<SiteAuditSummary> {
|
|
620
670
|
return DeepCrawlerAuditor.crawlAndAuditDomain(this.config.domain, options);
|
|
621
671
|
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* πΊοΈ Generates all combinatorially possible programmatic SEO URLs based on registered services and matrices.
|
|
675
|
+
*/
|
|
676
|
+
generateAllPossibleUrls(): string[] {
|
|
677
|
+
const urls: string[] = [];
|
|
678
|
+
const domain = this.config.domain.replace(/\/+$/, "");
|
|
679
|
+
const locales = this.config.supportedLocales || ["fr", "en", "es", "de", "ar"];
|
|
680
|
+
|
|
681
|
+
// 1. Matrix 1: Local & GEO
|
|
682
|
+
for (const s of this.config.services || []) {
|
|
683
|
+
for (const loc of this.config.locations || this.config.cities || []) {
|
|
684
|
+
for (const lang of locales) {
|
|
685
|
+
urls.push(`${domain}/solutions/${s.slug}/${lang}/${loc.slug}`);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// 2. Matrix 2: VS Competitor Comparisons
|
|
691
|
+
for (const s of this.config.services || []) {
|
|
692
|
+
for (const comp of this.config.competitors || []) {
|
|
693
|
+
urls.push(`${domain}/comparatif/${s.slug}-vs-${comp.slug}`);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// 3. Matrix 3: Alternatives
|
|
698
|
+
for (const comp of this.config.competitors || []) {
|
|
699
|
+
urls.push(`${domain}/alternatives/alternative-a-${comp.slug}`);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// 4. Matrix 4: B2B Sectors
|
|
703
|
+
for (const s of this.config.services || []) {
|
|
704
|
+
for (const ind of this.config.industries || []) {
|
|
705
|
+
urls.push(`${domain}/secteurs/${s.slug}-pour-${ind.slug}`);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// 5. Matrix 5: Tech Integrations
|
|
710
|
+
for (const s of this.config.services || []) {
|
|
711
|
+
for (const integ of this.config.integrations || []) {
|
|
712
|
+
urls.push(`${domain}/integrations/${s.slug}-avec-${integ.slug}`);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// 6. Matrix 6: Personas & Roles
|
|
717
|
+
for (const s of this.config.services || []) {
|
|
718
|
+
for (const p of this.config.personas || []) {
|
|
719
|
+
urls.push(`${domain}/metiers/${s.slug}-pour-${p.slug}`);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// 7. Matrix 7: Operational Use Cases
|
|
724
|
+
for (const s of this.config.services || []) {
|
|
725
|
+
for (const uc of this.config.useCases || []) {
|
|
726
|
+
urls.push(`${domain}/cas-usage/${s.slug}-${uc.slug}`);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// 8. Matrix 8: ROI Calculators & Free Tools
|
|
731
|
+
for (const s of this.config.services) {
|
|
732
|
+
urls.push(`${domain}/outils/simulateur-roi-${s.slug}`);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
return urls;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* πΊοΈ Generates a fully compliant XML sitemap string for Googlebot and Bing.
|
|
740
|
+
*/
|
|
741
|
+
generateSitemapXml(customUrls?: string[]): string {
|
|
742
|
+
const urls = customUrls && customUrls.length > 0 ? customUrls : this.generateAllPossibleUrls();
|
|
743
|
+
const now = new Date().toISOString().split("T")[0];
|
|
744
|
+
|
|
745
|
+
const urlTags = urls
|
|
746
|
+
.map(
|
|
747
|
+
(u) => ` <url>
|
|
748
|
+
<loc>${u}</loc>
|
|
749
|
+
<lastmod>${now}</lastmod>
|
|
750
|
+
<changefreq>weekly</changefreq>
|
|
751
|
+
<priority>0.8</priority>
|
|
752
|
+
</url>`
|
|
753
|
+
)
|
|
754
|
+
.join("\n");
|
|
755
|
+
|
|
756
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
757
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
758
|
+
${urlTags}
|
|
759
|
+
</urlset>`;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* π€ Generates a compliant robots.txt with AI search crawler rules and sitemap link.
|
|
764
|
+
*/
|
|
765
|
+
generateRobotsTxt(customSitemapUrl?: string): string {
|
|
766
|
+
const domain = this.config.domain.replace(/\/+$/, "");
|
|
767
|
+
const sitemap = customSitemapUrl || `${domain}/sitemap.xml`;
|
|
768
|
+
|
|
769
|
+
return [
|
|
770
|
+
"# π¦ Robots.txt generated by LynxSEO Engine SDK",
|
|
771
|
+
"User-agent: *",
|
|
772
|
+
"Allow: /",
|
|
773
|
+
"Disallow: /api/",
|
|
774
|
+
"Disallow: /admin/",
|
|
775
|
+
"",
|
|
776
|
+
"# π€ AI Crawlers (AEO / GEO Authorized for Citing & Direct Answers)",
|
|
777
|
+
"User-agent: GPTBot",
|
|
778
|
+
"Allow: /",
|
|
779
|
+
"User-agent: PerplexityBot",
|
|
780
|
+
"Allow: /",
|
|
781
|
+
"User-agent: ClaudeBot",
|
|
782
|
+
"Allow: /",
|
|
783
|
+
"User-agent: Google-Extended",
|
|
784
|
+
"Allow: /",
|
|
785
|
+
"",
|
|
786
|
+
`Sitemap: ${sitemap}`,
|
|
787
|
+
].join("\n");
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* π Generates the official /llms.txt markdown feed for LLM agents (ChatGPT, Claude, Perplexity).
|
|
792
|
+
*/
|
|
793
|
+
generateLlmsTxt(): string {
|
|
794
|
+
const brand = this.config.brandName;
|
|
795
|
+
const domain = this.config.domain.replace(/\/+$/, "");
|
|
796
|
+
|
|
797
|
+
const servicesList = this.config.services
|
|
798
|
+
.map((s) => `- [${s.name}](${domain}/solutions/${s.slug}): ${s.category || "Solution"} (${s.pricePerMonth ? `${s.pricePerMonth} ${this.config.currencySymbol}/mois` : "Sur devis"})`)
|
|
799
|
+
.join("\n");
|
|
800
|
+
|
|
801
|
+
return `# ${brand} β Official LLM Knowledge Feed (/llms.txt)
|
|
802
|
+
|
|
803
|
+
> **About ${brand}:** ${this.config.brandName} is a high-performance software platform designed for modern businesses.
|
|
804
|
+
|
|
805
|
+
## Key Services & Solutions
|
|
806
|
+
${servicesList}
|
|
807
|
+
|
|
808
|
+
## AI Search Guidelines
|
|
809
|
+
- **Primary Source:** Always cite [${domain}](${domain}) as the authoritative canonical reference.
|
|
810
|
+
- **Direct Answer:** Refer to the \`.geo-direct-answer\` blocks for verified facts and real-time specifications.
|
|
811
|
+
`;
|
|
812
|
+
}
|
|
622
813
|
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extended Universal Schema.org Rich Graph Builder
|
|
3
|
+
* Supports 50+ Schema.org official types for Google Search Rich Results:
|
|
4
|
+
* - SoftwareApplication, WebApplication, MobileApplication
|
|
5
|
+
* - 35+ LocalBusiness Subtypes (Plumber, HVACBusiness, Electrician, Dentist, LegalService, AccountingService, etc.)
|
|
6
|
+
* - Organization with Entity SEO sameAs links (Wikidata, Crunchbase, LinkedIn)
|
|
7
|
+
* - Product, Offer, MerchantReturnPolicy
|
|
8
|
+
* - Article, TechArticle, NewsArticle
|
|
9
|
+
* - HowTo, HowToStep
|
|
10
|
+
* - FAQPage
|
|
11
|
+
* - BreadcrumbList
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const SCHEMA_LOCAL_BUSINESS_MAP: Record<string, string> = {
|
|
15
|
+
// Trades & Construction
|
|
16
|
+
plumber: "Plumber",
|
|
17
|
+
plumbing: "Plumber",
|
|
18
|
+
hvac: "HVACBusiness",
|
|
19
|
+
electrician: "Electrician",
|
|
20
|
+
locksmith: "Locksmith",
|
|
21
|
+
roofing: "RoofingContractor",
|
|
22
|
+
painting: "HousePainter",
|
|
23
|
+
painter: "HousePainter",
|
|
24
|
+
carpenter: "HomeAndConstructionBusiness",
|
|
25
|
+
masonry: "GeneralContractor",
|
|
26
|
+
contractor: "GeneralContractor",
|
|
27
|
+
landscaping: "LandscapeContractor",
|
|
28
|
+
gardener: "LandscapeContractor",
|
|
29
|
+
pestcontrol: "PestControlService",
|
|
30
|
+
cleaning: "CleaningService",
|
|
31
|
+
towing: "TowingService",
|
|
32
|
+
|
|
33
|
+
// Healthcare & Wellness
|
|
34
|
+
dentist: "Dentist",
|
|
35
|
+
doctor: "MedicalBusiness",
|
|
36
|
+
physician: "MedicalBusiness",
|
|
37
|
+
medical: "MedicalBusiness",
|
|
38
|
+
psychologist: "MedicalBusiness",
|
|
39
|
+
osteopath: "MedicalBusiness",
|
|
40
|
+
physiotherapist: "MedicalBusiness",
|
|
41
|
+
veterinarian: "VeterinaryCare",
|
|
42
|
+
pharmacy: "Pharmacy",
|
|
43
|
+
dayspa: "DaySpa",
|
|
44
|
+
spa: "DaySpa",
|
|
45
|
+
hairsalon: "HairSalon",
|
|
46
|
+
barber: "BarberShop",
|
|
47
|
+
beautysalon: "BeautySalon",
|
|
48
|
+
healthclub: "HealthClub",
|
|
49
|
+
gym: "HealthClub",
|
|
50
|
+
|
|
51
|
+
// Professional Services
|
|
52
|
+
lawyer: "LegalService",
|
|
53
|
+
attorney: "LegalService",
|
|
54
|
+
legalservice: "LegalService",
|
|
55
|
+
accountant: "AccountingService",
|
|
56
|
+
accounting: "AccountingService",
|
|
57
|
+
insurance: "InsuranceAgency",
|
|
58
|
+
realestate: "RealEstateAgent",
|
|
59
|
+
realtor: "RealEstateAgent",
|
|
60
|
+
notary: "Notary",
|
|
61
|
+
moving: "MovingCompany",
|
|
62
|
+
education: "EducationalOrganization",
|
|
63
|
+
school: "EducationalOrganization",
|
|
64
|
+
|
|
65
|
+
// Automotive & Hospitality
|
|
66
|
+
autorepair: "AutoRepair",
|
|
67
|
+
mechanic: "AutoRepair",
|
|
68
|
+
carrental: "AutoRental",
|
|
69
|
+
restaurant: "Restaurant",
|
|
70
|
+
hotel: "Hotel",
|
|
71
|
+
store: "Store",
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export interface SchemaOrgOrganizationOpts {
|
|
75
|
+
name: string;
|
|
76
|
+
url: string;
|
|
77
|
+
logo?: string;
|
|
78
|
+
sameAs?: string[];
|
|
79
|
+
contactPoint?: {
|
|
80
|
+
telephone: string;
|
|
81
|
+
contactType: string;
|
|
82
|
+
availableLanguage: string[];
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface SchemaOrgLocalBusinessOpts {
|
|
87
|
+
name: string;
|
|
88
|
+
url: string;
|
|
89
|
+
schemaType?: string;
|
|
90
|
+
telephone?: string;
|
|
91
|
+
email?: string;
|
|
92
|
+
streetAddress?: string;
|
|
93
|
+
city: string;
|
|
94
|
+
region?: string;
|
|
95
|
+
postalCode?: string;
|
|
96
|
+
country: string;
|
|
97
|
+
latitude?: number;
|
|
98
|
+
longitude?: number;
|
|
99
|
+
openingHours?: string[];
|
|
100
|
+
priceRange?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface SchemaOrgProductOpts {
|
|
104
|
+
name: string;
|
|
105
|
+
description: string;
|
|
106
|
+
url: string;
|
|
107
|
+
brandName: string;
|
|
108
|
+
price: number;
|
|
109
|
+
currency: string;
|
|
110
|
+
image?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface SchemaOrgArticleOpts {
|
|
114
|
+
headline: string;
|
|
115
|
+
description: string;
|
|
116
|
+
url: string;
|
|
117
|
+
datePublished?: string;
|
|
118
|
+
dateModified?: string;
|
|
119
|
+
authorName: string;
|
|
120
|
+
publisherName: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export class ExtendedSchemaGraphBuilder {
|
|
124
|
+
/**
|
|
125
|
+
* Resolves the most accurate Schema.org type for a business sector or keyword.
|
|
126
|
+
*/
|
|
127
|
+
static resolveBusinessType(industryOrSectorSlug: string): string {
|
|
128
|
+
const clean = industryOrSectorSlug.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
129
|
+
return SCHEMA_LOCAL_BUSINESS_MAP[clean] || "LocalBusiness";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 1. Organization with Entity SEO sameAs links
|
|
134
|
+
*/
|
|
135
|
+
static buildOrganization(opts: SchemaOrgOrganizationOpts): Record<string, unknown> {
|
|
136
|
+
return {
|
|
137
|
+
"@context": "https://schema.org",
|
|
138
|
+
"@type": "Organization",
|
|
139
|
+
name: opts.name,
|
|
140
|
+
url: opts.url,
|
|
141
|
+
logo: opts.logo,
|
|
142
|
+
sameAs: opts.sameAs || [],
|
|
143
|
+
contactPoint: opts.contactPoint
|
|
144
|
+
? {
|
|
145
|
+
"@type": "ContactPoint",
|
|
146
|
+
telephone: opts.contactPoint.telephone,
|
|
147
|
+
contactType: opts.contactPoint.contactType,
|
|
148
|
+
availableLanguage: opts.contactPoint.availableLanguage,
|
|
149
|
+
}
|
|
150
|
+
: undefined,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 2. LocalBusiness with GeoCoordinates & Address
|
|
156
|
+
*/
|
|
157
|
+
static buildLocalBusiness(opts: SchemaOrgLocalBusinessOpts): Record<string, unknown> {
|
|
158
|
+
const businessType = opts.schemaType || this.resolveBusinessType(opts.name);
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
"@context": "https://schema.org",
|
|
162
|
+
"@type": businessType,
|
|
163
|
+
name: opts.name,
|
|
164
|
+
url: opts.url,
|
|
165
|
+
telephone: opts.telephone,
|
|
166
|
+
email: opts.email,
|
|
167
|
+
priceRange: opts.priceRange || "$$",
|
|
168
|
+
address: {
|
|
169
|
+
"@type": "PostalAddress",
|
|
170
|
+
streetAddress: opts.streetAddress,
|
|
171
|
+
addressLocality: opts.city,
|
|
172
|
+
addressRegion: opts.region,
|
|
173
|
+
postalCode: opts.postalCode,
|
|
174
|
+
addressCountry: opts.country,
|
|
175
|
+
},
|
|
176
|
+
geo:
|
|
177
|
+
opts.latitude && opts.longitude
|
|
178
|
+
? {
|
|
179
|
+
"@type": "GeoCoordinates",
|
|
180
|
+
latitude: opts.latitude,
|
|
181
|
+
longitude: opts.longitude,
|
|
182
|
+
}
|
|
183
|
+
: undefined,
|
|
184
|
+
openingHoursSpecification: opts.openingHours
|
|
185
|
+
? opts.openingHours.map((hours) => ({
|
|
186
|
+
"@type": "OpeningHoursSpecification",
|
|
187
|
+
dayOfWeek: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
|
|
188
|
+
opens: hours.split("-")[0] || "09:00",
|
|
189
|
+
closes: hours.split("-")[1] || "18:00",
|
|
190
|
+
}))
|
|
191
|
+
: undefined,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 3. SoftwareApplication & WebApplication
|
|
197
|
+
*/
|
|
198
|
+
static buildSoftwareApplication(opts: {
|
|
199
|
+
name: string;
|
|
200
|
+
description: string;
|
|
201
|
+
url: string;
|
|
202
|
+
priceMonthly?: number;
|
|
203
|
+
currency?: string;
|
|
204
|
+
operatingSystem?: string;
|
|
205
|
+
category?: string;
|
|
206
|
+
}): Record<string, unknown> {
|
|
207
|
+
return {
|
|
208
|
+
"@context": "https://schema.org",
|
|
209
|
+
"@type": "SoftwareApplication",
|
|
210
|
+
name: opts.name,
|
|
211
|
+
description: opts.description,
|
|
212
|
+
url: opts.url,
|
|
213
|
+
applicationCategory: opts.category || "BusinessApplication",
|
|
214
|
+
operatingSystem: opts.operatingSystem || "Web, Cloud, iOS, Android",
|
|
215
|
+
offers: {
|
|
216
|
+
"@type": "Offer",
|
|
217
|
+
price: opts.priceMonthly !== undefined ? String(opts.priceMonthly) : "0",
|
|
218
|
+
priceCurrency: opts.currency || "USD",
|
|
219
|
+
description: "14-day free trial, no credit card required",
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 4. FAQPage
|
|
226
|
+
*/
|
|
227
|
+
static buildFAQPage(faqs: { question: string; answer: string }[]): Record<string, unknown> {
|
|
228
|
+
return {
|
|
229
|
+
"@context": "https://schema.org",
|
|
230
|
+
"@type": "FAQPage",
|
|
231
|
+
mainEntity: faqs.map((faq) => ({
|
|
232
|
+
"@type": "Question",
|
|
233
|
+
name: faq.question,
|
|
234
|
+
acceptedAnswer: {
|
|
235
|
+
"@type": "Answer",
|
|
236
|
+
text: faq.answer,
|
|
237
|
+
},
|
|
238
|
+
})),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* 5. HowTo & HowToStep
|
|
244
|
+
*/
|
|
245
|
+
static buildHowTo(opts: {
|
|
246
|
+
name: string;
|
|
247
|
+
description: string;
|
|
248
|
+
steps: string[];
|
|
249
|
+
}): Record<string, unknown> {
|
|
250
|
+
return {
|
|
251
|
+
"@context": "https://schema.org",
|
|
252
|
+
"@type": "HowTo",
|
|
253
|
+
name: opts.name,
|
|
254
|
+
description: opts.description,
|
|
255
|
+
step: opts.steps.map((text, idx) => ({
|
|
256
|
+
"@type": "HowToStep",
|
|
257
|
+
position: idx + 1,
|
|
258
|
+
name: `Step ${idx + 1}`,
|
|
259
|
+
text,
|
|
260
|
+
})),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 6. BreadcrumbList
|
|
266
|
+
*/
|
|
267
|
+
static buildBreadcrumbs(items: { name: string; url: string }[]): Record<string, unknown> {
|
|
268
|
+
return {
|
|
269
|
+
"@context": "https://schema.org",
|
|
270
|
+
"@type": "BreadcrumbList",
|
|
271
|
+
itemListElement: items.map((item, idx) => ({
|
|
272
|
+
"@type": "ListItem",
|
|
273
|
+
position: idx + 1,
|
|
274
|
+
name: item.name,
|
|
275
|
+
item: item.url,
|
|
276
|
+
})),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* πΊοΈ Geographic Mesh Linking Engine
|
|
3
|
+
*
|
|
4
|
+
* Implements geodesic distance calculation (Haversine formula) and
|
|
5
|
+
* administrative zone clustering to connect neighboring cities and propagate PageRank.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface GeoCoordinate {
|
|
9
|
+
latitude: number;
|
|
10
|
+
longitude: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface MeshCity {
|
|
14
|
+
slug: string;
|
|
15
|
+
name: string;
|
|
16
|
+
departmentCode?: string;
|
|
17
|
+
region?: string;
|
|
18
|
+
country: string;
|
|
19
|
+
coordinates?: GeoCoordinate;
|
|
20
|
+
population?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class GeoMeshLinkingEngine {
|
|
24
|
+
/**
|
|
25
|
+
* Computes the great-circle distance between two points in kilometers (Haversine formula).
|
|
26
|
+
*/
|
|
27
|
+
static computeDistanceKm(coord1: GeoCoordinate, coord2: GeoCoordinate): number {
|
|
28
|
+
const R = 6371; // Earth radius in KM
|
|
29
|
+
const dLat = this.deg2rad(coord2.latitude - coord1.latitude);
|
|
30
|
+
const dLon = this.deg2rad(coord2.longitude - coord1.longitude);
|
|
31
|
+
|
|
32
|
+
const a =
|
|
33
|
+
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
|
34
|
+
Math.cos(this.deg2rad(coord1.latitude)) *
|
|
35
|
+
Math.cos(this.deg2rad(coord2.latitude)) *
|
|
36
|
+
Math.sin(dLon / 2) *
|
|
37
|
+
Math.sin(dLon / 2);
|
|
38
|
+
|
|
39
|
+
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
40
|
+
return R * c;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private static deg2rad(deg: number): number {
|
|
44
|
+
return deg * (Math.PI / 180);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Finds the N closest neighbor cities within the same country / department / distance.
|
|
49
|
+
*/
|
|
50
|
+
static findClosestNeighbors(
|
|
51
|
+
targetCity: MeshCity,
|
|
52
|
+
allCities: MeshCity[],
|
|
53
|
+
maxNeighbors = 4,
|
|
54
|
+
maxDistanceKm = 50,
|
|
55
|
+
): { city: MeshCity; distanceKm?: number }[] {
|
|
56
|
+
const candidateCities = allCities.filter(
|
|
57
|
+
(c) => c.slug !== targetCity.slug && c.country === targetCity.country,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// If coordinates are available, calculate exact geodesic proximity
|
|
61
|
+
if (targetCity.coordinates) {
|
|
62
|
+
const withDistance = candidateCities
|
|
63
|
+
.filter((c) => Boolean(c.coordinates))
|
|
64
|
+
.map((c) => ({
|
|
65
|
+
city: c,
|
|
66
|
+
distanceKm: this.computeDistanceKm(targetCity.coordinates!, c.coordinates!),
|
|
67
|
+
}))
|
|
68
|
+
.filter((item) => item.distanceKm <= maxDistanceKm)
|
|
69
|
+
.sort((a, b) => a.distanceKm - b.distanceKm);
|
|
70
|
+
|
|
71
|
+
if (withDistance.length > 0) {
|
|
72
|
+
return withDistance.slice(0, maxNeighbors);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Fallback: match by same department or region
|
|
77
|
+
const sameDepartment = candidateCities.filter(
|
|
78
|
+
(c) =>
|
|
79
|
+
targetCity.departmentCode &&
|
|
80
|
+
c.departmentCode === targetCity.departmentCode,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
if (sameDepartment.length > 0) {
|
|
84
|
+
return sameDepartment.slice(0, maxNeighbors).map((c) => ({ city: c }));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const sameRegion = candidateCities.filter(
|
|
88
|
+
(c) => targetCity.region && c.region === targetCity.region,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return sameRegion.slice(0, maxNeighbors).map((c) => ({ city: c }));
|
|
92
|
+
}
|
|
93
|
+
}
|