cc-codeconductor 0.2.8 → 0.2.9
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 +8 -1
- package/dist/index.js +1588 -55
- package/package.json +1 -1
- package/presets/seo-hotel/commands/cc-seo-llms.md +64 -0
- package/presets/seo-hotel/seo-hotel.yml +55 -0
package/dist/index.js
CHANGED
|
@@ -7044,7 +7044,7 @@ function getExitCode(error) {
|
|
|
7044
7044
|
// package.json
|
|
7045
7045
|
var package_default = {
|
|
7046
7046
|
name: "cc-codeconductor",
|
|
7047
|
-
version: "0.2.
|
|
7047
|
+
version: "0.2.9",
|
|
7048
7048
|
description: "A multi-agent orchestration framework for AI-assisted software engineering workflows.",
|
|
7049
7049
|
keywords: [
|
|
7050
7050
|
"ai",
|
|
@@ -12770,6 +12770,16 @@ import { homedir as homedir4 } from "node:os";
|
|
|
12770
12770
|
import { resolve as resolve8 } from "node:path";
|
|
12771
12771
|
|
|
12772
12772
|
// src/core/lsp/lsp-config-utils.ts
|
|
12773
|
+
function getLanguageServerConfig(lspIds) {
|
|
12774
|
+
const languageServers = {};
|
|
12775
|
+
for (const lspId of lspIds) {
|
|
12776
|
+
const config = getLspCommand(lspId);
|
|
12777
|
+
if (config) {
|
|
12778
|
+
languageServers[lspId] = { command: config.command, args: [...config.args] };
|
|
12779
|
+
}
|
|
12780
|
+
}
|
|
12781
|
+
return languageServers;
|
|
12782
|
+
}
|
|
12773
12783
|
function getLspCommand(lspId) {
|
|
12774
12784
|
switch (lspId) {
|
|
12775
12785
|
case "typescript":
|
|
@@ -12777,7 +12787,7 @@ function getLspCommand(lspId) {
|
|
|
12777
12787
|
case "php":
|
|
12778
12788
|
return { command: "intelephense", args: ["--stdio"] };
|
|
12779
12789
|
case "python":
|
|
12780
|
-
return { command: "
|
|
12790
|
+
return { command: "pyright-langserver", args: ["--stdio"] };
|
|
12781
12791
|
case "kotlin":
|
|
12782
12792
|
return { command: "kotlin-language-server", args: [] };
|
|
12783
12793
|
default:
|
|
@@ -12836,19 +12846,11 @@ class ClaudeLspGenerator {
|
|
|
12836
12846
|
if (successfulLsps.length === 0) {
|
|
12837
12847
|
return [];
|
|
12838
12848
|
}
|
|
12839
|
-
const
|
|
12840
|
-
|
|
12841
|
-
const config = getLspCommand(lsp.lspId);
|
|
12842
|
-
if (config) {
|
|
12843
|
-
mcpServers[lsp.lspId] = { command: config.command, args: [...config.args] };
|
|
12844
|
-
}
|
|
12845
|
-
}
|
|
12846
|
-
const content = JSON.stringify({
|
|
12847
|
-
mcpServers
|
|
12848
|
-
}, null, 2);
|
|
12849
|
+
const languageServers = getLanguageServerConfig(successfulLsps.map((lsp) => lsp.lspId));
|
|
12850
|
+
const content = JSON.stringify(languageServers, null, 2);
|
|
12849
12851
|
return [
|
|
12850
12852
|
{
|
|
12851
|
-
path: ".claude/
|
|
12853
|
+
path: ".claude/plugins/codeconductor-lsp/.lsp.json",
|
|
12852
12854
|
content,
|
|
12853
12855
|
overwrite: false
|
|
12854
12856
|
}
|
|
@@ -12863,8 +12865,6 @@ function createClaudeLspGenerator() {
|
|
|
12863
12865
|
}
|
|
12864
12866
|
|
|
12865
12867
|
// src/adapters/codex/codex-lsp-generator.ts
|
|
12866
|
-
var MCP_STARTUP_TIMEOUT_SEC = 120;
|
|
12867
|
-
|
|
12868
12868
|
class CodexLspGenerator {
|
|
12869
12869
|
name = "codex-lsp";
|
|
12870
12870
|
target = "codex";
|
|
@@ -12877,12 +12877,11 @@ class CodexLspGenerator {
|
|
|
12877
12877
|
for (const lsp of successfulLsps) {
|
|
12878
12878
|
const config = getLspCommand(lsp.lspId);
|
|
12879
12879
|
if (config) {
|
|
12880
|
-
sections.push(`[
|
|
12880
|
+
sections.push(`[language_servers.${lsp.lspId}]`);
|
|
12881
12881
|
sections.push(`command = "${config.command}"`);
|
|
12882
12882
|
if (config.args.length > 0) {
|
|
12883
12883
|
sections.push(`args = [${config.args.map((a) => `"${a}"`).join(", ")}]`);
|
|
12884
12884
|
}
|
|
12885
|
-
sections.push(`startup_timeout_sec = ${MCP_STARTUP_TIMEOUT_SEC}`);
|
|
12886
12885
|
sections.push("");
|
|
12887
12886
|
}
|
|
12888
12887
|
}
|
|
@@ -12912,19 +12911,13 @@ class CursorLspGenerator {
|
|
|
12912
12911
|
if (successfulLsps.length === 0) {
|
|
12913
12912
|
return [];
|
|
12914
12913
|
}
|
|
12915
|
-
const
|
|
12916
|
-
for (const lsp of successfulLsps) {
|
|
12917
|
-
const config = getLspCommand(lsp.lspId);
|
|
12918
|
-
if (config) {
|
|
12919
|
-
mcpServers[lsp.lspId] = { command: config.command, args: [...config.args] };
|
|
12920
|
-
}
|
|
12921
|
-
}
|
|
12914
|
+
const languageServers = getLanguageServerConfig(successfulLsps.map((lsp) => lsp.lspId));
|
|
12922
12915
|
const content = JSON.stringify({
|
|
12923
|
-
|
|
12916
|
+
languageServers
|
|
12924
12917
|
}, null, 2);
|
|
12925
12918
|
return [
|
|
12926
12919
|
{
|
|
12927
|
-
path: ".cursor/
|
|
12920
|
+
path: ".cursor/settings.json",
|
|
12928
12921
|
content,
|
|
12929
12922
|
overwrite: false
|
|
12930
12923
|
}
|
|
@@ -12947,15 +12940,9 @@ class GeminiLspGenerator {
|
|
|
12947
12940
|
if (successfulLsps.length === 0) {
|
|
12948
12941
|
return [];
|
|
12949
12942
|
}
|
|
12950
|
-
const
|
|
12951
|
-
for (const lsp of successfulLsps) {
|
|
12952
|
-
const config = getLspCommand(lsp.lspId);
|
|
12953
|
-
if (config) {
|
|
12954
|
-
mcpServers[lsp.lspId] = { command: config.command, args: [...config.args] };
|
|
12955
|
-
}
|
|
12956
|
-
}
|
|
12943
|
+
const languageServers = getLanguageServerConfig(successfulLsps.map((lsp) => lsp.lspId));
|
|
12957
12944
|
const content = JSON.stringify({
|
|
12958
|
-
|
|
12945
|
+
languageServers
|
|
12959
12946
|
}, null, 2);
|
|
12960
12947
|
return [
|
|
12961
12948
|
{
|
|
@@ -12974,38 +12961,45 @@ function createGeminiLspGenerator() {
|
|
|
12974
12961
|
}
|
|
12975
12962
|
|
|
12976
12963
|
// src/adapters/opencode/opencode-lsp-generator.ts
|
|
12964
|
+
var OPENCODE_LSP_EXTENSIONS = {
|
|
12965
|
+
typescript: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
|
|
12966
|
+
php: [".php"],
|
|
12967
|
+
python: [".py", ".pyi"],
|
|
12968
|
+
kotlin: [".kt", ".kts"]
|
|
12969
|
+
};
|
|
12970
|
+
|
|
12977
12971
|
class OpenCodeLspGenerator {
|
|
12978
12972
|
name = "opencode-lsp";
|
|
12979
12973
|
target = "opencode";
|
|
12980
12974
|
generate(installedLsps) {
|
|
12981
|
-
const successfulLsps = installedLsps.filter((
|
|
12975
|
+
const successfulLsps = installedLsps.filter((lsp2) => lsp2.status !== "failed");
|
|
12982
12976
|
if (successfulLsps.length === 0) {
|
|
12983
12977
|
return [];
|
|
12984
12978
|
}
|
|
12985
|
-
const
|
|
12986
|
-
|
|
12987
|
-
const config = getLspCommand(lsp.lspId);
|
|
12988
|
-
if (config) {
|
|
12989
|
-
mcp[lsp.lspId] = {
|
|
12990
|
-
type: "local",
|
|
12991
|
-
command: [config.command, ...config.args],
|
|
12992
|
-
enabled: true,
|
|
12993
|
-
timeout: 120000
|
|
12994
|
-
};
|
|
12995
|
-
}
|
|
12996
|
-
}
|
|
12979
|
+
const languageServers = getLanguageServerConfig(successfulLsps.map((lsp2) => lsp2.lspId));
|
|
12980
|
+
const lsp = this.toOpenCodeLspConfig(languageServers);
|
|
12997
12981
|
const content = JSON.stringify({
|
|
12998
12982
|
$schema: "https://opencode.ai/config.json",
|
|
12999
|
-
|
|
12983
|
+
lsp
|
|
13000
12984
|
}, null, 2);
|
|
13001
12985
|
return [
|
|
13002
12986
|
{
|
|
13003
12987
|
path: ".opencode/opencode.json",
|
|
13004
|
-
content
|
|
12988
|
+
content: `${content}
|
|
12989
|
+
`,
|
|
13005
12990
|
overwrite: false
|
|
13006
12991
|
}
|
|
13007
12992
|
];
|
|
13008
12993
|
}
|
|
12994
|
+
toOpenCodeLspConfig(languageServers) {
|
|
12995
|
+
return Object.fromEntries(Object.entries(languageServers).map(([name, config]) => [
|
|
12996
|
+
name,
|
|
12997
|
+
{
|
|
12998
|
+
command: [config.command, ...config.args],
|
|
12999
|
+
extensions: OPENCODE_LSP_EXTENSIONS[name] ?? []
|
|
13000
|
+
}
|
|
13001
|
+
]));
|
|
13002
|
+
}
|
|
13009
13003
|
async isAvailable() {
|
|
13010
13004
|
return true;
|
|
13011
13005
|
}
|
|
@@ -13201,13 +13195,13 @@ var LSP_DEFINITIONS = [
|
|
|
13201
13195
|
{
|
|
13202
13196
|
id: "python",
|
|
13203
13197
|
language: "python",
|
|
13204
|
-
serverName: "
|
|
13205
|
-
packageManager: "
|
|
13206
|
-
package: "
|
|
13207
|
-
binaryName: "
|
|
13208
|
-
installCmd: "
|
|
13198
|
+
serverName: "Pyright",
|
|
13199
|
+
packageManager: "npm",
|
|
13200
|
+
package: "pyright",
|
|
13201
|
+
binaryName: "pyright-langserver",
|
|
13202
|
+
installCmd: "npm install -g pyright",
|
|
13209
13203
|
versionFlag: "--version",
|
|
13210
|
-
|
|
13204
|
+
npmDetect: "pyright"
|
|
13211
13205
|
},
|
|
13212
13206
|
{
|
|
13213
13207
|
id: "kotlin",
|
|
@@ -13397,6 +13391,1505 @@ function getLspConfigGenerator(target) {
|
|
|
13397
13391
|
}
|
|
13398
13392
|
}
|
|
13399
13393
|
|
|
13394
|
+
// src/commands/seo-audit.command.ts
|
|
13395
|
+
import { writeFile as writeFile5, mkdir as mkdir6 } from "node:fs/promises";
|
|
13396
|
+
import { dirname as dirname4, resolve as resolve9 } from "node:path";
|
|
13397
|
+
|
|
13398
|
+
// src/infrastructure/http/safe-fetch.ts
|
|
13399
|
+
import { lookup } from "node:dns/promises";
|
|
13400
|
+
var PRIVATE_IP_RANGES = [
|
|
13401
|
+
/^10\./,
|
|
13402
|
+
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
13403
|
+
/^192\.168\./,
|
|
13404
|
+
/^127\./,
|
|
13405
|
+
/^0\./,
|
|
13406
|
+
/^169\.254\./,
|
|
13407
|
+
/^::1$/,
|
|
13408
|
+
/^fc00:/i,
|
|
13409
|
+
/^fe80:/i
|
|
13410
|
+
];
|
|
13411
|
+
function isPrivateIp(ip) {
|
|
13412
|
+
return PRIVATE_IP_RANGES.some((range) => range.test(ip));
|
|
13413
|
+
}
|
|
13414
|
+
async function validateUrl(urlString) {
|
|
13415
|
+
let parsed;
|
|
13416
|
+
try {
|
|
13417
|
+
parsed = new URL(urlString);
|
|
13418
|
+
} catch {
|
|
13419
|
+
throw new Error(`Invalid URL: ${urlString}`);
|
|
13420
|
+
}
|
|
13421
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
13422
|
+
throw new Error(`Blocked scheme: ${parsed.protocol}. Only http and https are allowed.`);
|
|
13423
|
+
}
|
|
13424
|
+
const hostname = parsed.hostname;
|
|
13425
|
+
if (hostname === "localhost" || hostname.endsWith(".localhost")) {
|
|
13426
|
+
throw new Error(`Blocked hostname: ${hostname}`);
|
|
13427
|
+
}
|
|
13428
|
+
try {
|
|
13429
|
+
const addresses = await lookup(hostname, { all: true });
|
|
13430
|
+
const addrList = Array.isArray(addresses) ? addresses : [addresses];
|
|
13431
|
+
for (const addr of addrList) {
|
|
13432
|
+
if (isPrivateIp(addr.address)) {
|
|
13433
|
+
throw new Error(`SSRF blocked: ${hostname} resolves to private IP ${addr.address}`);
|
|
13434
|
+
}
|
|
13435
|
+
}
|
|
13436
|
+
} catch (error) {
|
|
13437
|
+
if (error instanceof Error && error.message.startsWith("SSRF blocked")) {
|
|
13438
|
+
throw error;
|
|
13439
|
+
}
|
|
13440
|
+
throw new Error(`DNS resolution failed for ${hostname}: ${String(error)}`);
|
|
13441
|
+
}
|
|
13442
|
+
}
|
|
13443
|
+
async function safeFetch(urlString, options = {}) {
|
|
13444
|
+
const { timeout = 1e4, followRedirects = false } = options;
|
|
13445
|
+
await validateUrl(urlString);
|
|
13446
|
+
const controller = new AbortController;
|
|
13447
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
13448
|
+
const start = Date.now();
|
|
13449
|
+
try {
|
|
13450
|
+
const response = await fetch(urlString, {
|
|
13451
|
+
method: "GET",
|
|
13452
|
+
signal: controller.signal,
|
|
13453
|
+
redirect: followRedirects ? "follow" : "manual",
|
|
13454
|
+
headers: {
|
|
13455
|
+
"User-Agent": "CodeConductor-SEO/0.3.0",
|
|
13456
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
|
|
13457
|
+
}
|
|
13458
|
+
});
|
|
13459
|
+
const responseTime = Date.now() - start;
|
|
13460
|
+
const body = await response.text();
|
|
13461
|
+
const headers = {};
|
|
13462
|
+
response.headers.forEach((value, key) => {
|
|
13463
|
+
headers[key] = value;
|
|
13464
|
+
});
|
|
13465
|
+
return {
|
|
13466
|
+
status: response.status,
|
|
13467
|
+
headers,
|
|
13468
|
+
body,
|
|
13469
|
+
responseTime,
|
|
13470
|
+
url: urlString
|
|
13471
|
+
};
|
|
13472
|
+
} catch (error) {
|
|
13473
|
+
const responseTime = Date.now() - start;
|
|
13474
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
13475
|
+
throw new Error(`Request timed out after ${timeout}ms: ${urlString}`);
|
|
13476
|
+
}
|
|
13477
|
+
throw new Error(`Fetch failed (${responseTime}ms): ${urlString} — ${String(error)}`);
|
|
13478
|
+
} finally {
|
|
13479
|
+
clearTimeout(timer);
|
|
13480
|
+
}
|
|
13481
|
+
}
|
|
13482
|
+
async function delay(ms) {
|
|
13483
|
+
return new Promise((resolve9) => setTimeout(resolve9, ms));
|
|
13484
|
+
}
|
|
13485
|
+
|
|
13486
|
+
// src/infrastructure/parsers/sitemap-parser.ts
|
|
13487
|
+
function extractTag(xml, tag) {
|
|
13488
|
+
const match = xml.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, "i"));
|
|
13489
|
+
return match ? match[1].trim() : undefined;
|
|
13490
|
+
}
|
|
13491
|
+
function extractAllBlocks(xml, tag) {
|
|
13492
|
+
const regex = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, "gi");
|
|
13493
|
+
const blocks = [];
|
|
13494
|
+
let match;
|
|
13495
|
+
while ((match = regex.exec(xml)) !== null) {
|
|
13496
|
+
blocks.push(match[1]);
|
|
13497
|
+
}
|
|
13498
|
+
return blocks;
|
|
13499
|
+
}
|
|
13500
|
+
function parseUrlEntries(xml) {
|
|
13501
|
+
const urlBlocks = extractAllBlocks(xml, "url");
|
|
13502
|
+
return urlBlocks.map((block) => ({
|
|
13503
|
+
url: extractTag(block, "loc") ?? "",
|
|
13504
|
+
lastmod: extractTag(block, "lastmod"),
|
|
13505
|
+
changefreq: extractTag(block, "changefreq"),
|
|
13506
|
+
priority: extractTag(block, "priority")
|
|
13507
|
+
})).filter((entry) => entry.url.length > 0);
|
|
13508
|
+
}
|
|
13509
|
+
function parseSitemapLocs(xml) {
|
|
13510
|
+
const sitemapBlocks = extractAllBlocks(xml, "sitemap");
|
|
13511
|
+
return sitemapBlocks.map((block) => extractTag(block, "loc")).filter((loc) => loc !== undefined && loc.length > 0);
|
|
13512
|
+
}
|
|
13513
|
+
function getDomain(url) {
|
|
13514
|
+
try {
|
|
13515
|
+
return new URL(url).hostname;
|
|
13516
|
+
} catch {
|
|
13517
|
+
return "";
|
|
13518
|
+
}
|
|
13519
|
+
}
|
|
13520
|
+
function deduplicateEntries(entries) {
|
|
13521
|
+
const seen = new Set;
|
|
13522
|
+
return entries.filter((entry) => {
|
|
13523
|
+
if (seen.has(entry.url))
|
|
13524
|
+
return false;
|
|
13525
|
+
seen.add(entry.url);
|
|
13526
|
+
return true;
|
|
13527
|
+
});
|
|
13528
|
+
}
|
|
13529
|
+
function filterSameDomain(entries, domain) {
|
|
13530
|
+
return entries.filter((entry) => {
|
|
13531
|
+
try {
|
|
13532
|
+
return new URL(entry.url).hostname === domain;
|
|
13533
|
+
} catch {
|
|
13534
|
+
return false;
|
|
13535
|
+
}
|
|
13536
|
+
});
|
|
13537
|
+
}
|
|
13538
|
+
async function parseSitemap(sitemapUrl, options = {}) {
|
|
13539
|
+
const { maxDepth = 2, delay: requestDelay = 0 } = options;
|
|
13540
|
+
return parseSitemapRecursive(sitemapUrl, 0, maxDepth, requestDelay);
|
|
13541
|
+
}
|
|
13542
|
+
async function parseSitemapRecursive(sitemapUrl, depth, maxDepth, requestDelay) {
|
|
13543
|
+
const response = await safeFetch(sitemapUrl);
|
|
13544
|
+
const xml = response.body;
|
|
13545
|
+
const domain = getDomain(sitemapUrl);
|
|
13546
|
+
const isIndex = /<sitemapindex[\s>]/i.test(xml);
|
|
13547
|
+
if (isIndex) {
|
|
13548
|
+
const childUrls = parseSitemapLocs(xml);
|
|
13549
|
+
if (depth >= maxDepth) {
|
|
13550
|
+
return {
|
|
13551
|
+
entries: [],
|
|
13552
|
+
type: "sitemapindex",
|
|
13553
|
+
childSitemaps: childUrls
|
|
13554
|
+
};
|
|
13555
|
+
}
|
|
13556
|
+
const allEntries = [];
|
|
13557
|
+
const allChildSitemaps = [...childUrls];
|
|
13558
|
+
for (let i = 0;i < childUrls.length; i++) {
|
|
13559
|
+
if (i > 0 && requestDelay > 0) {
|
|
13560
|
+
await delay(requestDelay);
|
|
13561
|
+
}
|
|
13562
|
+
try {
|
|
13563
|
+
const childResult = await parseSitemapRecursive(childUrls[i], depth + 1, maxDepth, requestDelay);
|
|
13564
|
+
allEntries.push(...childResult.entries);
|
|
13565
|
+
allChildSitemaps.push(...childResult.childSitemaps);
|
|
13566
|
+
} catch {}
|
|
13567
|
+
}
|
|
13568
|
+
const deduped2 = deduplicateEntries(allEntries);
|
|
13569
|
+
const filtered2 = domain ? filterSameDomain(deduped2, domain) : deduped2;
|
|
13570
|
+
return {
|
|
13571
|
+
entries: filtered2,
|
|
13572
|
+
type: "sitemapindex",
|
|
13573
|
+
childSitemaps: allChildSitemaps
|
|
13574
|
+
};
|
|
13575
|
+
}
|
|
13576
|
+
const entries = parseUrlEntries(xml);
|
|
13577
|
+
const deduped = deduplicateEntries(entries);
|
|
13578
|
+
const filtered = domain ? filterSameDomain(deduped, domain) : deduped;
|
|
13579
|
+
return {
|
|
13580
|
+
entries: filtered,
|
|
13581
|
+
type: "urlset",
|
|
13582
|
+
childSitemaps: []
|
|
13583
|
+
};
|
|
13584
|
+
}
|
|
13585
|
+
|
|
13586
|
+
// src/domain/seo/meta-validator.ts
|
|
13587
|
+
function extractMetaContent(html, name) {
|
|
13588
|
+
const patterns = [
|
|
13589
|
+
new RegExp(`<meta[^>]*name=["']${name}["'][^>]*content=["']([^"']*)["']`, "i"),
|
|
13590
|
+
new RegExp(`<meta[^>]*content=["']([^"']*)["'][^>]*name=["']${name}["']`, "i")
|
|
13591
|
+
];
|
|
13592
|
+
for (const pattern of patterns) {
|
|
13593
|
+
const match = html.match(pattern);
|
|
13594
|
+
if (match)
|
|
13595
|
+
return match[1].trim();
|
|
13596
|
+
}
|
|
13597
|
+
return;
|
|
13598
|
+
}
|
|
13599
|
+
function extractPropertyContent(html, property) {
|
|
13600
|
+
const patterns = [
|
|
13601
|
+
new RegExp(`<meta[^>]*property=["']${property}["'][^>]*content=["']([^"']*)["']`, "i"),
|
|
13602
|
+
new RegExp(`<meta[^>]*content=["']([^"']*)["'][^>]*property=["']${property}["']`, "i")
|
|
13603
|
+
];
|
|
13604
|
+
for (const pattern of patterns) {
|
|
13605
|
+
const match = html.match(pattern);
|
|
13606
|
+
if (match)
|
|
13607
|
+
return match[1].trim();
|
|
13608
|
+
}
|
|
13609
|
+
return;
|
|
13610
|
+
}
|
|
13611
|
+
function extractTitle(html) {
|
|
13612
|
+
const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
13613
|
+
return match ? match[1].trim() : undefined;
|
|
13614
|
+
}
|
|
13615
|
+
function extractCanonical(html) {
|
|
13616
|
+
const match = html.match(/<link[^>]*rel=["']canonical["'][^>]*href=["']([^"']*)["']/i);
|
|
13617
|
+
return match ? match[1].trim() : undefined;
|
|
13618
|
+
}
|
|
13619
|
+
function extractH1Tags(html) {
|
|
13620
|
+
const regex = /<h1[^>]*>([\s\S]*?)<\/h1>/gi;
|
|
13621
|
+
const results = [];
|
|
13622
|
+
let match;
|
|
13623
|
+
while ((match = regex.exec(html)) !== null) {
|
|
13624
|
+
results.push(match[1].trim());
|
|
13625
|
+
}
|
|
13626
|
+
return results;
|
|
13627
|
+
}
|
|
13628
|
+
function extractHeadingHierarchy(html) {
|
|
13629
|
+
const regex = /<(h[1-6])[^>]*>([\s\S]*?)<\/\1>/gi;
|
|
13630
|
+
const results = [];
|
|
13631
|
+
let match;
|
|
13632
|
+
while ((match = regex.exec(html)) !== null) {
|
|
13633
|
+
results.push({ tag: match[1].toLowerCase(), text: match[2].trim() });
|
|
13634
|
+
}
|
|
13635
|
+
return results;
|
|
13636
|
+
}
|
|
13637
|
+
function extractImgWithoutAlt(html) {
|
|
13638
|
+
const imgRegex = /<img[^>]*>/gi;
|
|
13639
|
+
let count = 0;
|
|
13640
|
+
let match;
|
|
13641
|
+
while ((match = imgRegex.exec(html)) !== null) {
|
|
13642
|
+
if (!/alt=["'][^"']+["']/i.test(match[0])) {
|
|
13643
|
+
count++;
|
|
13644
|
+
}
|
|
13645
|
+
}
|
|
13646
|
+
return count;
|
|
13647
|
+
}
|
|
13648
|
+
function extractHtmlLang(html) {
|
|
13649
|
+
const match = html.match(/<html[^>]*lang=["']([^"']*)["']/i);
|
|
13650
|
+
return match ? match[1].trim() : undefined;
|
|
13651
|
+
}
|
|
13652
|
+
function extractViewport(html) {
|
|
13653
|
+
return extractMetaContent(html, "viewport");
|
|
13654
|
+
}
|
|
13655
|
+
function extractInternalLinks(html, baseUrl) {
|
|
13656
|
+
let domain;
|
|
13657
|
+
try {
|
|
13658
|
+
domain = new URL(baseUrl).hostname;
|
|
13659
|
+
} catch {
|
|
13660
|
+
return 0;
|
|
13661
|
+
}
|
|
13662
|
+
const linkRegex = /href=["']([^"']*)["']/gi;
|
|
13663
|
+
let count = 0;
|
|
13664
|
+
let match;
|
|
13665
|
+
while ((match = linkRegex.exec(html)) !== null) {
|
|
13666
|
+
const href = match[1];
|
|
13667
|
+
if (href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:"))
|
|
13668
|
+
continue;
|
|
13669
|
+
try {
|
|
13670
|
+
const resolved = new URL(href, baseUrl);
|
|
13671
|
+
if (resolved.hostname === domain)
|
|
13672
|
+
count++;
|
|
13673
|
+
} catch {
|
|
13674
|
+
if (href.startsWith("/"))
|
|
13675
|
+
count++;
|
|
13676
|
+
}
|
|
13677
|
+
}
|
|
13678
|
+
return count;
|
|
13679
|
+
}
|
|
13680
|
+
function validateMeta(html, url, responseTime) {
|
|
13681
|
+
const checks = [];
|
|
13682
|
+
const title = extractTitle(html);
|
|
13683
|
+
if (!title) {
|
|
13684
|
+
checks.push({
|
|
13685
|
+
name: "title-tag",
|
|
13686
|
+
category: "meta",
|
|
13687
|
+
severity: "error",
|
|
13688
|
+
message: "Missing <title> tag",
|
|
13689
|
+
remediation: "Add a <title> tag with 30-60 characters describing the page content."
|
|
13690
|
+
});
|
|
13691
|
+
} else if (title.length < 30) {
|
|
13692
|
+
checks.push({
|
|
13693
|
+
name: "title-tag",
|
|
13694
|
+
category: "meta",
|
|
13695
|
+
severity: "warning",
|
|
13696
|
+
message: `Title too short (${title.length} chars): "${title}"`,
|
|
13697
|
+
remediation: "Expand title to 30-60 characters."
|
|
13698
|
+
});
|
|
13699
|
+
} else if (title.length > 60) {
|
|
13700
|
+
checks.push({
|
|
13701
|
+
name: "title-tag",
|
|
13702
|
+
category: "meta",
|
|
13703
|
+
severity: "warning",
|
|
13704
|
+
message: `Title too long (${title.length} chars): "${title}"`,
|
|
13705
|
+
remediation: "Shorten title to 30-60 characters to avoid truncation in SERPs."
|
|
13706
|
+
});
|
|
13707
|
+
} else {
|
|
13708
|
+
checks.push({
|
|
13709
|
+
name: "title-tag",
|
|
13710
|
+
category: "meta",
|
|
13711
|
+
severity: "pass",
|
|
13712
|
+
message: `Title OK (${title.length} chars): "${title}"`
|
|
13713
|
+
});
|
|
13714
|
+
}
|
|
13715
|
+
const description = extractMetaContent(html, "description");
|
|
13716
|
+
if (!description) {
|
|
13717
|
+
checks.push({
|
|
13718
|
+
name: "meta-description",
|
|
13719
|
+
category: "meta",
|
|
13720
|
+
severity: "error",
|
|
13721
|
+
message: 'Missing <meta name="description">',
|
|
13722
|
+
remediation: "Add a meta description with 120-160 characters summarizing the page."
|
|
13723
|
+
});
|
|
13724
|
+
} else if (description.length < 120) {
|
|
13725
|
+
checks.push({
|
|
13726
|
+
name: "meta-description",
|
|
13727
|
+
category: "meta",
|
|
13728
|
+
severity: "warning",
|
|
13729
|
+
message: `Description too short (${description.length} chars)`,
|
|
13730
|
+
remediation: "Expand description to 120-160 characters."
|
|
13731
|
+
});
|
|
13732
|
+
} else if (description.length > 160) {
|
|
13733
|
+
checks.push({
|
|
13734
|
+
name: "meta-description",
|
|
13735
|
+
category: "meta",
|
|
13736
|
+
severity: "warning",
|
|
13737
|
+
message: `Description too long (${description.length} chars)`,
|
|
13738
|
+
remediation: "Shorten description to 120-160 characters."
|
|
13739
|
+
});
|
|
13740
|
+
} else {
|
|
13741
|
+
checks.push({
|
|
13742
|
+
name: "meta-description",
|
|
13743
|
+
category: "meta",
|
|
13744
|
+
severity: "pass",
|
|
13745
|
+
message: `Description OK (${description.length} chars)`
|
|
13746
|
+
});
|
|
13747
|
+
}
|
|
13748
|
+
const canonical = extractCanonical(html);
|
|
13749
|
+
if (!canonical) {
|
|
13750
|
+
checks.push({
|
|
13751
|
+
name: "canonical",
|
|
13752
|
+
category: "meta",
|
|
13753
|
+
severity: "warning",
|
|
13754
|
+
message: 'Missing <link rel="canonical">',
|
|
13755
|
+
remediation: "Add a canonical URL to prevent duplicate content issues."
|
|
13756
|
+
});
|
|
13757
|
+
} else if (canonical !== url) {
|
|
13758
|
+
checks.push({
|
|
13759
|
+
name: "canonical",
|
|
13760
|
+
category: "meta",
|
|
13761
|
+
severity: "info",
|
|
13762
|
+
message: `Canonical (${canonical}) differs from current URL (${url})`
|
|
13763
|
+
});
|
|
13764
|
+
} else {
|
|
13765
|
+
checks.push({
|
|
13766
|
+
name: "canonical",
|
|
13767
|
+
category: "meta",
|
|
13768
|
+
severity: "pass",
|
|
13769
|
+
message: `Canonical matches URL`
|
|
13770
|
+
});
|
|
13771
|
+
}
|
|
13772
|
+
const robots = extractMetaContent(html, "robots");
|
|
13773
|
+
if (robots) {
|
|
13774
|
+
if (/noindex/i.test(robots)) {
|
|
13775
|
+
checks.push({
|
|
13776
|
+
name: "robots-noindex",
|
|
13777
|
+
category: "crawl",
|
|
13778
|
+
severity: "warning",
|
|
13779
|
+
message: `Page has noindex directive: "${robots}"`,
|
|
13780
|
+
remediation: "Remove noindex if this page should appear in search results."
|
|
13781
|
+
});
|
|
13782
|
+
}
|
|
13783
|
+
if (/nofollow/i.test(robots)) {
|
|
13784
|
+
checks.push({
|
|
13785
|
+
name: "robots-nofollow",
|
|
13786
|
+
category: "crawl",
|
|
13787
|
+
severity: "warning",
|
|
13788
|
+
message: `Page has nofollow directive: "${robots}"`
|
|
13789
|
+
});
|
|
13790
|
+
}
|
|
13791
|
+
} else {
|
|
13792
|
+
checks.push({
|
|
13793
|
+
name: "robots-directive",
|
|
13794
|
+
category: "crawl",
|
|
13795
|
+
severity: "pass",
|
|
13796
|
+
message: "No restrictive robots directive"
|
|
13797
|
+
});
|
|
13798
|
+
}
|
|
13799
|
+
const hreflangRegex = /hreflang=["']([^"']*)["']/gi;
|
|
13800
|
+
const hreflangs = [];
|
|
13801
|
+
let hreflangMatch;
|
|
13802
|
+
while ((hreflangMatch = hreflangRegex.exec(html)) !== null) {
|
|
13803
|
+
hreflangs.push(hreflangMatch[1]);
|
|
13804
|
+
}
|
|
13805
|
+
if (hreflangs.length > 0) {
|
|
13806
|
+
checks.push({
|
|
13807
|
+
name: "hreflang",
|
|
13808
|
+
category: "meta",
|
|
13809
|
+
severity: "pass",
|
|
13810
|
+
message: `Found ${hreflangs.length} hreflang tags: ${hreflangs.join(", ")}`
|
|
13811
|
+
});
|
|
13812
|
+
}
|
|
13813
|
+
const ogTitle = extractPropertyContent(html, "og:title");
|
|
13814
|
+
const ogDescription = extractPropertyContent(html, "og:description");
|
|
13815
|
+
const ogImage = extractPropertyContent(html, "og:image");
|
|
13816
|
+
const ogUrl = extractPropertyContent(html, "og:url");
|
|
13817
|
+
if (!ogTitle) {
|
|
13818
|
+
checks.push({
|
|
13819
|
+
name: "og-title",
|
|
13820
|
+
category: "social",
|
|
13821
|
+
severity: "warning",
|
|
13822
|
+
message: "Missing og:title",
|
|
13823
|
+
remediation: 'Add <meta property="og:title" content="..."> for social sharing.'
|
|
13824
|
+
});
|
|
13825
|
+
} else {
|
|
13826
|
+
checks.push({ name: "og-title", category: "social", severity: "pass", message: "og:title present" });
|
|
13827
|
+
}
|
|
13828
|
+
if (!ogDescription) {
|
|
13829
|
+
checks.push({
|
|
13830
|
+
name: "og-description",
|
|
13831
|
+
category: "social",
|
|
13832
|
+
severity: "warning",
|
|
13833
|
+
message: "Missing og:description",
|
|
13834
|
+
remediation: 'Add <meta property="og:description" content="...">.'
|
|
13835
|
+
});
|
|
13836
|
+
} else {
|
|
13837
|
+
checks.push({ name: "og-description", category: "social", severity: "pass", message: "og:description present" });
|
|
13838
|
+
}
|
|
13839
|
+
if (!ogImage) {
|
|
13840
|
+
checks.push({
|
|
13841
|
+
name: "og-image",
|
|
13842
|
+
category: "social",
|
|
13843
|
+
severity: "warning",
|
|
13844
|
+
message: "Missing og:image",
|
|
13845
|
+
remediation: 'Add <meta property="og:image" content="..."> with a 1200x630px image.'
|
|
13846
|
+
});
|
|
13847
|
+
} else {
|
|
13848
|
+
checks.push({ name: "og-image", category: "social", severity: "pass", message: "og:image present" });
|
|
13849
|
+
}
|
|
13850
|
+
if (!ogUrl) {
|
|
13851
|
+
checks.push({
|
|
13852
|
+
name: "og-url",
|
|
13853
|
+
category: "social",
|
|
13854
|
+
severity: "info",
|
|
13855
|
+
message: "Missing og:url"
|
|
13856
|
+
});
|
|
13857
|
+
}
|
|
13858
|
+
const twitterCard = extractMetaContent(html, "twitter:card");
|
|
13859
|
+
if (!twitterCard) {
|
|
13860
|
+
checks.push({
|
|
13861
|
+
name: "twitter-card",
|
|
13862
|
+
category: "social",
|
|
13863
|
+
severity: "info",
|
|
13864
|
+
message: "Missing twitter:card",
|
|
13865
|
+
remediation: 'Add <meta name="twitter:card" content="summary_large_image">.'
|
|
13866
|
+
});
|
|
13867
|
+
} else {
|
|
13868
|
+
checks.push({ name: "twitter-card", category: "social", severity: "pass", message: `twitter:card: ${twitterCard}` });
|
|
13869
|
+
}
|
|
13870
|
+
const h1Tags = extractH1Tags(html);
|
|
13871
|
+
if (h1Tags.length === 0) {
|
|
13872
|
+
checks.push({
|
|
13873
|
+
name: "h1-tag",
|
|
13874
|
+
category: "content",
|
|
13875
|
+
severity: "error",
|
|
13876
|
+
message: "Missing <h1> tag",
|
|
13877
|
+
remediation: "Add exactly one <h1> tag with the main page heading."
|
|
13878
|
+
});
|
|
13879
|
+
} else if (h1Tags.length > 1) {
|
|
13880
|
+
checks.push({
|
|
13881
|
+
name: "h1-tag",
|
|
13882
|
+
category: "content",
|
|
13883
|
+
severity: "warning",
|
|
13884
|
+
message: `Multiple <h1> tags found (${h1Tags.length}): "${h1Tags.join('", "')}"`,
|
|
13885
|
+
remediation: "Use only one <h1> per page. Convert extras to <h2>."
|
|
13886
|
+
});
|
|
13887
|
+
} else {
|
|
13888
|
+
checks.push({
|
|
13889
|
+
name: "h1-tag",
|
|
13890
|
+
category: "content",
|
|
13891
|
+
severity: "pass",
|
|
13892
|
+
message: `H1 OK: "${h1Tags[0]}"`
|
|
13893
|
+
});
|
|
13894
|
+
}
|
|
13895
|
+
const headings = extractHeadingHierarchy(html);
|
|
13896
|
+
if (headings.length > 1) {
|
|
13897
|
+
let hasSkips = false;
|
|
13898
|
+
for (let i = 1;i < headings.length; i++) {
|
|
13899
|
+
const prev = parseInt(headings[i - 1].tag[1]);
|
|
13900
|
+
const curr = parseInt(headings[i].tag[1]);
|
|
13901
|
+
if (curr > prev + 1) {
|
|
13902
|
+
hasSkips = true;
|
|
13903
|
+
break;
|
|
13904
|
+
}
|
|
13905
|
+
}
|
|
13906
|
+
if (hasSkips) {
|
|
13907
|
+
checks.push({
|
|
13908
|
+
name: "heading-hierarchy",
|
|
13909
|
+
category: "content",
|
|
13910
|
+
severity: "warning",
|
|
13911
|
+
message: "Heading hierarchy has skipped levels (e.g., h2 → h4)",
|
|
13912
|
+
remediation: "Ensure headings follow sequential order: h1 → h2 → h3."
|
|
13913
|
+
});
|
|
13914
|
+
} else {
|
|
13915
|
+
checks.push({
|
|
13916
|
+
name: "heading-hierarchy",
|
|
13917
|
+
category: "content",
|
|
13918
|
+
severity: "pass",
|
|
13919
|
+
message: `Heading hierarchy OK (${headings.length} headings)`
|
|
13920
|
+
});
|
|
13921
|
+
}
|
|
13922
|
+
}
|
|
13923
|
+
const imgsWithoutAlt = extractImgWithoutAlt(html);
|
|
13924
|
+
if (imgsWithoutAlt > 0) {
|
|
13925
|
+
checks.push({
|
|
13926
|
+
name: "img-alt-text",
|
|
13927
|
+
category: "content",
|
|
13928
|
+
severity: "warning",
|
|
13929
|
+
message: `${imgsWithoutAlt} image(s) missing alt text`,
|
|
13930
|
+
remediation: "Add descriptive alt text to all images for accessibility and SEO."
|
|
13931
|
+
});
|
|
13932
|
+
} else {
|
|
13933
|
+
checks.push({
|
|
13934
|
+
name: "img-alt-text",
|
|
13935
|
+
category: "content",
|
|
13936
|
+
severity: "pass",
|
|
13937
|
+
message: "All images have alt text"
|
|
13938
|
+
});
|
|
13939
|
+
}
|
|
13940
|
+
const internalLinks = extractInternalLinks(html, url);
|
|
13941
|
+
checks.push({
|
|
13942
|
+
name: "internal-links",
|
|
13943
|
+
category: "content",
|
|
13944
|
+
severity: internalLinks > 0 ? "pass" : "warning",
|
|
13945
|
+
message: `${internalLinks} internal links found`,
|
|
13946
|
+
remediation: internalLinks === 0 ? "Add internal links to improve crawlability and page authority." : undefined
|
|
13947
|
+
});
|
|
13948
|
+
const lang = extractHtmlLang(html);
|
|
13949
|
+
if (!lang) {
|
|
13950
|
+
checks.push({
|
|
13951
|
+
name: "html-lang",
|
|
13952
|
+
category: "technical",
|
|
13953
|
+
severity: "warning",
|
|
13954
|
+
message: 'Missing <html lang="..."> attribute',
|
|
13955
|
+
remediation: 'Add lang attribute to <html> tag (e.g., lang="en").'
|
|
13956
|
+
});
|
|
13957
|
+
} else {
|
|
13958
|
+
checks.push({
|
|
13959
|
+
name: "html-lang",
|
|
13960
|
+
category: "technical",
|
|
13961
|
+
severity: "pass",
|
|
13962
|
+
message: `HTML lang: ${lang}`
|
|
13963
|
+
});
|
|
13964
|
+
}
|
|
13965
|
+
const viewport = extractViewport(html);
|
|
13966
|
+
if (!viewport) {
|
|
13967
|
+
checks.push({
|
|
13968
|
+
name: "viewport",
|
|
13969
|
+
category: "technical",
|
|
13970
|
+
severity: "error",
|
|
13971
|
+
message: 'Missing <meta name="viewport">',
|
|
13972
|
+
remediation: 'Add <meta name="viewport" content="width=device-width, initial-scale=1">.'
|
|
13973
|
+
});
|
|
13974
|
+
} else {
|
|
13975
|
+
checks.push({
|
|
13976
|
+
name: "viewport",
|
|
13977
|
+
category: "technical",
|
|
13978
|
+
severity: "pass",
|
|
13979
|
+
message: "Viewport meta tag present"
|
|
13980
|
+
});
|
|
13981
|
+
}
|
|
13982
|
+
const isHttps = url.startsWith("https://");
|
|
13983
|
+
checks.push({
|
|
13984
|
+
name: "https",
|
|
13985
|
+
category: "technical",
|
|
13986
|
+
severity: isHttps ? "pass" : "error",
|
|
13987
|
+
message: isHttps ? "HTTPS enabled" : "Site is not using HTTPS",
|
|
13988
|
+
remediation: isHttps ? undefined : "Migrate to HTTPS for security and SEO ranking."
|
|
13989
|
+
});
|
|
13990
|
+
if (responseTime < 3000) {
|
|
13991
|
+
checks.push({
|
|
13992
|
+
name: "response-time",
|
|
13993
|
+
category: "technical",
|
|
13994
|
+
severity: "pass",
|
|
13995
|
+
message: `Response time: ${responseTime}ms`
|
|
13996
|
+
});
|
|
13997
|
+
} else if (responseTime < 5000) {
|
|
13998
|
+
checks.push({
|
|
13999
|
+
name: "response-time",
|
|
14000
|
+
category: "technical",
|
|
14001
|
+
severity: "warning",
|
|
14002
|
+
message: `Slow response time: ${responseTime}ms`,
|
|
14003
|
+
remediation: "Optimize server response time to under 3 seconds."
|
|
14004
|
+
});
|
|
14005
|
+
} else {
|
|
14006
|
+
checks.push({
|
|
14007
|
+
name: "response-time",
|
|
14008
|
+
category: "technical",
|
|
14009
|
+
severity: "error",
|
|
14010
|
+
message: `Very slow response time: ${responseTime}ms`,
|
|
14011
|
+
remediation: "Server response time exceeds 5 seconds. Investigate server performance."
|
|
14012
|
+
});
|
|
14013
|
+
}
|
|
14014
|
+
return checks;
|
|
14015
|
+
}
|
|
14016
|
+
|
|
14017
|
+
// src/domain/seo/schema-validator.ts
|
|
14018
|
+
var HOTEL_TYPES = [
|
|
14019
|
+
"Hotel",
|
|
14020
|
+
"LodgingBusiness",
|
|
14021
|
+
"HotelRoom",
|
|
14022
|
+
"LocalBusiness",
|
|
14023
|
+
"Resort",
|
|
14024
|
+
"Motel",
|
|
14025
|
+
"Hostel",
|
|
14026
|
+
"BedAndBreakfast",
|
|
14027
|
+
"Campground",
|
|
14028
|
+
"RV Park"
|
|
14029
|
+
];
|
|
14030
|
+
var SUPPORTING_TYPES = [
|
|
14031
|
+
"BreadcrumbList",
|
|
14032
|
+
"FAQPage",
|
|
14033
|
+
"Review",
|
|
14034
|
+
"AggregateRating",
|
|
14035
|
+
"Organization",
|
|
14036
|
+
"WebSite",
|
|
14037
|
+
"ImageObject",
|
|
14038
|
+
"Event",
|
|
14039
|
+
"TouristAttraction",
|
|
14040
|
+
"HowTo",
|
|
14041
|
+
"WebPage",
|
|
14042
|
+
"Person"
|
|
14043
|
+
];
|
|
14044
|
+
var REQUIRED_HOTEL_PROPERTIES = ["name", "address", "telephone", "image"];
|
|
14045
|
+
var RECOMMENDED_HOTEL_PROPERTIES = ["priceRange", "description", "url", "geo"];
|
|
14046
|
+
function extractJsonLdBlocks(html) {
|
|
14047
|
+
const regex = /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
|
14048
|
+
const blocks = [];
|
|
14049
|
+
let match;
|
|
14050
|
+
while ((match = regex.exec(html)) !== null) {
|
|
14051
|
+
try {
|
|
14052
|
+
const parsed = JSON.parse(match[1].trim());
|
|
14053
|
+
const items = Array.isArray(parsed) ? parsed : [parsed];
|
|
14054
|
+
for (const item of items) {
|
|
14055
|
+
if (item && typeof item === "object" && "@type" in item) {
|
|
14056
|
+
blocks.push({
|
|
14057
|
+
type: String(item["@type"]),
|
|
14058
|
+
properties: item,
|
|
14059
|
+
raw: match[1].trim()
|
|
14060
|
+
});
|
|
14061
|
+
}
|
|
14062
|
+
}
|
|
14063
|
+
} catch {}
|
|
14064
|
+
}
|
|
14065
|
+
return blocks;
|
|
14066
|
+
}
|
|
14067
|
+
function validateHotelSchema(block) {
|
|
14068
|
+
const errors3 = [];
|
|
14069
|
+
const warnings = [];
|
|
14070
|
+
for (const prop of REQUIRED_HOTEL_PROPERTIES) {
|
|
14071
|
+
if (!(prop in block.properties)) {
|
|
14072
|
+
errors3.push(`Missing required property: ${prop}`);
|
|
14073
|
+
}
|
|
14074
|
+
}
|
|
14075
|
+
for (const prop of RECOMMENDED_HOTEL_PROPERTIES) {
|
|
14076
|
+
if (!(prop in block.properties)) {
|
|
14077
|
+
warnings.push(`Missing recommended property: ${prop}`);
|
|
14078
|
+
}
|
|
14079
|
+
}
|
|
14080
|
+
if ("address" in block.properties) {
|
|
14081
|
+
const address = block.properties.address;
|
|
14082
|
+
if (typeof address === "object" && address !== null) {
|
|
14083
|
+
const addrObj = address;
|
|
14084
|
+
if (!("streetAddress" in addrObj) && !("addressLocality" in addrObj)) {
|
|
14085
|
+
errors3.push("Address missing streetAddress or addressLocality");
|
|
14086
|
+
}
|
|
14087
|
+
}
|
|
14088
|
+
}
|
|
14089
|
+
if ("image" in block.properties) {
|
|
14090
|
+
const image = block.properties.image;
|
|
14091
|
+
if (typeof image === "string" && !image.startsWith("http")) {
|
|
14092
|
+
warnings.push("Image URL should be absolute");
|
|
14093
|
+
}
|
|
14094
|
+
}
|
|
14095
|
+
return {
|
|
14096
|
+
valid: errors3.length === 0,
|
|
14097
|
+
type: block.type,
|
|
14098
|
+
errors: errors3,
|
|
14099
|
+
warnings
|
|
14100
|
+
};
|
|
14101
|
+
}
|
|
14102
|
+
function validateSupportingSchema(block) {
|
|
14103
|
+
const errors3 = [];
|
|
14104
|
+
const warnings = [];
|
|
14105
|
+
if (block.type === "BreadcrumbList") {
|
|
14106
|
+
if (!("itemListElement" in block.properties)) {
|
|
14107
|
+
errors3.push("BreadcrumbList missing itemListElement");
|
|
14108
|
+
}
|
|
14109
|
+
}
|
|
14110
|
+
if (block.type === "FAQPage") {
|
|
14111
|
+
if (!("mainEntity" in block.properties)) {
|
|
14112
|
+
errors3.push("FAQPage missing mainEntity");
|
|
14113
|
+
}
|
|
14114
|
+
}
|
|
14115
|
+
if (block.type === "AggregateRating") {
|
|
14116
|
+
if (!("ratingValue" in block.properties)) {
|
|
14117
|
+
errors3.push("AggregateRating missing ratingValue");
|
|
14118
|
+
}
|
|
14119
|
+
if (!("reviewCount" in block.properties) && !("ratingCount" in block.properties)) {
|
|
14120
|
+
warnings.push("AggregateRating missing reviewCount or ratingCount");
|
|
14121
|
+
}
|
|
14122
|
+
}
|
|
14123
|
+
if (block.type === "Organization") {
|
|
14124
|
+
if (!("name" in block.properties)) {
|
|
14125
|
+
errors3.push("Organization missing name");
|
|
14126
|
+
}
|
|
14127
|
+
if (!("url" in block.properties)) {
|
|
14128
|
+
warnings.push("Organization missing url");
|
|
14129
|
+
}
|
|
14130
|
+
}
|
|
14131
|
+
return {
|
|
14132
|
+
valid: errors3.length === 0,
|
|
14133
|
+
type: block.type,
|
|
14134
|
+
errors: errors3,
|
|
14135
|
+
warnings
|
|
14136
|
+
};
|
|
14137
|
+
}
|
|
14138
|
+
function validateSchema(html) {
|
|
14139
|
+
const checks = [];
|
|
14140
|
+
const blocks = extractJsonLdBlocks(html);
|
|
14141
|
+
if (blocks.length === 0) {
|
|
14142
|
+
checks.push({
|
|
14143
|
+
name: "json-ld-presence",
|
|
14144
|
+
category: "schema",
|
|
14145
|
+
severity: "error",
|
|
14146
|
+
message: "No JSON-LD structured data found",
|
|
14147
|
+
remediation: 'Add <script type="application/ld+json"> with Hotel or LodgingBusiness schema.'
|
|
14148
|
+
});
|
|
14149
|
+
return checks;
|
|
14150
|
+
}
|
|
14151
|
+
checks.push({
|
|
14152
|
+
name: "json-ld-presence",
|
|
14153
|
+
category: "schema",
|
|
14154
|
+
severity: "pass",
|
|
14155
|
+
message: `Found ${blocks.length} JSON-LD block(s)`
|
|
14156
|
+
});
|
|
14157
|
+
const hotelBlocks = blocks.filter((b) => HOTEL_TYPES.includes(b.type));
|
|
14158
|
+
const supportingBlocks = blocks.filter((b) => SUPPORTING_TYPES.includes(b.type));
|
|
14159
|
+
const unknownBlocks = blocks.filter((b) => !HOTEL_TYPES.includes(b.type) && !SUPPORTING_TYPES.includes(b.type));
|
|
14160
|
+
if (hotelBlocks.length === 0) {
|
|
14161
|
+
checks.push({
|
|
14162
|
+
name: "hotel-schema",
|
|
14163
|
+
category: "schema",
|
|
14164
|
+
severity: "error",
|
|
14165
|
+
message: `No Hotel/Hospitality schema found. Types found: ${blocks.map((b) => b.type).join(", ")}`,
|
|
14166
|
+
remediation: "Add a Hotel, LodgingBusiness, or Resort schema with required properties."
|
|
14167
|
+
});
|
|
14168
|
+
}
|
|
14169
|
+
for (const block of hotelBlocks) {
|
|
14170
|
+
const result = validateHotelSchema(block);
|
|
14171
|
+
if (result.valid) {
|
|
14172
|
+
checks.push({
|
|
14173
|
+
name: `schema-${block.type}`,
|
|
14174
|
+
category: "schema",
|
|
14175
|
+
severity: "pass",
|
|
14176
|
+
message: `${block.type} schema valid`
|
|
14177
|
+
});
|
|
14178
|
+
} else {
|
|
14179
|
+
checks.push({
|
|
14180
|
+
name: `schema-${block.type}`,
|
|
14181
|
+
category: "schema",
|
|
14182
|
+
severity: "error",
|
|
14183
|
+
message: `${block.type} schema invalid: ${result.errors.join("; ")}`,
|
|
14184
|
+
remediation: `Fix the following properties: ${result.errors.join(", ")}`
|
|
14185
|
+
});
|
|
14186
|
+
}
|
|
14187
|
+
if (result.warnings.length > 0) {
|
|
14188
|
+
checks.push({
|
|
14189
|
+
name: `schema-${block.type}-warnings`,
|
|
14190
|
+
category: "schema",
|
|
14191
|
+
severity: "warning",
|
|
14192
|
+
message: `${block.type} recommendations: ${result.warnings.join("; ")}`
|
|
14193
|
+
});
|
|
14194
|
+
}
|
|
14195
|
+
}
|
|
14196
|
+
for (const block of supportingBlocks) {
|
|
14197
|
+
const result = validateSupportingSchema(block);
|
|
14198
|
+
checks.push({
|
|
14199
|
+
name: `schema-${block.type}`,
|
|
14200
|
+
category: "schema",
|
|
14201
|
+
severity: result.valid ? "pass" : "warning",
|
|
14202
|
+
message: result.valid ? `${block.type} schema valid` : `${block.type} schema issues: ${result.errors.join("; ")}`
|
|
14203
|
+
});
|
|
14204
|
+
}
|
|
14205
|
+
for (const block of unknownBlocks) {
|
|
14206
|
+
checks.push({
|
|
14207
|
+
name: `schema-${block.type}`,
|
|
14208
|
+
category: "schema",
|
|
14209
|
+
severity: "info",
|
|
14210
|
+
message: `Unknown schema type: ${block.type}`
|
|
14211
|
+
});
|
|
14212
|
+
}
|
|
14213
|
+
return checks;
|
|
14214
|
+
}
|
|
14215
|
+
|
|
14216
|
+
// src/domain/seo/geo-validator.ts
|
|
14217
|
+
function hasFactualStatements(html) {
|
|
14218
|
+
const bodyText = html.replace(/<[^>]*>/g, " ");
|
|
14219
|
+
const numberPattern = /\d{2,}/g;
|
|
14220
|
+
const datePattern = /\b(20\d{2}|19\d{2})\b/g;
|
|
14221
|
+
const properNounPattern = /\b[A-Z][a-z]{2,}\b/g;
|
|
14222
|
+
const numbers = bodyText.match(numberPattern) ?? [];
|
|
14223
|
+
const dates = bodyText.match(datePattern) ?? [];
|
|
14224
|
+
const properNouns = bodyText.match(properNounPattern) ?? [];
|
|
14225
|
+
return numbers.length >= 3 || dates.length >= 1 || properNouns.length >= 5;
|
|
14226
|
+
}
|
|
14227
|
+
function countStructuredLists(html) {
|
|
14228
|
+
const ulMatches = html.match(/<ul[^>]*>/gi) ?? [];
|
|
14229
|
+
const olMatches = html.match(/<ol[^>]*>/gi) ?? [];
|
|
14230
|
+
const dlMatches = html.match(/<dl[^>]*>/gi) ?? [];
|
|
14231
|
+
return ulMatches.length + olMatches.length + dlMatches.length;
|
|
14232
|
+
}
|
|
14233
|
+
function hasFaqSection(html) {
|
|
14234
|
+
const patterns = [
|
|
14235
|
+
/faqpage/i,
|
|
14236
|
+
/<details[^>]*>/i,
|
|
14237
|
+
/id=["'][^"']*faq[^"']*["']/i,
|
|
14238
|
+
/class=["'][^"']*faq[^"']*["']/i,
|
|
14239
|
+
/<h[2-4][^>]*>.*(?:faq|frequently asked)/i
|
|
14240
|
+
];
|
|
14241
|
+
return patterns.some((p) => p.test(html));
|
|
14242
|
+
}
|
|
14243
|
+
function hasContentDates(html) {
|
|
14244
|
+
const timeRegex2 = /<time[^>]*datetime=["']([^"']*)["']/gi;
|
|
14245
|
+
let match;
|
|
14246
|
+
while ((match = timeRegex2.exec(html)) !== null) {
|
|
14247
|
+
return true;
|
|
14248
|
+
}
|
|
14249
|
+
const datePattern = /\b(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{4}\b/i;
|
|
14250
|
+
return datePattern.test(html);
|
|
14251
|
+
}
|
|
14252
|
+
function validateGeo(html, url) {
|
|
14253
|
+
const checks = [];
|
|
14254
|
+
if (hasFactualStatements(html)) {
|
|
14255
|
+
checks.push({
|
|
14256
|
+
name: "citable-content",
|
|
14257
|
+
category: "geo",
|
|
14258
|
+
severity: "pass",
|
|
14259
|
+
message: "Page contains factual statements suitable for AI citation"
|
|
14260
|
+
});
|
|
14261
|
+
} else {
|
|
14262
|
+
checks.push({
|
|
14263
|
+
name: "citable-content",
|
|
14264
|
+
category: "geo",
|
|
14265
|
+
severity: "warning",
|
|
14266
|
+
message: "Page lacks factual statements (numbers, dates, proper nouns) for AI citation",
|
|
14267
|
+
remediation: "Add specific facts, numbers, and named entities that AI assistants can cite."
|
|
14268
|
+
});
|
|
14269
|
+
}
|
|
14270
|
+
const listCount = countStructuredLists(html);
|
|
14271
|
+
if (listCount > 0) {
|
|
14272
|
+
checks.push({
|
|
14273
|
+
name: "structured-lists",
|
|
14274
|
+
category: "geo",
|
|
14275
|
+
severity: "pass",
|
|
14276
|
+
message: `Found ${listCount} structured list(s) (ul/ol/dl)`
|
|
14277
|
+
});
|
|
14278
|
+
} else {
|
|
14279
|
+
checks.push({
|
|
14280
|
+
name: "structured-lists",
|
|
14281
|
+
category: "geo",
|
|
14282
|
+
severity: "warning",
|
|
14283
|
+
message: "No structured lists found",
|
|
14284
|
+
remediation: "Add <ul> or <ol> lists for amenities, features, and services. AI tools extract structured lists for citations."
|
|
14285
|
+
});
|
|
14286
|
+
}
|
|
14287
|
+
if (hasFaqSection(html)) {
|
|
14288
|
+
checks.push({
|
|
14289
|
+
name: "faq-section",
|
|
14290
|
+
category: "geo",
|
|
14291
|
+
severity: "pass",
|
|
14292
|
+
message: "FAQ section detected"
|
|
14293
|
+
});
|
|
14294
|
+
} else {
|
|
14295
|
+
checks.push({
|
|
14296
|
+
name: "faq-section",
|
|
14297
|
+
category: "geo",
|
|
14298
|
+
severity: "warning",
|
|
14299
|
+
message: "No FAQ section found",
|
|
14300
|
+
remediation: "Add an FAQ section with FAQPage schema. AI search tools prioritize Q&A formatted content."
|
|
14301
|
+
});
|
|
14302
|
+
}
|
|
14303
|
+
if (hasContentDates(html)) {
|
|
14304
|
+
checks.push({
|
|
14305
|
+
name: "content-freshness",
|
|
14306
|
+
category: "geo",
|
|
14307
|
+
severity: "pass",
|
|
14308
|
+
message: "Content has date signals"
|
|
14309
|
+
});
|
|
14310
|
+
} else {
|
|
14311
|
+
checks.push({
|
|
14312
|
+
name: "content-freshness",
|
|
14313
|
+
category: "geo",
|
|
14314
|
+
severity: "info",
|
|
14315
|
+
message: "No date signals found in content",
|
|
14316
|
+
remediation: "Add <time> elements or visible dates to signal content freshness."
|
|
14317
|
+
});
|
|
14318
|
+
}
|
|
14319
|
+
return checks;
|
|
14320
|
+
}
|
|
14321
|
+
async function checkLlmsTxt(baseUrl) {
|
|
14322
|
+
const checks = [];
|
|
14323
|
+
let root;
|
|
14324
|
+
try {
|
|
14325
|
+
const parsed = new URL(baseUrl);
|
|
14326
|
+
root = `${parsed.protocol}//${parsed.host}`;
|
|
14327
|
+
} catch {
|
|
14328
|
+
return [{
|
|
14329
|
+
name: "llms-txt",
|
|
14330
|
+
category: "geo",
|
|
14331
|
+
severity: "error",
|
|
14332
|
+
message: `Invalid base URL: ${baseUrl}`
|
|
14333
|
+
}];
|
|
14334
|
+
}
|
|
14335
|
+
try {
|
|
14336
|
+
const response = await safeFetch(`${root}/llms.txt`);
|
|
14337
|
+
if (response.status === 200 && response.body.length > 0) {
|
|
14338
|
+
checks.push({
|
|
14339
|
+
name: "llms-txt",
|
|
14340
|
+
category: "geo",
|
|
14341
|
+
severity: "pass",
|
|
14342
|
+
message: `llms.txt found (${response.body.length} bytes)`
|
|
14343
|
+
});
|
|
14344
|
+
if (!response.body.startsWith("#")) {
|
|
14345
|
+
checks.push({
|
|
14346
|
+
name: "llms-txt-format",
|
|
14347
|
+
category: "geo",
|
|
14348
|
+
severity: "warning",
|
|
14349
|
+
message: "llms.txt should start with a # heading",
|
|
14350
|
+
remediation: "Format: # Site Name\\n> Description\\n\\n## Pages\\n- [Title](url): description"
|
|
14351
|
+
});
|
|
14352
|
+
} else {
|
|
14353
|
+
checks.push({
|
|
14354
|
+
name: "llms-txt-format",
|
|
14355
|
+
category: "geo",
|
|
14356
|
+
severity: "pass",
|
|
14357
|
+
message: "llms.txt format looks correct"
|
|
14358
|
+
});
|
|
14359
|
+
}
|
|
14360
|
+
} else {
|
|
14361
|
+
checks.push({
|
|
14362
|
+
name: "llms-txt",
|
|
14363
|
+
category: "geo",
|
|
14364
|
+
severity: "error",
|
|
14365
|
+
message: `llms.txt returned status ${response.status}`,
|
|
14366
|
+
remediation: "Create a llms.txt file at the site root following the llms.txt specification."
|
|
14367
|
+
});
|
|
14368
|
+
}
|
|
14369
|
+
} catch {
|
|
14370
|
+
checks.push({
|
|
14371
|
+
name: "llms-txt",
|
|
14372
|
+
category: "geo",
|
|
14373
|
+
severity: "error",
|
|
14374
|
+
message: "llms.txt not found or unreachable",
|
|
14375
|
+
remediation: "Create a llms.txt file at the site root. Use `codeconductor seo llms` to generate one."
|
|
14376
|
+
});
|
|
14377
|
+
}
|
|
14378
|
+
try {
|
|
14379
|
+
const response = await safeFetch(`${root}/llms-full.txt`);
|
|
14380
|
+
if (response.status === 200 && response.body.length > 0) {
|
|
14381
|
+
checks.push({
|
|
14382
|
+
name: "llms-full-txt",
|
|
14383
|
+
category: "geo",
|
|
14384
|
+
severity: "pass",
|
|
14385
|
+
message: `llms-full.txt found (${response.body.length} bytes)`
|
|
14386
|
+
});
|
|
14387
|
+
}
|
|
14388
|
+
} catch {
|
|
14389
|
+
checks.push({
|
|
14390
|
+
name: "llms-full-txt",
|
|
14391
|
+
category: "geo",
|
|
14392
|
+
severity: "info",
|
|
14393
|
+
message: "llms-full.txt not found (optional)",
|
|
14394
|
+
remediation: "Consider creating llms-full.txt with extended content for AI tools."
|
|
14395
|
+
});
|
|
14396
|
+
}
|
|
14397
|
+
return checks;
|
|
14398
|
+
}
|
|
14399
|
+
|
|
14400
|
+
// src/domain/seo/seo-auditor.ts
|
|
14401
|
+
function computeSummary(pages) {
|
|
14402
|
+
let passed = 0;
|
|
14403
|
+
let warnings = 0;
|
|
14404
|
+
let errors3 = 0;
|
|
14405
|
+
let total = 0;
|
|
14406
|
+
for (const page of pages) {
|
|
14407
|
+
for (const check of page.checks) {
|
|
14408
|
+
total++;
|
|
14409
|
+
if (check.severity === "pass")
|
|
14410
|
+
passed++;
|
|
14411
|
+
else if (check.severity === "warning" || check.severity === "info")
|
|
14412
|
+
warnings++;
|
|
14413
|
+
else if (check.severity === "error")
|
|
14414
|
+
errors3++;
|
|
14415
|
+
}
|
|
14416
|
+
}
|
|
14417
|
+
const score = total > 0 ? Math.round(passed / total * 100) : 0;
|
|
14418
|
+
return { total, passed, warnings, errors: errors3, score };
|
|
14419
|
+
}
|
|
14420
|
+
async function auditSingleUrl(url, options = {}) {
|
|
14421
|
+
const response = await safeFetch(url, {
|
|
14422
|
+
followRedirects: options.followRedirects ?? false
|
|
14423
|
+
});
|
|
14424
|
+
const html = response.body;
|
|
14425
|
+
const checks = [];
|
|
14426
|
+
checks.push(...validateMeta(html, url, response.responseTime));
|
|
14427
|
+
checks.push(...validateSchema(html));
|
|
14428
|
+
checks.push(...validateGeo(html, url));
|
|
14429
|
+
return {
|
|
14430
|
+
url,
|
|
14431
|
+
checks,
|
|
14432
|
+
responseTime: response.responseTime
|
|
14433
|
+
};
|
|
14434
|
+
}
|
|
14435
|
+
async function auditSitemap(sitemapUrl, options = {}) {
|
|
14436
|
+
const { delay: requestDelay = 500, followRedirects = false, maxUrls, onProgress } = options;
|
|
14437
|
+
const sitemapResult = await parseSitemap(sitemapUrl, { delay: requestDelay });
|
|
14438
|
+
let entries = sitemapResult.entries;
|
|
14439
|
+
if (maxUrls && entries.length > maxUrls) {
|
|
14440
|
+
entries = entries.slice(0, maxUrls);
|
|
14441
|
+
}
|
|
14442
|
+
const pages = [];
|
|
14443
|
+
for (let i = 0;i < entries.length; i++) {
|
|
14444
|
+
const entry = entries[i];
|
|
14445
|
+
if (onProgress) {
|
|
14446
|
+
onProgress(i + 1, entries.length, entry.url);
|
|
14447
|
+
}
|
|
14448
|
+
if (i > 0 && requestDelay > 0) {
|
|
14449
|
+
await delay(requestDelay);
|
|
14450
|
+
}
|
|
14451
|
+
try {
|
|
14452
|
+
const result = await auditSingleUrl(entry.url, { followRedirects });
|
|
14453
|
+
pages.push(result);
|
|
14454
|
+
} catch (error) {
|
|
14455
|
+
pages.push({
|
|
14456
|
+
url: entry.url,
|
|
14457
|
+
checks: [{
|
|
14458
|
+
name: "fetch-error",
|
|
14459
|
+
category: "technical",
|
|
14460
|
+
severity: "error",
|
|
14461
|
+
message: `Failed to fetch: ${String(error)}`
|
|
14462
|
+
}],
|
|
14463
|
+
responseTime: 0
|
|
14464
|
+
});
|
|
14465
|
+
}
|
|
14466
|
+
}
|
|
14467
|
+
try {
|
|
14468
|
+
let siteRoot;
|
|
14469
|
+
try {
|
|
14470
|
+
const parsed = new URL(sitemapUrl);
|
|
14471
|
+
siteRoot = `${parsed.protocol}//${parsed.host}`;
|
|
14472
|
+
} catch {
|
|
14473
|
+
siteRoot = sitemapUrl;
|
|
14474
|
+
}
|
|
14475
|
+
const llmsChecks = await checkLlmsTxt(siteRoot);
|
|
14476
|
+
if (pages.length > 0) {
|
|
14477
|
+
pages[0] = {
|
|
14478
|
+
...pages[0],
|
|
14479
|
+
checks: [...pages[0].checks, ...llmsChecks]
|
|
14480
|
+
};
|
|
14481
|
+
}
|
|
14482
|
+
} catch {}
|
|
14483
|
+
const summary = computeSummary(pages);
|
|
14484
|
+
return {
|
|
14485
|
+
target: sitemapUrl,
|
|
14486
|
+
timestamp: new Date().toISOString(),
|
|
14487
|
+
pages,
|
|
14488
|
+
summary
|
|
14489
|
+
};
|
|
14490
|
+
}
|
|
14491
|
+
async function auditUrl(url, options = {}) {
|
|
14492
|
+
const page = await auditSingleUrl(url, options);
|
|
14493
|
+
try {
|
|
14494
|
+
const llmsChecks = await checkLlmsTxt(url);
|
|
14495
|
+
page.checks.push(...llmsChecks);
|
|
14496
|
+
} catch {}
|
|
14497
|
+
const summary = computeSummary([page]);
|
|
14498
|
+
return {
|
|
14499
|
+
target: url,
|
|
14500
|
+
timestamp: new Date().toISOString(),
|
|
14501
|
+
pages: [page],
|
|
14502
|
+
summary
|
|
14503
|
+
};
|
|
14504
|
+
}
|
|
14505
|
+
|
|
14506
|
+
// src/domain/seo/report-formatter.ts
|
|
14507
|
+
var SEVERITY_ICONS = {
|
|
14508
|
+
pass: "✓",
|
|
14509
|
+
warning: "⚠",
|
|
14510
|
+
error: "✗",
|
|
14511
|
+
info: "ℹ"
|
|
14512
|
+
};
|
|
14513
|
+
var SEVERITY_COLORS = {
|
|
14514
|
+
pass: "\x1B[32m",
|
|
14515
|
+
warning: "\x1B[33m",
|
|
14516
|
+
error: "\x1B[31m",
|
|
14517
|
+
info: "\x1B[36m"
|
|
14518
|
+
};
|
|
14519
|
+
var RESET = "\x1B[0m";
|
|
14520
|
+
var BOLD = "\x1B[1m";
|
|
14521
|
+
var DIM = "\x1B[2m";
|
|
14522
|
+
function formatCli(report) {
|
|
14523
|
+
const lines = [];
|
|
14524
|
+
lines.push("");
|
|
14525
|
+
lines.push(`${BOLD}SEO Audit Report${RESET}`);
|
|
14526
|
+
lines.push(`${DIM}Target: ${report.target}${RESET}`);
|
|
14527
|
+
lines.push(`${DIM}Time: ${report.timestamp}${RESET}`);
|
|
14528
|
+
lines.push(`${DIM}Pages: ${report.pages.length}${RESET}`);
|
|
14529
|
+
lines.push("");
|
|
14530
|
+
for (const page of report.pages) {
|
|
14531
|
+
lines.push(`${BOLD}── ${page.url} (${page.responseTime}ms) ──${RESET}`);
|
|
14532
|
+
const grouped = groupByCategory(page.checks);
|
|
14533
|
+
for (const [category, checks] of grouped) {
|
|
14534
|
+
lines.push(` ${DIM}${category}${RESET}`);
|
|
14535
|
+
for (const check of checks) {
|
|
14536
|
+
const icon = SEVERITY_ICONS[check.severity];
|
|
14537
|
+
const color = SEVERITY_COLORS[check.severity];
|
|
14538
|
+
lines.push(` ${color}${icon}${RESET} ${check.name}: ${check.message}`);
|
|
14539
|
+
if (check.remediation && check.severity !== "pass") {
|
|
14540
|
+
lines.push(` ${DIM}→ ${check.remediation}${RESET}`);
|
|
14541
|
+
}
|
|
14542
|
+
}
|
|
14543
|
+
}
|
|
14544
|
+
lines.push("");
|
|
14545
|
+
}
|
|
14546
|
+
const { summary } = report;
|
|
14547
|
+
lines.push(`${BOLD}Summary${RESET}`);
|
|
14548
|
+
lines.push(` Score: ${summary.score}%`);
|
|
14549
|
+
lines.push(` ${SEVERITY_COLORS.pass}✓ ${summary.passed} passed${RESET}`);
|
|
14550
|
+
lines.push(` ${SEVERITY_COLORS.warning}⚠ ${summary.warnings} warnings${RESET}`);
|
|
14551
|
+
lines.push(` ${SEVERITY_COLORS.error}✗ ${summary.errors} errors${RESET}`);
|
|
14552
|
+
lines.push(` Total checks: ${summary.total}`);
|
|
14553
|
+
lines.push("");
|
|
14554
|
+
return lines.join(`
|
|
14555
|
+
`);
|
|
14556
|
+
}
|
|
14557
|
+
function formatJson(report) {
|
|
14558
|
+
return JSON.stringify(report, null, 2);
|
|
14559
|
+
}
|
|
14560
|
+
function formatMarkdown(report) {
|
|
14561
|
+
const lines = [];
|
|
14562
|
+
lines.push("# SEO Audit Report");
|
|
14563
|
+
lines.push("");
|
|
14564
|
+
lines.push(`- **Target:** ${report.target}`);
|
|
14565
|
+
lines.push(`- **Date:** ${report.timestamp}`);
|
|
14566
|
+
lines.push(`- **Pages audited:** ${report.pages.length}`);
|
|
14567
|
+
lines.push("");
|
|
14568
|
+
lines.push("## Summary");
|
|
14569
|
+
lines.push("");
|
|
14570
|
+
lines.push(`| Metric | Value |`);
|
|
14571
|
+
lines.push(`|--------|-------|`);
|
|
14572
|
+
lines.push(`| Score | ${report.summary.score}% |`);
|
|
14573
|
+
lines.push(`| Passed | ${report.summary.passed} |`);
|
|
14574
|
+
lines.push(`| Warnings | ${report.summary.warnings} |`);
|
|
14575
|
+
lines.push(`| Errors | ${report.summary.errors} |`);
|
|
14576
|
+
lines.push(`| Total checks | ${report.summary.total} |`);
|
|
14577
|
+
lines.push("");
|
|
14578
|
+
for (const page of report.pages) {
|
|
14579
|
+
lines.push(`## ${page.url}`);
|
|
14580
|
+
lines.push("");
|
|
14581
|
+
lines.push(`Response time: ${page.responseTime}ms`);
|
|
14582
|
+
lines.push("");
|
|
14583
|
+
const errors3 = page.checks.filter((c) => c.severity === "error");
|
|
14584
|
+
const warnings = page.checks.filter((c) => c.severity === "warning");
|
|
14585
|
+
const passed = page.checks.filter((c) => c.severity === "pass");
|
|
14586
|
+
const info = page.checks.filter((c) => c.severity === "info");
|
|
14587
|
+
if (errors3.length > 0) {
|
|
14588
|
+
lines.push("### Errors");
|
|
14589
|
+
lines.push("");
|
|
14590
|
+
for (const check of errors3) {
|
|
14591
|
+
lines.push(`- **${check.name}** (${check.category}): ${check.message}`);
|
|
14592
|
+
if (check.remediation) {
|
|
14593
|
+
lines.push(` - Fix: ${check.remediation}`);
|
|
14594
|
+
}
|
|
14595
|
+
}
|
|
14596
|
+
lines.push("");
|
|
14597
|
+
}
|
|
14598
|
+
if (warnings.length > 0) {
|
|
14599
|
+
lines.push("### Warnings");
|
|
14600
|
+
lines.push("");
|
|
14601
|
+
for (const check of warnings) {
|
|
14602
|
+
lines.push(`- **${check.name}** (${check.category}): ${check.message}`);
|
|
14603
|
+
if (check.remediation) {
|
|
14604
|
+
lines.push(` - Fix: ${check.remediation}`);
|
|
14605
|
+
}
|
|
14606
|
+
}
|
|
14607
|
+
lines.push("");
|
|
14608
|
+
}
|
|
14609
|
+
if (passed.length > 0) {
|
|
14610
|
+
lines.push("### Passed");
|
|
14611
|
+
lines.push("");
|
|
14612
|
+
for (const check of passed) {
|
|
14613
|
+
lines.push(`- ${check.name}: ${check.message}`);
|
|
14614
|
+
}
|
|
14615
|
+
lines.push("");
|
|
14616
|
+
}
|
|
14617
|
+
if (info.length > 0) {
|
|
14618
|
+
lines.push("### Info");
|
|
14619
|
+
lines.push("");
|
|
14620
|
+
for (const check of info) {
|
|
14621
|
+
lines.push(`- ${check.name}: ${check.message}`);
|
|
14622
|
+
}
|
|
14623
|
+
lines.push("");
|
|
14624
|
+
}
|
|
14625
|
+
}
|
|
14626
|
+
lines.push("---");
|
|
14627
|
+
lines.push(`*Generated by CodeConductor SEO Audit on ${report.timestamp.split("T")[0]}*`);
|
|
14628
|
+
return lines.join(`
|
|
14629
|
+
`);
|
|
14630
|
+
}
|
|
14631
|
+
function groupByCategory(checks) {
|
|
14632
|
+
const groups = new Map;
|
|
14633
|
+
for (const check of checks) {
|
|
14634
|
+
if (!groups.has(check.category)) {
|
|
14635
|
+
groups.set(check.category, []);
|
|
14636
|
+
}
|
|
14637
|
+
groups.get(check.category).push(check);
|
|
14638
|
+
}
|
|
14639
|
+
return groups;
|
|
14640
|
+
}
|
|
14641
|
+
function computeExitCode(report, failOn) {
|
|
14642
|
+
if (report.summary.errors > 0)
|
|
14643
|
+
return 1;
|
|
14644
|
+
if (failOn === "warning" && report.summary.warnings > 0)
|
|
14645
|
+
return 2;
|
|
14646
|
+
if (report.summary.warnings > 0)
|
|
14647
|
+
return 0;
|
|
14648
|
+
return 0;
|
|
14649
|
+
}
|
|
14650
|
+
|
|
14651
|
+
// src/commands/seo-audit.command.ts
|
|
14652
|
+
async function seoAuditCommand(options) {
|
|
14653
|
+
const { url, sitemap, format, failOn, delay: delay2, output, followRedirects } = options;
|
|
14654
|
+
if (!url && !sitemap) {
|
|
14655
|
+
return {
|
|
14656
|
+
code: 1,
|
|
14657
|
+
data: {
|
|
14658
|
+
success: false,
|
|
14659
|
+
command: "seo audit",
|
|
14660
|
+
errors: ["Either --url or --sitemap is required"]
|
|
14661
|
+
}
|
|
14662
|
+
};
|
|
14663
|
+
}
|
|
14664
|
+
try {
|
|
14665
|
+
const report = sitemap ? await auditSitemap(sitemap, {
|
|
14666
|
+
delay: delay2,
|
|
14667
|
+
followRedirects,
|
|
14668
|
+
onProgress: format === "cli" ? (current, total, pageUrl) => {
|
|
14669
|
+
process.stderr.write(`\r Auditing ${current}/${total}: ${pageUrl}`);
|
|
14670
|
+
} : undefined
|
|
14671
|
+
}) : await auditUrl(url, { followRedirects });
|
|
14672
|
+
if (format === "cli") {
|
|
14673
|
+
process.stderr.write("\r" + " ".repeat(80) + "\r");
|
|
14674
|
+
}
|
|
14675
|
+
let formattedOutput;
|
|
14676
|
+
switch (format) {
|
|
14677
|
+
case "json":
|
|
14678
|
+
formattedOutput = formatJson(report);
|
|
14679
|
+
break;
|
|
14680
|
+
case "markdown":
|
|
14681
|
+
formattedOutput = formatMarkdown(report);
|
|
14682
|
+
break;
|
|
14683
|
+
default:
|
|
14684
|
+
formattedOutput = formatCli(report);
|
|
14685
|
+
}
|
|
14686
|
+
if (output) {
|
|
14687
|
+
const outputPath = resolve9(options.projectRoot, output);
|
|
14688
|
+
await mkdir6(dirname4(outputPath), { recursive: true });
|
|
14689
|
+
await writeFile5(outputPath, formattedOutput, "utf-8");
|
|
14690
|
+
} else if (format === "markdown") {
|
|
14691
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
14692
|
+
const defaultPath = resolve9(options.projectRoot, "seo-reports", `audit-report-${timestamp}.md`);
|
|
14693
|
+
await mkdir6(dirname4(defaultPath), { recursive: true });
|
|
14694
|
+
await writeFile5(defaultPath, formattedOutput, "utf-8");
|
|
14695
|
+
process.stderr.write(`Report saved to: ${defaultPath}
|
|
14696
|
+
`);
|
|
14697
|
+
}
|
|
14698
|
+
const exitCode = computeExitCode(report, failOn);
|
|
14699
|
+
return {
|
|
14700
|
+
code: exitCode,
|
|
14701
|
+
data: {
|
|
14702
|
+
success: true,
|
|
14703
|
+
command: "seo audit",
|
|
14704
|
+
report,
|
|
14705
|
+
output: formattedOutput,
|
|
14706
|
+
outputFile: output ? resolve9(options.projectRoot, output) : format === "markdown" ? resolve9(options.projectRoot, "seo-reports") : undefined
|
|
14707
|
+
}
|
|
14708
|
+
};
|
|
14709
|
+
} catch (error) {
|
|
14710
|
+
return {
|
|
14711
|
+
code: 3,
|
|
14712
|
+
data: {
|
|
14713
|
+
success: false,
|
|
14714
|
+
command: "seo audit",
|
|
14715
|
+
errors: [String(error)]
|
|
14716
|
+
}
|
|
14717
|
+
};
|
|
14718
|
+
}
|
|
14719
|
+
}
|
|
14720
|
+
|
|
14721
|
+
// src/commands/seo-llms.command.ts
|
|
14722
|
+
import { writeFile as writeFile6, mkdir as mkdir7 } from "node:fs/promises";
|
|
14723
|
+
import { dirname as dirname5, resolve as resolve10 } from "node:path";
|
|
14724
|
+
|
|
14725
|
+
// src/domain/seo/llms-generator.ts
|
|
14726
|
+
function extractTitle2(html) {
|
|
14727
|
+
const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
14728
|
+
return match ? match[1].trim() : "";
|
|
14729
|
+
}
|
|
14730
|
+
function extractDescription(html) {
|
|
14731
|
+
const patterns = [
|
|
14732
|
+
/<meta[^>]*name=["']description["'][^>]*content=["']([^"']*)["']/i,
|
|
14733
|
+
/<meta[^>]*content=["']([^"']*)["'][^>]*name=["']description["']/i,
|
|
14734
|
+
/<meta[^>]*property=["']og:description["'][^>]*content=["']([^"']*)["']/i
|
|
14735
|
+
];
|
|
14736
|
+
for (const pattern of patterns) {
|
|
14737
|
+
const match = html.match(pattern);
|
|
14738
|
+
if (match)
|
|
14739
|
+
return match[1].trim();
|
|
14740
|
+
}
|
|
14741
|
+
return "";
|
|
14742
|
+
}
|
|
14743
|
+
function extractFirstParagraph(html) {
|
|
14744
|
+
const bodyHtml = html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<nav[\s\S]*?<\/nav>/gi, "").replace(/<header[\s\S]*?<\/header>/gi, "").replace(/<footer[\s\S]*?<\/footer>/gi, "");
|
|
14745
|
+
const match = bodyHtml.match(/<p[^>]*>([\s\S]*?)<\/p>/i);
|
|
14746
|
+
if (!match)
|
|
14747
|
+
return "";
|
|
14748
|
+
return match[1].replace(/<[^>]*>/g, "").trim().slice(0, 200);
|
|
14749
|
+
}
|
|
14750
|
+
function groupByPathSegment(entries) {
|
|
14751
|
+
const groups = new Map;
|
|
14752
|
+
for (const entry of entries) {
|
|
14753
|
+
try {
|
|
14754
|
+
const parsed = new URL(entry.url);
|
|
14755
|
+
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
14756
|
+
const group = segments.length > 0 ? `/${segments[0]}/` : "/";
|
|
14757
|
+
if (!groups.has(group)) {
|
|
14758
|
+
groups.set(group, []);
|
|
14759
|
+
}
|
|
14760
|
+
groups.get(group).push(entry);
|
|
14761
|
+
} catch {
|
|
14762
|
+
if (!groups.has("/")) {
|
|
14763
|
+
groups.set("/", []);
|
|
14764
|
+
}
|
|
14765
|
+
groups.get("/").push(entry);
|
|
14766
|
+
}
|
|
14767
|
+
}
|
|
14768
|
+
return groups;
|
|
14769
|
+
}
|
|
14770
|
+
function formatLlmsTxt(siteName, siteDescription, entries) {
|
|
14771
|
+
const lines = [];
|
|
14772
|
+
lines.push(`# ${siteName}`);
|
|
14773
|
+
if (siteDescription) {
|
|
14774
|
+
lines.push(`> ${siteDescription}`);
|
|
14775
|
+
}
|
|
14776
|
+
lines.push("");
|
|
14777
|
+
const groups = groupByPathSegment(entries);
|
|
14778
|
+
for (const [group, groupEntries] of groups) {
|
|
14779
|
+
const groupName = group === "/" ? "Main Pages" : group.replace(/\//g, "").replace(/-/g, " ");
|
|
14780
|
+
lines.push(`## ${groupName.charAt(0).toUpperCase() + groupName.slice(1)}`);
|
|
14781
|
+
lines.push("");
|
|
14782
|
+
for (const entry of groupEntries) {
|
|
14783
|
+
const desc = entry.description ? `: ${entry.description}` : "";
|
|
14784
|
+
lines.push(`- [${entry.title}](${entry.url})${desc}`);
|
|
14785
|
+
}
|
|
14786
|
+
lines.push("");
|
|
14787
|
+
}
|
|
14788
|
+
lines.push("---");
|
|
14789
|
+
lines.push(`Generated by CodeConductor SEO on ${new Date().toISOString().split("T")[0]}`);
|
|
14790
|
+
return lines.join(`
|
|
14791
|
+
`);
|
|
14792
|
+
}
|
|
14793
|
+
async function generateLlmsTxtFromSitemap(sitemapUrl, options = {}) {
|
|
14794
|
+
const { delay: requestDelay = 500, maxUrls, onProgress } = options;
|
|
14795
|
+
const sitemapResult = await parseSitemap(sitemapUrl, { delay: requestDelay });
|
|
14796
|
+
let urls = sitemapResult.entries.map((e) => e.url);
|
|
14797
|
+
if (maxUrls && urls.length > maxUrls) {
|
|
14798
|
+
urls = urls.slice(0, maxUrls);
|
|
14799
|
+
}
|
|
14800
|
+
const entries = [];
|
|
14801
|
+
let siteName = "";
|
|
14802
|
+
let siteDescription = "";
|
|
14803
|
+
for (let i = 0;i < urls.length; i++) {
|
|
14804
|
+
const url = urls[i];
|
|
14805
|
+
if (onProgress) {
|
|
14806
|
+
onProgress(i + 1, urls.length, url);
|
|
14807
|
+
}
|
|
14808
|
+
if (i > 0 && requestDelay > 0) {
|
|
14809
|
+
await delay(requestDelay);
|
|
14810
|
+
}
|
|
14811
|
+
try {
|
|
14812
|
+
const response = await safeFetch(url);
|
|
14813
|
+
const html = response.body;
|
|
14814
|
+
const title = extractTitle2(html) || url;
|
|
14815
|
+
const description = extractDescription(html) || extractFirstParagraph(html);
|
|
14816
|
+
if (i === 0) {
|
|
14817
|
+
siteName = title;
|
|
14818
|
+
siteDescription = extractDescription(html);
|
|
14819
|
+
}
|
|
14820
|
+
entries.push({ title, url, description });
|
|
14821
|
+
} catch {
|
|
14822
|
+
entries.push({ title: url, url, description: "" });
|
|
14823
|
+
}
|
|
14824
|
+
}
|
|
14825
|
+
if (!siteName) {
|
|
14826
|
+
try {
|
|
14827
|
+
siteName = new URL(sitemapUrl).hostname;
|
|
14828
|
+
} catch {
|
|
14829
|
+
siteName = "Site";
|
|
14830
|
+
}
|
|
14831
|
+
}
|
|
14832
|
+
const content = formatLlmsTxt(siteName, siteDescription, entries);
|
|
14833
|
+
return { content, entries };
|
|
14834
|
+
}
|
|
14835
|
+
async function generateLlmsTxtFromUrl(url) {
|
|
14836
|
+
const response = await safeFetch(url);
|
|
14837
|
+
const html = response.body;
|
|
14838
|
+
const title = extractTitle2(html) || url;
|
|
14839
|
+
const description = extractDescription(html) || extractFirstParagraph(html);
|
|
14840
|
+
const entries = [{ title, url, description }];
|
|
14841
|
+
const content = formatLlmsTxt(title, description, entries);
|
|
14842
|
+
return { content, entries };
|
|
14843
|
+
}
|
|
14844
|
+
|
|
14845
|
+
// src/commands/seo-llms.command.ts
|
|
14846
|
+
async function seoLlmsCommand(options) {
|
|
14847
|
+
const { url, sitemap, output, delay: delay2 } = options;
|
|
14848
|
+
if (!url && !sitemap) {
|
|
14849
|
+
return {
|
|
14850
|
+
code: 1,
|
|
14851
|
+
data: {
|
|
14852
|
+
success: false,
|
|
14853
|
+
command: "seo llms",
|
|
14854
|
+
errors: ["Either --url or --sitemap is required"]
|
|
14855
|
+
}
|
|
14856
|
+
};
|
|
14857
|
+
}
|
|
14858
|
+
try {
|
|
14859
|
+
const result = sitemap ? await generateLlmsTxtFromSitemap(sitemap, {
|
|
14860
|
+
delay: delay2,
|
|
14861
|
+
onProgress: (current, total, pageUrl) => {
|
|
14862
|
+
process.stderr.write(`\r Processing ${current}/${total}: ${pageUrl}`);
|
|
14863
|
+
}
|
|
14864
|
+
}) : await generateLlmsTxtFromUrl(url);
|
|
14865
|
+
process.stderr.write("\r" + " ".repeat(80) + "\r");
|
|
14866
|
+
const outputPath = output ? resolve10(options.projectRoot, output) : resolve10(options.projectRoot, "llms.txt");
|
|
14867
|
+
await mkdir7(dirname5(outputPath), { recursive: true });
|
|
14868
|
+
await writeFile6(outputPath, result.content, "utf-8");
|
|
14869
|
+
process.stderr.write(`Generated: ${outputPath} (${result.entries.length} entries)
|
|
14870
|
+
`);
|
|
14871
|
+
return {
|
|
14872
|
+
code: 0,
|
|
14873
|
+
data: {
|
|
14874
|
+
success: true,
|
|
14875
|
+
command: "seo llms",
|
|
14876
|
+
outputFile: outputPath,
|
|
14877
|
+
entries: result.entries.length,
|
|
14878
|
+
content: result.content
|
|
14879
|
+
}
|
|
14880
|
+
};
|
|
14881
|
+
} catch (error) {
|
|
14882
|
+
return {
|
|
14883
|
+
code: 3,
|
|
14884
|
+
data: {
|
|
14885
|
+
success: false,
|
|
14886
|
+
command: "seo llms",
|
|
14887
|
+
errors: [String(error)]
|
|
14888
|
+
}
|
|
14889
|
+
};
|
|
14890
|
+
}
|
|
14891
|
+
}
|
|
14892
|
+
|
|
13400
14893
|
// src/commands/update.command.ts
|
|
13401
14894
|
async function updateCommand(options) {
|
|
13402
14895
|
const { dryRun, force, output, projectRoot } = options;
|
|
@@ -13599,6 +15092,8 @@ Commands:
|
|
|
13599
15092
|
install council Install generated council spec files to runner targets
|
|
13600
15093
|
install preset Install full preset (agents, prompts, skills, commands)
|
|
13601
15094
|
install lsp Install and configure LSP servers for AI coding tools
|
|
15095
|
+
seo audit Run SEO audit on a URL or sitemap
|
|
15096
|
+
seo llms Generate llms.txt from a URL or sitemap
|
|
13602
15097
|
doctor Validate configuration and generated files
|
|
13603
15098
|
update Update installed presets
|
|
13604
15099
|
|
|
@@ -13629,6 +15124,11 @@ Examples:
|
|
|
13629
15124
|
npx cc-codeconductor install lsp --target claude --dry-run
|
|
13630
15125
|
npx cc-codeconductor doctor
|
|
13631
15126
|
npx cc-codeconductor update --dry-run
|
|
15127
|
+
npx cc-codeconductor seo audit --url https://example.com
|
|
15128
|
+
npx cc-codeconductor seo audit --sitemap https://example.com/sitemap.xml
|
|
15129
|
+
npx cc-codeconductor seo audit --sitemap https://example.com/sitemap.xml --format markdown
|
|
15130
|
+
npx cc-codeconductor seo llms --sitemap https://example.com/sitemap.xml
|
|
15131
|
+
npx cc-codeconductor seo llms --url https://example.com --output llms.txt
|
|
13632
15132
|
`;
|
|
13633
15133
|
}
|
|
13634
15134
|
async function routeCommand(args, projectRoot) {
|
|
@@ -13701,6 +15201,37 @@ async function routeCommand(args, projectRoot) {
|
|
|
13701
15201
|
force: flags.force,
|
|
13702
15202
|
output: flags.output
|
|
13703
15203
|
});
|
|
15204
|
+
case "seo": {
|
|
15205
|
+
if (subcommand === "audit") {
|
|
15206
|
+
return seoAuditCommand({
|
|
15207
|
+
url: options.url,
|
|
15208
|
+
sitemap: options.sitemap,
|
|
15209
|
+
format: options.format ?? (flags.output === "json" ? "json" : "cli"),
|
|
15210
|
+
failOn: options["fail-on"] ?? "error",
|
|
15211
|
+
delay: options.delay ? parseInt(String(options.delay), 10) : 500,
|
|
15212
|
+
output: options.output,
|
|
15213
|
+
followRedirects: options["follow-redirects"] === true,
|
|
15214
|
+
projectRoot
|
|
15215
|
+
});
|
|
15216
|
+
}
|
|
15217
|
+
if (subcommand === "llms") {
|
|
15218
|
+
return seoLlmsCommand({
|
|
15219
|
+
url: options.url,
|
|
15220
|
+
sitemap: options.sitemap,
|
|
15221
|
+
output: options.output,
|
|
15222
|
+
delay: options.delay ? parseInt(String(options.delay), 10) : 500,
|
|
15223
|
+
projectRoot
|
|
15224
|
+
});
|
|
15225
|
+
}
|
|
15226
|
+
return {
|
|
15227
|
+
code: 1,
|
|
15228
|
+
data: {
|
|
15229
|
+
success: false,
|
|
15230
|
+
command: "seo",
|
|
15231
|
+
errors: ["Usage: seo audit|llms. Run `codeconductor seo audit --help` for details."]
|
|
15232
|
+
}
|
|
15233
|
+
};
|
|
15234
|
+
}
|
|
13704
15235
|
default:
|
|
13705
15236
|
return {
|
|
13706
15237
|
code: 1,
|
|
@@ -13792,6 +15323,8 @@ ${count} files processed${errCount > 0 ? `, ${errCount} errors` : ""}${note}`);
|
|
|
13792
15323
|
console.log(` - ${key}: ${value}`);
|
|
13793
15324
|
}
|
|
13794
15325
|
});
|
|
15326
|
+
} else if ("output" in data && typeof data.output === "string") {
|
|
15327
|
+
console.log(data.output);
|
|
13795
15328
|
}
|
|
13796
15329
|
}
|
|
13797
15330
|
}
|