search-auditor 0.1.0 → 0.2.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/CHANGELOG.md +16 -0
- package/README.md +87 -38
- package/dist/cli.js +17 -9
- package/dist/collect.d.ts +5 -1
- package/dist/collect.js +186 -11
- package/dist/i18n/en.d.ts +462 -7
- package/dist/i18n/en.js +480 -25
- package/dist/i18n/index.d.ts +924 -14
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/ru.js +484 -24
- package/dist/index.d.ts +3 -145
- package/dist/index.js +14 -53
- package/dist/rules/can/can-1.d.ts +2 -0
- package/dist/rules/can/can-1.js +14 -0
- package/dist/rules/can/can-2.d.ts +2 -0
- package/dist/rules/can/can-2.js +20 -0
- package/dist/rules/can/can-3.d.ts +2 -0
- package/dist/rules/can/can-3.js +32 -0
- package/dist/rules/can/can-4.d.ts +2 -0
- package/dist/rules/can/can-4.js +18 -0
- package/dist/rules/can/can-5.d.ts +2 -0
- package/dist/rules/can/can-5.js +17 -0
- package/dist/rules/can/can-6.d.ts +2 -0
- package/dist/rules/can/can-6.js +22 -0
- package/dist/rules/can/can-7.d.ts +2 -0
- package/dist/rules/can/can-7.js +32 -0
- package/dist/rules/can/can-8.d.ts +2 -0
- package/dist/rules/can/can-8.js +23 -0
- package/dist/rules/can/can-9.d.ts +2 -0
- package/dist/rules/can/can-9.js +24 -0
- package/dist/rules/can/index.d.ts +7 -0
- package/dist/rules/can/index.js +10 -0
- package/dist/rules/can/shared.d.ts +11 -0
- package/dist/rules/can/shared.js +26 -0
- package/dist/rules/data/data-1.d.ts +2 -0
- package/dist/rules/data/data-1.js +16 -0
- package/dist/rules/data/data-2.d.ts +2 -0
- package/dist/rules/data/data-2.js +25 -0
- package/dist/rules/data/data-3.d.ts +2 -0
- package/dist/rules/data/data-3.js +29 -0
- package/dist/rules/data/data-4.d.ts +2 -0
- package/dist/rules/data/data-4.js +17 -0
- package/dist/rules/data/data-5.d.ts +2 -0
- package/dist/rules/data/data-5.js +28 -0
- package/dist/rules/data/data-6.d.ts +2 -0
- package/dist/rules/data/data-6.js +16 -0
- package/dist/rules/data/index.d.ts +8 -0
- package/dist/rules/data/index.js +7 -0
- package/dist/rules/data/shared.d.ts +22 -0
- package/dist/rules/data/shared.js +59 -0
- package/dist/rules/idx/idx-1.d.ts +2 -0
- package/dist/rules/idx/idx-1.js +15 -0
- package/dist/rules/idx/idx-2.d.ts +2 -0
- package/dist/rules/idx/idx-2.js +22 -0
- package/dist/rules/idx/idx-3.d.ts +2 -0
- package/dist/rules/idx/idx-3.js +35 -0
- package/dist/rules/idx/idx-4.d.ts +2 -0
- package/dist/rules/idx/idx-4.js +40 -0
- package/dist/rules/idx/idx-5.d.ts +2 -0
- package/dist/rules/idx/idx-5.js +24 -0
- package/dist/rules/idx/idx-6.d.ts +2 -0
- package/dist/rules/idx/idx-6.js +25 -0
- package/dist/rules/idx/idx-7.d.ts +2 -0
- package/dist/rules/idx/idx-7.js +29 -0
- package/dist/rules/idx/idx-8.d.ts +2 -0
- package/dist/rules/idx/idx-8.js +25 -0
- package/dist/rules/idx/idx-9.d.ts +2 -0
- package/dist/rules/idx/idx-9.js +22 -0
- package/dist/rules/idx/index.d.ts +7 -0
- package/dist/rules/idx/index.js +10 -0
- package/dist/rules/idx/shared.d.ts +18 -0
- package/dist/rules/idx/shared.js +18 -0
- package/dist/rules/img/img-1.d.ts +2 -0
- package/dist/rules/img/img-1.js +18 -0
- package/dist/rules/img/img-2.d.ts +2 -0
- package/dist/rules/img/img-2.js +20 -0
- package/dist/rules/img/img-3.d.ts +2 -0
- package/dist/rules/img/img-3.js +24 -0
- package/dist/rules/img/img-4.d.ts +2 -0
- package/dist/rules/img/img-4.js +19 -0
- package/dist/rules/img/img-5.d.ts +2 -0
- package/dist/rules/img/img-5.js +18 -0
- package/dist/rules/img/img-6.d.ts +2 -0
- package/dist/rules/img/img-6.js +20 -0
- package/dist/rules/img/img-7.d.ts +2 -0
- package/dist/rules/img/img-7.js +22 -0
- package/dist/rules/img/index.d.ts +7 -0
- package/dist/rules/img/index.js +8 -0
- package/dist/rules/img/shared.d.ts +26 -0
- package/dist/rules/img/shared.js +38 -0
- package/dist/rules/index.d.ts +1 -1
- package/dist/rules/index.js +23 -6
- package/dist/rules/lang/index.d.ts +8 -0
- package/dist/rules/lang/index.js +9 -0
- package/dist/rules/lang/lang-1.d.ts +2 -0
- package/dist/rules/lang/lang-1.js +17 -0
- package/dist/rules/lang/lang-2.d.ts +2 -0
- package/dist/rules/lang/lang-2.js +30 -0
- package/dist/rules/lang/lang-3.d.ts +2 -0
- package/dist/rules/lang/lang-3.js +22 -0
- package/dist/rules/lang/lang-4.d.ts +2 -0
- package/dist/rules/lang/lang-4.js +23 -0
- package/dist/rules/lang/lang-5.d.ts +2 -0
- package/dist/rules/lang/lang-5.js +33 -0
- package/dist/rules/lang/lang-6.d.ts +2 -0
- package/dist/rules/lang/lang-6.js +25 -0
- package/dist/rules/lang/lang-7.d.ts +2 -0
- package/dist/rules/lang/lang-7.js +25 -0
- package/dist/rules/lang/lang-8.d.ts +2 -0
- package/dist/rules/lang/lang-8.js +19 -0
- package/dist/rules/lang/shared.d.ts +7 -0
- package/dist/rules/lang/shared.js +5 -0
- package/dist/rules/lnk/index.d.ts +8 -0
- package/dist/rules/lnk/index.js +8 -0
- package/dist/rules/lnk/lnk-1.d.ts +2 -0
- package/dist/rules/lnk/lnk-1.js +22 -0
- package/dist/rules/lnk/lnk-2.d.ts +2 -0
- package/dist/rules/lnk/lnk-2.js +18 -0
- package/dist/rules/lnk/lnk-3.d.ts +2 -0
- package/dist/rules/lnk/lnk-3.js +32 -0
- package/dist/rules/lnk/lnk-4.d.ts +2 -0
- package/dist/rules/lnk/lnk-4.js +18 -0
- package/dist/rules/lnk/lnk-5.d.ts +2 -0
- package/dist/rules/lnk/lnk-5.js +22 -0
- package/dist/rules/lnk/lnk-6.d.ts +2 -0
- package/dist/rules/lnk/lnk-6.js +24 -0
- package/dist/rules/lnk/lnk-7.d.ts +2 -0
- package/dist/rules/lnk/lnk-7.js +26 -0
- package/dist/rules/lnk/shared.d.ts +20 -0
- package/dist/rules/lnk/shared.js +29 -0
- package/dist/rules/map/index.d.ts +7 -0
- package/dist/rules/map/index.js +9 -0
- package/dist/rules/map/map-1.d.ts +2 -0
- package/dist/rules/map/map-1.js +15 -0
- package/dist/rules/map/map-2.d.ts +2 -0
- package/dist/rules/map/map-2.js +26 -0
- package/dist/rules/map/map-3.d.ts +2 -0
- package/dist/rules/map/map-3.js +24 -0
- package/dist/rules/map/map-4.d.ts +2 -0
- package/dist/rules/map/map-4.js +34 -0
- package/dist/rules/map/map-5.d.ts +2 -0
- package/dist/rules/map/map-5.js +28 -0
- package/dist/rules/map/map-6.d.ts +2 -0
- package/dist/rules/map/map-6.js +22 -0
- package/dist/rules/map/map-7.d.ts +2 -0
- package/dist/rules/map/map-7.js +18 -0
- package/dist/rules/map/map-8.d.ts +2 -0
- package/dist/rules/map/map-8.js +31 -0
- package/dist/rules/map/shared.d.ts +14 -0
- package/dist/rules/map/shared.js +12 -0
- package/dist/rules/meta/index.d.ts +8 -0
- package/dist/rules/meta/index.js +10 -0
- package/dist/rules/meta/meta-1.d.ts +2 -0
- package/dist/rules/meta/meta-1.js +20 -0
- package/dist/rules/meta/meta-2.d.ts +2 -0
- package/dist/rules/meta/meta-2.js +19 -0
- package/dist/rules/meta/meta-3.d.ts +2 -0
- package/dist/rules/meta/meta-3.js +26 -0
- package/dist/rules/meta/meta-4.d.ts +2 -0
- package/dist/rules/meta/meta-4.js +22 -0
- package/dist/rules/meta/meta-5.d.ts +2 -0
- package/dist/rules/meta/meta-5.js +17 -0
- package/dist/rules/meta/meta-6.d.ts +2 -0
- package/dist/rules/meta/meta-6.js +18 -0
- package/dist/rules/meta/meta-7.d.ts +2 -0
- package/dist/rules/meta/meta-7.js +18 -0
- package/dist/rules/meta/meta-8.d.ts +2 -0
- package/dist/rules/meta/meta-8.js +17 -0
- package/dist/rules/meta/meta-9.d.ts +2 -0
- package/dist/rules/meta/meta-9.js +16 -0
- package/dist/rules/meta/shared.d.ts +15 -0
- package/dist/rules/meta/shared.js +9 -0
- package/dist/rules/sec/index.d.ts +8 -0
- package/dist/rules/sec/index.js +8 -0
- package/dist/rules/sec/sec-1.d.ts +2 -0
- package/dist/rules/sec/sec-1.js +21 -0
- package/dist/rules/sec/sec-2.d.ts +2 -0
- package/dist/rules/sec/sec-2.js +17 -0
- package/dist/rules/sec/sec-3.d.ts +2 -0
- package/dist/rules/sec/sec-3.js +18 -0
- package/dist/rules/sec/sec-4.d.ts +2 -0
- package/dist/rules/sec/sec-4.js +23 -0
- package/dist/rules/sec/sec-5.d.ts +2 -0
- package/dist/rules/sec/sec-5.js +23 -0
- package/dist/rules/sec/sec-6.d.ts +2 -0
- package/dist/rules/sec/sec-6.js +32 -0
- package/dist/rules/sec/sec-7.d.ts +2 -0
- package/dist/rules/sec/sec-7.js +26 -0
- package/dist/rules/sec/shared.d.ts +10 -0
- package/dist/rules/sec/shared.js +8 -0
- package/dist/rules/str/index.d.ts +8 -0
- package/dist/rules/str/index.js +11 -0
- package/dist/rules/str/shared.d.ts +14 -0
- package/dist/rules/str/shared.js +16 -0
- package/dist/rules/str/str-1.d.ts +2 -0
- package/dist/rules/str/str-1.js +17 -0
- package/dist/rules/str/str-10.d.ts +2 -0
- package/dist/rules/str/str-10.js +15 -0
- package/dist/rules/str/str-2.d.ts +2 -0
- package/dist/rules/str/str-2.js +19 -0
- package/dist/rules/str/str-3.d.ts +2 -0
- package/dist/rules/str/str-3.js +14 -0
- package/dist/rules/str/str-4.d.ts +2 -0
- package/dist/rules/str/str-4.js +14 -0
- package/dist/rules/str/str-5.d.ts +2 -0
- package/dist/rules/str/str-5.js +19 -0
- package/dist/rules/str/str-6.d.ts +2 -0
- package/dist/rules/str/str-6.js +22 -0
- package/dist/rules/str/str-7.d.ts +2 -0
- package/dist/rules/str/str-7.js +18 -0
- package/dist/rules/str/str-8.d.ts +2 -0
- package/dist/rules/str/str-8.js +24 -0
- package/dist/rules/str/str-9.d.ts +2 -0
- package/dist/rules/str/str-9.js +14 -0
- package/dist/rules/util.d.ts +1 -1
- package/dist/trace.d.ts +20 -0
- package/dist/trace.js +77 -0
- package/dist/types.d.ts +86 -6
- package/package.json +4 -9
- package/dist/rules/links.d.ts +0 -2
- package/dist/rules/links.js +0 -95
- package/dist/rules/page.d.ts +0 -2
- package/dist/rules/page.js +0 -145
- package/dist/rules/site.d.ts +0 -2
- package/dist/rules/site.js +0 -177
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** META-8: The snippet is not forbidden. */
|
|
2
|
+
import { finder } from "auditor-engine";
|
|
3
|
+
export const meta8 = {
|
|
4
|
+
id: "META-8",
|
|
5
|
+
group: "META",
|
|
6
|
+
level: "warn",
|
|
7
|
+
run(site, _c, t) {
|
|
8
|
+
const { out, add } = finder(this);
|
|
9
|
+
for (const p of site.pages) {
|
|
10
|
+
const rules = `${p.metaRobots} ${p.fetched.headers.get("x-robots-tag") ?? ""}`;
|
|
11
|
+
const found = rules.match(/\bnosnippet\b|\bmax-snippet\s*:\s*0\b/i)?.[0];
|
|
12
|
+
if (found)
|
|
13
|
+
add(p.fetched.publicUrl, t.noSnippet(found));
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
},
|
|
17
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** META-9: The site has a favicon. */
|
|
2
|
+
import { finder } from "auditor-engine";
|
|
3
|
+
import { seoOf } from "../../collect.js";
|
|
4
|
+
export const meta9 = {
|
|
5
|
+
id: "META-9",
|
|
6
|
+
group: "META",
|
|
7
|
+
level: "warn",
|
|
8
|
+
run(site, _c, t) {
|
|
9
|
+
const { out, add } = finder(this);
|
|
10
|
+
// Checked only when the home page declares no icon: then /favicon.ico must be an image.
|
|
11
|
+
const f = seoOf(site).favicon;
|
|
12
|
+
if (f && (f.status !== 200 || !/^image\//i.test(f.headers.get("content-type") ?? "image/")))
|
|
13
|
+
add(`${site.base}/`, t.noFavicon(f.status ? String(f.status) : (f.error ?? "no answer")));
|
|
14
|
+
return out;
|
|
15
|
+
},
|
|
16
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Constants and helpers shared by the META checks. */
|
|
2
|
+
export declare const TITLE: {
|
|
3
|
+
min: number;
|
|
4
|
+
max: number;
|
|
5
|
+
};
|
|
6
|
+
export declare const DESCRIPTION: {
|
|
7
|
+
min: number;
|
|
8
|
+
max: number;
|
|
9
|
+
};
|
|
10
|
+
/** Characters as a person counts them: emoji and letters with marks are one each. */
|
|
11
|
+
export declare const len: (s: string) => number;
|
|
12
|
+
/** Titles that name a template, not a page: what a framework or an editor leaves when nobody writes one. */
|
|
13
|
+
export declare const GENERIC_TITLE: RegExp;
|
|
14
|
+
/** The same text for a person: case, spaces and trailing punctuation do not matter. */
|
|
15
|
+
export declare const plain: (s: string) => string;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Constants and helpers shared by the META checks. */
|
|
2
|
+
export const TITLE = { min: 10, max: 60 };
|
|
3
|
+
export const DESCRIPTION = { min: 50, max: 160 };
|
|
4
|
+
/** Characters as a person counts them: emoji and letters with marks are one each. */
|
|
5
|
+
export const len = (s) => [...s.trim()].length;
|
|
6
|
+
/** Titles that name a template, not a page: what a framework or an editor leaves when nobody writes one. */
|
|
7
|
+
export const GENERIC_TITLE = /^(home|home ?page|index|untitled|untitled document|document|new page|page|welcome|react app|vite app|vite \+ react|next\.?js app|create next app|gatsby|nuxt|my site|главная|главная страница|без названия|новая страница|страница|добро пожаловать)$/i;
|
|
8
|
+
/** The same text for a person: case, spaces and trailing punctuation do not matter. */
|
|
9
|
+
export const plain = (s) => s.toLowerCase().replace(/\s+/g, " ").replace(/[.!…]+$/, "").trim();
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security and HTTPS (SEC): is the site served securely, so browsers do not warn and search engines trust it.
|
|
3
|
+
* HTTPS with a permanent redirect from http://, a valid certificate that is not about to expire, HSTS, the site's own
|
|
4
|
+
* addresses on https://, no resources over http:// (mixed content), and basic security headers.
|
|
5
|
+
* The connection checks need the live site: a local build has no certificate and no real server headers.
|
|
6
|
+
*/
|
|
7
|
+
import type { SeoRule } from "../util.ts";
|
|
8
|
+
export declare const secRules: SeoRule[];
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { sec1 } from "./sec-1.js";
|
|
2
|
+
import { sec2 } from "./sec-2.js";
|
|
3
|
+
import { sec3 } from "./sec-3.js";
|
|
4
|
+
import { sec4 } from "./sec-4.js";
|
|
5
|
+
import { sec5 } from "./sec-5.js";
|
|
6
|
+
import { sec6 } from "./sec-6.js";
|
|
7
|
+
import { sec7 } from "./sec-7.js";
|
|
8
|
+
export const secRules = [sec1, sec2, sec3, sec4, sec5, sec6, sec7];
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** SEC-1: The site works over HTTPS, and http:// redirects to it. */
|
|
2
|
+
import { finder } from "auditor-engine";
|
|
3
|
+
import { seoOf } from "../../collect.js";
|
|
4
|
+
export const sec1 = {
|
|
5
|
+
id: "SEC-1",
|
|
6
|
+
group: "SEC",
|
|
7
|
+
level: "error",
|
|
8
|
+
applies: (site, t) => (seoOf(site).local ? t.skipLocal : null),
|
|
9
|
+
run(site, _c, t) {
|
|
10
|
+
const { out, add } = finder(this);
|
|
11
|
+
if (!site.base.startsWith("https://")) {
|
|
12
|
+
add("site", t.notHttps);
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
// Only an http:// answer that stays on http:// is a problem; a closed port 80 is not.
|
|
16
|
+
const home = seoOf(site).httpHome;
|
|
17
|
+
if (home && home.status > 0 && !(home.finalUrl ?? "").startsWith("https://"))
|
|
18
|
+
add(home.publicUrl, t.httpNoRedirect(home.finalUrl ?? home.publicUrl));
|
|
19
|
+
return out;
|
|
20
|
+
},
|
|
21
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** SEC-2: HTTPS pages do not load anything over http://. */
|
|
2
|
+
import { finder } from "auditor-engine";
|
|
3
|
+
export const sec2 = {
|
|
4
|
+
id: "SEC-2",
|
|
5
|
+
group: "SEC",
|
|
6
|
+
level: "warn",
|
|
7
|
+
applies: (site, t) => (site.base.startsWith("https://") ? null : t.skipNotHttps),
|
|
8
|
+
run(site, _c, t) {
|
|
9
|
+
const { out, add } = finder(this);
|
|
10
|
+
for (const p of site.pages) {
|
|
11
|
+
const insecure = p.resources.filter((r) => r.startsWith("http://"));
|
|
12
|
+
if (insecure.length)
|
|
13
|
+
add(p.fetched.publicUrl, t.mixedContent(insecure.length, insecure[0]));
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
},
|
|
17
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** SEC-3: http:// redirects to https:// permanently. */
|
|
2
|
+
import { finder } from "auditor-engine";
|
|
3
|
+
import { seoOf } from "../../collect.js";
|
|
4
|
+
import { liveHttps } from "./shared.js";
|
|
5
|
+
export const sec3 = {
|
|
6
|
+
id: "SEC-3",
|
|
7
|
+
group: "SEC",
|
|
8
|
+
level: "warn",
|
|
9
|
+
applies: (site, t) => (seoOf(site).local ? t.skipLocal : liveHttps(site, false) ? null : t.skipNotHttps),
|
|
10
|
+
run(site, _c, t) {
|
|
11
|
+
const { out, add } = finder(this);
|
|
12
|
+
const hop = seoOf(site).httpHop;
|
|
13
|
+
// 302 and 307 say «for now»: search engines keep the http:// address and do not pass its weight on.
|
|
14
|
+
if (hop && (hop.status === 302 || hop.status === 307) && hop.location.startsWith("https://"))
|
|
15
|
+
add(`http://${new URL(site.base).host}/`, t.temporaryRedirect(hop.status));
|
|
16
|
+
return out;
|
|
17
|
+
},
|
|
18
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** SEC-4: The certificate is valid and not about to expire. */
|
|
2
|
+
import { finder } from "auditor-engine";
|
|
3
|
+
import { seoOf } from "../../collect.js";
|
|
4
|
+
import { CERT_WARN_DAYS, liveHttps } from "./shared.js";
|
|
5
|
+
export const sec4 = {
|
|
6
|
+
id: "SEC-4",
|
|
7
|
+
group: "SEC",
|
|
8
|
+
level: "error",
|
|
9
|
+
applies: (site, t) => (seoOf(site).local ? t.skipLocal : liveHttps(site, false) ? null : t.skipNotHttps),
|
|
10
|
+
run(site, _c, t) {
|
|
11
|
+
const { out, add } = finder(this);
|
|
12
|
+
const cert = seoOf(site).cert;
|
|
13
|
+
if (!cert)
|
|
14
|
+
return out;
|
|
15
|
+
if (cert.daysLeft < 0)
|
|
16
|
+
add("site", t.certExpired(cert.validTo));
|
|
17
|
+
else if (!cert.valid)
|
|
18
|
+
add("site", t.certInvalid(cert.error ?? "untrusted"));
|
|
19
|
+
else if (cert.daysLeft < CERT_WARN_DAYS)
|
|
20
|
+
add("site", t.certExpiring(cert.daysLeft, cert.validTo), "warn");
|
|
21
|
+
return out;
|
|
22
|
+
},
|
|
23
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** SEC-5: The server asks browsers to use only https (HSTS). */
|
|
2
|
+
import { finder } from "auditor-engine";
|
|
3
|
+
import { seoOf } from "../../collect.js";
|
|
4
|
+
import { HSTS_MIN_AGE, homeOf, liveHttps } from "./shared.js";
|
|
5
|
+
export const sec5 = {
|
|
6
|
+
id: "SEC-5",
|
|
7
|
+
group: "SEC",
|
|
8
|
+
level: "warn",
|
|
9
|
+
applies: (site, t) => (seoOf(site).local ? t.skipLocal : liveHttps(site, false) ? null : t.skipNotHttps),
|
|
10
|
+
run(site, _c, t) {
|
|
11
|
+
const { out, add } = finder(this);
|
|
12
|
+
const home = homeOf(site);
|
|
13
|
+
if (!home)
|
|
14
|
+
return out;
|
|
15
|
+
const hsts = home.fetched.headers.get("strict-transport-security") ?? "";
|
|
16
|
+
const age = Number(hsts.match(/max-age\s*=\s*"?(\d+)/i)?.[1] ?? NaN);
|
|
17
|
+
if (!hsts)
|
|
18
|
+
add(home.fetched.publicUrl, t.noHsts);
|
|
19
|
+
else if (!(age >= HSTS_MIN_AGE))
|
|
20
|
+
add(home.fetched.publicUrl, t.shortHsts(Number.isFinite(age) ? Math.round(age / 86_400) : 0), "info");
|
|
21
|
+
return out;
|
|
22
|
+
},
|
|
23
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** SEC-6: The site's own addresses use https://. */
|
|
2
|
+
import { finder } from "auditor-engine";
|
|
3
|
+
export const sec6 = {
|
|
4
|
+
id: "SEC-6",
|
|
5
|
+
group: "SEC",
|
|
6
|
+
level: "warn",
|
|
7
|
+
applies: (site, t) => (site.base.startsWith("https://") ? null : t.skipNotHttps),
|
|
8
|
+
run(site, _c, t) {
|
|
9
|
+
const { out, add } = finder(this);
|
|
10
|
+
// The same host over http://: every such address costs a redirect, and a canonical to it names the wrong version.
|
|
11
|
+
const insecure = `http://${new URL(site.base).host}`;
|
|
12
|
+
const isInsecure = (u) => !!u && (u === insecure || u.startsWith(insecure + "/"));
|
|
13
|
+
for (const p of site.pages) {
|
|
14
|
+
const where = p.fetched.publicUrl;
|
|
15
|
+
if (isInsecure(p.canonical))
|
|
16
|
+
add(where, t.httpAddress("canonical", p.canonical));
|
|
17
|
+
for (const h of p.hreflang)
|
|
18
|
+
if (isInsecure(h.href))
|
|
19
|
+
add(where, t.httpAddress(`hreflang ${h.lang}`, h.href));
|
|
20
|
+
for (const key of ["url", "image"])
|
|
21
|
+
if (isInsecure(p.og[key]))
|
|
22
|
+
add(where, t.httpAddress(`og:${key}`, p.og[key]));
|
|
23
|
+
const links = p.links.filter(isInsecure);
|
|
24
|
+
if (links.length)
|
|
25
|
+
add(where, t.httpLinks(links.length, links[0]));
|
|
26
|
+
}
|
|
27
|
+
const listed = (site.sitemap?.urls ?? []).filter((u) => isInsecure(u.loc));
|
|
28
|
+
if (listed.length)
|
|
29
|
+
add(`${site.base}/sitemap.xml`, t.httpInSitemap(listed.length, listed[0].loc));
|
|
30
|
+
return out;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** SEC-7: Basic security headers are set. */
|
|
2
|
+
import { finder } from "auditor-engine";
|
|
3
|
+
import { seoOf } from "../../collect.js";
|
|
4
|
+
import { homeOf } from "./shared.js";
|
|
5
|
+
export const sec7 = {
|
|
6
|
+
id: "SEC-7",
|
|
7
|
+
group: "SEC",
|
|
8
|
+
level: "info",
|
|
9
|
+
applies: (site, t) => (seoOf(site).local ? t.skipLocal : null),
|
|
10
|
+
run(site, _c, t) {
|
|
11
|
+
const { out, add } = finder(this);
|
|
12
|
+
const home = homeOf(site);
|
|
13
|
+
if (!home)
|
|
14
|
+
return out;
|
|
15
|
+
const h = home.fetched.headers;
|
|
16
|
+
const csp = h.get("content-security-policy") ?? "";
|
|
17
|
+
const missing = [
|
|
18
|
+
!/nosniff/i.test(h.get("x-content-type-options") ?? "") && "X-Content-Type-Options: nosniff",
|
|
19
|
+
!h.get("x-frame-options") && !/frame-ancestors/i.test(csp) && "X-Frame-Options / CSP frame-ancestors",
|
|
20
|
+
!h.get("referrer-policy") && "Referrer-Policy",
|
|
21
|
+
].filter((x) => Boolean(x));
|
|
22
|
+
if (missing.length)
|
|
23
|
+
add(home.fetched.publicUrl, t.missingHeaders(missing.join(", ")));
|
|
24
|
+
return out;
|
|
25
|
+
},
|
|
26
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Constants and helpers shared by the SEC checks. */
|
|
2
|
+
import type { Page, Site } from "auditor-engine";
|
|
3
|
+
/** Half a year: the shortest HSTS max-age that browsers and the preload list treat as a real commitment. */
|
|
4
|
+
export declare const HSTS_MIN_AGE = 15552000;
|
|
5
|
+
/** A certificate that runs out within two weeks is about to take the site down. */
|
|
6
|
+
export declare const CERT_WARN_DAYS = 14;
|
|
7
|
+
/** The home page among the audited ones: its headers stand for the server's. */
|
|
8
|
+
export declare const homeOf: (site: Site) => Page | undefined;
|
|
9
|
+
/** The site is live and on https: the checks of the connection itself apply. */
|
|
10
|
+
export declare const liveHttps: (site: Site, local: boolean) => boolean;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Half a year: the shortest HSTS max-age that browsers and the preload list treat as a real commitment. */
|
|
2
|
+
export const HSTS_MIN_AGE = 15_552_000;
|
|
3
|
+
/** A certificate that runs out within two weeks is about to take the site down. */
|
|
4
|
+
export const CERT_WARN_DAYS = 14;
|
|
5
|
+
/** The home page among the audited ones: its headers stand for the server's. */
|
|
6
|
+
export const homeOf = (site) => site.pages.find((p) => new URL(p.fetched.publicUrl).pathname === "/") ?? site.pages[0];
|
|
7
|
+
/** The site is live and on https: the checks of the connection itself apply. */
|
|
8
|
+
export const liveHttps = (site, local) => !local && site.base.startsWith("https://");
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page structure (STR): can search engines and people read what a page is about, in which language, on any screen.
|
|
3
|
+
* One H1 with text, heading levels without gaps and without empty headings, a title and an H1 about the same thing,
|
|
4
|
+
* the page language in <html lang> that matches the text, a viewport for mobile that still allows zoom, the main
|
|
5
|
+
* content in <main>, and a doctype so browsers do not fall back to quirks mode.
|
|
6
|
+
*/
|
|
7
|
+
import type { SeoRule } from "../util.ts";
|
|
8
|
+
export declare const strRules: SeoRule[];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { str1 } from "./str-1.js";
|
|
2
|
+
import { str2 } from "./str-2.js";
|
|
3
|
+
import { str3 } from "./str-3.js";
|
|
4
|
+
import { str4 } from "./str-4.js";
|
|
5
|
+
import { str5 } from "./str-5.js";
|
|
6
|
+
import { str6 } from "./str-6.js";
|
|
7
|
+
import { str7 } from "./str-7.js";
|
|
8
|
+
import { str8 } from "./str-8.js";
|
|
9
|
+
import { str9 } from "./str-9.js";
|
|
10
|
+
import { str10 } from "./str-10.js";
|
|
11
|
+
export const strRules = [str1, str2, str3, str4, str5, str6, str7, str8, str9, str10];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Constants and helpers shared by the STR checks. */
|
|
2
|
+
/** Languages written in Cyrillic: a page in one of them is mostly Cyrillic letters. */
|
|
3
|
+
export declare const CYRILLIC_LANGS: Set<string>;
|
|
4
|
+
/** Languages written in Latin letters that a page may declare. */
|
|
5
|
+
export declare const LATIN_LANGS: Set<string>;
|
|
6
|
+
/** Below this many letters a page says too little to tell its language. */
|
|
7
|
+
export declare const MIN_LETTERS = 200;
|
|
8
|
+
/** Share of Cyrillic among the letters of a text, and how many letters there are. */
|
|
9
|
+
export declare function scriptOf(text: string): {
|
|
10
|
+
cyrillic: number;
|
|
11
|
+
letters: number;
|
|
12
|
+
};
|
|
13
|
+
/** Words of four letters and more, cut to five: «engineers» and «engineer», «инженер» and «инженера» match. */
|
|
14
|
+
export declare const stems: (s: string) => Set<string>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Constants and helpers shared by the STR checks. */
|
|
2
|
+
/** Languages written in Cyrillic: a page in one of them is mostly Cyrillic letters. */
|
|
3
|
+
export const CYRILLIC_LANGS = new Set(["ru", "uk", "be", "bg", "sr", "mk", "kk", "ky", "tg", "mn"]);
|
|
4
|
+
/** Languages written in Latin letters that a page may declare. */
|
|
5
|
+
export const LATIN_LANGS = new Set(["en", "de", "fr", "es", "it", "pt", "nl", "pl", "cs", "sk", "sv", "no", "nb", "da", "fi", "tr", "ro", "hu", "id", "vi", "lv", "lt", "et", "hr", "sl", "az", "uz"]);
|
|
6
|
+
/** Below this many letters a page says too little to tell its language. */
|
|
7
|
+
export const MIN_LETTERS = 200;
|
|
8
|
+
/** Share of Cyrillic among the letters of a text, and how many letters there are. */
|
|
9
|
+
export function scriptOf(text) {
|
|
10
|
+
const cyr = (text.match(/[Ѐ-ӿ]/g) ?? []).length;
|
|
11
|
+
const lat = (text.match(/[A-Za-zÀ-ɏ]/g) ?? []).length;
|
|
12
|
+
const letters = cyr + lat;
|
|
13
|
+
return { cyrillic: letters ? cyr / letters : 0, letters };
|
|
14
|
+
}
|
|
15
|
+
/** Words of four letters and more, cut to five: «engineers» and «engineer», «инженер» and «инженера» match. */
|
|
16
|
+
export const stems = (s) => new Set((s.toLowerCase().match(/[\p{L}\p{N}]{4,}/gu) ?? []).map((w) => w.slice(0, 5)));
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** STR-1: One H1 per page. */
|
|
2
|
+
import { finder } from "auditor-engine";
|
|
3
|
+
export const str1 = {
|
|
4
|
+
id: "STR-1",
|
|
5
|
+
group: "STR",
|
|
6
|
+
level: "warn",
|
|
7
|
+
run(site, _c, t) {
|
|
8
|
+
const { out, add } = finder(this);
|
|
9
|
+
for (const p of site.pages) {
|
|
10
|
+
if (!p.h1.length)
|
|
11
|
+
add(p.fetched.publicUrl, t.noH1);
|
|
12
|
+
else if (p.h1.length > 1)
|
|
13
|
+
add(p.fetched.publicUrl, t.manyH1(p.h1.length));
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
},
|
|
17
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** STR-10: The page starts with <!doctype html>. */
|
|
2
|
+
import { finder } from "auditor-engine";
|
|
3
|
+
export const str10 = {
|
|
4
|
+
id: "STR-10",
|
|
5
|
+
group: "STR",
|
|
6
|
+
level: "warn",
|
|
7
|
+
run(site, _c, t) {
|
|
8
|
+
const { out, add } = finder(this);
|
|
9
|
+
// Comments and a byte order mark may come first; anything else puts the browser in quirks mode.
|
|
10
|
+
for (const p of site.pages)
|
|
11
|
+
if (!/^()?(\s|<!--[\s\S]*?-->)*<!doctype\s+html/i.test(p.fetched.body))
|
|
12
|
+
add(p.fetched.publicUrl, t.noDoctype);
|
|
13
|
+
return out;
|
|
14
|
+
},
|
|
15
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** STR-2: Heading levels do not skip. */
|
|
2
|
+
import { finder } from "auditor-engine";
|
|
3
|
+
import { short } from "../util.js";
|
|
4
|
+
export const str2 = {
|
|
5
|
+
id: "STR-2",
|
|
6
|
+
group: "STR",
|
|
7
|
+
level: "info",
|
|
8
|
+
run(site, _c, t) {
|
|
9
|
+
const { out, add } = finder(this);
|
|
10
|
+
for (const p of site.pages) {
|
|
11
|
+
// One finding per page: the first skip is enough to show the pattern.
|
|
12
|
+
const hs = p.headingsAll;
|
|
13
|
+
const i = hs.findIndex((h, k) => k > 0 && h.level > hs[k - 1].level + 1);
|
|
14
|
+
if (i > 0)
|
|
15
|
+
add(p.fetched.publicUrl, t.skippedLevel(hs[i - 1].level, hs[i].level, short(hs[i].text, 50)));
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
},
|
|
19
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** STR-3: The page declares its language. */
|
|
2
|
+
import { finder } from "auditor-engine";
|
|
3
|
+
export const str3 = {
|
|
4
|
+
id: "STR-3",
|
|
5
|
+
group: "STR",
|
|
6
|
+
level: "warn",
|
|
7
|
+
run(site, _c, t) {
|
|
8
|
+
const { out, add } = finder(this);
|
|
9
|
+
for (const p of site.pages)
|
|
10
|
+
if (!p.htmlLang.trim())
|
|
11
|
+
add(p.fetched.publicUrl, t.noLang);
|
|
12
|
+
return out;
|
|
13
|
+
},
|
|
14
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** STR-4: Pages are set up for mobile. */
|
|
2
|
+
import { finder } from "auditor-engine";
|
|
3
|
+
export const str4 = {
|
|
4
|
+
id: "STR-4",
|
|
5
|
+
group: "STR",
|
|
6
|
+
level: "warn",
|
|
7
|
+
run(site, _c, t) {
|
|
8
|
+
const { out, add } = finder(this);
|
|
9
|
+
for (const p of site.pages)
|
|
10
|
+
if (!/width\s*=\s*device-width/i.test(p.viewport))
|
|
11
|
+
add(p.fetched.publicUrl, t.noViewport);
|
|
12
|
+
return out;
|
|
13
|
+
},
|
|
14
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** STR-5: Headings are not empty. */
|
|
2
|
+
import { finder } from "auditor-engine";
|
|
3
|
+
export const str5 = {
|
|
4
|
+
id: "STR-5",
|
|
5
|
+
group: "STR",
|
|
6
|
+
level: "warn",
|
|
7
|
+
run(site, _c, t) {
|
|
8
|
+
const { out, add } = finder(this);
|
|
9
|
+
for (const p of site.pages) {
|
|
10
|
+
// An H1 that is only a logo reads as no H1 at all: the alt text of an image is not heading text.
|
|
11
|
+
if (p.h1.length && p.h1.every((h) => !h.trim()))
|
|
12
|
+
add(p.fetched.publicUrl, t.emptyH1);
|
|
13
|
+
const empty = p.headingsAll.filter((h) => h.level > 1 && !h.text.trim());
|
|
14
|
+
if (empty.length)
|
|
15
|
+
add(p.fetched.publicUrl, t.emptyHeadings(empty.length, [...new Set(empty.map((h) => `H${h.level}`))].join(", ")), "info");
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
},
|
|
19
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** STR-6: The title and the H1 are about the same thing. */
|
|
2
|
+
import { finder } from "auditor-engine";
|
|
3
|
+
import { short } from "../util.js";
|
|
4
|
+
import { stems } from "./shared.js";
|
|
5
|
+
export const str6 = {
|
|
6
|
+
id: "STR-6",
|
|
7
|
+
group: "STR",
|
|
8
|
+
level: "info",
|
|
9
|
+
run(site, _c, t) {
|
|
10
|
+
const { out, add } = finder(this);
|
|
11
|
+
for (const p of site.pages) {
|
|
12
|
+
const h1 = p.h1.find((h) => h.trim()) ?? "";
|
|
13
|
+
if (!h1 || !p.title.trim())
|
|
14
|
+
continue;
|
|
15
|
+
const [a, b] = [stems(p.title), stems(h1)];
|
|
16
|
+
// Not one word in common: Google then often rewrites the title in the results, from the H1 or the text.
|
|
17
|
+
if (a.size && b.size && ![...a].some((w) => b.has(w)))
|
|
18
|
+
add(p.fetched.publicUrl, t.titleH1Apart(short(p.title, 50), short(h1, 50)));
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
},
|
|
22
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** STR-7: The page can be zoomed on a phone. */
|
|
2
|
+
import { finder } from "auditor-engine";
|
|
3
|
+
export const str7 = {
|
|
4
|
+
id: "STR-7",
|
|
5
|
+
group: "STR",
|
|
6
|
+
level: "warn",
|
|
7
|
+
run(site, _c, t) {
|
|
8
|
+
const { out, add } = finder(this);
|
|
9
|
+
for (const p of site.pages) {
|
|
10
|
+
const v = p.viewport;
|
|
11
|
+
const max = Number(v.match(/maximum-scale\s*=\s*([\d.]+)/i)?.[1] ?? NaN);
|
|
12
|
+
// Zoom up to 2× at least: below that, people with weaker sight cannot read the page.
|
|
13
|
+
if (/user-scalable\s*=\s*(no|0)\b/i.test(v) || max < 2)
|
|
14
|
+
add(p.fetched.publicUrl, t.noZoom(v));
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
},
|
|
18
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** STR-8: <html lang> matches the language of the text. */
|
|
2
|
+
import { finder } from "auditor-engine";
|
|
3
|
+
import { CYRILLIC_LANGS, LATIN_LANGS, MIN_LETTERS, scriptOf } from "./shared.js";
|
|
4
|
+
export const str8 = {
|
|
5
|
+
id: "STR-8",
|
|
6
|
+
group: "STR",
|
|
7
|
+
level: "warn",
|
|
8
|
+
run(site, _c, t) {
|
|
9
|
+
const { out, add } = finder(this);
|
|
10
|
+
for (const p of site.pages) {
|
|
11
|
+
const lang = p.htmlLang.trim().toLowerCase().split(/[-_]/)[0] ?? "";
|
|
12
|
+
const { cyrillic, letters } = scriptOf(p.text);
|
|
13
|
+
if (!lang || letters < MIN_LETTERS)
|
|
14
|
+
continue;
|
|
15
|
+
// Only a clear mismatch: a Cyrillic page declared as a Latin-script language, or the other way round.
|
|
16
|
+
const percent = Math.round(cyrillic * 100);
|
|
17
|
+
if (LATIN_LANGS.has(lang) && cyrillic > 0.7)
|
|
18
|
+
add(p.fetched.publicUrl, t.langMismatch(lang, t.scriptCyrillic, percent));
|
|
19
|
+
if (CYRILLIC_LANGS.has(lang) && cyrillic < 0.3)
|
|
20
|
+
add(p.fetched.publicUrl, t.langMismatch(lang, t.scriptLatin, 100 - percent));
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
},
|
|
24
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** STR-9: The main content sits in <main>. */
|
|
2
|
+
import { finder } from "auditor-engine";
|
|
3
|
+
export const str9 = {
|
|
4
|
+
id: "STR-9",
|
|
5
|
+
group: "STR",
|
|
6
|
+
level: "info",
|
|
7
|
+
run(site, _c, t) {
|
|
8
|
+
const { out, add } = finder(this);
|
|
9
|
+
for (const p of site.pages)
|
|
10
|
+
if (!/<main\b|\brole\s*=\s*["']?main\b/i.test(p.fetched.body))
|
|
11
|
+
add(p.fetched.publicUrl, t.noMain);
|
|
12
|
+
return out;
|
|
13
|
+
},
|
|
14
|
+
};
|
package/dist/rules/util.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Fetched, Rule } from "
|
|
1
|
+
import type { Fetched, Rule } from "auditor-engine";
|
|
2
2
|
import type { SeoMessages } from "../i18n/index.ts";
|
|
3
3
|
import type { SearchConfig } from "../types.ts";
|
|
4
4
|
/** An SEO check: same contract as the AI search rules, with its own messages and config. */
|
package/dist/trace.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type Origins } from "auditor-engine";
|
|
2
|
+
import type { Certificate } from "./types.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Follows redirects one at a time, the way a crawler counts them: fetch with redirect: "follow" hides the steps.
|
|
5
|
+
* Returns the public URL of every hop after the first, whether it looped, and the final status.
|
|
6
|
+
*/
|
|
7
|
+
export declare function trace(url: string, o: Origins, timeoutMs?: number): Promise<{
|
|
8
|
+
hops: string[];
|
|
9
|
+
loop: boolean;
|
|
10
|
+
status: number;
|
|
11
|
+
}>;
|
|
12
|
+
/** Time to the response headers, in ms: what a crawler waits before the page starts to arrive. */
|
|
13
|
+
export declare function timeToFirstByte(url: string, o: Origins, timeoutMs?: number): Promise<number | null>;
|
|
14
|
+
/** The first answer to a URL without following redirects: its status and Location. */
|
|
15
|
+
export declare function firstHop(url: string, timeoutMs?: number): Promise<{
|
|
16
|
+
status: number;
|
|
17
|
+
location: string;
|
|
18
|
+
} | null>;
|
|
19
|
+
/** Opens a TLS connection to the host and reads its certificate: trusted or not, and how long it has left. */
|
|
20
|
+
export declare function certificateOf(host: string, timeoutMs?: number): Promise<Certificate | null>;
|