diff --git a/.github/workflows/ci-validation.yml b/.github/workflows/ci-validation.yml index 0e0161f..8cb267b 100644 --- a/.github/workflows/ci-validation.yml +++ b/.github/workflows/ci-validation.yml @@ -1,6 +1,9 @@ name: CI validation -on: [push, pull_request] +on: [push, pull_request, merge_group] + +permissions: + contents: read jobs: build: @@ -11,3 +14,8 @@ jobs: with: node-version: 20 - run: node validate.js --check + - name: Install schema validation dependencies + run: npm ci --prefix tools/schema --ignore-scripts + - run: node --test schema.test.js tools/schema/validate.test.cjs + - name: Validate bot definitions against the JSON Schema + run: node tools/schema/validate.cjs diff --git a/AGENTS.md b/AGENTS.md index 46fe27b..e9b33c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,13 +14,19 @@ This repository maintains a curated list of well-known bots, crawlers, validator ## Working with This Repository -### No Package Manager Required +### Validation Dependencies -This is a **data repository** with NO package.json or dependencies. It uses only Node.js built-in modules. +This is a **data repository** with no root package.json or project installation. +The built-in validator and CIDR tests use only Node.js built-in modules. JSON +Schema validation uses the isolated `tools/schema/` package. -- ❌ **DO NOT** run `npm install`, `npm init`, or any package manager commands -- ❌ **DO NOT** create a package.json file -- ✅ **DO** use Node.js directly to run scripts (e.g., `node validate.js --check`) +- Do not create a root package.json or install dependencies at the repository root. +- Install validation dependencies with `npm ci --prefix tools/schema --ignore-scripts`. +- Run schema validation with `node tools/schema/validate.cjs`. +- Keep validation dependency versions exact and commit the generated lockfile. + For an intentional update, run `npm install --prefix tools/schema --ignore-scripts + --save-exact @`, review the lockfile, and run + `npm audit --prefix tools/schema` plus all validation checks. ### Validation Script (Critical) @@ -77,8 +83,8 @@ When adding or modifying bot entries, refer to the [README.md](README.md) for: The repository uses GitHub Actions for validation: -- **Trigger**: Runs on every push and pull request -- **What it does**: Executes `node validate.js --check` +- **Trigger**: Runs on every push, pull request, and merge group +- **What it does**: Installs the locked schema dependencies with scripts disabled, then runs `node validate.js --check`, `node --test schema.test.js tools/schema/validate.test.cjs`, and `node tools/schema/validate.cjs` - **Node version**: 20.x - **Location**: `.github/workflows/ci-validation.yml` @@ -139,14 +145,13 @@ node validate.js --check ## Testing Your Changes -Since this is a data repository with no test suite, validation IS the testing: +Run the built-in validation, regression tests, and JSON Schema validation: ```bash -# Run validation - this is your test suite +npm ci --prefix tools/schema --ignore-scripts node validate.js --check - -# Expected output: (nothing) with exit code 0 -# If there are errors, they will be printed to stderr +node --test schema.test.js tools/schema/validate.test.cjs +node tools/schema/validate.cjs ``` ## Tips for Agents diff --git a/README.md b/README.md index dfe8f92..809f0a6 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,35 @@ To add a new bot to the list, you need to edit the `well-known-bots.json` file a 5. **Run validation** to ensure your entry is correct: `node validate.js --check` 6. **Submit a pull request** with your changes +### JSON Schema + +The [`well-known-bots.schema.json`](well-known-bots.schema.json) file describes +the intended structure of `well-known-bots.json` for editors and external JSON +Schema validators. The repository's built-in `validate.js` script is still the +source of truth for checks that JSON Schema cannot express, such as compiling +JavaScript regular expressions and testing `instances` against those patterns. + +Install the locked validation dependencies, then validate the JSON file with Ajv: + +```bash +npm ci --prefix tools/schema --ignore-scripts +node tools/schema/validate.cjs +``` + +CI runs both validators and the regression tests: + +```bash +node --test schema.test.js tools/schema/validate.test.cjs +``` + +Validation dependencies are isolated in `tools/schema/`. Its lockfile fixes the +full dependency tree and package integrity hashes; installation disables lifecycle +scripts. Review dependency and lockfile updates together. To validate a different +JSON file, pass its path to `node tools/schema/validate.cjs`. + +Static IP lists accept IPv4 and IPv6 addresses and CIDR ranges, including IPv6 +ranges with an embedded IPv4 address. + ### Bot Entry Structure Each entry in the JSON represents a specific bot or crawler and includes the following fields: @@ -60,6 +89,7 @@ Each entry in the JSON represents a specific bot or crawler and includes the fol - **`accepted`** (array): User-Agent strings that should match the pattern - **`rejected`** (array): User-Agent strings that should not match - **`aliases`** (array): Alternative identifiers for the bot used in other data sources +- **`description`** (string): Free-form human-readable notes about the bot - **`addition_date`** (string): Date the bot was added in YYYY/MM/DD format ### Available Categories diff --git a/schema.test.js b/schema.test.js new file mode 100644 index 0000000..0f8e999 --- /dev/null +++ b/schema.test.js @@ -0,0 +1,72 @@ +const assert = require("node:assert/strict"); +const { isIP } = require("node:net"); +const { test } = require("node:test"); +const schema = require("./well-known-bots.schema.json"); + +const patterns = { + 4: new RegExp(schema.$defs.ipv4Cidr.pattern, "u"), + 6: new RegExp(schema.$defs.ipv6Cidr.pattern, "u"), +}; + +// Check the schema's CIDR patterns against Node's independent address parser. +function checkCidr(value, expected) { + const parts = value.split("/"); + const family = isIP(parts[0]); + const valid = parts.length === 2 && family !== 0 && + !parts[0].includes("%") && /^(0|[1-9][0-9]*)(?![\s\S])/u.test(parts[1]) && + Number(parts[1]) <= (family === 4 ? 32 : 128); + assert.equal(valid, expected, `Node parser: ${JSON.stringify(value)}`); + assert.equal( + patterns[4].test(value) || patterns[6].test(value), + expected, + `Schema: ${JSON.stringify(value)}`, + ); +} + +test("CIDRs accept valid addresses and prefix boundaries", () => { + for (const address of ["0.0.0.0", "192.0.2.1", "255.255.255.255"]) { + for (let prefix = 0; prefix <= 32; prefix++) { + checkCidr(`${address}/${prefix}`, true); + } + } + for (const address of [ + "::", "::1", "2001:DB8::", "2001:db8:1:2:3:4:5:6", + "::ffff:192.0.2.1", "1:2:3:4:5:6:192.0.2.1", + ]) { + for (let prefix = 0; prefix <= 128; prefix++) { + checkCidr(`${address}/${prefix}`, true); + } + } +}); + +test("IPv6 compression accepts every position and rejects too many groups", () => { + for (const embedded of [false, true]) { + const groups = embedded ? 6 : 8; + for (let left = 0; left <= groups + 1; left++) { + for (let right = 0; right <= groups + 1; right++) { + const before = Array(left).fill("abcd").join(":"); + const after = [ + ...Array(right).fill("1234"), + ...(embedded ? ["192.0.2.1"] : []), + ].join(":"); + checkCidr(`${before}::${after}/64`, left + right < groups); + } + } + } +}); + +test("CIDRs reject malformed addresses and prefixes", () => { + for (const value of [ + "deadbeef/64", "::::/128", "1.2.3.4/64", + "192.0.2.1/33", "192.0.2.256/24", "192.00.2.1/24", + "2001:db8::/129", "2001:db8::/-1", "2001:db8::/01", + "2001:db8::/1.5", "2001:db8::/+64", "2001:db8::/", + "2001:db8::/64/64", "1:2:3:4:5:6:7/64", "1:2:3:4:5:6:7:8:9/64", + "2001::db8::1/64", "2001:db8:12345::/64", "2001:db8:xyz::/64", + "::ffff:256.1.2.3/64", "::ffff:192.00.2.1/64", "fe80::1%eth0/64", + " 2001:db8::/64", "2001:db8::/64 ", "2001:db8::/64\n", + "192.0.2.1/24\n", "2001:db8::", "192.0.2.1", + ]) { + checkCidr(value, false); + } +}); diff --git a/tools/schema/package-lock.json b/tools/schema/package-lock.json new file mode 100644 index 0000000..d06c2cf --- /dev/null +++ b/tools/schema/package-lock.json @@ -0,0 +1,84 @@ +{ + "name": "well-known-bots-schema-validation", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "well-known-bots-schema-validation", + "dependencies": { + "ajv": "8.20.0", + "ajv-formats": "3.0.1" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + } + } +} diff --git a/tools/schema/package.json b/tools/schema/package.json new file mode 100644 index 0000000..4ab4041 --- /dev/null +++ b/tools/schema/package.json @@ -0,0 +1,8 @@ +{ + "name": "well-known-bots-schema-validation", + "private": true, + "dependencies": { + "ajv": "8.20.0", + "ajv-formats": "3.0.1" + } +} diff --git a/tools/schema/validate.cjs b/tools/schema/validate.cjs new file mode 100644 index 0000000..5b6eaef --- /dev/null +++ b/tools/schema/validate.cjs @@ -0,0 +1,18 @@ +const { readFileSync } = require("node:fs"); +const { basename, resolve } = require("node:path"); +const Ajv = require("ajv/dist/2020"); +const addFormats = require("ajv-formats"); + +const schema = require("../../well-known-bots.schema.json"); +const dataPath = process.argv[2] ?? resolve(__dirname, "../../well-known-bots.json"); +const data = JSON.parse(readFileSync(dataPath, "utf8")); +const ajv = new Ajv({ allErrors: true, strict: true }); +addFormats(ajv); +const validate = ajv.compile(schema); + +if (validate(data)) { + console.log(`${basename(dataPath)} valid`); +} else { + console.error(ajv.errorsText(validate.errors, { separator: "\n" })); + process.exitCode = 1; +} diff --git a/tools/schema/validate.test.cjs b/tools/schema/validate.test.cjs new file mode 100644 index 0000000..82e4bf4 --- /dev/null +++ b/tools/schema/validate.test.cjs @@ -0,0 +1,56 @@ +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const { mkdtempSync, rmSync, writeFileSync } = require("node:fs"); +const { tmpdir } = require("node:os"); +const { join } = require("node:path"); +const { test } = require("node:test"); + +const script = join(__dirname, "validate.cjs"); + +function validateFixture(t, changes) { + const directory = mkdtempSync(join(tmpdir(), "bot-schema-test-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const file = join(directory, "bots.json"); + const bot = { + id: "test-bot", + categories: ["monitor"], + pattern: { accepted: ["TestBot"], forbidden: [] }, + verification: [], + ...changes, + }; + writeFileSync(file, JSON.stringify([bot])); + return spawnSync(process.execPath, [script, file], { encoding: "utf8" }); +} + +test("validates the repository data independently of the working directory", () => { + const result = spawnSync(process.execPath, [script], { + cwd: tmpdir(), + encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /well-known-bots\.json valid/); +}); + +test("rejects structural schema violations", (t) => { + const result = validateFixture(t, { categories: ["not-a-category"] }); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stderr, /categories/); +}); + +test("enforces the registered IP address formats", (t) => { + const result = validateFixture(t, { + verification: [{ type: "ip", ips: ["999.999.999.999"] }], + }); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stderr, /verification/); +}); + +test("rejects the malformed CIDRs from the review through the full schema", (t) => { + for (const ip of ["deadbeef/64", "::::/128", "1.2.3.4/64"]) { + const result = validateFixture(t, { + verification: [{ type: "ip", ips: [ip] }], + }); + assert.equal(result.status, 1, `${ip}: ${result.stderr}`); + assert.match(result.stderr, /verification/); + } +}); diff --git a/well-known-bots.json b/well-known-bots.json index c27761a..4a5fb4e 100644 --- a/well-known-bots.json +++ b/well-known-bots.json @@ -2278,7 +2278,6 @@ "forbidden": [] }, "addition_date": "2011/06/21", - "url": "", "verification": [], "instances": { "accepted": [ @@ -4273,9 +4272,6 @@ "forbidden": [] }, "url": "http://www.archive.org/details/archive.org_bot", - "depends_on": [ - "heritrix" - ], "verification": [], "instances": { "accepted": [ @@ -8933,9 +8929,6 @@ "forbidden": [] }, "addition_date": "2018/10/14", - "depends_on": [ - "libwww-perl" - ], "verification": [], "instances": { "accepted": [ diff --git a/well-known-bots.schema.json b/well-known-bots.schema.json new file mode 100644 index 0000000..503c476 --- /dev/null +++ b/well-known-bots.schema.json @@ -0,0 +1,388 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/arcjet/well-known-bots/main/well-known-bots.schema.json", + "title": "Well Known Bots", + "description": "Schema for the well-known-bots.json bot definition list.", + "type": "array", + "items": { + "$ref": "#/$defs/bot" + }, + "minItems": 1, + "uniqueItems": true, + "$defs": { + "bot": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "categories", + "pattern", + "verification" + ], + "properties": { + "id": { + "description": "Unique kebab-case bot identifier.", + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "categories": { + "type": "array", + "items": { + "$ref": "#/$defs/category" + }, + "minItems": 1, + "uniqueItems": true + }, + "pattern": { + "$ref": "#/$defs/userAgentPattern" + }, + "verification": { + "type": "array", + "items": { + "$ref": "#/$defs/verification" + }, + "uniqueItems": true + }, + "url": { + "description": "Documentation URL for the bot.", + "$ref": "#/$defs/httpUrl" + }, + "instances": { + "$ref": "#/$defs/instances" + }, + "aliases": { + "description": "Alternative identifiers used by other data sources.", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1, + "uniqueItems": true + }, + "description": { + "description": "Free-form human-readable notes about the bot.", + "type": "string", + "minLength": 1 + }, + "addition_date": { + "description": "Date the bot was added, in YYYY/MM/DD format.", + "$ref": "#/$defs/slashDate" + } + } + }, + "httpUrl": { + "type": "string", + "format": "uri", + "pattern": "^https?://" + }, + "slashDate": { + "type": "string", + "pattern": "^(?:(?:19|20)\\d{2}/(?:(?:0[13578]|1[02])/(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)/(?:0[1-9]|[12]\\d|30)|02/(?:0[1-9]|1\\d|2[0-8]))|(?:19(?:0[48]|[2468][048]|[13579][26])|2000|20(?:0[48]|[2468][048]|[13579][26]))/02/29)$" + }, + "category": { + "type": "string", + "enum": [ + "academic", + "advertising", + "ai", + "amazon", + "apple", + "archive", + "feedfetcher", + "google", + "meta", + "microsoft", + "monitor", + "optimizer", + "preview", + "programmatic", + "search-engine", + "slack", + "social", + "tool", + "unknown", + "vercel", + "webhook", + "yahoo" + ] + }, + "userAgentPattern": { + "type": "object", + "additionalProperties": false, + "required": [ + "accepted", + "forbidden" + ], + "properties": { + "accepted": { + "description": "JavaScript regular expressions that must all match the User-Agent.", + "type": "array", + "items": { + "$ref": "#/$defs/regexString" + }, + "minItems": 1, + "uniqueItems": true + }, + "forbidden": { + "description": "JavaScript regular expressions that disqualify the User-Agent when any match.", + "type": "array", + "items": { + "$ref": "#/$defs/regexString" + }, + "uniqueItems": true + } + } + }, + "regexString": { + "type": "string", + "minLength": 1, + "format": "regex" + }, + "instances": { + "type": "object", + "additionalProperties": false, + "required": [ + "accepted", + "rejected" + ], + "properties": { + "accepted": { + "description": "User-Agent examples that must match every accepted pattern and no forbidden pattern.", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "rejected": { + "description": "User-Agent examples that must not be accepted by this definition.", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + } + } + }, + "verification": { + "oneOf": [ + { + "$ref": "#/$defs/dnsVerification" + }, + { + "$ref": "#/$defs/cidrVerification" + }, + { + "$ref": "#/$defs/staticIpVerification" + }, + { + "$ref": "#/$defs/sourceIpVerification" + } + ] + }, + "dnsVerification": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "masks" + ], + "properties": { + "type": { + "const": "dns" + }, + "masks": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1, + "uniqueItems": true + } + } + }, + "cidrVerification": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "sources" + ], + "properties": { + "type": { + "const": "cidr" + }, + "sources": { + "type": "array", + "items": { + "$ref": "#/$defs/cidrSource" + }, + "minItems": 1, + "uniqueItems": true + } + } + }, + "staticIpVerification": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "ips" + ], + "properties": { + "type": { + "const": "ip" + }, + "ips": { + "type": "array", + "items": { + "$ref": "#/$defs/ipAddressOrCidr" + }, + "minItems": 1, + "uniqueItems": true + } + } + }, + "sourceIpVerification": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "sources" + ], + "properties": { + "type": { + "const": "ip" + }, + "sources": { + "type": "array", + "items": { + "$ref": "#/$defs/ipSource" + }, + "minItems": 1, + "uniqueItems": true + } + } + }, + "cidrSource": { + "oneOf": [ + { + "$ref": "#/$defs/httpJsonSource" + }, + { + "$ref": "#/$defs/httpCsvSource" + }, + { + "$ref": "#/$defs/httpTextSource" + } + ] + }, + "ipSource": { + "oneOf": [ + { + "$ref": "#/$defs/httpJsonSource" + }, + { + "$ref": "#/$defs/httpTextSource" + } + ] + }, + "httpJsonSource": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "url", + "selector" + ], + "properties": { + "type": { + "const": "http-json" + }, + "url": { + "$ref": "#/$defs/httpUrl" + }, + "selector": { + "type": "string", + "minLength": 1, + "not": { + "pattern": "\\\\\"" + } + } + } + }, + "httpCsvSource": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "url" + ], + "properties": { + "type": { + "const": "http-csv" + }, + "url": { + "$ref": "#/$defs/httpUrl" + } + } + }, + "httpTextSource": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "url" + ], + "properties": { + "type": { + "const": "http-text" + }, + "url": { + "$ref": "#/$defs/httpUrl" + } + } + }, + "ipAddress": { + "type": "string", + "anyOf": [ + { + "format": "ipv4" + }, + { + "format": "ipv6" + } + ] + }, + "ipAddressOrCidr": { + "type": "string", + "anyOf": [ + { + "format": "ipv4" + }, + { + "format": "ipv6" + }, + { + "$ref": "#/$defs/ipv4Cidr" + }, + { + "$ref": "#/$defs/ipv6Cidr" + } + ] + }, + "ipv4Cidr": { + "type": "string", + "pattern": "^(?:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)/(?:[0-9]|[12]\\d|3[0-2])(?![\\s\\S])" + }, + "ipv6Cidr": { + "$comment": "The alternatives cover eight explicit hextets, :: compression with zero through seven leading hextets, and embedded IPv4 (which occupies two hextets). A compressed address must omit at least one hextet. Scoped addresses are not network ranges.", + "type": "string", + "pattern": "^(?:(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,6})?|[0-9A-Fa-f]{1,4}::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,5})?|[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,4})?|[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,3})?|[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,2})?|[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,1})?|[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,0})?|[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}::|(?:[0-9A-Fa-f]{1,4}:){6}(?:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)|::(?:[0-9A-Fa-f]{1,4}:){0,5}(?:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)|[0-9A-Fa-f]{1,4}::(?:[0-9A-Fa-f]{1,4}:){0,4}(?:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)|[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}::(?:[0-9A-Fa-f]{1,4}:){0,3}(?:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)|[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}::(?:[0-9A-Fa-f]{1,4}:){0,2}(?:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)|[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}::(?:[0-9A-Fa-f]{1,4}:){0,1}(?:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)|[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}::(?:[0-9A-Fa-f]{1,4}:){0,0}(?:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d))/(?:[0-9]|[1-9]\\d|1[01]\\d|12[0-8])(?![\\s\\S])" + } + } +}