MetadataParser

A solution to parse user-script metadata blocks.

此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.greasyfork.org/scripts/566592/1756806/MetadataParser.js

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name MetadataParser
// @namespace -
// @version 1.0.0
// @description A solution to parse user-script metadata blocks.
// @author NotYou
// @grant none
// @require https://unpkg.com/[email protected]/lib/index.umd.js
// @require https://update.greasyfork.org/scripts/565798/1752557/Zod%203x%20Error%20Formatter.js
// ==/UserScript==

/* global ZodErrorFormatter */

(function(z) {
    'use strict';

    // Source of the meta keys formatting:

    // https://greasyfork.org/en/help/meta-keys
    // https://wiki.greasespot.net/Metadata_Block
    // https://www.tampermonkey.net/documentation.php
    // https://violentmonkey.github.io/api/metadata-block/
    // https://docs.scriptcat.org/docs/dev/meta/

    class Metadata {
        get MetadataItemSchema() {
            return z.object({
                key: z.string(),
                value: z.string().nullable(),
                locale: z.string().refine(val => /^[a-zA-Z]{2,2}(\-[a-zA-Z]{2,2})?$/.test(val), {
                    message:
                    'Locale must be either a null or ISO 639-1 string (for example: "en", "es"),' +
                    'may also include region code in this format: xx-YY (for example: "en-US", "es-ES")'
                }).nullable()
            })
        }

        constructor(metadata) {
            try {
                this.metadata = this.MetadataItemSchema.array().parse(metadata)
            } catch(err) {
                if (err instanceof z.ZodError) {
                    throw new Error(ZodErrorFormatter.format(err))
                }

                throw new Error(err)
            }
        }

        getRawData() {
            return this.metadata
        }

        indexOf(key, locale = null) {
            if (typeof locale === 'string') {
                const fullKey = key + ':' + locale

                return this.metadata.findIndex(item => typeof item.locale === 'string' && (item.key + ':' + item.locale) === fullKey)
            }

            return this.metadata.findIndex(item => item.key === key)
        }

        get(key, locale = null) {
            return this.metadata[this.indexOf(key, locale)]
        }

        getAll(key, locale = null) {
            if (typeof locale === 'string') {
                const fullKey = key + ':' + locale

                return this.metadata.filter(item => typeof item.locale === 'string' && (item.key + ':' + item.locale) === fullKey)
            }

            return this.metadata.filter(item => item.key === key)
        }

        has(key, locale) {
            return this.get(key, locale) !== undefined
        }

        add(item) {
            try {
                this.MetadataItemSchema.parse(item)

                this.metadata.push(item)
            } catch(err) {
                if (err instanceof z.ZodError) {
                    throw new Error(ZodErrorFormatter.format(err))
                }

                throw new Error(err)
            }
        }

        set(key, value, locale = null) {
            const item = this.get(key, locale)

            if (item) {
                item.value = value
            } else {
                this.add({
                    key,
                    value,
                    locale
                })
            }
        }

        forEach(callback) {
            this.metadata.forEach(callback)
        }

        remove(key, locale = null) {
            const index = this.indexOf(key, locale)

            if (index === -1) return null

            return this.metadata.splice(index, 1)
        }

        removeAll(key, locale = null) {
            const removed = []
            const final = []

            if (typeof locale === 'string') {
                for (const item of this.metadata) {
                    if (
                        typeof item.locale === 'string' &&
                        item.key + ':' + item.locale === key + ':' + locale
                    ) {
                        removed.push(item)
                    } else {
                        final.push(item)
                    }
                }

                this.metadata = final
                return removed
            }

            for (const item of this.metadata) {
                if (item.key === key) {
                    removed.push(item)
                } else {
                    final.push(item)
                }
            }

            this.metadata = final
            return removed
        }

        [Symbol.toPrimitive](hint) {
            if (typeof hint === 'number') {
                return NaN
            }

            return this.stringify()
        }

        stringify(pretty = false) {
            let longestKeyLength = 0
            if (pretty) {
                longestKeyLength = this.getRawData().reduce((acc, curr) => {
                    if (typeof curr.locale === 'string' && curr.key.length + curr.locale.length > acc) {
                        return curr.key.length + curr.locale.length + 1
                    } else if (curr.key.length > acc) {
                        return curr.key.length
                    }

                    return acc
                }, 0)
            }

            return '// ==UserScript==\n' + this.metadata.reduce((acc, curr) => {
                let leftPart = ''
                let rightPart = ''

                if (curr.locale) {
                    leftPart = `// @${curr.key}:${curr.locale}`
                } else {
                    leftPart += `// @${curr.key}`
                }

                if (curr.value) {
                    const spacesCount = longestKeyLength - curr.key.length - (typeof curr.locale === 'string' ? curr.locale.length + 1 : 0)

                    rightPart = ' '.repeat(spacesCount <= 0 ? 0 : spacesCount) + ' ' + curr.value
                }

                const metadataItem = leftPart + rightPart + '\n'

                acc += metadataItem

                return acc
            }, '') + '// ==/UserScript=='
        }
    }

    class MetadataSanitizer {
        SanitizationError = class extends Error {
            constructor(message) {
                super(message)

                this.name = 'SanitizationError'
            }
        }

        get FormatIdSchema() {
            return z.string().refine(value => Object.keys(this._formats).indexOf(value) !== -1, {
                message: 'Format id must be one of these values: ' + Object.keys(this._formats).map(e => `"${e}"`).join(', ')
            })
        }

        get ConfigSchema() {
            return z.object({
                format: this.FormatIdSchema,
                allowNonExistentKeys: z.boolean().default(true),
                allowUnexpectedMultilingualKeys: z.boolean().default(false),
                allowDuplicatesForUniqueKeys: z.boolean().default(false),
                requireMandatoryKeys: z.boolean().default(true)
            })
        }

        constructor(config = {}) {
            try {
                this.config = this.ConfigSchema.parse(config)
            } catch(err) {
                if (err instanceof z.ZodError) {
                    throw new Error('Invalid configuration provided, ' + ZodErrorFormatter.format(err))
                }

                throw new Error(err)
            }
        }

        _formats = {
            greasyfork: {
                name: {
                    required: true,
                    multilingual: true,
                    multiple: false
                },
                description: {
                    required: true,
                    multilingual: true,
                    multiple: false
                },
                namespace: {
                    required: true,
                    multilingual: false,
                    multiple: false
                },
                version: {
                    required: true,
                    multilingual: false,
                    multiple: false
                },
                match: {
                    required: true,
                    multilingual: false,
                    multiple: true,
                    alternatives: ['include']
                },
                exclude: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                require: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                resource: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                updateURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                installURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                downloadURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                license: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                supportURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                contributionURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                contributionAmount: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                compatible: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                incompatible: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                antifeature: {
                    required: false,
                    multilingual: true,
                    multiple: false
                }
            },
            greasemonkey: {
                description: {
                    required: false,
                    multilingual: true,
                    multiple: false
                },
                exclude: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                grant: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                homepageUrl: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                icon: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                match: {
                    required: false,
                    multilingual: false,
                    multiple: true,
                    alternatives: ['include']
                },
                name: {
                    required: true,
                    multilingual: true,
                    multiple: false
                },
                namespace: {
                    required: true,
                    multilingual: false,
                    multiple: false
                },
                noframes: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                require: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                resource: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                'run-at': {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                version: {
                    required: false,
                    multilingual: false,
                    multiple: false
                }
            },
            tampermonkey: {
                name: {
                    required: true,
                    multilingual: true,
                    multiple: false
                },
                namespace: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                copyright: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                version: {
                    required: true,
                    multilingual: false,
                    multiple: false
                },
                description: {
                    required: true,
                    multilingual: true,
                    multiple: false
                },
                icon: {
                    required: false,
                    multilingual: false,
                    multiple: false,
                    alternatives: ['iconURL', 'defaulticon']
                },
                icon64: {
                    required: false,
                    multilingual: false,
                    multiple: false,
                    alternatives: ['icon64URL']
                },
                grant: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                author: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                homepage: {
                    required: false,
                    multilingual: false,
                    multiple: false,
                    alternatives: ['homepageURL', 'website', 'source']
                },
                antifeature: {
                    required: false,
                    multilingual: true,
                    multiple: true
                },
                require: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                resource: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                resource: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                match: {
                    required: false,
                    multilingual: false,
                    multiple: true,
                    alternatives: ['include']
                },
                exclude: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                'run-at': {
                    required: false,
                    multilingual: false,
                    multiple: false,
                },
                'run-in': {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                sandbox: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                tag: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                connect: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                noframes: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                updateURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                downloadURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                supportURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                webRequest: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                unwrap: {
                    required: false,
                    multilingual: false,
                    multiple: false
                }
            },
            violentmonkey: {
                name: {
                    required: true,
                    multilingual: true,
                    multiple: false
                },
                namespace: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                match: {
                    required: false,
                    multilingual: false,
                    multiple: true,
                    alternatives: ['include']
                },
                'match-exclude': {
                    required: false,
                    multilingual: false,
                    multiple: true,
                    alternatives: ['exclude']
                },
                version: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                description: {
                    required: false,
                    multilingual: true,
                    multiple: false
                },
                icon: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                require: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                resource: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                'run-at': {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                noframes: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                grant: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                'inject-into': {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                downloadURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                supportURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                homepageURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                unwrap: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                'top-level-await': {
                    required: false,
                    multilingual: false,
                    multiple: false
                }
            },
            scriptcat: {
                name: {
                    required: true,
                    multilingual: true,
                    multiple: false
                },
                namespace: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                version: {
                    required: true,
                    multilingual: false,
                    multiple: false
                },
                description: {
                    required: true,
                    multilingual: true,
                    multiple: false
                },
                author: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                'run-at': {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                'run-in': {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                'early-start': {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                'inject-into': {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                storageName: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                background: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                crontab: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                match: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                include: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                exclude: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                grant: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                connect: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                resource: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                require: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                'require-css': {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                noframes: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                definition: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                antifeature: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                license: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                updateURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                downloadURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                supportURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                homepage: {
                    required: false,
                    multilingual: false,
                    multiple: false,
                    alternatives: ['homepageURL', 'website']
                },
                source: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                icon: {
                    required: false,
                    multilingual: false,
                    multiple: false,
                    alternatives: ['iconURL', 'defaulticon']
                },
                icon64: {
                    required: false,
                    multilingual: false,
                    multiple: false,
                    alternatives: ['icon64URL']
                }
            }
        }

        _getKeyData(queryKey) {
            const format = this._formats[this.config.format]

            for (const key in format) {
                const keyData = format[key]

                if (queryKey === key) {
                    return { key, keyData }
                } else if (keyData.alternatives && keyData.alternatives.indexOf(queryKey) !== -1) {
                    return { key, keyData }
                }
            }

            return null
        }

        sanitize(metadata) {
            const sanitized = []
            const format = this._formats[this.config.format]
            const acquiredKeys = {}
            const items = metadata.getRawData()

            for (const item of items) {
                const keyInfo = this._getKeyData(item.key)
                const fullKey = item.key + (item.locale ? ':' + item.locale : '')

                const keyDataNotFound = keyInfo === null

                if (keyDataNotFound && !this.config.allowNonExistentKeys) {
                    throw new this.SanitizationError(`You cannot use key "${item.key}" because it is not available in the format "${this.config.format}"`)
                } else if (keyDataNotFound) {
                    sanitized.push(item)

                    continue
                }

                const keyData = keyInfo.keyData
                const notMultilingualButHasLocale = item.locale && !keyData.multilingual

                if (notMultilingualButHasLocale && !this.config.allowUnexpectedMultilingualKeys) {
                    throw new this.SanitizationError(`You cannot use locale codes for the non-multilingual key "${item.key}" with the format "${this.config.format}"`)
                }

                if (acquiredKeys[fullKey] && !keyData.multiple && !this.config.allowDuplicatesForUniqueKeys) {
                    throw new this.SanitizationError(`You cannot use key "${item.key}" multiple times with the format "${this.config.format}"`)
                }

                acquiredKeys[keyInfo.key] = true
                acquiredKeys[fullKey] = true

                if (keyData.alternatives) {
                    for (const alternative of keyData.alternatives) {
                        acquiredKeys[alternative] = true
                    }
                }

                sanitized.push(item)
            }


            if (this.config.requireMandatoryKeys) {
                const requiredKeys = Object.keys(format).filter(key => format[key].required)
                const missingKeys = requiredKeys.filter(requiredKey => !acquiredKeys[requiredKey])

                if (missingKeys.length !== 0) {
                    throw new this.SanitizationError('Not all mandatory keys are present. These keys are missing: ' + missingKeys.map(key => `"${key}"`).join(', '))
                }
            }

            return new Metadata(sanitized)
        }
    }

    class MetadataParser {
        ParsingError = class extends Error {
            constructor(message) {
                super(message)

                this.name = 'ParsingError'
            }
        }

        get ParseOptionsSchema() {
            return z.object({
                sanitizer: z.custom(value => value instanceof MetadataSanitizer).optional(),
                strictSyntax: z.boolean().default(false)
            })
        }

        constructor(defaultOptions = {}) {
            try {
                this.defaultOptions = this.ParseOptionsSchema.parse(defaultOptions)
            } catch(err) {
                if (err instanceof z.ZodError) {
                    throw new Error('Invalid options provided, ' + ZodErrorFormatter.format(err))
                }

                throw new Error(err)
            }
        }

        parse(metadataString, options) {
            if (typeof metadataString !== 'string') {
                throw new Error('Provided metadata is not a string')
            }

            const parsed = this.ParseOptionsSchema.safeParse(options)

            if (!parsed.success) {
                if (this.defaultOptions) {
                    options = this.defaultOptions
                } else {
                    throw new Error(ZodErrorFormatter.format(parsed.error))
                }
            } else {
                options = parsed.data
            }

            metadataString = metadataString.trim()

            const metadataStart = metadataString.match(/\/\/\s+==UserScript==/)
            const metadataEnd = metadataString.match(/\/\/\s+==\/UserScript==/)

            if (metadataStart === null || metadataEnd === null) {
                throw new this.ParsingError('Invalid string, cannot parse data. Could not find user script metadata comment block')
            }

            if (metadataStart.index > metadataEnd.index) {
                throw new this.ParsingError('Invalid string, cannot parse data. Metadata start comment is farther than metadata end comment')
            }

            const metadataRaw = metadataString.slice(
                metadataStart.index + metadataStart[0].length, // cut start comment -> // ==UserScript==
                metadataEnd.index // cut end comment -> // ==/UserScript==
            ).split('\n')

            const result = []

            const reMetaItem = /\/\/\s+@(?<key>[a-zA-Z\-]+)(\:(?<locale>[a-zA-Z]{2,2}(\-[a-zA-Z]{2,2})?)?)?(\s+(?<value>.*?))?$/
            const falsyToNull = val => val ? val : null

            for (let i = 0; i < metadataRaw.length; i++) {
                const line = metadataRaw[i].trim()

                if (line === '') continue

                const match = line.match(reMetaItem)
                const isInvalidSyntax = match === null

                if (isInvalidSyntax && options.strictSyntax) {
                    throw new this.ParsingError(`Invalid syntax, cannot parse the metadata. Invalid line: "${line}"`)
                } else if (isInvalidSyntax) {
                    continue
                }

                const { key, value, locale } = match.groups

                result.push({ key, value: falsyToNull(value), locale: falsyToNull(locale) })
            }

            const metadata = new Metadata(result)

            if (options.sanitizer) {
                return options.sanitizer.sanitize(metadata)
            }

            return metadata
        }

        safeParse(metadataString, options) {
            try {
                const result = this.parse(metadataString, options)

                return { success: true, data: result }
            } catch(error) {
                return { success: false, error }
            }
        }
    }

    window.Metadata = Metadata
    window.MetadataSanitizer = MetadataSanitizer
    window.MetadataParser = MetadataParser
})(window.Zod);