@foxygeo/audit 0.1.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/PUBLISHING.md +73 -0
- package/README.md +56 -0
- package/action.yml +39 -0
- package/bin/foxygeo-audit.js +11 -0
- package/package.json +45 -0
- package/src/ai-access.js +174 -0
- package/src/cli.js +123 -0
- package/src/discover.js +88 -0
- package/src/findings.js +97 -0
- package/src/github.js +48 -0
- package/src/index.js +5 -0
- package/src/live.js +60 -0
- package/src/publish.js +33 -0
- package/src/report.js +61 -0
- package/src/schema.js +31 -0
package/PUBLISHING.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Launch `@foxygeo/audit`
|
|
2
|
+
|
|
3
|
+
## Gate before any public launch
|
|
4
|
+
|
|
5
|
+
1. Create the `@foxygeo` npm organization and authenticate the release account.
|
|
6
|
+
2. Publish the package from this directory:
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm publish --access public
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
3. From a directory outside this repository, prove the public package works:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npx --yes @foxygeo/audit example.com
|
|
16
|
+
npx --yes @foxygeo/audit --ci --publish example.com
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
4. Create the public `github.com/foxygeo/audit` repository from this package
|
|
20
|
+
directory. Keep `action.yml` at its root, tag `v1.0.0`, then publish the
|
|
21
|
+
release as a GitHub Marketplace Action. The Marketplace requires a public
|
|
22
|
+
repository and a root action metadata file.
|
|
23
|
+
|
|
24
|
+
The product link for general posts is
|
|
25
|
+
[`foxygeo.com/tools/cli-audit`](https://foxygeo.com/tools/cli-audit). The
|
|
26
|
+
GitHub repository is the better Show HN destination because readers can inspect
|
|
27
|
+
and run the code immediately rather than arriving at a marketing page.
|
|
28
|
+
|
|
29
|
+
## Distribution order
|
|
30
|
+
|
|
31
|
+
| Priority | Channel | What to publish | Success signal |
|
|
32
|
+
| --- | --- | --- | --- |
|
|
33
|
+
| 1 | npm | `@foxygeo/audit` | Clean-room `npx` works. |
|
|
34
|
+
| 2 | GitHub + Marketplace | Public source, `action.yml`, `v1` release and workflow example | `uses: foxygeo/audit@v1` is copyable. |
|
|
35
|
+
| 3 | Show HN | A personally written launch post linking to the GitHub repository | Developers try the CLI and ask implementation questions. |
|
|
36
|
+
| 4 | Dev.to / Hashnode | A technical walkthrough with canonical URL set to the tool landing page | Search discovery and workflow copies. |
|
|
37
|
+
| 5 | Relevant communities | One useful, rule-compliant post in each community | Qualified runs, not impressions. |
|
|
38
|
+
| 6 | X / LinkedIn | A real PR-comment screenshot plus a 15-second terminal recording | Clicks to the tool page and GitHub workflow copies. |
|
|
39
|
+
|
|
40
|
+
## Channel guidance
|
|
41
|
+
|
|
42
|
+
### GitHub Marketplace
|
|
43
|
+
|
|
44
|
+
This is the strongest intent channel: people already looking for a CI action can
|
|
45
|
+
install it directly. Keep the dedicated public repository limited to the Action
|
|
46
|
+
and its docs. Pin the workflow snippet and tag the first release as `v1` after
|
|
47
|
+
testing the immutable tag.
|
|
48
|
+
|
|
49
|
+
### Show HN
|
|
50
|
+
|
|
51
|
+
Do not submit until the package is publicly runnable. Show HN is for work that
|
|
52
|
+
readers can try without a signup barrier; a landing page alone is not enough.
|
|
53
|
+
Use the GitHub repository as the submitted URL and include the `npx` command,
|
|
54
|
+
an actual public report and the GitHub Actions example in the project README.
|
|
55
|
+
|
|
56
|
+
The maker should write the Show HN title and first comment personally. Hacker
|
|
57
|
+
News currently asks submitters not to post AI-generated or AI-edited text, and
|
|
58
|
+
the thread should be answered by the person who built the tool. Do not ask for
|
|
59
|
+
upvotes or coordinated comments.
|
|
60
|
+
|
|
61
|
+
### Communities worth testing after HN
|
|
62
|
+
|
|
63
|
+
- `r/webdev`: a practical CI workflow, only if its current self-promotion
|
|
64
|
+
rules permit it.
|
|
65
|
+
- `r/github`: GitHub Action angle, with the YAML example.
|
|
66
|
+
- `r/SEO`: only the `llms.txt`/crawler-access audit angle; avoid pitching it
|
|
67
|
+
as a generic AI product.
|
|
68
|
+
- Indie Hackers: build log and the report-generation architecture, not an ad.
|
|
69
|
+
- Dev.to and Hashnode: one detailed implementation article with the canonical
|
|
70
|
+
URL pointing to the FoxyGEO tool page.
|
|
71
|
+
|
|
72
|
+
Use one channel at a time. Measure clean-room installs, completed audits,
|
|
73
|
+
`--publish` reports, GitHub workflow copies, and return usage—not likes.
|
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# `@foxygeo/audit`
|
|
2
|
+
|
|
3
|
+
Free CI linter for AI crawler readiness. Checks `llms.txt`, GPTBot/ClaudeBot access, and JSON-LD in about two seconds, then comments the score on a pull request with a public report on [foxygeo.com](https://foxygeo.com/tools/cli-audit).
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx @foxygeo/audit
|
|
7
|
+
npx @foxygeo/audit example.com
|
|
8
|
+
npx @foxygeo/audit --ci --publish example.com
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## GitHub Actions
|
|
12
|
+
|
|
13
|
+
```yaml
|
|
14
|
+
name: FoxyGEO
|
|
15
|
+
on: [pull_request]
|
|
16
|
+
jobs:
|
|
17
|
+
audit:
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
permissions:
|
|
20
|
+
contents: read
|
|
21
|
+
pull-requests: write
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
- uses: actions/setup-node@v4
|
|
25
|
+
with:
|
|
26
|
+
node-version: 20
|
|
27
|
+
- run: npx --yes @foxygeo/audit --ci --publish example.com
|
|
28
|
+
env:
|
|
29
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The PR comment looks like:
|
|
33
|
+
|
|
34
|
+
> FoxyGEO AI Readiness: 94/100
|
|
35
|
+
> Public audit: https://foxygeo.com/report/example-com
|
|
36
|
+
|
|
37
|
+
Preview hosts (`*.vercel.app`, `*.netlify.app`, and similar) are never published.
|
|
38
|
+
|
|
39
|
+
## What it scores
|
|
40
|
+
|
|
41
|
+
| Check | Weight |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| GPTBot / ClaudeBot not blocked at `/` | 40 |
|
|
44
|
+
| `llms.txt` present and useful | 30 |
|
|
45
|
+
| JSON-LD (`Organization` / `WebSite` / `FAQPage` / …) | 30 |
|
|
46
|
+
|
|
47
|
+
Local mode reads files in the repo. Domain mode fetches `/robots.txt`, `/llms.txt`, and the homepage.
|
|
48
|
+
|
|
49
|
+
## Publish later
|
|
50
|
+
|
|
51
|
+
1. Create the npm org `@foxygeo` and run `npm publish --access public` from this directory.
|
|
52
|
+
2. Point a public GitHub repo (or `foxygeo/audit-action`) at `action.yml`.
|
|
53
|
+
3. Show HN title: `npx @foxygeo/audit — lint llms.txt, GPTBot access, and schema in CI`.
|
|
54
|
+
|
|
55
|
+
See [PUBLISHING.md](./PUBLISHING.md) for the release gate, Marketplace setup,
|
|
56
|
+
and channel order.
|
package/action.yml
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
name: FoxyGEO Audit
|
|
2
|
+
description: Lint llms.txt, GPTBot/ClaudeBot access, and schema. Comment AI Readiness on the PR with a public FoxyGEO report.
|
|
3
|
+
author: FoxyGEO
|
|
4
|
+
branding:
|
|
5
|
+
icon: search
|
|
6
|
+
color: blue
|
|
7
|
+
inputs:
|
|
8
|
+
domain:
|
|
9
|
+
description: Production domain to publish as a public foxygeo.com report. Preview hosts are ignored.
|
|
10
|
+
required: false
|
|
11
|
+
default: ''
|
|
12
|
+
publish:
|
|
13
|
+
description: Set to "false" to lint without publishing a public report.
|
|
14
|
+
required: false
|
|
15
|
+
default: 'true'
|
|
16
|
+
fail-on:
|
|
17
|
+
description: error | warning | never
|
|
18
|
+
required: false
|
|
19
|
+
default: 'error'
|
|
20
|
+
github-token:
|
|
21
|
+
description: Token used to write the sticky PR comment.
|
|
22
|
+
required: false
|
|
23
|
+
default: ${{ github.token }}
|
|
24
|
+
working-directory:
|
|
25
|
+
description: Directory that contains robots.txt / llms.txt / HTML.
|
|
26
|
+
required: false
|
|
27
|
+
default: '.'
|
|
28
|
+
runs:
|
|
29
|
+
using: composite
|
|
30
|
+
steps:
|
|
31
|
+
- name: Run FoxyGEO audit
|
|
32
|
+
working-directory: ${{ inputs.working-directory }}
|
|
33
|
+
shell: bash
|
|
34
|
+
env:
|
|
35
|
+
GITHUB_TOKEN: ${{ inputs.github-token }}
|
|
36
|
+
FOXYGEO_DOMAIN: ${{ inputs.domain }}
|
|
37
|
+
FOXYGEO_PUBLISH: ${{ inputs.publish }}
|
|
38
|
+
FOXYGEO_FAIL_ON: ${{ inputs.fail-on }}
|
|
39
|
+
run: node "$GITHUB_ACTION_PATH/bin/foxygeo-audit.js" --ci
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@foxygeo/audit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Lint llms.txt, GPTBot/ClaudeBot access, and schema in CI. Comments AI Readiness on the PR and publishes a public FoxyGEO report.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"foxygeo-audit": "bin/foxygeo-audit.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"src",
|
|
15
|
+
"action.yml",
|
|
16
|
+
"README.md",
|
|
17
|
+
"PUBLISHING.md"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test tests/*.test.mjs",
|
|
21
|
+
"audit": "node bin/foxygeo-audit.js"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"homepage": "https://foxygeo.com/tools/cli-audit",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/foxygeo/audit.git"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"llms.txt",
|
|
34
|
+
"geo",
|
|
35
|
+
"gptbot",
|
|
36
|
+
"claudebot",
|
|
37
|
+
"schema",
|
|
38
|
+
"ci",
|
|
39
|
+
"github-actions",
|
|
40
|
+
"ai-search"
|
|
41
|
+
],
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/ai-access.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
export const AI_AGENTS = [
|
|
2
|
+
'gptbot',
|
|
3
|
+
'claudebot',
|
|
4
|
+
'chatgpt-user',
|
|
5
|
+
'oai-searchbot',
|
|
6
|
+
'claude-user',
|
|
7
|
+
'anthropic-ai',
|
|
8
|
+
'perplexitybot',
|
|
9
|
+
'google-extended',
|
|
10
|
+
'ccbot',
|
|
11
|
+
'applebot-extended',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export const PRIMARY_AGENTS = ['gptbot', 'claudebot'];
|
|
15
|
+
export const SECONDARY_AGENTS = ['perplexitybot', 'google-extended', 'ccbot', 'anthropic-ai'];
|
|
16
|
+
|
|
17
|
+
export const PREVIEW_SUFFIXES = [
|
|
18
|
+
'.vercel.app',
|
|
19
|
+
'.netlify.app',
|
|
20
|
+
'.github.io',
|
|
21
|
+
'.herokuapp.com',
|
|
22
|
+
'.railway.app',
|
|
23
|
+
'.onrender.com',
|
|
24
|
+
'.fly.dev',
|
|
25
|
+
'.web.app',
|
|
26
|
+
'.firebaseapp.com',
|
|
27
|
+
'.pages.dev',
|
|
28
|
+
'.ngrok.io',
|
|
29
|
+
'.ngrok-free.app',
|
|
30
|
+
'.trycloudflare.com',
|
|
31
|
+
'.amplifyapp.com',
|
|
32
|
+
'.azurewebsites.net',
|
|
33
|
+
'.cloudfront.net',
|
|
34
|
+
'.workers.dev',
|
|
35
|
+
'.localtest.me',
|
|
36
|
+
'.nip.io',
|
|
37
|
+
'.sslip.io',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const BRAND_SCHEMA_TYPES = new Set(['Organization', 'WebSite', 'LocalBusiness', 'Corporation', 'OnlineStore']);
|
|
41
|
+
const RICH_SCHEMA_TYPES = new Set(['FAQPage', 'Article', 'NewsArticle', 'BlogPosting', 'Product', 'SoftwareApplication', 'Service']);
|
|
42
|
+
|
|
43
|
+
export function isPreviewHost(host) {
|
|
44
|
+
let name = String(host || '').trim().toLowerCase().replace(/\.$/, '');
|
|
45
|
+
if (name.startsWith('www.')) name = name.slice(4);
|
|
46
|
+
if (!name) return true;
|
|
47
|
+
return PREVIEW_SUFFIXES.some((suffix) => name.endsWith(suffix));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isRootRule(rule) {
|
|
51
|
+
const value = String(rule || '').trim();
|
|
52
|
+
return value === '/' || value === '/*';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function parseRobots(text) {
|
|
56
|
+
if (!text || !String(text).trim()) {
|
|
57
|
+
return {
|
|
58
|
+
present: false,
|
|
59
|
+
blocksAi: false,
|
|
60
|
+
blocksAll: false,
|
|
61
|
+
agents: Object.fromEntries(AI_AGENTS.map((agent) => [agent, 'allow'])),
|
|
62
|
+
sitemaps: [],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const groups = [];
|
|
67
|
+
let current = null;
|
|
68
|
+
const sitemaps = [];
|
|
69
|
+
|
|
70
|
+
for (const rawLine of String(text).split(/\r?\n/)) {
|
|
71
|
+
const line = rawLine.split('#', 1)[0].trim();
|
|
72
|
+
if (!line || !line.includes(':')) continue;
|
|
73
|
+
const idx = line.indexOf(':');
|
|
74
|
+
const key = line.slice(0, idx).trim().toLowerCase();
|
|
75
|
+
const value = line.slice(idx + 1).trim();
|
|
76
|
+
if (key === 'user-agent') {
|
|
77
|
+
const agent = value.toLowerCase();
|
|
78
|
+
if (!current || current.hasDirective) {
|
|
79
|
+
current = { agents: new Set([agent]), allow: [], disallow: [], hasDirective: false };
|
|
80
|
+
groups.push(current);
|
|
81
|
+
} else {
|
|
82
|
+
current.agents.add(agent);
|
|
83
|
+
}
|
|
84
|
+
} else if (key === 'allow' && current) {
|
|
85
|
+
current.allow.push(value);
|
|
86
|
+
current.hasDirective = true;
|
|
87
|
+
} else if (key === 'disallow' && current) {
|
|
88
|
+
current.disallow.push(value);
|
|
89
|
+
current.hasDirective = true;
|
|
90
|
+
} else if (key === 'sitemap' && value) {
|
|
91
|
+
sitemaps.push(value);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function groupFor(agent) {
|
|
96
|
+
return groups.find((group) => group.agents.has(agent)) || groups.find((group) => group.agents.has('*')) || null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function rootBlocked(group) {
|
|
100
|
+
if (!group) return false;
|
|
101
|
+
if (group.allow.some(isRootRule)) return false;
|
|
102
|
+
return group.disallow.some((rule) => String(rule || '').trim() && isRootRule(rule));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const blocksAll = rootBlocked(groupFor('*'));
|
|
106
|
+
const agents = {};
|
|
107
|
+
for (const agent of AI_AGENTS) {
|
|
108
|
+
agents[agent] = rootBlocked(groupFor(agent)) ? 'block' : 'allow';
|
|
109
|
+
}
|
|
110
|
+
const primaryBlocked = PRIMARY_AGENTS.some((agent) => agents[agent] === 'block');
|
|
111
|
+
return {
|
|
112
|
+
present: true,
|
|
113
|
+
blocksAi: blocksAll || primaryBlocked,
|
|
114
|
+
blocksAll,
|
|
115
|
+
agents,
|
|
116
|
+
sitemaps,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function scoreLlms(text, exists) {
|
|
121
|
+
if (!exists) return { score: 0, ok: false, quality: 'missing' };
|
|
122
|
+
const body = String(text || '').trim();
|
|
123
|
+
if (body.length < 40) return { score: 10, ok: true, quality: 'thin' };
|
|
124
|
+
const lowered = body.toLowerCase();
|
|
125
|
+
const hasHeading = body.startsWith('#') || lowered.includes('llms');
|
|
126
|
+
const urlCount = (lowered.match(/https?:\/\//g) || []).length;
|
|
127
|
+
const hasOverview = body.includes('>') || body.includes('## ');
|
|
128
|
+
if (hasHeading && hasOverview && urlCount >= 2) return { score: 30, ok: true, quality: 'strong' };
|
|
129
|
+
if (hasHeading || urlCount >= 1) return { score: 20, ok: true, quality: 'basic' };
|
|
130
|
+
return { score: 10, ok: true, quality: 'thin' };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function scoreSchema(schemaTypes) {
|
|
134
|
+
const types = new Set((schemaTypes || []).filter(Boolean));
|
|
135
|
+
let score = 0;
|
|
136
|
+
if (types.size) score += 10;
|
|
137
|
+
if ([...types].some((item) => BRAND_SCHEMA_TYPES.has(item))) score += 12;
|
|
138
|
+
if ([...types].some((item) => RICH_SCHEMA_TYPES.has(item))) score += 8;
|
|
139
|
+
return {
|
|
140
|
+
score,
|
|
141
|
+
ok: types.size > 0,
|
|
142
|
+
types: [...types].sort(),
|
|
143
|
+
hasBrand: [...types].some((item) => BRAND_SCHEMA_TYPES.has(item)),
|
|
144
|
+
hasRich: [...types].some((item) => RICH_SCHEMA_TYPES.has(item)),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function scoreRobots(parsed) {
|
|
149
|
+
let score = 0;
|
|
150
|
+
if (parsed.agents.gptbot === 'allow') score += 14;
|
|
151
|
+
if (parsed.agents.claudebot === 'allow') score += 14;
|
|
152
|
+
if (SECONDARY_AGENTS.every((agent) => parsed.agents[agent] === 'allow')) score += 8;
|
|
153
|
+
if (parsed.present) score += 4;
|
|
154
|
+
return {
|
|
155
|
+
score,
|
|
156
|
+
ok: !parsed.blocksAi,
|
|
157
|
+
present: parsed.present,
|
|
158
|
+
blocksAi: parsed.blocksAi,
|
|
159
|
+
blocksAll: parsed.blocksAll,
|
|
160
|
+
agents: parsed.agents,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function aiReadiness({ robots, llmsExists, llmsText, schemaTypes }) {
|
|
165
|
+
const robotsPart = scoreRobots(robots);
|
|
166
|
+
const llmsPart = scoreLlms(llmsText, llmsExists);
|
|
167
|
+
const schemaPart = scoreSchema(schemaTypes);
|
|
168
|
+
return {
|
|
169
|
+
score: Math.min(100, robotsPart.score + llmsPart.score + schemaPart.score),
|
|
170
|
+
robots: robotsPart,
|
|
171
|
+
llms: llmsPart,
|
|
172
|
+
schema: schemaPart,
|
|
173
|
+
};
|
|
174
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { isPreviewHost } from './ai-access.js';
|
|
3
|
+
import { discoverLocal } from './discover.js';
|
|
4
|
+
import { exitCode, findingsFromLive, findingsFromLocal } from './findings.js';
|
|
5
|
+
import { githubAnnotations, jsonReport, markdownReport, textReport } from './report.js';
|
|
6
|
+
import { pullRequestNumber, upsertPullRequestComment } from './github.js';
|
|
7
|
+
import { liveSnapshot } from './live.js';
|
|
8
|
+
import { publishAudit } from './publish.js';
|
|
9
|
+
|
|
10
|
+
const HELP = `FoxyGEO AI Readiness linter
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
npx @foxygeo/audit
|
|
14
|
+
npx @foxygeo/audit .
|
|
15
|
+
npx @foxygeo/audit example.com
|
|
16
|
+
npx @foxygeo/audit --ci --publish example.com
|
|
17
|
+
|
|
18
|
+
Options:
|
|
19
|
+
--publish <domain> Create/update the public report on foxygeo.com
|
|
20
|
+
--no-publish Skip publishing even when a domain is given
|
|
21
|
+
--ci GitHub annotations + sticky PR comment
|
|
22
|
+
--format <name> text | json | github (default: text, github in --ci)
|
|
23
|
+
--fail-on <level> error | warning | never
|
|
24
|
+
--root <dir> Repo root for local file discovery
|
|
25
|
+
-h, --help Show this help
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
export function parseArgs(argv) {
|
|
29
|
+
const args = {
|
|
30
|
+
target: null,
|
|
31
|
+
publish: process.env.FOXYGEO_DOMAIN || '',
|
|
32
|
+
ci: false,
|
|
33
|
+
format: '',
|
|
34
|
+
failOn: process.env.FOXYGEO_FAIL_ON || 'error',
|
|
35
|
+
noPublish: process.env.FOXYGEO_PUBLISH === 'false',
|
|
36
|
+
root: process.cwd(),
|
|
37
|
+
help: false,
|
|
38
|
+
};
|
|
39
|
+
const positional = [];
|
|
40
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
41
|
+
const token = argv[i];
|
|
42
|
+
if (token === '--ci') args.ci = true;
|
|
43
|
+
else if (token === '--no-publish') args.noPublish = true;
|
|
44
|
+
else if (token === '--publish') args.publish = argv[++i] || '';
|
|
45
|
+
else if (token === '--format') args.format = argv[++i] || '';
|
|
46
|
+
else if (token === '--fail-on') args.failOn = argv[++i] || 'error';
|
|
47
|
+
else if (token === '--root') args.root = argv[++i] || args.root;
|
|
48
|
+
else if (token === '--help' || token === '-h') args.help = true;
|
|
49
|
+
else if (token.startsWith('-')) throw new Error(`Unknown option ${token}`);
|
|
50
|
+
else positional.push(token);
|
|
51
|
+
}
|
|
52
|
+
args.target = positional[0] || null;
|
|
53
|
+
if (!args.format) args.format = args.ci ? 'github' : 'text';
|
|
54
|
+
if (process.env.GITHUB_ACTIONS === 'true') args.ci = true;
|
|
55
|
+
return args;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function looksLikeDomain(value) {
|
|
59
|
+
if (!value || value === '.' || value === './') return false;
|
|
60
|
+
if (value.includes('/') || value.includes('\\')) return false;
|
|
61
|
+
return /^[a-z0-9.-]+\.[a-z]{2,}$/i.test(value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function run(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env, fetchImpl = fetch } = {}) {
|
|
65
|
+
const args = parseArgs(argv);
|
|
66
|
+
if (args.help) {
|
|
67
|
+
stdout.write(`${HELP}\n`);
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const domainTarget = looksLikeDomain(args.target) ? args.target : (looksLikeDomain(args.publish) ? args.publish : '');
|
|
72
|
+
let result;
|
|
73
|
+
if (domainTarget) {
|
|
74
|
+
result = findingsFromLive(await liveSnapshot(domainTarget));
|
|
75
|
+
} else {
|
|
76
|
+
const local = await discoverLocal(path.resolve(args.root, args.target && !looksLikeDomain(args.target) ? args.target : '.'));
|
|
77
|
+
result = findingsFromLocal(local);
|
|
78
|
+
if (!local.robots && !local.llms && local.htmlFiles.length === 0) {
|
|
79
|
+
stderr.write('No robots.txt, llms.txt, or HTML files found. Pass a domain: npx @foxygeo/audit example.com\n');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let publicUrl = null;
|
|
84
|
+
const publishDomain = args.noPublish ? '' : (args.publish || domainTarget);
|
|
85
|
+
if (publishDomain && looksLikeDomain(publishDomain) && !isPreviewHost(publishDomain)) {
|
|
86
|
+
try {
|
|
87
|
+
const published = await publishAudit(publishDomain, { fetchImpl });
|
|
88
|
+
if (published.published) publicUrl = published.publicUrl;
|
|
89
|
+
else if (published.reason !== 'preview-host') {
|
|
90
|
+
stderr.write(`Publish skipped (${published.reason}). Local lint results still apply.\n`);
|
|
91
|
+
}
|
|
92
|
+
} catch (error) {
|
|
93
|
+
stderr.write(`Publish failed: ${error instanceof Error ? error.message : error}. Local lint results still apply.\n`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (args.format === 'json') {
|
|
98
|
+
stdout.write(`${JSON.stringify(jsonReport(result, { publicUrl }), null, 2)}\n`);
|
|
99
|
+
} else if (args.format === 'github') {
|
|
100
|
+
stdout.write(`${markdownReport(result, { publicUrl })}\n`);
|
|
101
|
+
for (const annotation of githubAnnotations(result)) stdout.write(`${annotation}\n`);
|
|
102
|
+
} else {
|
|
103
|
+
stdout.write(`${textReport(result, { publicUrl })}\n`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (args.ci && env.GITHUB_TOKEN && env.GITHUB_REPOSITORY) {
|
|
107
|
+
const number = await pullRequestNumber({ eventPath: env.GITHUB_EVENT_PATH, ref: env.GITHUB_REF });
|
|
108
|
+
if (number) {
|
|
109
|
+
const body = markdownReport(result, { publicUrl });
|
|
110
|
+
const posted = await upsertPullRequestComment({
|
|
111
|
+
token: env.GITHUB_TOKEN,
|
|
112
|
+
repository: env.GITHUB_REPOSITORY,
|
|
113
|
+
number,
|
|
114
|
+
body,
|
|
115
|
+
apiBase: env.GITHUB_API_URL,
|
|
116
|
+
fetchImpl,
|
|
117
|
+
});
|
|
118
|
+
if (!posted.posted) stderr.write(`PR comment skipped (${posted.reason}).\n`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return exitCode(result, args.failOn);
|
|
123
|
+
}
|
package/src/discover.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const IGNORE_DIRS = new Set([
|
|
5
|
+
'.git',
|
|
6
|
+
'node_modules',
|
|
7
|
+
'dist',
|
|
8
|
+
'build',
|
|
9
|
+
'coverage',
|
|
10
|
+
'.next',
|
|
11
|
+
'.nuxt',
|
|
12
|
+
'.output',
|
|
13
|
+
'vendor',
|
|
14
|
+
'.turbo',
|
|
15
|
+
'.cache',
|
|
16
|
+
'out',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
const TEXT_CANDIDATES = [
|
|
20
|
+
'robots.txt',
|
|
21
|
+
'public/robots.txt',
|
|
22
|
+
'static/robots.txt',
|
|
23
|
+
'app/robots.txt',
|
|
24
|
+
'src/robots.txt',
|
|
25
|
+
'llms.txt',
|
|
26
|
+
'public/llms.txt',
|
|
27
|
+
'static/llms.txt',
|
|
28
|
+
'llms-full.txt',
|
|
29
|
+
'public/llms-full.txt',
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const HTML_EXT = new Set(['.html', '.htm', '.php']);
|
|
33
|
+
|
|
34
|
+
export async function readIfExists(filePath) {
|
|
35
|
+
try {
|
|
36
|
+
return await fs.readFile(filePath, 'utf8');
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function findNamedFile(root, names) {
|
|
43
|
+
for (const relative of names) {
|
|
44
|
+
const full = path.join(root, relative);
|
|
45
|
+
const text = await readIfExists(full);
|
|
46
|
+
if (text !== null) return { path: full, relative, text };
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function discoverLocal(root, { maxHtml = 40 } = {}) {
|
|
52
|
+
const robots = await findNamedFile(root, TEXT_CANDIDATES.filter((item) => item.endsWith('robots.txt')));
|
|
53
|
+
const llms = await findNamedFile(root, TEXT_CANDIDATES.filter((item) => item.includes('llms')));
|
|
54
|
+
const htmlFiles = [];
|
|
55
|
+
await walk(root, root, htmlFiles, maxHtml);
|
|
56
|
+
return { root, robots, llms, htmlFiles };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function walk(root, current, htmlFiles, maxHtml) {
|
|
60
|
+
if (htmlFiles.length >= maxHtml) return;
|
|
61
|
+
let entries;
|
|
62
|
+
try {
|
|
63
|
+
entries = await fs.readdir(current, { withFileTypes: true });
|
|
64
|
+
} catch {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
if (htmlFiles.length >= maxHtml) return;
|
|
69
|
+
if (entry.name.startsWith('.') && entry.name !== '.html') {
|
|
70
|
+
if (IGNORE_DIRS.has(entry.name)) continue;
|
|
71
|
+
if (entry.isDirectory()) continue;
|
|
72
|
+
}
|
|
73
|
+
const full = path.join(current, entry.name);
|
|
74
|
+
if (entry.isDirectory()) {
|
|
75
|
+
if (IGNORE_DIRS.has(entry.name)) continue;
|
|
76
|
+
await walk(root, full, htmlFiles, maxHtml);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (!HTML_EXT.has(path.extname(entry.name).toLowerCase())) continue;
|
|
80
|
+
const text = await readIfExists(full);
|
|
81
|
+
if (text === null) continue;
|
|
82
|
+
htmlFiles.push({
|
|
83
|
+
path: full,
|
|
84
|
+
relative: path.relative(root, full),
|
|
85
|
+
text,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
package/src/findings.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { PRIMARY_AGENTS, SECONDARY_AGENTS, aiReadiness, parseRobots } from './ai-access.js';
|
|
2
|
+
import { extractSchemaTypes } from './schema.js';
|
|
3
|
+
|
|
4
|
+
export function findingsFromLocal({ robots, llms, htmlFiles }) {
|
|
5
|
+
const robotsText = robots?.text || '';
|
|
6
|
+
const parsed = parseRobots(robots ? robotsText : null);
|
|
7
|
+
const schemaTypes = [...new Set((htmlFiles || []).flatMap((file) => extractSchemaTypes(file.text)))];
|
|
8
|
+
const readiness = aiReadiness({
|
|
9
|
+
robots: parsed,
|
|
10
|
+
llmsExists: Boolean(llms),
|
|
11
|
+
llmsText: llms?.text || '',
|
|
12
|
+
schemaTypes,
|
|
13
|
+
});
|
|
14
|
+
return buildResult({
|
|
15
|
+
mode: 'local',
|
|
16
|
+
robotsPath: robots?.relative || null,
|
|
17
|
+
llmsPath: llms?.relative || null,
|
|
18
|
+
parsed,
|
|
19
|
+
readiness,
|
|
20
|
+
schemaTypes,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function findingsFromLive(snapshot) {
|
|
25
|
+
const readiness = aiReadiness({
|
|
26
|
+
robots: snapshot.robots.parsed,
|
|
27
|
+
llmsExists: snapshot.llms.exists,
|
|
28
|
+
llmsText: snapshot.llms.text,
|
|
29
|
+
schemaTypes: snapshot.homepage.schemaTypes,
|
|
30
|
+
});
|
|
31
|
+
return buildResult({
|
|
32
|
+
mode: 'live',
|
|
33
|
+
robotsPath: snapshot.robots.exists ? '/robots.txt' : null,
|
|
34
|
+
llmsPath: snapshot.llms.exists ? '/llms.txt' : null,
|
|
35
|
+
parsed: snapshot.robots.parsed,
|
|
36
|
+
readiness,
|
|
37
|
+
schemaTypes: snapshot.homepage.schemaTypes,
|
|
38
|
+
domain: snapshot.domain,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function buildResult({ mode, robotsPath, llmsPath, parsed, readiness, schemaTypes, domain = null }) {
|
|
43
|
+
const findings = [];
|
|
44
|
+
if (parsed.agents.gptbot === 'block') {
|
|
45
|
+
findings.push({ severity: 'error', id: 'gptbot-blocked', message: 'robots.txt blocks GPTBot at /' });
|
|
46
|
+
}
|
|
47
|
+
if (parsed.agents.claudebot === 'block') {
|
|
48
|
+
findings.push({ severity: 'error', id: 'claudebot-blocked', message: 'robots.txt blocks ClaudeBot at /' });
|
|
49
|
+
}
|
|
50
|
+
if (parsed.blocksAll) {
|
|
51
|
+
findings.push({ severity: 'error', id: 'robots-blocks-all', message: 'User-agent * disallows /' });
|
|
52
|
+
}
|
|
53
|
+
for (const agent of SECONDARY_AGENTS) {
|
|
54
|
+
if (parsed.agents[agent] === 'block') {
|
|
55
|
+
findings.push({ severity: 'warning', id: `${agent}-blocked`, message: `robots.txt blocks ${agent}` });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (!readiness.llms.ok) {
|
|
59
|
+
findings.push({ severity: 'warning', id: 'llms-missing', message: 'llms.txt is missing' });
|
|
60
|
+
} else if (readiness.llms.quality === 'thin') {
|
|
61
|
+
findings.push({ severity: 'warning', id: 'llms-thin', message: 'llms.txt exists but is too thin to guide AI crawlers' });
|
|
62
|
+
}
|
|
63
|
+
if (!readiness.schema.ok) {
|
|
64
|
+
findings.push({ severity: 'warning', id: 'schema-missing', message: 'No JSON-LD schema types found' });
|
|
65
|
+
} else if (!readiness.schema.hasBrand) {
|
|
66
|
+
findings.push({ severity: 'info', id: 'schema-brand', message: 'Add Organization or WebSite JSON-LD' });
|
|
67
|
+
}
|
|
68
|
+
if (!parsed.present) {
|
|
69
|
+
findings.push({ severity: 'info', id: 'robots-missing', message: 'No robots.txt found; AI crawlers are allowed by default' });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const failed = findings.some((item) => item.severity === 'error');
|
|
73
|
+
const warned = findings.some((item) => item.severity === 'warning');
|
|
74
|
+
return {
|
|
75
|
+
mode,
|
|
76
|
+
domain,
|
|
77
|
+
score: readiness.score,
|
|
78
|
+
robotsPath,
|
|
79
|
+
llmsPath,
|
|
80
|
+
gptbot: parsed.agents.gptbot,
|
|
81
|
+
claudebot: parsed.agents.claudebot,
|
|
82
|
+
agents: parsed.agents,
|
|
83
|
+
schemaTypes,
|
|
84
|
+
readiness,
|
|
85
|
+
findings,
|
|
86
|
+
failed,
|
|
87
|
+
warned,
|
|
88
|
+
primaryOpen: PRIMARY_AGENTS.every((agent) => parsed.agents[agent] === 'allow'),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function exitCode(result, failOn = 'error') {
|
|
93
|
+
if (failOn === 'never') return 0;
|
|
94
|
+
if (result.failed) return 1;
|
|
95
|
+
if (failOn === 'warning' && result.warned) return 1;
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
package/src/github.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { MARKER } from './report.js';
|
|
3
|
+
|
|
4
|
+
export async function pullRequestNumber({ eventPath = process.env.GITHUB_EVENT_PATH, ref = process.env.GITHUB_REF } = {}) {
|
|
5
|
+
if (eventPath) {
|
|
6
|
+
try {
|
|
7
|
+
const event = JSON.parse(await fs.readFile(eventPath, 'utf8'));
|
|
8
|
+
const number = event.pull_request?.number || event.number;
|
|
9
|
+
if (number) return Number(number);
|
|
10
|
+
} catch {
|
|
11
|
+
// Fall through to ref parsing.
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const match = String(ref || '').match(/^refs\/pull\/(\d+)\//);
|
|
15
|
+
return match ? Number(match[1]) : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function upsertPullRequestComment({
|
|
19
|
+
token,
|
|
20
|
+
repository,
|
|
21
|
+
number,
|
|
22
|
+
body,
|
|
23
|
+
apiBase = process.env.GITHUB_API_URL || 'https://api.github.com',
|
|
24
|
+
fetchImpl = fetch,
|
|
25
|
+
}) {
|
|
26
|
+
if (!token || !repository || !number) return { posted: false, reason: 'missing-github-context' };
|
|
27
|
+
const headers = {
|
|
28
|
+
accept: 'application/vnd.github+json',
|
|
29
|
+
authorization: `Bearer ${token}`,
|
|
30
|
+
'x-github-api-version': '2022-11-28',
|
|
31
|
+
'user-agent': 'foxygeo-audit',
|
|
32
|
+
};
|
|
33
|
+
const listUrl = `${apiBase}/repos/${repository}/issues/${number}/comments?per_page=100`;
|
|
34
|
+
const listed = await fetchImpl(listUrl, { headers });
|
|
35
|
+
if (!listed.ok) return { posted: false, reason: `list-failed:${listed.status}` };
|
|
36
|
+
const comments = await listed.json();
|
|
37
|
+
const existing = Array.isArray(comments) ? comments.find((comment) => String(comment.body || '').includes(MARKER)) : null;
|
|
38
|
+
const target = existing
|
|
39
|
+
? `${apiBase}/repos/${repository}/issues/comments/${existing.id}`
|
|
40
|
+
: `${apiBase}/repos/${repository}/issues/${number}/comments`;
|
|
41
|
+
const method = existing ? 'PATCH' : 'POST';
|
|
42
|
+
const written = await fetchImpl(target, {
|
|
43
|
+
method,
|
|
44
|
+
headers: { ...headers, 'content-type': 'application/json' },
|
|
45
|
+
body: JSON.stringify({ body }),
|
|
46
|
+
});
|
|
47
|
+
return { posted: written.ok, reason: written.ok ? (existing ? 'updated' : 'created') : `write-failed:${written.status}` };
|
|
48
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { aiReadiness, isPreviewHost, parseRobots } from './ai-access.js';
|
|
2
|
+
export { discoverLocal } from './discover.js';
|
|
3
|
+
export { findingsFromLive, findingsFromLocal, exitCode } from './findings.js';
|
|
4
|
+
export { markdownReport, textReport } from './report.js';
|
|
5
|
+
export { parseArgs, run } from './cli.js';
|
package/src/live.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { extractSchemaTypes } from './schema.js';
|
|
2
|
+
import { parseRobots } from './ai-access.js';
|
|
3
|
+
|
|
4
|
+
const TIMEOUT_MS = 8000;
|
|
5
|
+
|
|
6
|
+
async function fetchText(url) {
|
|
7
|
+
const controller = new AbortController();
|
|
8
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
9
|
+
try {
|
|
10
|
+
const response = await fetch(url, {
|
|
11
|
+
redirect: 'follow',
|
|
12
|
+
signal: controller.signal,
|
|
13
|
+
headers: {
|
|
14
|
+
'user-agent': 'FoxyGEO-Audit/0.1 (+https://foxygeo.com/tools/cli-audit)',
|
|
15
|
+
accept: 'text/html,text/plain,*/*',
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
const text = response.ok ? await response.text() : '';
|
|
19
|
+
return { ok: response.ok, status: response.status, url: response.url, text: text.slice(0, 512_000) };
|
|
20
|
+
} catch {
|
|
21
|
+
return { ok: false, status: 0, url, text: '' };
|
|
22
|
+
} finally {
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function firstOk(urls) {
|
|
28
|
+
let last = { ok: false, status: 0, url: urls[0], text: '' };
|
|
29
|
+
for (const url of urls) {
|
|
30
|
+
last = await fetchText(url);
|
|
31
|
+
if (last.ok) return last;
|
|
32
|
+
}
|
|
33
|
+
return last;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function liveSnapshot(domain) {
|
|
37
|
+
const host = domain.replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/^www\./, '');
|
|
38
|
+
const [robots, llms, home] = await Promise.all([
|
|
39
|
+
firstOk([`https://${host}/robots.txt`, `http://${host}/robots.txt`]),
|
|
40
|
+
firstOk([`https://${host}/llms.txt`, `http://${host}/llms.txt`]),
|
|
41
|
+
firstOk([`https://${host}/`, `http://${host}/`]),
|
|
42
|
+
]);
|
|
43
|
+
return {
|
|
44
|
+
domain: host,
|
|
45
|
+
robots: {
|
|
46
|
+
exists: robots.ok,
|
|
47
|
+
text: robots.ok ? robots.text : '',
|
|
48
|
+
parsed: parseRobots(robots.ok ? robots.text : null),
|
|
49
|
+
},
|
|
50
|
+
llms: {
|
|
51
|
+
exists: llms.ok,
|
|
52
|
+
text: llms.ok ? llms.text : '',
|
|
53
|
+
},
|
|
54
|
+
homepage: {
|
|
55
|
+
exists: home.ok,
|
|
56
|
+
url: home.url,
|
|
57
|
+
schemaTypes: home.ok ? extractSchemaTypes(home.text) : [],
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
package/src/publish.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { isPreviewHost } from './ai-access.js';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_API = 'https://foxygeo.com';
|
|
4
|
+
|
|
5
|
+
export async function publishAudit(domain, { apiBase = process.env.FOXYGEO_API_URL || DEFAULT_API, fetchImpl = fetch } = {}) {
|
|
6
|
+
const host = String(domain || '').replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/^www\./, '').toLowerCase();
|
|
7
|
+
if (!host) throw new Error('A public domain is required to publish an audit');
|
|
8
|
+
if (isPreviewHost(host)) {
|
|
9
|
+
return { published: false, reason: 'preview-host', publicUrl: null };
|
|
10
|
+
}
|
|
11
|
+
const response = await fetchImpl(`${apiBase.replace(/\/$/, '')}/api/v1/cli/audits`, {
|
|
12
|
+
method: 'POST',
|
|
13
|
+
headers: {
|
|
14
|
+
accept: 'application/json',
|
|
15
|
+
'content-type': 'application/json',
|
|
16
|
+
'user-agent': 'foxygeo-audit/0.1',
|
|
17
|
+
},
|
|
18
|
+
body: JSON.stringify({ domain: host, source: 'cli' }),
|
|
19
|
+
});
|
|
20
|
+
if (!response.ok) {
|
|
21
|
+
const detail = await response.text();
|
|
22
|
+
return { published: false, reason: `api:${response.status}`, detail: detail.slice(0, 300), publicUrl: null };
|
|
23
|
+
}
|
|
24
|
+
const payload = await response.json();
|
|
25
|
+
return {
|
|
26
|
+
published: true,
|
|
27
|
+
reused: Boolean(payload.reused),
|
|
28
|
+
publicUrl: payload.public_url,
|
|
29
|
+
reportUrl: payload.report_url,
|
|
30
|
+
aiReadiness: payload.ai_readiness,
|
|
31
|
+
geoScore: payload.geo_score,
|
|
32
|
+
};
|
|
33
|
+
}
|
package/src/report.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const MARKER = '<!-- foxygeo-audit -->';
|
|
2
|
+
|
|
3
|
+
export function markdownReport(result, { publicUrl = null } = {}) {
|
|
4
|
+
const icon = result.score >= 85 ? '✅' : result.score >= 60 ? '⚠️' : '❌';
|
|
5
|
+
const rows = [
|
|
6
|
+
`| llms.txt | ${result.readiness.llms.ok ? `pass (${result.readiness.llms.quality})` : 'missing'} |`,
|
|
7
|
+
`| GPTBot | ${result.gptbot === 'allow' ? 'allowed' : 'blocked'} |`,
|
|
8
|
+
`| ClaudeBot | ${result.claudebot === 'allow' ? 'allowed' : 'blocked'} |`,
|
|
9
|
+
`| Schema | ${result.schemaTypes.length ? result.schemaTypes.slice(0, 6).join(', ') : 'none'} |`,
|
|
10
|
+
];
|
|
11
|
+
const findingLines = result.findings.length
|
|
12
|
+
? result.findings.map((item) => `- **${item.severity}:** ${item.message}`).join('\n')
|
|
13
|
+
: '- No blocking issues.';
|
|
14
|
+
const link = publicUrl
|
|
15
|
+
? `\n**Public audit:** ${publicUrl}\n`
|
|
16
|
+
: '\n';
|
|
17
|
+
return `${MARKER}
|
|
18
|
+
## ${icon} FoxyGEO AI Readiness: ${result.score}/100
|
|
19
|
+
|
|
20
|
+
| Check | Status |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
${rows.join('\n')}
|
|
23
|
+
${link}
|
|
24
|
+
${findingLines}
|
|
25
|
+
|
|
26
|
+
<sub>Run locally: <code>npx @foxygeo/audit</code> · [docs](https://foxygeo.com/tools/cli-audit)</sub>
|
|
27
|
+
`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function textReport(result, { publicUrl = null } = {}) {
|
|
31
|
+
const lines = [
|
|
32
|
+
`FoxyGEO AI Readiness: ${result.score}/100`,
|
|
33
|
+
`llms.txt: ${result.readiness.llms.ok ? result.readiness.llms.quality : 'missing'}`,
|
|
34
|
+
`GPTBot: ${result.gptbot}`,
|
|
35
|
+
`ClaudeBot: ${result.claudebot}`,
|
|
36
|
+
`Schema: ${result.schemaTypes.join(', ') || 'none'}`,
|
|
37
|
+
];
|
|
38
|
+
if (publicUrl) lines.push(`Public audit: ${publicUrl}`);
|
|
39
|
+
for (const finding of result.findings) {
|
|
40
|
+
lines.push(`[${finding.severity}] ${finding.message}`);
|
|
41
|
+
}
|
|
42
|
+
return lines.join('\n');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function jsonReport(result, extras = {}) {
|
|
46
|
+
return {
|
|
47
|
+
tool: 'foxygeo-audit',
|
|
48
|
+
score: result.score,
|
|
49
|
+
...result,
|
|
50
|
+
...extras,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function githubAnnotations(result) {
|
|
55
|
+
return result.findings.map((item) => {
|
|
56
|
+
const level = item.severity === 'error' ? 'error' : item.severity === 'warning' ? 'warning' : 'notice';
|
|
57
|
+
return `::${level}::${item.message}`;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export { MARKER };
|
package/src/schema.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function extractSchemaTypes(html) {
|
|
2
|
+
const types = new Set();
|
|
3
|
+
if (!html) return [];
|
|
4
|
+
const scriptRe = /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
|
5
|
+
let match;
|
|
6
|
+
while ((match = scriptRe.exec(html))) {
|
|
7
|
+
const raw = match[1].trim();
|
|
8
|
+
if (!raw) continue;
|
|
9
|
+
try {
|
|
10
|
+
walk(JSON.parse(raw), types);
|
|
11
|
+
} catch {
|
|
12
|
+
// Ignore broken JSON-LD blocks; the linter still reports missing coverage.
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return [...types];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function walk(node, types) {
|
|
19
|
+
if (!node) return;
|
|
20
|
+
if (Array.isArray(node)) {
|
|
21
|
+
for (const item of node) walk(item, types);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (typeof node !== 'object') return;
|
|
25
|
+
const value = node['@type'];
|
|
26
|
+
if (typeof value === 'string') types.add(value);
|
|
27
|
+
else if (Array.isArray(value)) {
|
|
28
|
+
for (const item of value) if (typeof item === 'string') types.add(item);
|
|
29
|
+
}
|
|
30
|
+
for (const child of Object.values(node)) walk(child, types);
|
|
31
|
+
}
|