Валидация JSON-LD через CLI: jq + jsonld expansion + schema.org
Быстрая CLI-проверка JSON-LD: синтаксис JSON, корректность JSON-LD (expand/normalize) и базовая проверка schema.org. Подходит для CI и ручной диагностики.
Как использовать
- Сначала проверь JSON (jq).
- Потом проверь JSON-LD (expand/normalize через jsonld).
- Если нужно — прогони через schema.org валидатор (см. sd-validate).
JSON-LD можно валидировать на 3 уровнях:
- валидный JSON, 2) валидный JSON-LD (контекст/expand), 3) валидность по schema.org (типы/свойства).
1) Проверка: это вообще JSON (jq)
jq -e . < schema.jsonld >/dev/null && echo "OK: valid JSON" || echo "FAIL: invalid JSON"
Если JSON кривой — дальше смысла нет.
2) Проверка JSON-LD (expand/normalize) через Node CLI
Установка:
npm i -g jsonld
Скрипт sd-validate.mjs:
#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import { createHash } from "node:crypto";
import process from "node:process";
import chalk from "chalk";
import yargs from "yargs/yargs";
import { hideBin } from "yargs/helpers";
import SDV from "@adobe/structured-data-validator";
import WebAutoExtractor from "@marbec/web-auto-extractor";
// ESM/CJS-safe import for different package builds
const Validator = SDV?.Validator ?? SDV?.default ?? SDV;
if (!Validator) throw new Error("Cannot resolve Validator export from @adobe/structured-data-validator");
const argv = yargs(hideBin(process.argv))
.scriptName("sd-validate")
.usage("$0 [options] <url|file.html>")
.option("out", {
type: "string",
default: "",
describe: "Путь для отчёта (json). По умолчанию: ./sd-report-<hash>.json",
})
.option("onlyErrors", {
type: "boolean",
default: false,
describe: "Показывать только ERROR (WARNING скрыть)",
})
.option("microdataOnly", {
type: "boolean",
default: false,
describe: "Фильтр сообщений по microdata/itemprop/itemscope/itemtype",
})
.option("timeout", {
type: "number",
default: 20000,
describe: "Таймаут загрузки URL (мс)",
})
.option("userAgent", {
type: "string",
default:
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) sd-validate/1.0",
describe: "User-Agent для скачивания HTML по URL",
})
.option("quiet", {
type: "boolean",
default: false,
describe: "Тихий режим: выводить summary + ERROR (warnings не печатать)",
})
.option("max", {
type: "number",
default: 200,
describe: "Максимум сообщений в Details (после фильтров). 0 = без лимита",
})
.option("dump", {
type: "string",
default: "none",
choices: ["none", "jsonld", "extracted", "all"],
describe:
"Сохранить артефакты: jsonld (только JSON-LD), extracted (всё, что извлёк парсер), all (оба)",
})
.option("format", {
type: "string",
default: "text",
choices: ["text", "json"],
describe: "Формат вывода в консоль",
})
.demandCommand(1)
.help()
.parseSync();
const input = String(argv._[0]);
function isUrl(s) {
return /^https?:\/\//i.test(s);
}
function sha1(s) {
return createHash("sha1").update(s).digest("hex").slice(0, 10);
}
async function fetchWithTimeout(url, { timeout, headers }) {
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), timeout);
try {
const res = await fetch(url, { redirect: "follow", signal: ac.signal, headers });
const text = await res.text();
return { ok: res.ok, status: res.status, text };
} finally {
clearTimeout(t);
}
}
function formatIssue(i) {
const sev =
i.severity === "ERROR"
? chalk.bgRed.white(" ERROR ")
: chalk.bgYellow.black(" WARN ");
const msg = i.issueMessage ?? "Unknown issue";
const fields = Array.isArray(i.fieldNames) && i.fieldNames.length
? `fields: ${i.fieldNames.join(", ")}`
: "";
const loc = i.location ? `loc: ${i.location}` : "";
const pathStr = Array.isArray(i.path) && i.path.length
? `path: ${JSON.stringify(i.path)}`
: "";
const meta = [fields, loc, pathStr].filter(Boolean).join(" | ");
return `${sev} ${msg}${meta ? chalk.dim(` (${meta})`) : ""}`;
}
function hr() {
console.log(chalk.dim("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
}
function extractJsonLdBlocksFromHtml(html) {
// Вытаскиваем *все* <script type="application/ld+json">...</script>
const re = /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
const blocks = [];
let m;
while ((m = re.exec(html))) {
const raw = (m[1] || "").trim();
if (!raw) continue;
// JSON-LD может быть объектом ИЛИ массивом
try {
blocks.push(JSON.parse(raw));
} catch (e) {
blocks.push({
__parse_error__: e.message,
__raw_preview__: raw.slice(0, 500),
});
}
}
// чтобы отчёт был удобным: если один блок — объект, иначе массив
return blocks.length === 1 ? blocks[0] : blocks;
}
function limitArray(arr, max) {
if (!max || max <= 0) return arr;
return arr.slice(0, max);
}
async function main() {
const runId = sha1(input);
const defaultReportPath = path.resolve(process.cwd(), `sd-report-${runId}.json`);
const reportPath = argv.out && argv.out.trim() ? path.resolve(argv.out) : defaultReportPath;
const reportDir = path.dirname(reportPath);
// 1) Load HTML
let html = "";
const sourceMeta = { type: isUrl(input) ? "url" : "file", input };
if (isUrl(input)) {
const { ok, status, text } = await fetchWithTimeout(input, {
timeout: argv.timeout,
headers: { "user-agent": argv.userAgent },
});
html = text;
sourceMeta.http = { status, ok };
if (!html || html.trim().length < 50) {
throw new Error(`Похоже, вернулся пустой/короткий HTML. HTTP=${status}`);
}
} else {
html = await fs.readFile(input, "utf8");
sourceMeta.file = { abs: path.resolve(input) };
}
// 2) Quick microdata scan (raw)
const microdataCount = (html.match(/\bitemscope\b/gi) || []).length;
const itemtypeCount = (html.match(/\bitemtype\s*=\s*["'][^"']+["']/gi) || []).length;
const itempropCount = (html.match(/\bitemprop\s*=\s*["'][^"']+["']/gi) || []).length;
// 3) Extract structured data (JSON-LD + microdata + RDFa)
const extractor = new WebAutoExtractor({
addLocation: true,
embedSource: ["rdfa", "microdata"],
});
const extracted = extractor.parse(html);
// 4) JSON-LD dump (from raw HTML, не через extractor)
const jsonld = extractJsonLdBlocksFromHtml(html);
// 5) Load schema.org model
const schemaOrgJson = await (
await fetch("https://schema.org/version/latest/schemaorg-all-https.jsonld")
).json();
// 6) Validate
const validator = new Validator(schemaOrgJson);
const issuesAll = await validator.validate(extracted);
let issues = Array.isArray(issuesAll) ? issuesAll : [];
if (argv.onlyErrors) issues = issues.filter((x) => x.severity === "ERROR");
if (argv.microdataOnly) {
issues = issues.filter((x) => {
const s = JSON.stringify(x).toLowerCase();
return (
s.includes("microdata") ||
s.includes("itemprop") ||
s.includes("itemscope") ||
s.includes("itemtype")
);
});
}
const errorsCountAll = issues.filter((x) => x.severity === "ERROR").length;
const warnsCountAll = issues.filter((x) => x.severity !== "ERROR").length;
// console filtering for "quiet"
let issuesToPrint = issues;
if (argv.quiet) issuesToPrint = issues.filter((x) => x.severity === "ERROR");
issuesToPrint = limitArray(issuesToPrint, argv.max);
// 7) Save report (+ dumps)
await fs.mkdir(reportDir, { recursive: true });
const report = {
version: "1.1",
ts: new Date().toISOString(),
input: sourceMeta,
stats: { issues: issues.length, errors: errorsCountAll, warnings: warnsCountAll },
htmlScan: { itemscope: microdataCount, itemtype: itemtypeCount, itemprop: itempropCount },
dump: {
jsonld: argv.dump === "jsonld" || argv.dump === "all" ? `sd-jsonld-${runId}.json` : null,
extracted: argv.dump === "extracted" || argv.dump === "all" ? `sd-extracted-${runId}.json` : null,
},
issues,
};
await fs.writeFile(reportPath, JSON.stringify(report, null, 2), "utf8");
if (argv.dump === "jsonld" || argv.dump === "all") {
const p = path.join(reportDir, `sd-jsonld-${runId}.json`);
await fs.writeFile(p, JSON.stringify(jsonld, null, 2), "utf8");
}
if (argv.dump === "extracted" || argv.dump === "all") {
const p = path.join(reportDir, `sd-extracted-${runId}.json`);
await fs.writeFile(p, JSON.stringify(extracted, null, 2), "utf8");
}
// 8) Output
if (argv.format === "json") {
const out = {
input,
report: reportPath,
stats: report.stats,
printed: { quiet: argv.quiet, max: argv.max, count: issuesToPrint.length },
};
console.log(JSON.stringify(out, null, 2));
} else {
console.log(chalk.cyan("🔍 Structured Data validation"));
hr();
console.log(`Input: ${chalk.bold(input)}`);
console.log("");
console.log(chalk.green("📦 Found in HTML (raw scan):"));
console.log(` • itemscope: ${microdataCount}`);
console.log(` • itemtype: ${itemtypeCount}`);
console.log(` • itemprop: ${itempropCount}`);
console.log("");
hr();
console.log(`✅ Issues total: ${chalk.bold(String(issues.length))}`);
console.log(`❌ Errors: ${errorsCountAll ? chalk.red(errorsCountAll) : chalk.green("0")}`);
console.log(`⚠️ Warnings: ${warnsCountAll ? chalk.yellow(warnsCountAll) : chalk.green("0")}`);
hr();
if (issuesToPrint.length) {
const mode = argv.quiet ? "ERROR only" : "all (after filters)";
const lim = argv.max && argv.max > 0 ? ` (limited to ${argv.max})` : "";
console.log(chalk.cyan(`📋 Details: ${mode}${lim}`));
for (const i of issuesToPrint) console.log(" " + formatIssue(i));
const hidden = issues.length - issuesToPrint.length;
if (hidden > 0) {
console.log("");
console.log(
chalk.dim(`… скрыто ${hidden} сообщений. Используй --max 0 или убери --quiet.`)
);
}
} else {
console.log(chalk.green("🎉 No issues found (for this validator output mode)."));
}
console.log("");
console.log(chalk.gray(`💾 Report saved: ${reportPath}`));
if (argv.dump !== "none") {
console.log(
chalk.gray(
`📦 Dumps: ${report.dump.jsonld || "-"} | ${report.dump.extracted || "-"} (dir: ${reportDir})`
)
);
}
}
// Exit codes for CI
if (errorsCountAll > 0) process.exit(1);
process.exit(0);
}
main().catch((e) => {
console.error(chalk.bgRed.white(" FAIL "), chalk.red(e?.message || String(e)));
process.exit(2);
});
Запуск
chmod +x sd-validate.mjs
node sd-validate.mjs "https://example.tld/page"
3) Проверка schema.org (типы/свойства)
Практичный вариант — валидировать schema.org через sd-validate.mjs.
Вырезать JSON-LD из HTML и сохранить в файл (без “node -e” простыни)
Используй встроенный дамп JSON-LD:
node sd-validate.mjs --dump jsonld "https://example.tld/page"
В результате рядом с отчётом появится файл:
sd-jsonld-<hash>.json— JSON-LD, извлечённый из<script type="application/ld+json">sd-report-<hash>.json— полный отчёт (issues + мета)
Дальше: валидировать именно JSON-LD как отдельный артефакт
Если нужно прогнать валидатор только по JSON-LD, добавь в sd-validate.mjs режим --jsonld:
--jsonld <file>— читать JSON-LD напрямую из файла--dump extracted/--dump all— сохранять “сырое” извлечённое дерево (для дебага парсинга)
(Если надо — дам патч под --jsonld без рефакторинга всего скрипта.)
Что считать “валидатором JSON-LD”
jq→ валидатор JSONjsonld.expand/normalize→ валидатор JSON-LDsd-validate/ schema.org validator → валидатор schema.org семантики
Связанный сниппет: CLI валидатор микроразметки (JSON-LD + Microdata).