structured-data-testing-tool
Version:
A library and command line tool to help test for Structured Data.
615 lines (548 loc) • 21.8 kB
JavaScript
const WAE = require('web-auto-extractor').default
const fetch = require('node-fetch')
const jmespath = require('jmespath')
const validator = require('validator')
const isStream = require('is-stream')
const getStream = require('get-stream')
const { schemas: validSchemas } = require('./lib/schemas')
const _structuredDataTest = (structuredData, options) => {
// By default, will auto-detect data structures and test them for validity
// If you want to avoid this (e.g. and only perform explicit tests you pass)
// you can set this to false when using the API to disable this feature.
// This may mean you miss some errors but may make it easier to debug issues.
const auto = (options && options.auto === false) ? false : true
const presetsSpecified = (options && options.presets) ? options.presets : []
const schemasSpecified = (options && options.schemas) ? options.schemas : []
const schemasFound = _findSchemas(structuredData) // Find schemas in structuredData
const schemas = [] // All schemas found *or* explicitly specified
let tests = options.tests || [] // Contains all tests to be run
let testGroups = []
let testsPassed = [] // Only tests that passed
let testsFailed = [] // Only tests that failed
let testsWarning = [] // Only tests that generated warnings (technically non critical failures)
let testsOptional = [] // Optional tests (regardless if passed or failed; they do not count towards either)
let testsSkipped = [] // Only that were skipped
// Combine schemas found with any schemas specified.
let arrayOfSchemas = []
if (auto === true) {
// When auto is true, automatically combine schemas found with schemas explicitly specified.
// Schemas found automatically actually take precedent, as we know the type for them already
// (jsonld, microdata, rdfa, etc) so can easily add the specific tests for them.
arrayOfSchemas = schemasFound.concat(
schemasSpecified.filter(schemaSpecified => {
let [ structuredDataType, schemaName ] = schemaSpecified.split(':')
if (!schemaName)
schemaName = structuredDataType
if (schemasFound.includes(schemaName) || schemasFound.includes(schemaSpecified)) {
return false
} else {
return true
}
})
)
} else {
// When auto is false, only test schemas explicitly specified.
arrayOfSchemas = schemasSpecified
}
const metatags = {}
Object.keys(structuredData.metatags).map(tag => {
if (tag !== 'undefined')
metatags[tag] = (structuredData.metatags[tag] !== 'undefined') ? structuredData.metatags[tag] : null
})
if (Object.keys(metatags).length > 0) {
Object.keys(metatags).map(tag => {
tests.push({
test: `"${tag}"`,
type: 'metatag',
group: 'Metatags',
description: tag,
optional: true,
autoDetected: true
})
})
}
let schemaTestsWithoutType = []
let schemaTestsWithType = []
arrayOfSchemas.forEach(schema => {
// Schema names can optionally have a suffix to indicate how it should be declared.
// A schema can just be the schema name like 'Article', but it can also be
// 'jsonld:Article', 'microdata:Article' or 'rdfa:Article' if you wanted to check
// for a schema specified on a page in a particular way.
let [ structuredDataType, schemaName ] = schema.split(':')
if (!schemaName) {
schemaName = structuredDataType
structuredDataType = null
}
// Add schema name to list of schemas
schemas.push(schemaName)
let schemaGroups = (Object(validSchemas).hasOwnProperty(schemaName)) ? ['Schema.org', schemaName] : [schemaName]
const _addTestsForProperties = (name, groups, type, props, path, index) => {
if (props) {
Object.keys(props).map(propName => {
// @TODO Add test to check if prop contents is valid
const propValue = props[propName]
const pathToProp = (Array.isArray(path)) ? path.concat(propName) : [propName]
if (typeof(propValue) === 'object') {
_addTestsForProperties(name, groups, type, propValue, pathToProp, index)
} else {
const testPath = `${name}[${index}]` + pathToProp.map(pathItem => (/^\d+$/.test(pathItem)) ? `[${pathItem}]` : `."${pathItem}"`).join('')
const description = pathToProp.map(pathItem => (/^\d+$/.test(pathItem)) ? `[${pathItem}]` : `.${pathItem}`).join('').replace(/^\./, '')
// As we do not have schema property validation in place, treat all autotoamted tests for properties as optional
tests.push({
test: testPath,
schema: name,
type: structuredDataType || 'any',
group: name,
groups: groups,
description,
optional: true,
autoDetected: true
})
}
})
}
}
// If there is more than one schema of the same type on a page, then group
// them by putting them in subgroups named #0, #1, #2… etc. so that the results
// for each instances of a schema are easy to iterate over.
if (structuredDataType) {
if (!schemaTestsWithType.includes(schemaName))
schemaTestsWithType.push(schemaName)
if (structuredData[structuredDataType][schemaName]) {
const schemaInstances = structuredData[structuredDataType][schemaName]
if (schemaInstances.length === 1) {
_addTestsForSchema(tests, schemaName, schemaGroups, structuredDataType, 0)
_addTestsForProperties(schemaName, schemaGroups, structuredDataType, schemaInstances[0], null, 0)
} else {
schemaInstances.map((schemaInstance, i) => {
_addTestsForSchema(tests, schemaName, schemaGroups.concat(`#${i}`), structuredDataType, i)
_addTestsForProperties(schemaName, schemaGroups.concat(`#${i}`), structuredDataType, schemaInstance, null, i)
})
}
} else {
_addTestsForSchema(tests, schemaName, schemaGroups, structuredDataType)
}
} else {
if (!schemaTestsWithoutType.includes(schemaName))
schemaTestsWithoutType.push(schemaName)
}
})
schemaTestsWithoutType.forEach(schemaName => {
if (!schemaTestsWithType.includes(schemaName)) {
const schemaGroups = (Object(validSchemas).hasOwnProperty(schemaName)) ? ['Schema.org', schemaName] : [schemaName]
_addTestsForSchema(tests, schemaName, schemaGroups)
}
})
// This is a recursive function scoped to this function
const _addTestsFromPresets = (presets, structuredData, tests, testsSkipped, testGroups) => {
presets.forEach(preset => {
if (!preset)
throw new Error(`Invalid preset specified`)
if (!preset.name)
throw new Error(`Preset specified does not have a 'name' (required)`)
const groups = (Array.isArray(testGroups)) ? testGroups.concat(preset.name) : [preset.name]
let ignorePreset = false
if (preset.conditional) {
// Evaluate `preset.conditional` test against structuredData to see if
// preset tests should be added (don't run if does not pass)
const { testPassed } = _runTest(preset.conditional, structuredData)
if (!testPassed)
ignorePreset = true
}
// Ignore all tests in preset if the preset conditional does not pass
if (!ignorePreset) {
// Loop through all tests in preset, but only add the ones that either don't have
// a conditional tests or where the conditional test passes.
getTestsFromPreset(preset, structuredData, groups).forEach(test => {
let skipTest = false
// Evaluate `test.conditional` test against structuredData to see if
// test should be added (add test to skipped tests if not)
if (test.conditional) {
const { testPassed } = _runTest(test.conditional, structuredData)
if (!testPassed)
skipTest = true
}
if (skipTest) {
testsSkipped.push(test)
} else {
tests.push(test)
}
})
}
// If the preset has other presets associated with it, apply them too.
if (preset.presets) {
_addTestsFromPresets(preset.presets, structuredData, tests, testsSkipped, groups)
}
})
}
// Add tests for each preset specified to the tests to be performed.
// This function is recursive to handle presets with other presets (to any depth level)
_addTestsFromPresets(presetsSpecified, structuredData, tests, testsSkipped)
tests.forEach(test => {
test.passed = false
if (!test.type) test.type = 'any'
if (test.group && !testGroups.includes(test.group))
testGroups.push(test.group)
const { testPassed, testError } = _runTest(test, structuredData)
test.passed = testPassed
test.error = testError
// Delete test.error property if it is null
if (test.error === null) delete test.error
// Put test into appropriate array for response object
if (test.optional === true) {
testsOptional.push(test)
} else if (test.passed === true) {
testsPassed.push(test)
} else if (test.warning) {
testsWarning.push(test)
} else {
testsFailed.push(test)
}
})
// Create response object
const response = {
tests,
passed: testsPassed,
failed: testsFailed,
warnings: testsWarning,
optional: testsOptional,
skipped: testsSkipped,
groups: testGroups,
schemas: schemasFound.map(schema => {
const splitResult = schema.split(':')
return (splitResult[1]) ? splitResult[1] : splitResult[0]
}),
structuredData,
options,
}
if (testsFailed.length === 0) {
// If all tests passed, resolve promise
return Promise.resolve(response)
} else {
// If any tests did not pass, reject promise
let error = new Error('Validation failed')
error.type = 'VALIDATION_FAILED'
error.res = response
return Promise.reject(error)
}
}
const _test = (test, json) => {
// TEST_DEFAULT_VALUE is useful to be able to set to normalize null/undefined/empty values
// This is particularly useful when testing metadata properties which would otherwise have
// different default values depending on the reason for a test failing, depending on if the
// property is missing, the value is missing or if the value is empty.
const TEST_DEFAULT_VALUE = ''
let testPassed = false
let testError = null
let path = null
test.value = TEST_DEFAULT_VALUE
try {
path = test.test
const pathValue = jmespath.search(json, path)
test.value = test.type === 'metatag'
? (pathValue && pathValue[0] && typeof pathValue[0] !== 'undefined') ? pathValue[0] : TEST_DEFAULT_VALUE
: (pathValue !== null) ? pathValue : TEST_DEFAULT_VALUE
if (typeof test.expect === 'undefined' || test.expect === true) {
// If 'expect' is 'true' then a pathValue should exist.
// If no value for expect then assume is a simple check to see it exists.
// Note: It's okay if the value is zero, or false but it should not be empty!
if (test.value !== 0 && test.value !== false && (!test.value || test.value.length === 0)) {
if (test.autoDetected) {
// Auto Detected properties by definition must exist, so if
// the test fail it can only be because they have no value.
testError = {
type: 'NULL',
message: `No value found for "${path}"`,
}
} else {
testError = {
type: 'NOT_FOUND',
message: `Could not find "${path}"`,
}
}
} else {
testPassed = true
}
} else if (test.expect === false) {
// If 'expect' is 'false' then a pathValue SHOULD NOT exist
if ((pathValue === null) || (test.type === 'metatag' && pathValue && pathValue[0] && typeof pathValue[0] === 'undefined')) {
testPassed = true
} else {
testError = {
type: 'PROPERTY_SHOULD_NOT_EXIST',
message: `Property "${path}" should not be defined`,
}
}
} else if (test.value && test.expect instanceof RegExp) {
// If test is a Regular Expression…
if (Array.isArray(test.value)) {
// If value is array and any key matches then test passes
test.value.map(value => {
if (value.match(test.expect))
testPassed = true
})
} else {
// If value is not an array, treat as a string
testPassed = test.value.match(test.expect)
}
if (!testPassed) {
testError = {
type: 'REGEXP_FAILED',
message: `Failed RegExp test for "${path}"`,
expected: test.expect,
found: test.value
}
}
} else if (!test.value || test.value.length === 0) {
// If item not found (or has no value) then error
testError = {
type: 'TEST_FAILED',
message: `Test "${path}" failed`,
}
} else if (test.value == test.expect || (Array.isArray(test.value) && test.value.includes(test.expect))) {
// If value is found and matches what we expect…
// …or in the case of metadata, if the returned value is an array
// and one of the items matches what we expect then the test passes
testPassed = true
} else {
// If a value is found, but is does not match what we expect then fail
testError = {
type: 'INCORRECT_VALUE',
message: `Incorrect value found for "${path}"`,
expected: test.expect,
found: test.value,
}
}
} catch (e) {
testError = {
type: 'TEST_ERROR',
message: `Error evaluating test "${path}", check test syntax`,
e
}
}
return {
testPassed,
testError
}
}
const _runTest = (test, structuredData) => {
if (test.type == 'metatag') {
// Look for meta tags`
return _test(test, structuredData.metatags)
} else if (test.type == 'jsonld') {
// Look for data in jsonld
return _test(test, structuredData.jsonld)
} else if (test.type == 'microdata') {
// Look for data in microdata
return _test(test, structuredData.microdata)
} else if (test.type == 'rdfa') {
// Look for data in rdfa
return _test(test, structuredData.rdfa)
} else if (test.type == 'any' || !Object(test).hasOwnProperty('type')) {
let result = {}
// Look for data in jsonld, then microdata then rdfa then metatags until found
// If a test passes, set the test type to reflect where it was found
result = _test(test, structuredData.jsonld)
if (result.testPassed) {
test.type = 'jsonld'
return result
}
// If was not found in jsonld, look for data in microdata
result = _test(test, structuredData.microdata)
if (result.testPassed) {
test.type = 'microdata'
return result
}
// If was not found in jsonld or microdata, look for data in rdfa
result = _test(test, structuredData.rdfa)
if (result.testPassed) {
test.type = 'rdfa'
return result
}
// If was not found in jsonld or microdata or rdfa, look for data in metatags
result = _test(test, structuredData.metatags)
if (result.testPassed) {
test.type = 'metatag'
return result
}
// If property not found anywhere
return {
testPassed: false,
testError: {
type: 'NOT_FOUND',
message: `The property "${test.test}" was not found`,
}
}
} else {
return {
testPassed: false,
testError: {
type: 'INVALID_TYPE',
message: `The type "${test.type}" is invalid`,
}
}
}
}
const _findSchemas = (structuredData) => {
const schemas = []
const rdfa = Object.keys(structuredData.rdfa)
rdfa.map(schema => {
if (schema !== 'undefined' && !schemas.includes(schema)) schemas.push(`rdfa:${schema}`)
})
const microdata = Object.keys(structuredData.microdata)
microdata.map(schema => {
if (schema !== 'undefined' && !schemas.includes(schema)) schemas.push(`microdata:${schema}`)
})
const jsonld = Object.keys(structuredData.jsonld)
jsonld.map(schema => {
if (schema !== 'undefined' && !schemas.includes(schema)) schemas.push(`jsonld:${schema}`)
})
return schemas
}
const structuredDataTestUrl = async (url, options) => {
const res = await fetch(url)
const html = await res.text()
return structuredDataTestString(html, { url, res, ...options })
}
const structuredDataTestString = async (input, options) => {
// Try to parse the string input as a JSON object.
//
// If it is a JSON object, then wrap it in <script> tags and then parse it as HTML.
// If it is not, then assume it is HTML and try to parse it as it is.
//
// This is potentially computationally expensive, but making the call that
// performance concerns are secondary to utility in the case of this tool.
let html = input
try { html = JSON.parse(input) ? `<script type="application/ld+json">${input}</script>` : input } catch (e) { }
return structuredDataTestHtml(html, options)
}
const structuredDataTestHtml = async (html, options) => {
let structuredData = WAE().parse(html)
structuredData = __transformStructuredData(structuredData)
return _structuredDataTest(structuredData, { html, ...options })
}
const structuredDataTest = async (input, options) => {
if (typeof(input) === 'string') {
// If is a string…
// Assumed to be URL or HTML
if (validator.isURL(input, { require_tld: false })) {
// @TODO Improve URL error handling
const url = input
const res = await fetch(url)
const html = await res.text()
if (!html) {
let error = new Error('No HTML returned')
error.type = 'HTML_EMPTY'
throw error
}
return structuredDataTestUrl(url, options)
} else {
const html = input
return structuredDataTestString(html, options)
}
} else if (Buffer.isBuffer(input)) {
// If is a buffer…
// Convert buffer to string
const html = input.toString('utf8')
return structuredDataTestString(html, options)
} else if (isStream.readable(input)) {
// If is a readable stream…
// Convert readable stream to string
const html = await getStream(input)
return structuredDataTestString(html, options)
} else {
// Else ??
const structuredData = input
return _structuredDataTest(structuredData, options)
}
}
// Fixes a big when finding itemprops in microdata, where the itemprop
// contained more than more property (separated by a space).
// Have not seen this behaviour in examples on Schema.org but is supported
// by the Google Structured Data Testing Tool. For more info see:
// https://github.com/glitchdigital/structured-data-testing-tool/issues/4
const __transformStructuredData = (structuredData) => {
let result = structuredData
Object.keys(result.microdata).forEach(schema => {
result.microdata[schema].forEach(object => {
Object.keys(object).forEach(key => {
if (key.includes(' ')) {
key.split(' ').forEach(newKey => {
object[newKey] = object[key]
})
delete object[key]
}
})
})
})
return result
}
const getTestsFromPreset = (preset, structuredData, testGroup) => {
const tests = []
if (!preset.tests)
return tests
const _setTestGroup = (test, preset) => {
// If test does not explicitly have a group defined, use the the default one preset,
// if defined. If no default group is is defined for the preset, fallback to using
// the preset name to group the results (every preset SHOULD have a name!)
if (!test.group) {
if (preset.group) {
test.group = preset.group
} else if (preset.name) {
test.group = preset.name
} else {
test.group = 'DEFAULT'
}
}
}
if (preset.schema) {
// If preset has a schema, return tests that check every instance of that schema
Object.keys(structuredData).map(dataType => {
if (dataType === 'metatags')
return
Object.keys(structuredData[dataType]).map((schemaName) => {
if (schemaName === preset.schema) {
structuredData[dataType][schemaName].forEach((instance, i) => {
const groups = testGroup.concat(`#${i} (${dataType})`)
preset.tests.forEach(t => {
// Note: Must copy object so we can re-use tests objects from a preset in different tests
const test = Object.assign({}, t)
test.schema = preset.schema
test.test = test.test.replace(/(.*)?\[\*\]/, `${preset.schema}[${i}]`)
test.type = dataType
test.groups = groups
if (!test.description)
test.description = test.test.replace(/(.*)?\[\d\]\./, '').replace(/"/g, '')
tests.push(test)
})
})
}
})
})
} else {
// If preset does not have a schema, then return only the tests in the preset
preset.tests.forEach(test => {
_setTestGroup(test, preset)
tests.push(test)
})
}
return tests
}
// Add a test for any schema explicitly specified (or that was detected)
const _addTestsForSchema = (tests, name, groups, type, index) => {
tests.push({
test: `${name}[${(typeof(index) === 'undefined') ? '*': index}]`,
schema: name,
type: type || 'any',
group: name,
groups: groups,
description: (type) ? `schema in ${type}` : `schema found`
})
}
module.exports = {
_structuredDataTest,
structuredDataTest,
structuredDataTestUrl,
structuredDataTestString,
structuredDataTestHtml
}