├── .travis.yml ├── test ├── modules │ ├── tap-test.js │ ├── dependencies-test.js │ └── dev-dependencies-test.js └── lib │ ├── pagespeed-test.js │ ├── pagespeed-web-test.js │ └── inputs-test.js ├── renovate.json ├── tst.js ├── .github └── workflows │ └── release.yml ├── lib └── get-result.js ├── .gitignore ├── index.js ├── LICENSE ├── package.json ├── README.md └── return-example.md /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "14" 4 | after_success: 5 | - npm run coveralls 6 | -------------------------------------------------------------------------------- /test/modules/tap-test.js: -------------------------------------------------------------------------------- 1 | const tap = require('tap') 2 | 3 | tap.equal(true, true, 'tap works ok') 4 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>zrrrzzt/renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /test/modules/dependencies-test.js: -------------------------------------------------------------------------------- 1 | const tap = require('tap') 2 | const pkg = require('../../package.json') 3 | const dependencies = pkg.dependencies || {} 4 | 5 | Object.keys(dependencies).forEach((dependency) => { 6 | const module = require(dependency) 7 | tap.ok(module, `${dependency} loads ok`) 8 | }) 9 | -------------------------------------------------------------------------------- /test/modules/dev-dependencies-test.js: -------------------------------------------------------------------------------- 1 | const tap = require('tap') 2 | const pkg = require('../../package.json') 3 | const dependencies = pkg.devDependencies || {} 4 | 5 | Object.keys(dependencies).forEach((dependency) => { 6 | const module = require(dependency) 7 | tap.ok(module, `${dependency} loads ok`) 8 | }) 9 | -------------------------------------------------------------------------------- /tst.js: -------------------------------------------------------------------------------- 1 | (async () => { 2 | const pagespeed = require('./index') 3 | const url = 'https://github.com' 4 | const options = { nokey: true, url: url, useweb: true } 5 | 6 | try { 7 | const data = await pagespeed(options) 8 | console.log(JSON.stringify(data, null, 2)) 9 | } catch (error) { 10 | console.error(error) 11 | } 12 | })() 13 | -------------------------------------------------------------------------------- /test/lib/pagespeed-test.js: -------------------------------------------------------------------------------- 1 | const tap = require('tap') 2 | const pagespeed = require('../../index') 3 | const url = 'https://www.example.com' 4 | 5 | tap.test('return data as JSON via googleapis', async test => { 6 | const options = { nokey: true, url: url } 7 | try { 8 | const data = await pagespeed(options) 9 | tap.equal('pagespeedonline#result', data.kind, 'data ok') 10 | return test.end() 11 | } catch (error) { 12 | console.error(error) 13 | throw error 14 | } 15 | }) 16 | -------------------------------------------------------------------------------- /test/lib/pagespeed-web-test.js: -------------------------------------------------------------------------------- 1 | const tap = require('tap') 2 | const pagespeed = require('../../index') 3 | const url = 'https://www.example.com' 4 | 5 | tap.test('return data as JSON via https', async test => { 6 | const options = { nokey: true, url: url, useweb: true } 7 | try { 8 | const data = await pagespeed(options) 9 | tap.equal('pagespeedonline#result', data.kind, 'data ok') 10 | return test.end() 11 | } catch (error) { 12 | console.error(error) 13 | throw error 14 | } 15 | }) 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build, Test, and Publish on release 2 | on: 3 | release: 4 | types: [published] 5 | branches: [main] 6 | jobs: 7 | build: 8 | name: Build, test and publish 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@master 12 | - uses: actions/setup-node@v2 13 | with: 14 | node-version: 14 15 | registry-url: 'https://registry.npmjs.org' 16 | - run: npm install 17 | - run: npm test 18 | - run: npm publish 19 | env: 20 | NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} -------------------------------------------------------------------------------- /lib/get-result.js: -------------------------------------------------------------------------------- 1 | const https = require('https') 2 | const querystring = require('querystring') 3 | 4 | module.exports = options => { 5 | return new Promise((resolve, reject) => { 6 | let body = '' 7 | const url = `${options.apiUrl}?${querystring.stringify(options.qs)}` 8 | 9 | https.get(url, response => { 10 | response.on('data', (chunk) => { 11 | body += chunk.toString() 12 | }) 13 | 14 | response.on('end', () => { 15 | return resolve(JSON.parse(body)) 16 | }) 17 | }) 18 | .on('error', error => { 19 | return reject(error) 20 | }) 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE 2 | .idea 3 | .vs 4 | .vscode 5 | 6 | # Mac OS 7 | .DS_Store 8 | 9 | # Logs 10 | logs 11 | *.log 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Compiled binary addons (http://nodejs.org/api/addons.html) 29 | build/Release 30 | 31 | # Dependency directory 32 | # Deployed apps should consider commenting this line out: 33 | # see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git 34 | node_modules 35 | 36 | # Env 37 | .env 38 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const { google } = require('googleapis') 2 | const validUrl = require('valid-url') 3 | const getResults = require('./lib/get-result') 4 | 5 | module.exports = async options => { 6 | if (!options.key && !options.nokey) { 7 | const error = new Error('Missing required param: key') 8 | throw error 9 | } 10 | 11 | if (!options.url) { 12 | const error = new Error('Missing required param: url') 13 | throw error 14 | } 15 | 16 | if (options.url && !validUrl.isWebUri(options.url)) { 17 | const error = new Error('Invalid url') 18 | throw error 19 | } 20 | 21 | const apiVersion = options.apiversion || 'v5' 22 | 23 | if (options.useweb) { 24 | const pagespeedUrl = `https://www.googleapis.com/pagespeedonline/${apiVersion}/runPagespeed` 25 | const data = await getResults({ apiUrl: pagespeedUrl, qs: options }) 26 | return data 27 | } else { 28 | const pagespeedonline = google.pagespeedonline(apiVersion) 29 | const { data } = await pagespeedonline.pagespeedapi.runpagespeed(options) 30 | return data 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Geir Gåsodden 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /test/lib/inputs-test.js: -------------------------------------------------------------------------------- 1 | const tap = require('tap') 2 | const gps = require('../../index') 3 | 4 | tap.test('it requires url to exist', (test) => { 5 | const options = { 6 | url: false, 7 | key: true 8 | } 9 | const expectedErrorMessage = 'Missing required param: url' 10 | 11 | gps(options) 12 | .then(data => { 13 | console.log(data) 14 | }) 15 | .catch((error) => { 16 | tap.equal(error.message, expectedErrorMessage, expectedErrorMessage) 17 | test.end() 18 | }) 19 | }) 20 | 21 | tap.test('it requires url to be valid', (test) => { 22 | const options = { 23 | url: 'pysje', 24 | key: true 25 | } 26 | const expectedErrorMessage = 'Invalid url' 27 | 28 | gps(options) 29 | .then(data => { 30 | console.log(data) 31 | }) 32 | .catch((error) => { 33 | tap.equal(error.message, expectedErrorMessage, expectedErrorMessage) 34 | test.end() 35 | }) 36 | }) 37 | 38 | tap.test('it requires a key', (test) => { 39 | const options = { 40 | url: 'https://www.example.com', 41 | key: false 42 | } 43 | const expectedErrorMessage = 'Missing required param: key' 44 | 45 | gps(options) 46 | .then(data => { 47 | console.log(data) 48 | }) 49 | .catch((error) => { 50 | tap.equal(error.message, expectedErrorMessage, expectedErrorMessage) 51 | test.end() 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gpagespeed", 3 | "description": "Analyze a webpage with Google PageSpeed", 4 | "version": "8.0.2", 5 | "license": "MIT", 6 | "author": { 7 | "name": "Geir Gåsodden", 8 | "email": "geir.gasodden@pythonia.no", 9 | "url": "https://github.com/zrrrzzt" 10 | }, 11 | "main": "index.js", 12 | "engines": { 13 | "node": ">=14.17.3" 14 | }, 15 | "scripts": { 16 | "test": "standard && tap --reporter=spec test/**/*.js", 17 | "test-offline": "standard && tap --reporter=spec test/**/*.js", 18 | "coverage": "tap test/**/*.js --coverage", 19 | "coveralls": "tap --cov --coverage-report=lcov test/**/*.js && cat coverage/lcov.info | coveralls", 20 | "standard-fix": "standard --fix", 21 | "refresh": "rm -rf node_modules && rm package-lock.json && npm install" 22 | }, 23 | "keywords": [ 24 | "pagespeed", 25 | "insights" 26 | ], 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/zrrrzzt/gpagespeed.git" 30 | }, 31 | "bugs": { 32 | "url": "https://github.com/zrrrzzt/gpagespeed/issues" 33 | }, 34 | "homepage": "https://github.com/zrrrzzt/gpagespeed#readme", 35 | "dependencies": { 36 | "googleapis": "81.0.0", 37 | "valid-url": "1.0.9" 38 | }, 39 | "devDependencies": { 40 | "coveralls": "3.1.1", 41 | "standard": "16.0.4", 42 | "tap": "15.1.5" 43 | }, 44 | "tap": { 45 | "check-coverage": false 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.com/zrrrzzt/gpagespeed.svg?branch=main)](https://travis-ci.com/zrrrzzt/gpagespeed) 2 | [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat)](https://github.com/feross/standard) 3 | 4 | # gpagespeed 5 | 6 | Node.js module for analyzing a webpage with [Google PageSpeed Insights](https://developers.google.com/speed/pagespeed/insights/). 7 | 8 | You must acquire an API key from [Google Developers Console](https://console.developers.google.com/). 9 | 10 | ## Usage 11 | 12 | Pass an object with properties. 13 | 14 | **url** and **key** are required, all other are optional. 15 | 16 | You can see a list of all alternatives on the page for [Google PageSpeed standard query parameters](https://developers.google.com/speed/docs/insights/v4/reference/pagespeedapi/runpagespeed). 17 | 18 | ```JavaScript 19 | const pagespeed = require('gpagespeed') 20 | const options = { 21 | url: 'http://url-to-check', 22 | key: 'insert-your-key' 23 | } 24 | 25 | pagespeed(options) 26 | .then(data => { 27 | console.log(data) 28 | }) 29 | .catch(error => { 30 | console.error(error) 31 | }) 32 | ``` 33 | 34 | ## Alternative api 35 | 36 | In addition you can choose to use https instead of googleapis and another version of the PageSpeed api (defaults to v4). 37 | 38 | ```JavaScript 39 | const pagespeed = require('gpagespeed') 40 | const options = { 41 | url: 'http://url-to-check', 42 | key: 'insert-your-key', 43 | useweb: true, 44 | apiversion: 'v3beta1' 45 | } 46 | 47 | pagespeed(options) 48 | .then((data) => { 49 | console.log(data) 50 | }) 51 | .catch((error) => { 52 | console.error(error) 53 | }) 54 | ``` 55 | ## Returns 56 | 57 | [return-example.md](return-example.md) 58 | 59 | ## Related 60 | 61 | - [gpagespeed-cli](https://github.com/zrrrzzt/gpagespeed-cli) CLI for this module 62 | 63 | ## License 64 | 65 | [MIT](LICENSE) 66 | -------------------------------------------------------------------------------- /return-example.md: -------------------------------------------------------------------------------- 1 | # Return example 2 | 3 | ```JavaScript 4 | { 5 | "captchaResult": "CAPTCHA_NOT_NEEDED", 6 | "kind": "pagespeedonline#result", 7 | "id": "https://www.google.com/", 8 | "responseCode": 200, 9 | "title": "Google", 10 | "ruleGroups": { 11 | "SPEED": { 12 | "score": 95 13 | } 14 | }, 15 | "loadingExperience": { 16 | "id": "https://www.google.com/", 17 | "metrics": { 18 | "FIRST_CONTENTFUL_PAINT_MS": { 19 | "median": 675, 20 | "distributions": [ 21 | { 22 | "min": 0, 23 | "max": 984, 24 | "proportion": 0.6493244163829708 25 | }, 26 | { 27 | "min": 984, 28 | "max": 2073, 29 | "proportion": 0.1719613615949542 30 | }, 31 | { 32 | "min": 2073, 33 | "proportion": 0.17871422202207507 34 | } 35 | ], 36 | "category": "FAST" 37 | }, 38 | "DOM_CONTENT_LOADED_EVENT_FIRED_MS": { 39 | "median": 738, 40 | "distributions": [ 41 | { 42 | "min": 0, 43 | "max": 1366, 44 | "proportion": 0.7176583317020245 45 | }, 46 | { 47 | "min": 1366, 48 | "max": 2787, 49 | "proportion": 0.13515601984023135 50 | }, 51 | { 52 | "min": 2787, 53 | "proportion": 0.147185648457744 54 | } 55 | ], 56 | "category": "FAST" 57 | } 58 | }, 59 | "overall_category": "FAST", 60 | "initial_url": "https://www.google.com/" 61 | }, 62 | "pageStats": { 63 | "numberResources": 16, 64 | "numberHosts": 7, 65 | "totalRequestBytes": "2759", 66 | "numberStaticResources": 11, 67 | "htmlResponseBytes": "227543", 68 | "overTheWireResponseBytes": "415879", 69 | "imageResponseBytes": "42287", 70 | "javascriptResponseBytes": "873988", 71 | "otherResponseBytes": "1459", 72 | "numberJsResources": 5, 73 | "numTotalRoundTrips": 10, 74 | "numRenderBlockingRoundTrips": 0 75 | }, 76 | "formattedResults": { 77 | "locale": "en_US", 78 | "ruleResults": { 79 | "AvoidLandingPageRedirects": { 80 | "localizedRuleName": "Avoid landing page redirects", 81 | "ruleImpact": 0, 82 | "groups": [ 83 | "SPEED" 84 | ], 85 | "summary": { 86 | "format": "Your page has no redirects. Learn more about {{BEGIN_LINK}}avoiding landing page redirects{{END_LINK}}.", 87 | "args": [ 88 | { 89 | "type": "HYPERLINK", 90 | "key": "LINK", 91 | "value": "https://developers.google.com/speed/docs/insights/AvoidRedirects" 92 | } 93 | ] 94 | } 95 | }, 96 | "EnableGzipCompression": { 97 | "localizedRuleName": "Enable compression", 98 | "ruleImpact": 0, 99 | "groups": [ 100 | "SPEED" 101 | ], 102 | "summary": { 103 | "format": "You have compression enabled. Learn more about {{BEGIN_LINK}}enabling compression{{END_LINK}}.", 104 | "args": [ 105 | { 106 | "type": "HYPERLINK", 107 | "key": "LINK", 108 | "value": "https://developers.google.com/speed/docs/insights/EnableCompression" 109 | } 110 | ] 111 | } 112 | }, 113 | "LeverageBrowserCaching": { 114 | "localizedRuleName": "Leverage browser caching", 115 | "ruleImpact": 0, 116 | "groups": [ 117 | "SPEED" 118 | ], 119 | "summary": { 120 | "format": "You have enabled browser caching. Learn more about {{BEGIN_LINK}}browser caching recommendations{{END_LINK}}.", 121 | "args": [ 122 | { 123 | "type": "HYPERLINK", 124 | "key": "LINK", 125 | "value": "https://developers.google.com/speed/docs/insights/LeverageBrowserCaching" 126 | } 127 | ] 128 | } 129 | }, 130 | "MainResourceServerResponseTime": { 131 | "localizedRuleName": "Reduce server response time", 132 | "ruleImpact": 0, 133 | "groups": [ 134 | "SPEED" 135 | ], 136 | "summary": { 137 | "format": "Your server responded quickly. Learn more about {{BEGIN_LINK}}server response time optimization{{END_LINK}}.", 138 | "args": [ 139 | { 140 | "type": "HYPERLINK", 141 | "key": "LINK", 142 | "value": "https://developers.google.com/speed/docs/insights/Server" 143 | } 144 | ] 145 | } 146 | }, 147 | "MinifyCss": { 148 | "localizedRuleName": "Minify CSS", 149 | "ruleImpact": 0, 150 | "groups": [ 151 | "SPEED" 152 | ], 153 | "summary": { 154 | "format": "Your CSS is minified. Learn more about {{BEGIN_LINK}}minifying CSS{{END_LINK}}.", 155 | "args": [ 156 | { 157 | "type": "HYPERLINK", 158 | "key": "LINK", 159 | "value": "https://developers.google.com/speed/docs/insights/MinifyResources" 160 | } 161 | ] 162 | } 163 | }, 164 | "MinifyHTML": { 165 | "localizedRuleName": "Minify HTML", 166 | "ruleImpact": 0, 167 | "groups": [ 168 | "SPEED" 169 | ], 170 | "summary": { 171 | "format": "Your HTML is minified. Learn more about {{BEGIN_LINK}}minifying HTML{{END_LINK}}.", 172 | "args": [ 173 | { 174 | "type": "HYPERLINK", 175 | "key": "LINK", 176 | "value": "https://developers.google.com/speed/docs/insights/MinifyResources" 177 | } 178 | ] 179 | } 180 | }, 181 | "MinifyJavaScript": { 182 | "localizedRuleName": "Minify JavaScript", 183 | "ruleImpact": 0, 184 | "groups": [ 185 | "SPEED" 186 | ], 187 | "summary": { 188 | "format": "Your JavaScript content is minified. Learn more about {{BEGIN_LINK}}minifying JavaScript{{END_LINK}}.", 189 | "args": [ 190 | { 191 | "type": "HYPERLINK", 192 | "key": "LINK", 193 | "value": "https://developers.google.com/speed/docs/insights/MinifyResources" 194 | } 195 | ] 196 | } 197 | }, 198 | "MinimizeRenderBlockingResources": { 199 | "localizedRuleName": "Eliminate render-blocking JavaScript and CSS in above-the-fold content", 200 | "ruleImpact": 0, 201 | "groups": [ 202 | "SPEED" 203 | ], 204 | "summary": { 205 | "format": "You have no render-blocking resources. Learn more about {{BEGIN_LINK}}removing render-blocking resources{{END_LINK}}.", 206 | "args": [ 207 | { 208 | "type": "HYPERLINK", 209 | "key": "LINK", 210 | "value": "https://developers.google.com/speed/docs/insights/BlockingJS" 211 | } 212 | ] 213 | } 214 | }, 215 | "OptimizeImages": { 216 | "localizedRuleName": "Optimize images", 217 | "ruleImpact": 0.078, 218 | "groups": [ 219 | "SPEED" 220 | ], 221 | "summary": { 222 | "format": "Properly formatting and compressing images can save many bytes of data." 223 | }, 224 | "urlBlocks": [ 225 | { 226 | "header": { 227 | "format": "{{BEGIN_LINK}}Optimize the following images{{END_LINK}} to reduce their size by {{SIZE_IN_BYTES}} ({{PERCENTAGE}} reduction).", 228 | "args": [ 229 | { 230 | "type": "HYPERLINK", 231 | "key": "LINK", 232 | "value": "https://developers.google.com/speed/docs/insights/OptimizeImages" 233 | }, 234 | { 235 | "type": "BYTES", 236 | "key": "SIZE_IN_BYTES", 237 | "value": "780B" 238 | }, 239 | { 240 | "type": "PERCENTAGE", 241 | "key": "PERCENTAGE", 242 | "value": "18%" 243 | } 244 | ] 245 | }, 246 | "urls": [ 247 | { 248 | "result": { 249 | "format": "Compressing {{URL}} could save {{SIZE_IN_BYTES}} ({{PERCENTAGE}} reduction).", 250 | "args": [ 251 | { 252 | "type": "URL", 253 | "key": "URL", 254 | "value": "https://www.google.com/images/hpp/shield_privacy_checkup_green_2x_web_96dp.png" 255 | }, 256 | { 257 | "type": "BYTES", 258 | "key": "SIZE_IN_BYTES", 259 | "value": "780B" 260 | }, 261 | { 262 | "type": "PERCENTAGE", 263 | "key": "PERCENTAGE", 264 | "value": "18%" 265 | } 266 | ] 267 | } 268 | } 269 | ] 270 | } 271 | ] 272 | }, 273 | "PrioritizeVisibleContent": { 274 | "localizedRuleName": "Prioritize visible content", 275 | "ruleImpact": 4, 276 | "groups": [ 277 | "SPEED" 278 | ], 279 | "summary": { 280 | "format": "Your page requires additional network round trips to render the above-the-fold content. For best performance, reduce the amount of HTML needed to render above-the-fold content." 281 | }, 282 | "urlBlocks": [ 283 | { 284 | "header": { 285 | "format": "The entire HTML response was not sufficient to render the above-the-fold content. This usually indicates that additional resources, loaded after HTML parsing, were required to render above-the-fold content. {{BEGIN_LINK}}Prioritize visible content{{END_LINK}} that is needed for rendering above-the-fold by including it directly in the HTML response.", 286 | "args": [ 287 | { 288 | "type": "HYPERLINK", 289 | "key": "LINK", 290 | "value": "https://developers.google.com/speed/docs/insights/PrioritizeVisibleContent" 291 | } 292 | ] 293 | }, 294 | "urls": [ 295 | { 296 | "result": { 297 | "format": "Only about {{PERCENTAGE}} of the final above-the-fold content could be rendered with the full HTML response.", 298 | "args": [ 299 | { 300 | "type": "PERCENTAGE", 301 | "key": "PERCENTAGE", 302 | "value": "64%" 303 | } 304 | ] 305 | } 306 | }, 307 | { 308 | "result": { 309 | "format": "Click to see the screenshot with only the HTML response: {{SCREENSHOT}}", 310 | "args": [ 311 | { 312 | "type": "SNAPSHOT_RECT", 313 | "key": "SCREENSHOT", 314 | "value": "snapshot:2" 315 | } 316 | ] 317 | } 318 | } 319 | ] 320 | } 321 | ] 322 | } 323 | } 324 | }, 325 | "version": { 326 | "major": 1, 327 | "minor": 15 328 | } 329 | } 330 | 331 | ``` --------------------------------------------------------------------------------