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,47 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
const DIR_NAME = ".agent-web";
|
|
5
|
+
const HISTORY_FILE = "history.json";
|
|
6
|
+
|
|
7
|
+
export async function saveResult(result, baseDir) {
|
|
8
|
+
const dir = join(baseDir, DIR_NAME);
|
|
9
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
10
|
+
|
|
11
|
+
const historyPath = join(dir, HISTORY_FILE);
|
|
12
|
+
const history = loadHistory(historyPath);
|
|
13
|
+
|
|
14
|
+
history.scans.push({
|
|
15
|
+
id: generateId(),
|
|
16
|
+
timestamp: result.timestamp,
|
|
17
|
+
score: result.score,
|
|
18
|
+
grade: result.grade,
|
|
19
|
+
siteType: result.siteType,
|
|
20
|
+
target: result.target,
|
|
21
|
+
agentReadiness: result.agentReadiness.score,
|
|
22
|
+
aiVisibility: result.aiVisibility.score,
|
|
23
|
+
benchmark: result.benchmark?.score ?? null,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
writeFileSync(historyPath, JSON.stringify(history, null, 2));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function loadHistory(historyPath) {
|
|
30
|
+
if (existsSync(historyPath)) {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(readFileSync(historyPath, "utf8"));
|
|
33
|
+
} catch {
|
|
34
|
+
/* corrupted — start fresh */
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { scans: [] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getHistory(baseDir) {
|
|
41
|
+
const historyPath = join(baseDir, DIR_NAME, HISTORY_FILE);
|
|
42
|
+
return loadHistory(historyPath);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function generateId() {
|
|
46
|
+
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
47
|
+
}
|
package/src/index.js
ADDED
package/src/scan.js
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { fetchUrl, resolveUrl } from "./utils/fetch.js";
|
|
3
|
+
import { detectSiteType } from "./utils/detect-type.js";
|
|
4
|
+
import { runDiscoveryChecks } from "./checks/agent-readiness/discovery.js";
|
|
5
|
+
import { runContentStructureChecks } from "./checks/agent-readiness/content-structure.js";
|
|
6
|
+
import { runCapabilityChecks } from "./checks/agent-readiness/capability.js";
|
|
7
|
+
import { runActionableChecks } from "./checks/agent-readiness/actionable.js";
|
|
8
|
+
import { runStructuredDataChecks } from "./checks/ai-visibility/structured-data.js";
|
|
9
|
+
import { runCitationReadinessChecks } from "./checks/ai-visibility/citation-readiness.js";
|
|
10
|
+
import { runAuthorityChecks } from "./checks/ai-visibility/authority.js";
|
|
11
|
+
import { runFreshnessChecks } from "./checks/ai-visibility/freshness.js";
|
|
12
|
+
import { runBenchmark } from "./benchmark/agentic-seo.js";
|
|
13
|
+
import { saveResult } from "./history/index.js";
|
|
14
|
+
import { generateDashboard } from "./dashboard/generate.js";
|
|
15
|
+
import { readdirSync, readFileSync, existsSync } from "fs";
|
|
16
|
+
import { join } from "path";
|
|
17
|
+
import { exec } from "child_process";
|
|
18
|
+
|
|
19
|
+
export async function scan(opts) {
|
|
20
|
+
const { url, dir, json, benchmark } = opts;
|
|
21
|
+
const mode = url ? "url" : "repo";
|
|
22
|
+
|
|
23
|
+
if (!json) {
|
|
24
|
+
console.log(
|
|
25
|
+
chalk.bold("\n aeo-ready") + chalk.dim(" — AI readiness audit\n"),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const context = await gatherContext(mode, url, dir);
|
|
30
|
+
|
|
31
|
+
if (!json) {
|
|
32
|
+
console.log(chalk.dim(` Mode: ${mode} | Type: ${context.siteType}\n`));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const agentReadiness = await runAgentReadinessChecks(context);
|
|
36
|
+
const aiVisibility = await runAiVisibilityChecks(context);
|
|
37
|
+
|
|
38
|
+
const score = agentReadiness.score + aiVisibility.score;
|
|
39
|
+
const grade = scoreToGrade(score);
|
|
40
|
+
|
|
41
|
+
let benchmarkResult = null;
|
|
42
|
+
if (benchmark) {
|
|
43
|
+
benchmarkResult = await runBenchmark(mode === "url" ? url : dir);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const result = {
|
|
47
|
+
score,
|
|
48
|
+
grade,
|
|
49
|
+
siteType: context.siteType,
|
|
50
|
+
mode,
|
|
51
|
+
target: url || dir,
|
|
52
|
+
timestamp: new Date().toISOString(),
|
|
53
|
+
agentReadiness,
|
|
54
|
+
aiVisibility,
|
|
55
|
+
benchmark: benchmarkResult,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
if (!json) {
|
|
59
|
+
printReport(result);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const baseDir = dir || process.cwd();
|
|
63
|
+
await saveResult(result, baseDir);
|
|
64
|
+
|
|
65
|
+
if (!json) {
|
|
66
|
+
const dashPath = await generateDashboard(result, baseDir);
|
|
67
|
+
console.log(chalk.dim(` Dashboard: ${dashPath}\n`));
|
|
68
|
+
openInBrowser(dashPath);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function gatherContext(mode, url, dir) {
|
|
75
|
+
const context = { mode, url, dir, pages: {}, files: [], html: "" };
|
|
76
|
+
|
|
77
|
+
if (mode === "url") {
|
|
78
|
+
const homepage = await fetchUrl(url);
|
|
79
|
+
context.html = homepage.text;
|
|
80
|
+
context.pages.home = homepage;
|
|
81
|
+
|
|
82
|
+
const fetches = [
|
|
83
|
+
fetchUrl(resolveUrl(url, "/llms.txt")),
|
|
84
|
+
fetchUrl(resolveUrl(url, "/robots.txt")),
|
|
85
|
+
fetchUrl(resolveUrl(url, "/sitemap.xml")),
|
|
86
|
+
fetchUrl(resolveUrl(url, "/.well-known/agent.json")),
|
|
87
|
+
fetchUrl(resolveUrl(url, "/openapi.json")),
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
const [llms, robots, sitemap, agentJson, openapi] =
|
|
91
|
+
await Promise.all(fetches);
|
|
92
|
+
context.pages.llmsTxt = llms;
|
|
93
|
+
context.pages.robotsTxt = robots;
|
|
94
|
+
context.pages.sitemap = sitemap;
|
|
95
|
+
context.pages.agentJson = agentJson;
|
|
96
|
+
context.pages.openapi = openapi;
|
|
97
|
+
} else {
|
|
98
|
+
const fileList = listFiles(dir);
|
|
99
|
+
context.files = fileList;
|
|
100
|
+
context.fileContents = {};
|
|
101
|
+
for (const f of fileList) {
|
|
102
|
+
try {
|
|
103
|
+
context.fileContents[f] = readFileSync(join(dir, f), "utf8");
|
|
104
|
+
} catch {
|
|
105
|
+
/* skip binary/unreadable */
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const indexHtml = [
|
|
109
|
+
"index.html",
|
|
110
|
+
"public/index.html",
|
|
111
|
+
"dist/index.html",
|
|
112
|
+
].find((p) => existsSync(join(dir, p)));
|
|
113
|
+
if (indexHtml) {
|
|
114
|
+
context.html = readFileSync(join(dir, indexHtml), "utf8");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
context.siteType = detectSiteType({
|
|
119
|
+
html: context.html,
|
|
120
|
+
files: context.files,
|
|
121
|
+
url,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
return context;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function listFiles(dir) {
|
|
128
|
+
const skip = new Set([
|
|
129
|
+
"node_modules",
|
|
130
|
+
".git",
|
|
131
|
+
"dist",
|
|
132
|
+
"build",
|
|
133
|
+
".next",
|
|
134
|
+
".venv",
|
|
135
|
+
"__pycache__",
|
|
136
|
+
"target",
|
|
137
|
+
".aeo-ready",
|
|
138
|
+
]);
|
|
139
|
+
const results = [];
|
|
140
|
+
|
|
141
|
+
function walk(current, prefix) {
|
|
142
|
+
let entries;
|
|
143
|
+
try {
|
|
144
|
+
entries = readdirSync(join(dir, current), { withFileTypes: true });
|
|
145
|
+
} catch {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
for (const entry of entries) {
|
|
149
|
+
if (skip.has(entry.name)) continue;
|
|
150
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
151
|
+
if (entry.isDirectory()) {
|
|
152
|
+
walk(join(current, entry.name), rel);
|
|
153
|
+
} else {
|
|
154
|
+
results.push(rel);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
walk("", "");
|
|
160
|
+
return results;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function runAgentReadinessChecks(context) {
|
|
164
|
+
const discovery = await runDiscoveryChecks(context);
|
|
165
|
+
const contentStructure = await runContentStructureChecks(context);
|
|
166
|
+
const capability = await runCapabilityChecks(context);
|
|
167
|
+
const actionable = await runActionableChecks(context);
|
|
168
|
+
|
|
169
|
+
const score =
|
|
170
|
+
discovery.score +
|
|
171
|
+
contentStructure.score +
|
|
172
|
+
capability.score +
|
|
173
|
+
actionable.score;
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
score,
|
|
177
|
+
maxScore: 50,
|
|
178
|
+
categories: { discovery, contentStructure, capability, actionable },
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function runAiVisibilityChecks(context) {
|
|
183
|
+
const structuredData = await runStructuredDataChecks(context);
|
|
184
|
+
const citationReadiness = await runCitationReadinessChecks(context);
|
|
185
|
+
const authority = await runAuthorityChecks(context);
|
|
186
|
+
const freshness = await runFreshnessChecks(context);
|
|
187
|
+
|
|
188
|
+
const score =
|
|
189
|
+
structuredData.score +
|
|
190
|
+
citationReadiness.score +
|
|
191
|
+
authority.score +
|
|
192
|
+
freshness.score;
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
score,
|
|
196
|
+
maxScore: 50,
|
|
197
|
+
categories: { structuredData, citationReadiness, authority, freshness },
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function scoreToGrade(score) {
|
|
202
|
+
if (score >= 80) return "A";
|
|
203
|
+
if (score >= 65) return "B";
|
|
204
|
+
if (score >= 50) return "C";
|
|
205
|
+
if (score >= 35) return "D";
|
|
206
|
+
return "F";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function printReport(result) {
|
|
210
|
+
const { score, grade, agentReadiness, aiVisibility, benchmark } = result;
|
|
211
|
+
|
|
212
|
+
const gradeColor =
|
|
213
|
+
grade === "A"
|
|
214
|
+
? chalk.green
|
|
215
|
+
: grade === "B"
|
|
216
|
+
? chalk.cyan
|
|
217
|
+
: grade === "C"
|
|
218
|
+
? chalk.yellow
|
|
219
|
+
: chalk.red;
|
|
220
|
+
|
|
221
|
+
console.log(
|
|
222
|
+
` ${bar(score, 100, 40)} ${gradeColor.bold(grade)} ${score}/100\n`,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
console.log(
|
|
226
|
+
chalk.dim(
|
|
227
|
+
" Agent Readiness — can AI agents find, read, and use your site?",
|
|
228
|
+
),
|
|
229
|
+
);
|
|
230
|
+
console.log(
|
|
231
|
+
chalk.dim(
|
|
232
|
+
" AI Visibility — how accurately do AI search engines describe you?",
|
|
233
|
+
),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
if (agentReadiness.score < 25) {
|
|
237
|
+
console.log(
|
|
238
|
+
chalk.dim(
|
|
239
|
+
"\n Agent Readiness is low — AI engines can't cite what they can't read.",
|
|
240
|
+
),
|
|
241
|
+
);
|
|
242
|
+
console.log(chalk.dim(" Fix the agent side first. Visibility follows."));
|
|
243
|
+
}
|
|
244
|
+
console.log("");
|
|
245
|
+
|
|
246
|
+
printScorecard("Agent Readiness", agentReadiness);
|
|
247
|
+
printScorecard("AI Visibility", aiVisibility);
|
|
248
|
+
|
|
249
|
+
const allChecks = [
|
|
250
|
+
...Object.values(agentReadiness.categories),
|
|
251
|
+
...Object.values(aiVisibility.categories),
|
|
252
|
+
].flatMap((c) => c.checks);
|
|
253
|
+
const passed = allChecks.filter((c) => c.passed).length;
|
|
254
|
+
const failed = allChecks.filter((c) => !c.passed && c.points === 0).length;
|
|
255
|
+
const partial = allChecks.length - passed - failed;
|
|
256
|
+
|
|
257
|
+
console.log(
|
|
258
|
+
chalk.dim(
|
|
259
|
+
` ${chalk.green(passed + " passed")} · ${partial ? chalk.yellow(partial + " partial") + " · " : ""}${chalk.red(failed + " failed")} · ${allChecks.length} checks total\n`,
|
|
260
|
+
),
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
if (benchmark && benchmark.available) {
|
|
264
|
+
printBenchmark(benchmark);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function printScorecard(name, scorecard) {
|
|
269
|
+
const pct = Math.round((scorecard.score / scorecard.maxScore) * 100);
|
|
270
|
+
console.log(
|
|
271
|
+
chalk.bold(` ${name}`) +
|
|
272
|
+
chalk.dim(` ${scorecard.score}/${scorecard.maxScore} (${pct}%)`),
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
for (const [catName, cat] of Object.entries(scorecard.categories)) {
|
|
276
|
+
const label = formatCategoryName(catName);
|
|
277
|
+
const catPct = Math.round((cat.score / cat.maxScore) * 100);
|
|
278
|
+
const icon =
|
|
279
|
+
catPct >= 80
|
|
280
|
+
? chalk.green("✓")
|
|
281
|
+
: catPct >= 40
|
|
282
|
+
? chalk.yellow("◑")
|
|
283
|
+
: chalk.red("✗");
|
|
284
|
+
console.log(
|
|
285
|
+
` ${icon} ${label.padEnd(28)} ${bar(cat.score, cat.maxScore, 20)} ${chalk.dim(`${cat.score}/${cat.maxScore} (${catPct}%)`)}`,
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
for (const check of cat.checks) {
|
|
289
|
+
if (check.passed) {
|
|
290
|
+
console.log(
|
|
291
|
+
chalk.green(` +`) +
|
|
292
|
+
chalk.dim(` ${check.name} [${check.points}]`),
|
|
293
|
+
);
|
|
294
|
+
} else {
|
|
295
|
+
console.log(
|
|
296
|
+
chalk.red(` -`) +
|
|
297
|
+
` ${check.name} ` +
|
|
298
|
+
chalk.dim(`[${check.points || 0}/${check.maxPoints}]`),
|
|
299
|
+
);
|
|
300
|
+
if (check.fix) {
|
|
301
|
+
console.log(chalk.dim(` ${check.fix}`));
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
console.log("");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function printBenchmark(benchmark) {
|
|
310
|
+
console.log(
|
|
311
|
+
chalk.dim(" ─── Second opinion: agentic-seo ───────────────────\n"),
|
|
312
|
+
);
|
|
313
|
+
console.log(
|
|
314
|
+
` ${bar(benchmark.score, benchmark.maxScore, 20)} ${chalk.dim(`${benchmark.score}/${benchmark.maxScore}`)}${benchmark.grade ? chalk.dim(` (${benchmark.grade})`) : ""}\n`,
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
if (benchmark.categories) {
|
|
318
|
+
for (const [, cat] of Object.entries(benchmark.categories)) {
|
|
319
|
+
const pct =
|
|
320
|
+
cat.percentage ?? Math.round((cat.score / cat.maxScore) * 100);
|
|
321
|
+
const icon =
|
|
322
|
+
pct >= 80
|
|
323
|
+
? chalk.green("✓")
|
|
324
|
+
: pct >= 40
|
|
325
|
+
? chalk.yellow("◑")
|
|
326
|
+
: chalk.red("✗");
|
|
327
|
+
console.log(
|
|
328
|
+
` ${icon} ${cat.name.padEnd(24)} ${bar(cat.score, cat.maxScore, 16)} ${chalk.dim(`${cat.score}/${cat.maxScore} (${pct}%)`)}`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
console.log("");
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function bar(value, max, width) {
|
|
336
|
+
const filled = max > 0 ? Math.round((value / max) * width) : 0;
|
|
337
|
+
const empty = width - filled;
|
|
338
|
+
const pct = max > 0 ? Math.round((value / max) * 100) : 0;
|
|
339
|
+
const color = pct >= 80 ? chalk.green : pct >= 50 ? chalk.yellow : chalk.red;
|
|
340
|
+
return color("█".repeat(filled)) + chalk.dim("░".repeat(empty));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function formatCategoryName(camelCase) {
|
|
344
|
+
return camelCase
|
|
345
|
+
.replace(/([A-Z])/g, " $1")
|
|
346
|
+
.replace(/^./, (c) => c.toUpperCase())
|
|
347
|
+
.trim();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function openInBrowser(filePath) {
|
|
351
|
+
const cmd =
|
|
352
|
+
process.platform === "darwin"
|
|
353
|
+
? "open"
|
|
354
|
+
: process.platform === "win32"
|
|
355
|
+
? "start"
|
|
356
|
+
: "xdg-open";
|
|
357
|
+
exec(`${cmd} "${filePath}"`, () => {});
|
|
358
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
|
|
3
|
+
const PROVIDERS = {
|
|
4
|
+
claude: {
|
|
5
|
+
name: "Claude",
|
|
6
|
+
url: "https://api.anthropic.com/v1/messages",
|
|
7
|
+
envKey: "ANTHROPIC_API_KEY",
|
|
8
|
+
model: "claude-sonnet-4-6",
|
|
9
|
+
call: callClaude,
|
|
10
|
+
},
|
|
11
|
+
openai: {
|
|
12
|
+
name: "ChatGPT",
|
|
13
|
+
url: "https://api.openai.com/v1/chat/completions",
|
|
14
|
+
envKey: "OPENAI_API_KEY",
|
|
15
|
+
model: "gpt-4o-mini",
|
|
16
|
+
call: callOpenAI,
|
|
17
|
+
},
|
|
18
|
+
google: {
|
|
19
|
+
name: "Gemini",
|
|
20
|
+
envKey: "GOOGLE_API_KEY",
|
|
21
|
+
model: "gemini-2.0-flash",
|
|
22
|
+
call: callGoogle,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const DEFAULT_PROMPTS = [
|
|
27
|
+
{ template: "What is {company}?", category: "brand" },
|
|
28
|
+
{ template: "What does {company} do?", category: "brand" },
|
|
29
|
+
{ template: "Best {category} tools?", category: "discovery" },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export async function track(scanResult, config) {
|
|
33
|
+
const company = config.company || inferCompany(scanResult);
|
|
34
|
+
const category = config.category || "";
|
|
35
|
+
const prompts = config.prompts || DEFAULT_PROMPTS;
|
|
36
|
+
|
|
37
|
+
const available = getAvailableProviders();
|
|
38
|
+
if (available.length === 0) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const rendered = prompts.map((p) => ({
|
|
43
|
+
...p,
|
|
44
|
+
text: p.template
|
|
45
|
+
.replace("{company}", company)
|
|
46
|
+
.replace("{category}", category),
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
const results = [];
|
|
50
|
+
|
|
51
|
+
for (const prompt of rendered) {
|
|
52
|
+
for (const provider of available) {
|
|
53
|
+
try {
|
|
54
|
+
const response = await provider.call(prompt.text, provider.model);
|
|
55
|
+
results.push({
|
|
56
|
+
provider: provider.name,
|
|
57
|
+
prompt: prompt.text,
|
|
58
|
+
category: prompt.category,
|
|
59
|
+
response: response.slice(0, 500),
|
|
60
|
+
mentions: response.toLowerCase().includes(company.toLowerCase()),
|
|
61
|
+
});
|
|
62
|
+
} catch {
|
|
63
|
+
results.push({
|
|
64
|
+
provider: provider.name,
|
|
65
|
+
prompt: prompt.text,
|
|
66
|
+
category: prompt.category,
|
|
67
|
+
response: "(failed)",
|
|
68
|
+
mentions: false,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return results;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function printTrackResults(results, company) {
|
|
78
|
+
if (!results || results.length === 0) return;
|
|
79
|
+
|
|
80
|
+
console.log(chalk.bold("\n AI Visibility — what models say about you\n"));
|
|
81
|
+
|
|
82
|
+
const grouped = {};
|
|
83
|
+
for (const r of results) {
|
|
84
|
+
if (!grouped[r.prompt]) grouped[r.prompt] = [];
|
|
85
|
+
grouped[r.prompt].push(r);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const [prompt, responses] of Object.entries(grouped)) {
|
|
89
|
+
console.log(chalk.dim(` > "${prompt}"\n`));
|
|
90
|
+
for (const r of responses) {
|
|
91
|
+
const icon = r.mentions ? chalk.green("cited") : chalk.red("not cited");
|
|
92
|
+
console.log(` ${chalk.bold(r.provider)} [${icon}]`);
|
|
93
|
+
const lines = r.response.split("\n").slice(0, 3);
|
|
94
|
+
for (const line of lines) {
|
|
95
|
+
console.log(chalk.dim(` ${line.slice(0, 80)}`));
|
|
96
|
+
}
|
|
97
|
+
console.log("");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const total = results.length;
|
|
102
|
+
const cited = results.filter((r) => r.mentions).length;
|
|
103
|
+
const rate = Math.round((cited / total) * 100);
|
|
104
|
+
console.log(chalk.bold(` Citation rate: ${cited}/${total} (${rate}%)\n`));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function getAvailableProviders() {
|
|
108
|
+
return Object.values(PROVIDERS).filter((p) => process.env[p.envKey]);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function inferCompany(scanResult) {
|
|
112
|
+
const target = scanResult.target || "";
|
|
113
|
+
try {
|
|
114
|
+
return new URL(target).hostname.replace("www.", "").split(".")[0];
|
|
115
|
+
} catch {
|
|
116
|
+
return target.split("/").pop() || "unknown";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function callClaude(prompt, model) {
|
|
121
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: {
|
|
124
|
+
"x-api-key": process.env.ANTHROPIC_API_KEY,
|
|
125
|
+
"anthropic-version": "2023-06-01",
|
|
126
|
+
"content-type": "application/json",
|
|
127
|
+
},
|
|
128
|
+
body: JSON.stringify({
|
|
129
|
+
model,
|
|
130
|
+
max_tokens: 300,
|
|
131
|
+
messages: [{ role: "user", content: prompt }],
|
|
132
|
+
}),
|
|
133
|
+
});
|
|
134
|
+
const data = await res.json();
|
|
135
|
+
return data.content?.[0]?.text || "";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function callOpenAI(prompt, model) {
|
|
139
|
+
const res = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers: {
|
|
142
|
+
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
|
|
143
|
+
"content-type": "application/json",
|
|
144
|
+
},
|
|
145
|
+
body: JSON.stringify({
|
|
146
|
+
model,
|
|
147
|
+
messages: [{ role: "user", content: prompt }],
|
|
148
|
+
max_tokens: 300,
|
|
149
|
+
}),
|
|
150
|
+
});
|
|
151
|
+
const data = await res.json();
|
|
152
|
+
return data.choices?.[0]?.message?.content || "";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function callGoogle(prompt, model) {
|
|
156
|
+
const key = process.env.GOOGLE_API_KEY;
|
|
157
|
+
const res = await fetch(
|
|
158
|
+
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${key}`,
|
|
159
|
+
{
|
|
160
|
+
method: "POST",
|
|
161
|
+
headers: { "content-type": "application/json" },
|
|
162
|
+
body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] }),
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
const data = await res.json();
|
|
166
|
+
return data.candidates?.[0]?.content?.parts?.[0]?.text || "";
|
|
167
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
export function detectSiteType(signals) {
|
|
2
|
+
const { html, files, url } = signals;
|
|
3
|
+
const text = (html || "").toLowerCase();
|
|
4
|
+
const fileList = (files || []).map((f) => f.toLowerCase());
|
|
5
|
+
|
|
6
|
+
const scores = {
|
|
7
|
+
saas: scoreSaas(text, fileList, url),
|
|
8
|
+
api: scoreApi(text, fileList, url),
|
|
9
|
+
content: scoreContent(text, fileList, url),
|
|
10
|
+
personal: scorePersonal(text, fileList, url),
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]);
|
|
14
|
+
if (sorted[0][1] === 0) return "unknown";
|
|
15
|
+
return sorted[0][0];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function scoreSaas(text, files, url) {
|
|
19
|
+
let score = 0;
|
|
20
|
+
if (text.includes("/pricing") || text.includes("/plans")) score += 3;
|
|
21
|
+
if (text.includes("pricing") && !text.includes("/pricing")) score += 1;
|
|
22
|
+
if (text.includes("sign up") || text.includes("signup")) score += 2;
|
|
23
|
+
if (text.includes("free trial") || text.includes("start free")) score += 2;
|
|
24
|
+
if (text.includes("dashboard") && text.includes("login")) score += 2;
|
|
25
|
+
if (text.includes("per month") || text.includes("/mo")) score += 2;
|
|
26
|
+
if (files.some((f) => f.includes("pricing"))) score += 2;
|
|
27
|
+
return score;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function scoreApi(text, files, url) {
|
|
31
|
+
let score = 0;
|
|
32
|
+
const hostname = extractHostname(url);
|
|
33
|
+
if (
|
|
34
|
+
hostname &&
|
|
35
|
+
(hostname.startsWith("docs.") || hostname.startsWith("developer."))
|
|
36
|
+
)
|
|
37
|
+
score += 4;
|
|
38
|
+
if (text.includes("api reference") || text.includes("api docs")) score += 3;
|
|
39
|
+
if (text.includes("sdk") || text.includes("client library")) score += 2;
|
|
40
|
+
if (text.includes("openapi") || text.includes("swagger")) score += 3;
|
|
41
|
+
if (text.includes("endpoint") || text.includes("authentication")) score += 1;
|
|
42
|
+
if (text.includes("npm install") || text.includes("pip install")) score += 2;
|
|
43
|
+
if (files.some((f) => f.includes("openapi") || f.includes("swagger")))
|
|
44
|
+
score += 3;
|
|
45
|
+
if (files.some((f) => f.includes("sdk") || f.includes("client"))) score += 1;
|
|
46
|
+
return score;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function scoreContent(text, files, url) {
|
|
50
|
+
let score = 0;
|
|
51
|
+
if (text.includes("/blog") || text.includes("blog")) score += 2;
|
|
52
|
+
if ((text.match(/article/g) || []).length >= 3) score += 2;
|
|
53
|
+
if (text.includes("published") && text.includes("author")) score += 2;
|
|
54
|
+
if (text.includes("read more") || text.includes("continue reading"))
|
|
55
|
+
score += 2;
|
|
56
|
+
if (text.includes("subscribe") && text.includes("newsletter")) score += 1;
|
|
57
|
+
if (files.some((f) => f.includes("blog") || f.includes("posts"))) score += 3;
|
|
58
|
+
if (files.filter((f) => f.endsWith(".md")).length > 10) score += 1;
|
|
59
|
+
return score;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function scorePersonal(text, files, url) {
|
|
63
|
+
let score = 0;
|
|
64
|
+
const hostname = extractHostname(url);
|
|
65
|
+
if (hostname && isPersonalDomain(hostname)) score += 3;
|
|
66
|
+
if (text.includes("portfolio") || text.includes("my work")) score += 3;
|
|
67
|
+
if (
|
|
68
|
+
text.includes("about me") ||
|
|
69
|
+
text.includes("i am") ||
|
|
70
|
+
text.includes("i'm a")
|
|
71
|
+
)
|
|
72
|
+
score += 3;
|
|
73
|
+
if (text.includes("resume") || text.includes("cv")) score += 2;
|
|
74
|
+
if (text.includes("hire me") || text.includes("available for")) score += 2;
|
|
75
|
+
if (text.includes("linkedin.com/in/") || text.includes("github.com/"))
|
|
76
|
+
score += 1;
|
|
77
|
+
if (text.includes("@") && text.includes("contact")) score += 1;
|
|
78
|
+
return score;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function extractHostname(url) {
|
|
82
|
+
if (!url) return null;
|
|
83
|
+
try {
|
|
84
|
+
return new URL(url).hostname.replace("www.", "");
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isPersonalDomain(hostname) {
|
|
91
|
+
const parts = hostname.split(".");
|
|
92
|
+
const name = parts[0];
|
|
93
|
+
const tld = parts.slice(1).join(".");
|
|
94
|
+
const personalTlds = ["com", "me", "io", "dev", "co", "net", "org"];
|
|
95
|
+
if (!personalTlds.includes(tld)) return false;
|
|
96
|
+
return /^[a-z]+[a-z]+$/.test(name) && name.length >= 6 && name.length <= 20;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const SITE_TYPES = ["saas", "api", "content", "personal", "unknown"];
|