aeo-ready 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.aeo-ready/dashboard.html +339 -0
- package/README.md +116 -0
- package/bin/cli.js +106 -0
- package/package.json +35 -0
- package/skills/agent-web/SKILL.md +135 -0
- package/skills/agent-web/best-practices.md +303 -0
- package/src/benchmark/agentic-seo.js +40 -0
- package/src/checks/agent-readiness/actionable.js +165 -0
- package/src/checks/agent-readiness/capability.js +209 -0
- package/src/checks/agent-readiness/content-structure.js +242 -0
- package/src/checks/agent-readiness/discovery.js +231 -0
- package/src/checks/ai-visibility/authority.js +195 -0
- package/src/checks/ai-visibility/citation-readiness.js +228 -0
- package/src/checks/ai-visibility/freshness.js +182 -0
- package/src/checks/ai-visibility/structured-data.js +180 -0
- package/src/dashboard/generate.js +156 -0
- package/src/dashboard/sections/agent-readiness.js +71 -0
- package/src/dashboard/sections/ai-visibility.js +67 -0
- package/src/dashboard/sections/history-table.js +36 -0
- package/src/dashboard/sections/overall-score.js +128 -0
- package/src/dashboard/sections/recommendations.js +49 -0
- package/src/dashboard/sections/trend-chart.js +78 -0
- package/src/fix/generators/agents-json.js +73 -0
- package/src/fix/generators/agents-md.js +85 -0
- package/src/fix/generators/llms-txt.js +166 -0
- package/src/fix/generators/robots-txt.js +64 -0
- package/src/fix/index.js +177 -0
- package/src/history/index.js +47 -0
- package/src/index.js +2 -0
- package/src/scan.js +358 -0
- package/src/track/index.js +167 -0
- package/src/utils/detect-type.js +99 -0
- package/src/utils/fetch.js +42 -0
- package/src/utils/tokens.js +18 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
export async function runAuthorityChecks(context) {
|
|
2
|
+
const checks = [
|
|
3
|
+
checkEeat(context),
|
|
4
|
+
checkEntityOptimization(context),
|
|
5
|
+
checkExternalValidation(context),
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
const score = checks.reduce((sum, c) => sum + (c.passed ? c.points : 0), 0);
|
|
9
|
+
return { score, maxScore: 13, checks };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function checkEeat(context) {
|
|
13
|
+
const allContent = getAllContent(context);
|
|
14
|
+
const html = context.html || "";
|
|
15
|
+
|
|
16
|
+
let points = 0;
|
|
17
|
+
const signals = [];
|
|
18
|
+
|
|
19
|
+
const hasAuthorBio =
|
|
20
|
+
/author|written by|by [A-Z]/i.test(allContent) &&
|
|
21
|
+
(/bio|about the author|expertise|experience/i.test(allContent) ||
|
|
22
|
+
allContent.includes("Person"));
|
|
23
|
+
if (hasAuthorBio) {
|
|
24
|
+
points += 1;
|
|
25
|
+
signals.push("author bio");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const hasCredentials =
|
|
29
|
+
/credential|certified|years of experience|expert|specialist|phd|mba/i.test(
|
|
30
|
+
allContent,
|
|
31
|
+
);
|
|
32
|
+
if (hasCredentials) {
|
|
33
|
+
points += 1;
|
|
34
|
+
signals.push("credentials");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const hasExperience =
|
|
38
|
+
/built|shipped|worked on|founded|created|contributed to/i.test(allContent);
|
|
39
|
+
if (hasExperience) {
|
|
40
|
+
points += 1;
|
|
41
|
+
signals.push("experience signals");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const hasPersonSchema =
|
|
45
|
+
allContent.includes('"Person"') || allContent.includes("'Person'");
|
|
46
|
+
if (hasPersonSchema) {
|
|
47
|
+
points += 1;
|
|
48
|
+
signals.push("Person schema");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (points === 4) return pass("E-E-A-T signals", 4);
|
|
52
|
+
if (points > 0) {
|
|
53
|
+
const missing = [];
|
|
54
|
+
if (!hasAuthorBio) missing.push("author bios");
|
|
55
|
+
if (!hasCredentials) missing.push("credentials");
|
|
56
|
+
if (!hasExperience) missing.push("first-hand experience");
|
|
57
|
+
if (!hasPersonSchema) missing.push("Person schema");
|
|
58
|
+
return partial(
|
|
59
|
+
"E-E-A-T signals",
|
|
60
|
+
points,
|
|
61
|
+
4,
|
|
62
|
+
`Has: ${signals.join(", ")}. Missing: ${missing.join(", ")}.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return fail(
|
|
66
|
+
"E-E-A-T signals",
|
|
67
|
+
4,
|
|
68
|
+
"No E-E-A-T signals (author bios, credentials, experience markers, Person schema). AI prioritizes authoritative sources.",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function checkEntityOptimization(context) {
|
|
73
|
+
const allContent = getAllContent(context);
|
|
74
|
+
|
|
75
|
+
let points = 0;
|
|
76
|
+
const signals = [];
|
|
77
|
+
|
|
78
|
+
const hasSameAs = allContent.includes("sameAs");
|
|
79
|
+
if (hasSameAs) {
|
|
80
|
+
points += 1;
|
|
81
|
+
signals.push("sameAs links");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const hasConsistentNaming = checkNameConsistency(allContent, context);
|
|
85
|
+
if (hasConsistentNaming) {
|
|
86
|
+
points += 1;
|
|
87
|
+
signals.push("consistent naming");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const hasIdentifiers = /github\.com|linkedin\.com|twitter\.com|x\.com/i.test(
|
|
91
|
+
allContent,
|
|
92
|
+
);
|
|
93
|
+
if (hasIdentifiers) {
|
|
94
|
+
points += 2;
|
|
95
|
+
signals.push("cross-platform identifiers");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (points >= 3) return pass("Entity optimization", 3);
|
|
99
|
+
if (points > 0) {
|
|
100
|
+
return partial(
|
|
101
|
+
"Entity optimization",
|
|
102
|
+
Math.min(points, 2),
|
|
103
|
+
3,
|
|
104
|
+
`Entity signals: ${signals.join(", ")}. Add sameAs links and consistent naming across platforms.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return fail(
|
|
108
|
+
"Entity optimization",
|
|
109
|
+
3,
|
|
110
|
+
"No entity optimization. Add sameAs links, consistent naming, and cross-platform profile links for AI entity resolution.",
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function checkExternalValidation(context) {
|
|
115
|
+
const allContent = getAllContent(context);
|
|
116
|
+
|
|
117
|
+
let points = 0;
|
|
118
|
+
|
|
119
|
+
const hasExternalLinks =
|
|
120
|
+
/https?:\/\/(?!.*(?:localhost|127\.0\.0|example\.com))/i.test(allContent);
|
|
121
|
+
if (hasExternalLinks) points += 1;
|
|
122
|
+
|
|
123
|
+
const hasCitations =
|
|
124
|
+
/\[\d+\]|source:|reference:|according to|research shows/i.test(allContent);
|
|
125
|
+
if (hasCitations) points += 1;
|
|
126
|
+
|
|
127
|
+
const hasDataClaims =
|
|
128
|
+
/\d+%|\d+x|\$[\d,]+|increased by|reduced by|improved/i.test(allContent);
|
|
129
|
+
if (hasDataClaims) points += 1;
|
|
130
|
+
|
|
131
|
+
if (points === 3) return pass("External validation & references", 3);
|
|
132
|
+
if (points > 0) {
|
|
133
|
+
const missing = [];
|
|
134
|
+
if (!hasExternalLinks) missing.push("external source links");
|
|
135
|
+
if (!hasCitations) missing.push("cited references");
|
|
136
|
+
if (!hasDataClaims) missing.push("quantified claims");
|
|
137
|
+
return partial(
|
|
138
|
+
"External validation & references",
|
|
139
|
+
points,
|
|
140
|
+
3,
|
|
141
|
+
`Add: ${missing.join(", ")}. AI trusts content that cites sources.`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return fail(
|
|
145
|
+
"External validation & references",
|
|
146
|
+
3,
|
|
147
|
+
"No external validation — no cited sources, references, or quantified claims. AI weighs evidence-backed content higher.",
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function checkNameConsistency(content, context) {
|
|
152
|
+
if (context.mode === "url" && context.url) {
|
|
153
|
+
try {
|
|
154
|
+
const hostname = new URL(context.url).hostname
|
|
155
|
+
.replace("www.", "")
|
|
156
|
+
.split(".")[0];
|
|
157
|
+
const mentions =
|
|
158
|
+
content.toLowerCase().split(hostname.toLowerCase()).length - 1;
|
|
159
|
+
return mentions >= 3;
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const pkg = context.fileContents?.["package.json"];
|
|
165
|
+
if (pkg) {
|
|
166
|
+
try {
|
|
167
|
+
const name = JSON.parse(pkg).name;
|
|
168
|
+
return name && content.split(name).length > 3;
|
|
169
|
+
} catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function getAllContent(context) {
|
|
177
|
+
if (context.mode === "url") {
|
|
178
|
+
return Object.values(context.pages || {})
|
|
179
|
+
.map((p) => p?.text || "")
|
|
180
|
+
.join("\n");
|
|
181
|
+
}
|
|
182
|
+
return Object.values(context.fileContents || {}).join("\n");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function pass(name, points) {
|
|
186
|
+
return { name, passed: true, points, maxPoints: points };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function partial(name, points, maxPoints, fix) {
|
|
190
|
+
return { name, passed: false, points, maxPoints, fix };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function fail(name, maxPoints, fix) {
|
|
194
|
+
return { name, passed: false, points: 0, maxPoints, fix };
|
|
195
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
export async function runCitationReadinessChecks(context) {
|
|
2
|
+
const checks = [
|
|
3
|
+
checkDirectAnswerFormat(context),
|
|
4
|
+
checkQuestionHeadings(context),
|
|
5
|
+
checkCitationStructure(context),
|
|
6
|
+
checkDefinitiveStatements(context),
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
const score = checks.reduce((sum, c) => sum + (c.passed ? c.points : 0), 0);
|
|
10
|
+
return { score, maxScore: 15, checks };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function checkDirectAnswerFormat(context) {
|
|
14
|
+
const pages = getPageContents(context);
|
|
15
|
+
if (pages.length === 0) {
|
|
16
|
+
return fail("Direct answer formatting", 4, "No content pages to analyze.");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let directAnswerCount = 0;
|
|
20
|
+
for (const page of pages.slice(0, 10)) {
|
|
21
|
+
const paragraphs = extractParagraphs(page.content);
|
|
22
|
+
const firstSubstantial = paragraphs.find((p) => p.length > 80);
|
|
23
|
+
if (firstSubstantial) {
|
|
24
|
+
const wordCount = firstSubstantial.split(/\s+/).length;
|
|
25
|
+
if (wordCount >= 30 && wordCount <= 80) directAnswerCount++;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ratio = directAnswerCount / Math.min(pages.length, 10);
|
|
30
|
+
if (ratio >= 0.6) return pass("Direct answer formatting", 4);
|
|
31
|
+
if (ratio >= 0.3)
|
|
32
|
+
return partial(
|
|
33
|
+
"Direct answer formatting",
|
|
34
|
+
2,
|
|
35
|
+
4,
|
|
36
|
+
`Only ${Math.round(ratio * 100)}% of pages lead with a 40-60 word summary. AI systems extract these as citations.`,
|
|
37
|
+
);
|
|
38
|
+
return partial(
|
|
39
|
+
"Direct answer formatting",
|
|
40
|
+
1,
|
|
41
|
+
4,
|
|
42
|
+
"Most pages don't lead with a concise summary (40-60 words). This is what AI systems cite.",
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function checkQuestionHeadings(context) {
|
|
47
|
+
const content = getAllTextContent(context);
|
|
48
|
+
if (!content) {
|
|
49
|
+
return fail("Question-based headings", 4, "No content to analyze.");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const headings = extractHeadings(content);
|
|
53
|
+
if (headings.length === 0) {
|
|
54
|
+
return fail("Question-based headings", 4, "No headings found in content.");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const questionHeadings = headings.filter(
|
|
58
|
+
(h) =>
|
|
59
|
+
h.endsWith("?") ||
|
|
60
|
+
/^(what|how|why|when|where|who|which|can|does|is|are|do)\b/i.test(h),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const ratio = questionHeadings.length / headings.length;
|
|
64
|
+
if (ratio >= 0.3) return pass("Question-based headings", 4);
|
|
65
|
+
if (ratio >= 0.1)
|
|
66
|
+
return partial(
|
|
67
|
+
"Question-based headings",
|
|
68
|
+
2,
|
|
69
|
+
4,
|
|
70
|
+
`${questionHeadings.length}/${headings.length} headings are question-based. Aim for 30%+ — these match how users query AI.`,
|
|
71
|
+
);
|
|
72
|
+
if (questionHeadings.length > 0)
|
|
73
|
+
return partial(
|
|
74
|
+
"Question-based headings",
|
|
75
|
+
1,
|
|
76
|
+
4,
|
|
77
|
+
"Very few question-based headings. Rephrase key H2/H3s as questions users would ask AI.",
|
|
78
|
+
);
|
|
79
|
+
return fail(
|
|
80
|
+
"Question-based headings",
|
|
81
|
+
4,
|
|
82
|
+
'No question-based headings. AI systems match user queries to headings — use "What is X?" / "How to Y?" format.',
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function checkCitationStructure(context) {
|
|
87
|
+
const pages = getPageContents(context);
|
|
88
|
+
if (pages.length === 0) {
|
|
89
|
+
return fail(
|
|
90
|
+
"Citation-friendly structure",
|
|
91
|
+
4,
|
|
92
|
+
"No content pages to analyze.",
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let score = 0;
|
|
97
|
+
let issues = [];
|
|
98
|
+
|
|
99
|
+
const avgParaLength = getAverageParagraphLength(pages);
|
|
100
|
+
if (avgParaLength <= 100) {
|
|
101
|
+
score += 2;
|
|
102
|
+
} else if (avgParaLength <= 150) {
|
|
103
|
+
score += 1;
|
|
104
|
+
issues.push("paragraphs slightly long for citation extraction");
|
|
105
|
+
} else {
|
|
106
|
+
issues.push("paragraphs too long — AI prefers short, citable blocks");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const hasLists = pages.some(
|
|
110
|
+
(p) =>
|
|
111
|
+
p.content.includes("- ") ||
|
|
112
|
+
p.content.includes("* ") ||
|
|
113
|
+
/<[ou]l/i.test(p.content),
|
|
114
|
+
);
|
|
115
|
+
if (hasLists) score += 1;
|
|
116
|
+
else issues.push("no lists — AI extracts bulleted info easily");
|
|
117
|
+
|
|
118
|
+
const hasTables = pages.some(
|
|
119
|
+
(p) => p.content.includes("|") || /<table/i.test(p.content),
|
|
120
|
+
);
|
|
121
|
+
if (hasTables) score += 1;
|
|
122
|
+
else issues.push("no tables — structured comparisons get cited");
|
|
123
|
+
|
|
124
|
+
if (score === 4) return pass("Citation-friendly structure", 4);
|
|
125
|
+
return partial(
|
|
126
|
+
"Citation-friendly structure",
|
|
127
|
+
score,
|
|
128
|
+
4,
|
|
129
|
+
issues.join("; ") + ".",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function checkDefinitiveStatements(context) {
|
|
134
|
+
const content = getAllTextContent(context);
|
|
135
|
+
if (!content) {
|
|
136
|
+
return fail("Definitive statements", 3, "No content to analyze.");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const sentences = content.split(/[.!]\s+/).filter((s) => s.length > 20);
|
|
140
|
+
const definitive = sentences.filter(
|
|
141
|
+
(s) =>
|
|
142
|
+
/\b(is|are|was|means|defined as|refers to|consists of)\b/i.test(s) &&
|
|
143
|
+
!/\b(might|maybe|perhaps|possibly|could be|generally)\b/i.test(s),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const ratio = sentences.length > 0 ? definitive.length / sentences.length : 0;
|
|
147
|
+
if (ratio >= 0.2) return pass("Definitive statements", 3);
|
|
148
|
+
if (ratio >= 0.1)
|
|
149
|
+
return partial(
|
|
150
|
+
"Definitive statements",
|
|
151
|
+
2,
|
|
152
|
+
3,
|
|
153
|
+
"Some definitive statements but too much hedging. AI cites confident claims over uncertain ones.",
|
|
154
|
+
);
|
|
155
|
+
return partial(
|
|
156
|
+
"Definitive statements",
|
|
157
|
+
1,
|
|
158
|
+
3,
|
|
159
|
+
"Content uses weak/uncertain language. Make clear, definitive claims that AI can cite with confidence.",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function getPageContents(context) {
|
|
164
|
+
if (context.mode === "url") {
|
|
165
|
+
return context.html ? [{ name: "homepage", content: context.html }] : [];
|
|
166
|
+
}
|
|
167
|
+
return context.files
|
|
168
|
+
.filter((f) => f.endsWith(".md") || f.endsWith(".html"))
|
|
169
|
+
.map((f) => ({ name: f, content: context.fileContents[f] || "" }))
|
|
170
|
+
.filter((p) => p.content.length > 0);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function getAllTextContent(context) {
|
|
174
|
+
if (context.mode === "url") return context.html || "";
|
|
175
|
+
return context.files
|
|
176
|
+
.filter(
|
|
177
|
+
(f) => f.endsWith(".md") || f.endsWith(".html") || f.endsWith(".txt"),
|
|
178
|
+
)
|
|
179
|
+
.map((f) => context.fileContents[f] || "")
|
|
180
|
+
.join("\n");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function extractParagraphs(content) {
|
|
184
|
+
if (content.includes("<p")) {
|
|
185
|
+
return [...content.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)].map((m) =>
|
|
186
|
+
stripTags(m[1]).trim(),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
return content
|
|
190
|
+
.split(/\n\n+/)
|
|
191
|
+
.map((p) => p.trim())
|
|
192
|
+
.filter((p) => p.length > 0 && !p.startsWith("#"));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function extractHeadings(content) {
|
|
196
|
+
const md = [...content.matchAll(/^#{1,6}\s+(.+)$/gm)].map((m) => m[1]);
|
|
197
|
+
const html = [...content.matchAll(/<h[1-6][^>]*>([^<]+)/gi)].map((m) => m[1]);
|
|
198
|
+
return md.length > html.length ? md : html;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function getAverageParagraphLength(pages) {
|
|
202
|
+
let total = 0;
|
|
203
|
+
let count = 0;
|
|
204
|
+
for (const page of pages) {
|
|
205
|
+
const paras = extractParagraphs(page.content).filter((p) => p.length > 20);
|
|
206
|
+
for (const p of paras) {
|
|
207
|
+
total += p.split(/\s+/).length;
|
|
208
|
+
count++;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return count > 0 ? total / count : 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function stripTags(html) {
|
|
215
|
+
return html.replace(/<[^>]+>/g, "");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function pass(name, points) {
|
|
219
|
+
return { name, passed: true, points, maxPoints: points };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function partial(name, points, maxPoints, fix) {
|
|
223
|
+
return { name, passed: false, points, maxPoints, fix };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function fail(name, maxPoints, fix) {
|
|
227
|
+
return { name, passed: false, points: 0, maxPoints, fix };
|
|
228
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
export async function runFreshnessChecks(context) {
|
|
2
|
+
const checks = [
|
|
3
|
+
checkModifiedDates(context),
|
|
4
|
+
checkPublicationCadence(context),
|
|
5
|
+
checkContentRecency(context),
|
|
6
|
+
checkEvergreenFlags(context),
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
const score = checks.reduce((sum, c) => sum + (c.passed ? c.points : 0), 0);
|
|
10
|
+
return { score, maxScore: 10, checks };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function checkModifiedDates(context) {
|
|
14
|
+
const allContent = getAllContent(context);
|
|
15
|
+
const html = context.html || "";
|
|
16
|
+
|
|
17
|
+
const hasDateModified = /dateModified|lastmod|last.modified|updated/i.test(
|
|
18
|
+
allContent,
|
|
19
|
+
);
|
|
20
|
+
const hasDatePublished = /datePublished|pubdate|published/i.test(allContent);
|
|
21
|
+
const hasMetaDate = /article:modified_time|article:published_time/i.test(
|
|
22
|
+
html,
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
let points = 0;
|
|
26
|
+
if (hasDateModified || hasMetaDate) points += 2;
|
|
27
|
+
if (hasDatePublished) points += 1;
|
|
28
|
+
|
|
29
|
+
if (points === 3) return pass("Last modified dates", 3);
|
|
30
|
+
if (points > 0) {
|
|
31
|
+
const missing = [];
|
|
32
|
+
if (!hasDateModified && !hasMetaDate) missing.push("dateModified");
|
|
33
|
+
if (!hasDatePublished) missing.push("datePublished");
|
|
34
|
+
return partial(
|
|
35
|
+
"Last modified dates",
|
|
36
|
+
points,
|
|
37
|
+
3,
|
|
38
|
+
`Add ${missing.join(" and ")} to structured data. AI systems prioritize fresh content.`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return fail(
|
|
42
|
+
"Last modified dates",
|
|
43
|
+
3,
|
|
44
|
+
"No date metadata found. Add dateModified and datePublished to structured data or meta tags.",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function checkPublicationCadence(context) {
|
|
49
|
+
const allContent = getAllContent(context);
|
|
50
|
+
|
|
51
|
+
const dates = extractDates(allContent);
|
|
52
|
+
if (dates.length < 2) {
|
|
53
|
+
return partial(
|
|
54
|
+
"Publication cadence",
|
|
55
|
+
1,
|
|
56
|
+
2,
|
|
57
|
+
"Not enough dated content to assess publication cadence.",
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const sorted = dates.sort((a, b) => b - a);
|
|
62
|
+
const gaps = [];
|
|
63
|
+
for (let i = 0; i < Math.min(sorted.length - 1, 5); i++) {
|
|
64
|
+
gaps.push(sorted[i] - sorted[i + 1]);
|
|
65
|
+
}
|
|
66
|
+
const avgGapDays =
|
|
67
|
+
gaps.reduce((s, g) => s + g, 0) / gaps.length / (1000 * 60 * 60 * 24);
|
|
68
|
+
|
|
69
|
+
if (avgGapDays <= 30) return pass("Publication cadence", 2);
|
|
70
|
+
if (avgGapDays <= 90)
|
|
71
|
+
return partial(
|
|
72
|
+
"Publication cadence",
|
|
73
|
+
1,
|
|
74
|
+
2,
|
|
75
|
+
`Average ${Math.round(avgGapDays)} days between publications. Monthly or better signals active maintenance.`,
|
|
76
|
+
);
|
|
77
|
+
return fail(
|
|
78
|
+
"Publication cadence",
|
|
79
|
+
2,
|
|
80
|
+
`Infrequent updates (~${Math.round(avgGapDays)} day gaps). Regular publishing signals authority to AI systems.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function checkContentRecency(context) {
|
|
85
|
+
const allContent = getAllContent(context);
|
|
86
|
+
const dates = extractDates(allContent);
|
|
87
|
+
|
|
88
|
+
if (dates.length === 0) {
|
|
89
|
+
return fail("Content recency", 3, "No dates found to assess content age.");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
const oneYear = 365 * 24 * 60 * 60 * 1000;
|
|
94
|
+
const recentDates = dates.filter((d) => now - d < oneYear);
|
|
95
|
+
const ratio = recentDates.length / dates.length;
|
|
96
|
+
|
|
97
|
+
if (ratio >= 0.5) return pass("Content recency", 3);
|
|
98
|
+
if (ratio >= 0.2)
|
|
99
|
+
return partial(
|
|
100
|
+
"Content recency",
|
|
101
|
+
2,
|
|
102
|
+
3,
|
|
103
|
+
`Only ${Math.round(ratio * 100)}% of dated content is from the last 12 months.`,
|
|
104
|
+
);
|
|
105
|
+
return partial(
|
|
106
|
+
"Content recency",
|
|
107
|
+
1,
|
|
108
|
+
3,
|
|
109
|
+
"Most content appears older than 12 months. Update key pages or add new content.",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function checkEvergreenFlags(context) {
|
|
114
|
+
const allContent = getAllContent(context);
|
|
115
|
+
|
|
116
|
+
const hasEvergreen = /evergreen|timeless|guide|reference|documentation/i.test(
|
|
117
|
+
allContent,
|
|
118
|
+
);
|
|
119
|
+
const hasVersioning = /v\d|version \d|updated for \d{4}|as of \d{4}/i.test(
|
|
120
|
+
allContent,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
if (hasEvergreen && hasVersioning)
|
|
124
|
+
return pass("Evergreen content flagged", 2);
|
|
125
|
+
if (hasEvergreen || hasVersioning)
|
|
126
|
+
return partial(
|
|
127
|
+
"Evergreen content flagged",
|
|
128
|
+
1,
|
|
129
|
+
2,
|
|
130
|
+
'Some content signals permanence. Add "Updated for 2026" or version markers to evergreen pieces.',
|
|
131
|
+
);
|
|
132
|
+
return fail(
|
|
133
|
+
"Evergreen content flagged",
|
|
134
|
+
2,
|
|
135
|
+
"No evergreen content markers. Flag reference content as timeless so AI doesn't deprioritize it for age.",
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function extractDates(content) {
|
|
140
|
+
const patterns = [
|
|
141
|
+
/\d{4}-\d{2}-\d{2}/g,
|
|
142
|
+
/\d{4}\/\d{2}\/\d{2}/g,
|
|
143
|
+
/(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\w*\s+\d{1,2},?\s+\d{4}/gi,
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
const dates = [];
|
|
147
|
+
for (const pattern of patterns) {
|
|
148
|
+
let match;
|
|
149
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
150
|
+
const d = new Date(match[0]);
|
|
151
|
+
if (
|
|
152
|
+
!isNaN(d.getTime()) &&
|
|
153
|
+
d.getFullYear() >= 2015 &&
|
|
154
|
+
d.getFullYear() <= 2030
|
|
155
|
+
) {
|
|
156
|
+
dates.push(d.getTime());
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return [...new Set(dates)];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function getAllContent(context) {
|
|
164
|
+
if (context.mode === "url") {
|
|
165
|
+
return Object.values(context.pages || {})
|
|
166
|
+
.map((p) => p?.text || "")
|
|
167
|
+
.join("\n");
|
|
168
|
+
}
|
|
169
|
+
return Object.values(context.fileContents || {}).join("\n");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function pass(name, points) {
|
|
173
|
+
return { name, passed: true, points, maxPoints: points };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function partial(name, points, maxPoints, fix) {
|
|
177
|
+
return { name, passed: false, points, maxPoints, fix };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function fail(name, maxPoints, fix) {
|
|
181
|
+
return { name, passed: false, points: 0, maxPoints, fix };
|
|
182
|
+
}
|