├── .all-contributorsrc ├── .github └── workflows │ ├── release.yml │ └── website.yml ├── .gitignore ├── .releaserc.json ├── .travis.yml ├── CNAME ├── LICENSE ├── README.md ├── _config.yml ├── package-lock.json ├── package.json ├── src ├── cli.ts ├── commands │ ├── files.spec.ts │ ├── files.ts │ ├── mouse.spec.ts │ ├── mouse.ts │ ├── navigation.spec.ts │ ├── navigation.ts │ ├── save-page-as.spec.ts │ ├── save-page-as.ts │ ├── timers.spec.ts │ └── timers.ts ├── examples │ └── go-to.txt ├── helpers.spec.ts ├── helpers.ts ├── index.spec.ts └── index.ts ├── tsconfig.json └── website ├── app.scss ├── app.ts ├── assets ├── api.svg ├── coffee.svg ├── icon.svg ├── ifttt.svg ├── schedule.svg ├── scrape.svg ├── slack.svg └── sleek.svg └── index.html /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [ 8 | { 9 | "login": "AnandChowdhary", 10 | "name": "Anand Chowdhary", 11 | "avatar_url": "https://avatars3.githubusercontent.com/u/2841780?v=4", 12 | "profile": "https://anandchowdhary.com/?utm_source=github&utm_medium=about&utm_campaign=about-link", 13 | "contributions": [ 14 | "ideas", 15 | "code", 16 | "test", 17 | "doc" 18 | ] 19 | }, 20 | { 21 | "login": "gajus", 22 | "name": "Gajus Kuizinas", 23 | "avatar_url": "https://avatars2.githubusercontent.com/u/973543?v=4", 24 | "profile": "https://gitspo.com", 25 | "contributions": [ 26 | "infra" 27 | ] 28 | } 29 | ], 30 | "contributorsPerLine": 7, 31 | "projectName": "puppet", 32 | "projectOwner": "AnandChowdhary", 33 | "repoType": "github", 34 | "repoHost": "https://github.com", 35 | "skipCi": true 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | release: 8 | name: Build, test, and release 9 | runs-on: ubuntu-18.04 10 | if: "!contains(github.event.head_commit.message, '[skip ci]')" 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v1 14 | - name: Setup Node.js 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: 12 18 | - name: Install dependencies 19 | run: npm ci 20 | - name: Build TypeScript 21 | run: npm run build 22 | - name: Run tests 23 | run: npm run test 24 | - name: Release 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 28 | run: npx semantic-release 29 | -------------------------------------------------------------------------------- /.github/workflows/website.yml: -------------------------------------------------------------------------------- 1 | name: Website 2 | on: 3 | push: 4 | branches: 5 | - master 6 | paths: 7 | - "website/**/*" 8 | jobs: 9 | release: 10 | name: Build website 11 | runs-on: ubuntu-18.04 12 | if: "!contains(github.event.head_commit.message, '[skip ci]')" 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v1 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: 12 20 | - name: Install dependencies 21 | run: npm ci 22 | - name: Build TypeScript 23 | run: npm run build-website 24 | env: 25 | NODE_ENV: "production" 26 | - uses: maxheld83/ghpages@v0.2.1 27 | name: GitHub Pages Deploy 28 | env: 29 | BUILD_DIR: "dist/" 30 | GH_PAT: ${{ secrets.GH_PAT }} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 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 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # Built files 107 | dist/ 108 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | [ 4 | "semantic-release-gitmoji", 5 | { 6 | "releaseRules": { 7 | "patch": { 8 | "include": [":bento:", ":recycle:"] 9 | } 10 | } 11 | } 12 | ], 13 | "@semantic-release/github", 14 | "@semantic-release/npm", 15 | [ 16 | "@semantic-release/git", 17 | { 18 | "message": ":bookmark: v${nextRelease.version} [skip ci]\n\nhttps://github.com/AnandChowdhary/puppet/releases/tag/${nextRelease.gitTag}" 19 | } 20 | ] 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | script: 5 | - "npm run build" 6 | - "npm run test-report" 7 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | puppet.js.org 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Anand Chowdhary 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🎭 Puppet 2 | 3 | Natural-language web automation using [Puppeteer](https://github.com/puppeteer/puppeteer). 4 | 5 | [![Node CI](https://img.shields.io/github/workflow/status/AnandChowdhary/puppet/Node%20CI?label=GitHub%20CI&logo=github)](https://github.com/AnandChowdhary/puppet/actions) 6 | [![Travis CI](https://img.shields.io/travis/AnandChowdhary/puppet?label=Travis%20CI&logo=travis%20ci&logoColor=%23fff)](https://travis-ci.org/AnandChowdhary/puppet) 7 | [![Coverage](https://coveralls.io/repos/github/AnandChowdhary/puppet/badge.svg?branch=master&v=2)](https://coveralls.io/github/AnandChowdhary/puppet?branch=master) 8 | [![Dependencies](https://img.shields.io/librariesio/release/npm/puppet)](https://libraries.io/npm/puppet) 9 | [![License](https://img.shields.io/npm/l/puppet)](https://github.com/AnandChowdhary/puppet/blob/master/LICENSE) 10 | [![Vulnerabilities](https://img.shields.io/snyk/vulnerabilities/npm/puppet.svg)](https://snyk.io/test/npm/puppet) 11 | [![Based on Node.ts](https://img.shields.io/badge/based%20on-node.ts-brightgreen)](https://github.com/AnandChowdhary/node.ts) 12 | [![npm type definitions](https://img.shields.io/npm/types/puppet.svg)](https://unpkg.com/browse/puppet/dist/index.d.ts) 13 | [![npm package](https://img.shields.io/npm/v/puppet.svg)](https://www.npmjs.com/package/puppet) 14 | [![npm downloads](https://img.shields.io/npm/dw/puppet)](https://www.npmjs.com/package/puppet) 15 | [![Contributors](https://img.shields.io/github/contributors/AnandChowdhary/puppet)](https://github.com/AnandChowdhary/puppet/graphs/contributors) 16 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 17 | 18 | [![npm](https://nodei.co/npm/puppet.png)](https://www.npmjs.com/package/puppet) 19 | 20 | ## ⭐️ How it works 21 | 22 | Write in natural language (following the [Commands](#-commands) section). For example, you can create a file with the following set of commands: 23 | 24 | **`path/to/download.puppet`**: 25 | 26 | ```txt 27 | Go to typeform.com 28 | Click on the login link 29 | Type username user@example.com 30 | Type password 3rjiw9qie2308 31 | Click on login button 32 | Take a screenshot 33 | Download https://admin.typeform.com/export 34 | Save to to report.csv 35 | ``` 36 | 37 | Then, run the command: 38 | 39 | ```bash 40 | puppet "path/to/download.puppet" 41 | ``` 42 | 43 | ## 💡 Usage 44 | 45 | ### CLI 46 | 47 | Install the package globally from [npm](https://www.npmjs.com/package/puppet): 48 | 49 | ```bash 50 | npm install --global puppet 51 | ``` 52 | 53 | ```bash 54 | # Local Puppet file 55 | puppet "path/to/commands.puppet" 56 | 57 | # Remote Puppet file 58 | puppet https://pastebin.com/raw/AeY1MAwF 59 | 60 | # Commands directly in CLI 61 | puppet "open example.com" "get page HTML" "save as page.html" 62 | ``` 63 | 64 | ### API 65 | 66 | Import and use the API: 67 | 68 | ```ts 69 | const { puppet } = require("puppet"); // Node.js 70 | import { puppet } from "puppet"; // TypeScript/ES6 71 | 72 | // Local Puppet file 73 | await puppet("path/to/commands.puppet"); 74 | 75 | // Remote Puppet file 76 | await puppet("https://pastebin.com/raw/AeY1MAwF"); 77 | 78 | // Commands directly as an array of strings 79 | await puppet(["open example.com", "get page HTML", "save as page.html"]); 80 | ``` 81 | 82 | ## 🔫 Commands 83 | 84 | ### Navigation 85 | 86 | - `Go to example.com` 87 | - `Navigate to URL https://example.com` 88 | - `Go to the page on example.com` 89 | - `Open www.example.com` 90 | 91 | ### Timers 92 | 93 | - `Wait for 10 seconds` 94 | - `Wait for 2 minutes` 95 | - `Wait for 100ms` 96 | - `Wait for navigation` 97 | 98 | ### Screenshot 99 | 100 | - `Take a screenshot of this page` 101 | - `Take a JPEG screenshot` 102 | - `Full screenshot this page` 103 | - `Make a transparent screenshot` 104 | - `Screenshot and omit the background` 105 | 106 | ### Export page to PDF/HTML 107 | 108 | - `Save this page as PDF` 109 | - `Save page HTML` 110 | - `Get the HTML` 111 | - `Save the whole page as PDF` 112 | 113 | ### Save to file 114 | 115 | - `Save result to path/to/file` 116 | - `Save this screenshot to path/to/file` 117 | - `Save this to the file path/to/file` 118 | 119 | ### Mouse events 120 | 121 | - `Click on point [123, 456]` 122 | - `Right click on coordinates 123, 456` 123 | - `Move mouse cursor to points 123, 456` 124 | - `Click on 123, 456 using middle mouse button` 125 | 126 | ## 👩‍💻 Development 127 | 128 | Build TypeScript: 129 | 130 | ```bash 131 | npm run build 132 | ``` 133 | 134 | Run unit tests and view coverage: 135 | 136 | ```bash 137 | npm run test-without-reporting 138 | ``` 139 | 140 | ## Related work 141 | 142 | - [Puppeteer](https://github.com/puppeteer/puppeteer) is the headless Chrome API for Node.js 143 | - [Archiver](https://github.com/AnandChowdhary/archiver) is the Internet Archive saver I made using Puppeteer 144 | - [TagUI](https://github.com/kelaberetiv/TagUI) is a CLI for digital process automation (RPA) 145 | 146 | ## ✨ Contributors 147 | 148 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 |

Anand Chowdhary

🤔 💻 ⚠️ 📖

Gajus Kuizinas

🚇
159 | 160 | 161 | 162 | 163 | 164 | 165 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 166 | 167 | ## 📄 License 168 | 169 | - Code: [MIT](./LICENSE) © [Anand Chowdhary](https://anandchowdhary.com) 170 | - Landing page copy: CC-BY 4.0 Puppet 171 | - Icon: CC-BY 3.0 [Jon Trillana](https://thenounproject.com/search/?q=puppet&i=44227) 172 | - Illustrations: CC-0 [Pablo Stanley](https://www.opendoodles.com) 173 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-minimal 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "puppet", 3 | "version": "1.5.0", 4 | "description": "Natural-language web automation using Puppeteer", 5 | "main": "dist/index.js", 6 | "bin": "dist/cli.js", 7 | "types": "dist/index.d.ts", 8 | "files": [ 9 | "dist" 10 | ], 11 | "scripts": { 12 | "build": "tsc", 13 | "test": "jest --runInBand --forceExit", 14 | "test-report": "jest --forceExit --runInBand --coverage --coverageReporters=text-lcov | coveralls", 15 | "test-without-reporting": "jest --forceExit --runInBand --coverage", 16 | "semantic-release": "semantic-release", 17 | "build-website": "parcel build website/index.html && echo 'puppet.js.org' > dist/CNAME", 18 | "start-website": "parcel website/index.html" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/AnandChowdhary/puppet.git" 23 | }, 24 | "keywords": [ 25 | "node.js", 26 | "typescript", 27 | "javascript", 28 | "library", 29 | "puppeteer", 30 | "scraping", 31 | "automation" 32 | ], 33 | "author": "Anand Chowdhary ", 34 | "engines": { 35 | "node": ">=10.0.0" 36 | }, 37 | "license": "MIT", 38 | "bugs": { 39 | "url": "https://github.com/AnandChowdhary/puppet/issues" 40 | }, 41 | "homepage": "https://anandchowdhary.github.io/puppet/", 42 | "dependencies": { 43 | "@medv/finder": "^1.1.2", 44 | "fs-extra": "^9.0.0", 45 | "got": "^10.7.0", 46 | "ms": "^2.1.2", 47 | "natural": "^0.6.3", 48 | "puppeteer": "^2.1.1", 49 | "signale": "^1.4.0" 50 | }, 51 | "devDependencies": { 52 | "@semantic-release/git": "^9.0.0", 53 | "@types/fs-extra": "^8.1.0", 54 | "@types/jest": "^25.2.1", 55 | "@types/ms": "^0.7.31", 56 | "@types/natural": "^0.6.3", 57 | "@types/node": "^13.11.1", 58 | "@types/puppeteer": "^2.0.1", 59 | "@types/signale": "^1.4.1", 60 | "coveralls": "^3.0.11", 61 | "jest": "^25.3.0", 62 | "parcel": "^1.12.4", 63 | "sass": "^1.26.3", 64 | "semantic-release": "^17.0.4", 65 | "semantic-release-gitmoji": "^1.3.3", 66 | "ts-jest": "25.2.1", 67 | "typescript": "^3.8.3" 68 | }, 69 | "jest": { 70 | "roots": [ 71 | "" 72 | ], 73 | "transform": { 74 | "^.+\\.tsx?$": "ts-jest" 75 | }, 76 | "moduleFileExtensions": [ 77 | "js", 78 | "ts", 79 | "json" 80 | ] 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { puppet } from "./"; 3 | 4 | const pathToFile = process.argv[2]; 5 | if (!pathToFile) throw new Error("Path required: puppet 'path/to/file.puppet'"); 6 | 7 | const numberOfSteps = process.argv.length - 2; 8 | 9 | if (numberOfSteps === 1) { 10 | puppet(pathToFile); 11 | } else { 12 | const arr = [...process.argv]; 13 | arr.splice(0, 2); 14 | puppet(arr); 15 | } 16 | -------------------------------------------------------------------------------- /src/commands/files.spec.ts: -------------------------------------------------------------------------------- 1 | import { puppet } from "../"; 2 | import { saveToFile } from "./files"; 3 | import { readFile, unlink } from "fs-extra"; 4 | import { join } from "path"; 5 | jest.setTimeout(30000); 6 | 7 | describe("puppet - files", () => { 8 | it("saves to file", async () => { 9 | await saveToFile("save to example.txt", {} as any, "Hello, world!"); 10 | const txt = await readFile(join(".", "example.txt"), "utf8"); 11 | expect(txt).toBe("Hello, world!"); 12 | }); 13 | it("gets HTML and saves", async () => { 14 | await puppet([ 15 | "go to example.com", 16 | "get page html", 17 | "save to example.html", 18 | ]); 19 | const txt = await readFile(join(".", "example.html"), "utf8"); 20 | expect(txt.includes("Example Domain")).toBeTruthy(); 21 | }); 22 | afterAll(async () => { 23 | await unlink(join(".", "example.txt")); 24 | await unlink(join(".", "example.html")); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/commands/files.ts: -------------------------------------------------------------------------------- 1 | import { Page } from "puppeteer"; 2 | import { complete, pending } from "signale"; 3 | import { join } from "path"; 4 | import { lastWord } from "../helpers"; 5 | import { writeFile } from "fs-extra"; 6 | 7 | export const saveToFile = async ( 8 | command: string, 9 | page: Page, 10 | lastResult: any 11 | ) => { 12 | const path = join(".", lastWord(command)); 13 | pending(`Saving ${Buffer.from(lastResult || "").byteLength}b file`); 14 | await writeFile(path, lastResult); 15 | complete(`Saved to file ${path}...`); 16 | }; 17 | -------------------------------------------------------------------------------- /src/commands/mouse.spec.ts: -------------------------------------------------------------------------------- 1 | import { puppet } from "../"; 2 | import { launch } from "puppeteer"; 3 | import { triggerMouseClickMove } from "./mouse"; 4 | import { join } from "path"; 5 | jest.setTimeout(30000); 6 | 7 | describe("puppet - mouse", () => { 8 | it("trigger mouse click", async () => { 9 | const browser = await launch(); 10 | const page = await browser.newPage(); 11 | await page.goto("http://example.com"); 12 | const result = await triggerMouseClickMove( 13 | "click on point 100, 100", 14 | page, 15 | "" 16 | ); 17 | await browser.close(); 18 | expect(result).toBeUndefined(); 19 | }); 20 | it("trigger mouse move", async () => { 21 | const browser = await launch(); 22 | const page = await browser.newPage(); 23 | await page.goto("http://example.com"); 24 | const result = await triggerMouseClickMove( 25 | "move to point 100,100", 26 | page, 27 | "" 28 | ); 29 | await browser.close(); 30 | expect(result).toBeUndefined(); 31 | }); 32 | it("loads page and clicks", async () => { 33 | const { url } = await puppet([ 34 | "go to example.com", 35 | "click on point 199, 12", 36 | ]); 37 | expect(url).toBe("http://example.com/"); 38 | }); 39 | it("loads page and moves", async () => { 40 | const { url } = await puppet(["go to example.com", "move to [199, 12]"]); 41 | expect(url).toBe("http://example.com/"); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/commands/mouse.ts: -------------------------------------------------------------------------------- 1 | import { Page } from "puppeteer"; 2 | import { complete, pending } from "signale"; 3 | import { removeWords } from "../helpers"; 4 | 5 | export const triggerMouseClickMove = async ( 6 | command: string, 7 | page: Page, 8 | lastResult: any 9 | ) => { 10 | const points = removeWords( 11 | command, 12 | "mouse", 13 | "click", 14 | "down", 15 | "move", 16 | "up", 17 | "cursor", 18 | "mouseclick", 19 | "mousedown", 20 | "mousemove", 21 | "mouseup", 22 | "on", 23 | "to", 24 | "point", 25 | "points", 26 | "coordinate", 27 | "coordinates", 28 | "using", 29 | "button", 30 | "middle", 31 | "the", 32 | "left", 33 | "right", 34 | "x", 35 | "[", 36 | "]" 37 | ) 38 | .split(" ") 39 | .map((i) => i.split(",")) 40 | .flat() 41 | .map((i) => Number(i.trim().replace(/\D/g, ""))) 42 | .filter((i) => i && !isNaN(i)); 43 | const x = points[0]; 44 | const y = points[1]; 45 | if (x === undefined || y === undefined) 46 | throw new Error("Both X, Y coordinates not found"); 47 | if (command.includes("click")) { 48 | pending(`Clicking on point [${x}, ${y}]`); 49 | await page.mouse.click(x, y, { 50 | button: command.includes("right") 51 | ? "right" 52 | : command.includes("middle") 53 | ? "middle" 54 | : "left", 55 | }); 56 | } else if (command.includes("move")) { 57 | pending(`Moving to position [${x}, ${y}]`); 58 | await page.mouse.move(x, y); 59 | } else throw new Error("`click` or `move` required"); 60 | complete("Clicked"); 61 | }; 62 | -------------------------------------------------------------------------------- /src/commands/navigation.spec.ts: -------------------------------------------------------------------------------- 1 | import { puppet } from "../"; 2 | import { launch } from "puppeteer"; 3 | import { navigateTo } from "./navigation"; 4 | jest.setTimeout(30000); 5 | 6 | describe("puppet - HTML", () => { 7 | it("navigate to page", async () => { 8 | const browser = await launch(); 9 | const page = await browser.newPage(); 10 | await page.goto("http://example.com"); 11 | const result = (await navigateTo("navigate to example.org", page, "")) || { 12 | url: () => "", 13 | }; 14 | await browser.close(); 15 | expect(result.url()).toBe("http://example.org/"); 16 | }); 17 | it("go to url", async () => { 18 | const browser = await launch(); 19 | const page = await browser.newPage(); 20 | await page.goto("http://example.com"); 21 | const result = (await navigateTo( 22 | "go to url http://example.org", 23 | page, 24 | "" 25 | )) || { 26 | url: () => "", 27 | }; 28 | await browser.close(); 29 | expect(result.url()).toBe("http://example.org/"); 30 | }); 31 | it("open page", async () => { 32 | const browser = await launch(); 33 | const page = await browser.newPage(); 34 | await page.goto("http://example.com"); 35 | const result = (await navigateTo("open example.org", page, "")) || { 36 | url: () => "", 37 | }; 38 | await browser.close(); 39 | expect(result.url()).toBe("http://example.org/"); 40 | }); 41 | it("go to page", async () => { 42 | const result = await puppet(["go to example.com"]); 43 | expect(result.url).toBe("http://example.com/"); 44 | }); 45 | it("navigate to page", async () => { 46 | const result = await puppet(["navigate to example.com"]); 47 | expect(result.url).toBe("http://example.com/"); 48 | }); 49 | it("open url", async () => { 50 | const result = await puppet(["open http://example.com"]); 51 | expect(result.url).toBe("http://example.com/"); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/commands/navigation.ts: -------------------------------------------------------------------------------- 1 | import { Page } from "puppeteer"; 2 | import { complete, pending } from "signale"; 3 | import { removeWords } from "../helpers"; 4 | 5 | export const navigateTo = async ( 6 | command: string, 7 | page: Page, 8 | lastResult: any 9 | ) => { 10 | const query = removeWords( 11 | command, 12 | "navigate", 13 | "go", 14 | "open", 15 | "to", 16 | "page", 17 | "url" 18 | ); 19 | const url = query.startsWith("http") ? query : `http://${query}`; 20 | pending(`Navigating to ${url}`); 21 | const result = await page.goto(url, { waitUntil: "load" }); 22 | complete(`Navigated to ${url}`); 23 | return result; 24 | }; 25 | -------------------------------------------------------------------------------- /src/commands/save-page-as.spec.ts: -------------------------------------------------------------------------------- 1 | import { puppet } from "../"; 2 | import { launch } from "puppeteer"; 3 | import { screenshot, saveAsPdf, saveAsHtml } from "./save-page-as"; 4 | import { readFile, unlink } from "fs-extra"; 5 | import { join } from "path"; 6 | jest.setTimeout(30000); 7 | 8 | describe("puppet - HTML", () => { 9 | it("saveAsHtml", async () => { 10 | const browser = await launch(); 11 | const page = await browser.newPage(); 12 | await page.goto("http://example.com"); 13 | const result = await saveAsHtml("save as HTML", page, ""); 14 | await browser.close(); 15 | expect(result.length).toBeGreaterThan(1000); 16 | }); 17 | it("download page HTML", async () => { 18 | await puppet(["go to example.com", "save as HTML", "save to basic.html"]); 19 | const file = await readFile(join(".", "basic.html")); 20 | expect(file).toBeDefined(); 21 | expect(file.length).toBeGreaterThan(100); 22 | }); 23 | }); 24 | 25 | describe("puppet - PDF", () => { 26 | it("saveAsPdf", async () => { 27 | const browser = await launch(); 28 | const page = await browser.newPage(); 29 | await page.goto("http://example.com"); 30 | const result = await saveAsPdf("save as PDF", page, ""); 31 | await browser.close(); 32 | expect(result.length).toBeGreaterThan(1000); 33 | }); 34 | it("create a PDF", async () => { 35 | await puppet(["go to example.com", "save as PDF", "save to basic.pdf"]); 36 | const file = await readFile(join(".", "basic.pdf")); 37 | expect(file).toBeDefined(); 38 | expect(file.length).toBeGreaterThan(1); 39 | }); 40 | }); 41 | 42 | describe("puppet - screenshot", () => { 43 | it("screenshot", async () => { 44 | const browser = await launch(); 45 | const page = await browser.newPage(); 46 | await page.goto("http://example.com"); 47 | const result = await screenshot("take a screenshot", page, ""); 48 | await browser.close(); 49 | expect(result.length).toBeGreaterThan(1000); 50 | }); 51 | it("JPEG screenshot", async () => { 52 | const browser = await launch(); 53 | const page = await browser.newPage(); 54 | await page.goto("http://example.com"); 55 | const result = await screenshot("take a JPEG screenshot", page, ""); 56 | await browser.close(); 57 | expect(result.length).toBeGreaterThan(1000); 58 | }); 59 | it("full screenshot", async () => { 60 | const browser = await launch(); 61 | const page = await browser.newPage(); 62 | await page.goto("http://example.com"); 63 | const result = await screenshot("take a full screenshot", page, ""); 64 | await browser.close(); 65 | expect(result.length).toBeGreaterThan(1000); 66 | }); 67 | it("transparent screenshot", async () => { 68 | const browser = await launch(); 69 | const page = await browser.newPage(); 70 | await page.goto("http://example.com"); 71 | const result = await screenshot("take a transparent screenshot", page, ""); 72 | await browser.close(); 73 | expect(result.length).toBeGreaterThan(1000); 74 | }); 75 | it("take basic screenshot", async () => { 76 | await puppet([ 77 | "go to example.com", 78 | "take a screenshot", 79 | "save to basic.png", 80 | ]); 81 | const file = await readFile(join(".", "basic.png")); 82 | expect(file).toBeDefined(); 83 | expect(file.length).toBeGreaterThan(1); 84 | }); 85 | it("take JPEG screenshot", async () => { 86 | await puppet([ 87 | "go to example.com", 88 | "take a JPG screenshot", 89 | "save to basic.jpeg", 90 | ]); 91 | const file = await readFile(join(".", "basic.jpeg")); 92 | expect(file).toBeDefined(); 93 | expect(file.length).toBeGreaterThan(1); 94 | }); 95 | it("take full page screenshot", async () => { 96 | await puppet([ 97 | "go to example.com", 98 | "take a full screenshot", 99 | "save to full.png", 100 | ]); 101 | const file = await readFile(join(".", "full.png")); 102 | expect(file).toBeDefined(); 103 | }); 104 | it("take transparent screenshot", async () => { 105 | await puppet([ 106 | "go to example.com", 107 | "take a transparent screenshot", 108 | "save to transparent.png", 109 | ]); 110 | const file = await readFile(join(".", "transparent.png")); 111 | expect(file).toBeDefined(); 112 | }); 113 | it("take background-less screenshot", async () => { 114 | await puppet([ 115 | "go to example.com", 116 | "take a screenshot, omit background", 117 | "save to transparent.png", 118 | ]); 119 | const file = await readFile(join(".", "transparent.png")); 120 | expect(file).toBeDefined(); 121 | }); 122 | afterAll(async () => { 123 | await unlink(join(".", "basic.pdf")); 124 | await unlink(join(".", "basic.html")); 125 | await unlink(join(".", "basic.png")); 126 | await unlink(join(".", "basic.jpeg")); 127 | await unlink(join(".", "full.png")); 128 | await unlink(join(".", "transparent.png")); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /src/commands/save-page-as.ts: -------------------------------------------------------------------------------- 1 | import { Page } from "puppeteer"; 2 | import { complete, pending } from "signale"; 3 | 4 | export const screenshot = async ( 5 | command: string, 6 | page: Page, 7 | lastResult: any 8 | ) => { 9 | pending("Taking a screenshot..."); 10 | const shot = await page.screenshot({ 11 | fullPage: command.includes("full"), 12 | type: command.includes("jpg") || command.includes("jpeg") ? "jpeg" : "png", 13 | omitBackground: 14 | command.includes("transparent") || 15 | (command.includes("background") && 16 | (command.includes("remove") || 17 | command.includes("without") || 18 | command.includes("omit"))), 19 | }); 20 | complete("Took a screenshot"); 21 | return shot; 22 | }; 23 | 24 | export const saveAsPdf = async ( 25 | command: string, 26 | page: Page, 27 | lastResult: any 28 | ) => { 29 | pending("Generating PDF from page..."); 30 | const pdf = await page.pdf(); 31 | complete("Generated page PDF"); 32 | return pdf; 33 | }; 34 | 35 | export const saveAsHtml = async ( 36 | command: string, 37 | page: Page, 38 | lastResult: any 39 | ) => { 40 | pending("Getting HTML from page..."); 41 | const html = await page.content(); 42 | complete("Got page HTML"); 43 | return html; 44 | }; 45 | -------------------------------------------------------------------------------- /src/commands/timers.spec.ts: -------------------------------------------------------------------------------- 1 | import { puppet } from "../"; 2 | import { waitForTime, waitForNavigation } from "./timers"; 3 | import { launch } from "puppeteer"; 4 | jest.setTimeout(30000); 5 | 6 | describe("puppet - timers", () => { 7 | it("saves to file", async () => { 8 | const now = new Date().getTime(); 9 | await waitForTime("wait for 1 second", {} as any, ""); 10 | expect(new Date().getTime() - now).toBeGreaterThanOrEqual(1000); 11 | }); 12 | it("waits in puppet", async () => { 13 | const now = new Date().getTime(); 14 | await puppet([ 15 | "go to example.com", 16 | "wait for 1 second", 17 | "go to example.org", 18 | ]); 19 | expect(new Date().getTime() - now).toBeGreaterThanOrEqual(1000); 20 | }); 21 | it("wait for navigation", async () => { 22 | const browser = await launch(); 23 | const page = await browser.newPage(); 24 | await page.goto("http://example.com"); 25 | const [_, navigationResult] = await Promise.all([ 26 | page.click("body > div > p:nth-child(3) > a"), 27 | waitForNavigation("", page, "") 28 | ]); 29 | expect(navigationResult.url()).toBe("https://www.iana.org/domains/reserved"); 30 | await browser.close(); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/commands/timers.ts: -------------------------------------------------------------------------------- 1 | import { Page } from "puppeteer"; 2 | import { complete, pending } from "signale"; 3 | import { wait, removeWords } from "../helpers"; 4 | import ms from "ms"; 5 | 6 | export const waitForTime = async ( 7 | command: string, 8 | page: Page, 9 | lastResult: any 10 | ) => { 11 | pending("Waiting..."); 12 | await wait(ms(removeWords(command, "wait", "for"))); 13 | complete("Waited"); 14 | }; 15 | 16 | export const waitForNavigation = async ( 17 | command: string, 18 | page: Page, 19 | lastResult: any 20 | ) => { 21 | pending("Waiting for navigation..."); 22 | const result = await page.waitForNavigation(); 23 | complete("Waited"); 24 | return result; 25 | }; -------------------------------------------------------------------------------- /src/examples/go-to.txt: -------------------------------------------------------------------------------- 1 | go to example.com 2 | go to example.org 3 | -------------------------------------------------------------------------------- /src/helpers.spec.ts: -------------------------------------------------------------------------------- 1 | import { wait, lastWord } from "./helpers"; 2 | 3 | describe("helpers", () => { 4 | it("wait", async () => { 5 | const time = new Date().getTime(); 6 | await wait(1000); 7 | expect(new Date().getTime() - time).toBeGreaterThanOrEqual(1000); 8 | }); 9 | 10 | it("last word", async () => { 11 | expect(lastWord("Hello, world")).toBe("world"); 12 | }); 13 | it("last word with multiple spaces", async () => { 14 | expect(lastWord("Hello world")).toBe("world"); 15 | }); 16 | it("last word with end space", async () => { 17 | expect(lastWord("Hello world ")).toBe("world"); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | export const wait = (ms: number): Promise => 2 | new Promise((resolve) => setTimeout(resolve, ms)); 3 | 4 | export const lastWord = (text: string) => { 5 | const safe = text 6 | .trim() 7 | .split(" ") 8 | .filter((i) => i); 9 | return safe[safe.length - 1]; 10 | }; 11 | 12 | export const removeWords = (text: string, ...words: string[]) => 13 | text 14 | .split(" ") 15 | .filter((i) => !words.includes(i.trim())) 16 | .join(" ") 17 | .replace(/\s\s+/g, " ") 18 | .trim(); 19 | -------------------------------------------------------------------------------- /src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { puppet } from "./index"; 2 | jest.setTimeout(30000); 3 | 4 | describe("puppet basic run", () => { 5 | it("downloads and runs URL", async () => { 6 | const result = await puppet("https://pastebin.com/raw/AeY1MAwF"); 7 | expect(result).toBeTruthy(); 8 | }); 9 | it("runs array of commands", async () => { 10 | const result = await puppet(["go to example.com", "go to example.org"]); 11 | expect(result).toBeTruthy(); 12 | }); 13 | it("runs a file path", async () => { 14 | const result = await puppet("src/examples/go-to.txt"); 15 | expect(result).toBeTruthy(); 16 | }); 17 | it("throws if no commands", async () => { 18 | expect((puppet as any)()).rejects.toEqual( 19 | new Error("Argument must be a string or an array of strings") 20 | ); 21 | }); 22 | }); 23 | 24 | describe("cleans commands", () => { 25 | it("trims commands", async () => { 26 | const result = await puppet([ 27 | "go to example.com ", 28 | " go to example.org", 29 | ]); 30 | expect(result.commands).toEqual(["go to example.com", "go to example.org"]); 31 | }); 32 | it("removes empty commands", async () => { 33 | const result = await puppet(["go to example.com", "go to example.org", ""]); 34 | expect(result.commands).toEqual(["go to example.com", "go to example.org"]); 35 | }); 36 | it("lowercases commands", async () => { 37 | const result = await puppet(["Go to www.example.com"]); 38 | expect(result.commands).toEqual(["go to www.example.com"]); 39 | }); 40 | }); 41 | 42 | describe("puppet commands", () => { 43 | it("throws if invalid commands", async () => { 44 | expect(puppet(["Unknown command"])).rejects.toEqual( 45 | new Error("Command not understood: unknown command") 46 | ); 47 | }); 48 | it("waits for a specific time", async () => { 49 | const time = new Date().getTime(); 50 | await puppet(["wait for 1 second"]); 51 | expect(new Date().getTime() - time).toBeGreaterThan(1000); 52 | }); 53 | it("go to a URL", async () => { 54 | const result = await puppet(["go to example.com"]); 55 | expect(result.url).toBe("http://example.com/"); 56 | }); 57 | it("go to a full URL", async () => { 58 | const result = await puppet(["go to https://example.com"]); 59 | expect(result.url).toBe("https://example.com/"); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { launch, Page } from "puppeteer"; 2 | import { start, success, debug } from "signale"; 3 | import got from "got"; 4 | import { readFile } from "fs-extra"; 5 | import { join } from "path"; 6 | import { navigateTo } from "./commands/navigation"; 7 | import { screenshot, saveAsPdf, saveAsHtml } from "./commands/save-page-as"; 8 | import { saveToFile } from "./commands/files"; 9 | import { waitForTime, waitForNavigation } from "./commands/timers"; 10 | import { triggerMouseClickMove } from "./commands/mouse"; 11 | 12 | /** 13 | * 14 | * @param commandsOrFile - Commands for Pupper or a file path/URL 15 | * @example puppet("path/to/commands.puppet") 16 | * @example puppet("https://example.com/commands.puppet") 17 | * @example puppet(["go to example.com", "download page as PDF"]) 18 | */ 19 | export const puppet = async (commandsOrFile: string[] | string) => { 20 | if (typeof commandsOrFile === "string") { 21 | if ( 22 | commandsOrFile.startsWith("https://") || 23 | commandsOrFile.startsWith("http://") 24 | ) { 25 | const commands = await got.get(commandsOrFile); 26 | return _puppet(commands.body.split("\n")); 27 | } 28 | const commands = await readFile(join(".", commandsOrFile), "utf8"); 29 | return _puppet(commands.split("\n")); 30 | } else if (Array.isArray(commandsOrFile)) { 31 | return _puppet(commandsOrFile); 32 | } 33 | throw new Error("Argument must be a string or an array of strings"); 34 | }; 35 | 36 | /** 37 | * Runs Puppet commands 38 | * @param commands - Commands to run 39 | */ 40 | const _puppet = async (commands: string[]) => { 41 | commands = commands.map((i) => i.toLocaleLowerCase().trim()).filter((i) => i); 42 | start("Starting Puppet"); 43 | const browser = await launch(); 44 | const page = await browser.newPage(); 45 | let lastResult: any = undefined; 46 | for await (const command of commands) { 47 | lastResult = await _command(command, page, lastResult); 48 | } 49 | const result = { commands, url: page.url() }; 50 | await browser.close(); 51 | success("Completed Puppet commands"); 52 | return result; 53 | }; 54 | 55 | const _command = async (command: string, page: Page, lastResult: any) => { 56 | debug("Running command", command); 57 | 58 | if ( 59 | command.startsWith("go") || 60 | command.startsWith("open") || 61 | command.startsWith("navigate") 62 | ) 63 | return navigateTo(command, page, lastResult); 64 | 65 | if ( 66 | (command.startsWith("save") || command.startsWith("get")) && 67 | command.endsWith(" pdf") 68 | ) 69 | return saveAsPdf(command, page, lastResult); 70 | 71 | if ( 72 | (command.startsWith("save") || command.startsWith("get")) && 73 | command.endsWith(" html") 74 | ) 75 | return saveAsHtml(command, page, lastResult); 76 | 77 | if (command.startsWith("save")) return saveToFile(command, page, lastResult); 78 | 79 | if (command.includes("screenshot")) 80 | return screenshot(command, page, lastResult); 81 | 82 | if (command.startsWith("wait for")) 83 | if (command.includes("navigation")) 84 | return waitForNavigation(command, page, lastResult); 85 | else 86 | return waitForTime(command, page, lastResult); 87 | 88 | if ( 89 | command.startsWith("move") || 90 | (command.startsWith("click") && 91 | (command.includes("point") || 92 | command.includes("coordinate") || 93 | command.includes("mouse"))) 94 | ) 95 | return triggerMouseClickMove(command, page, lastResult); 96 | 97 | throw new Error(`Command not understood: ${command}`); 98 | }; 99 | 100 | // puppet([ 101 | // "go to example.com", 102 | // "wait for 3 seconds", 103 | // "save to screenshot.html", 104 | // "click on more information link", 105 | // "wait for navigation", 106 | // ]); 107 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "esnext", 5 | "module": "commonjs", 6 | "lib": ["dom", "esnext"], 7 | "strict": true, 8 | "sourceMap": true, 9 | "declaration": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "experimentalDecorators": true, 13 | "emitDecoratorMetadata": true, 14 | "declarationDir": "./dist", 15 | "outDir": "./dist", 16 | "typeRoots": ["node_modules/@types", "@types"] 17 | }, 18 | "include": ["src"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /website/app.scss: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Manrope", sans-serif; 3 | margin: 0; 4 | line-height: 1.5; 5 | font-size: 125%; 6 | } 7 | 8 | code { 9 | font-family: "SFMono-Regular", Consolas, Liberation Mono, Menlo, monospace; 10 | } 11 | 12 | a:hover { 13 | opacity: 0.5; 14 | } 15 | 16 | .container { 17 | position: relative; 18 | max-width: 1000px; 19 | margin: 0 auto; 20 | } 21 | 22 | header { 23 | background-color: #ffe600; 24 | padding: 5vh 0 10vh 0; 25 | nav { 26 | display: flex; 27 | justify-content: space-between; 28 | margin-bottom: 10vh; 29 | } 30 | nav > a:first-child { 31 | text-transform: uppercase; 32 | letter-spacing: 0.33rem; 33 | } 34 | nav > div { 35 | display: flex; 36 | a + a { 37 | margin-left: 2.5rem; 38 | } 39 | } 40 | nav a { 41 | color: inherit; 42 | font-weight: bold; 43 | text-decoration: none; 44 | display: flex; 45 | align-items: center; 46 | img { 47 | display: block; 48 | margin: 0 0.75rem 0 0; 49 | height: 3.5rem; 50 | } 51 | } 52 | p { 53 | max-width: 500px; 54 | } 55 | } 56 | header .container > div { 57 | max-width: 600px; 58 | } 59 | 60 | h1, 61 | h2 { 62 | line-height: 1.2; 63 | margin-top: 0; 64 | } 65 | 66 | .button { 67 | background-color: #66029c; 68 | color: #fff; 69 | text-decoration: none; 70 | padding: 0.75rem 1.5rem; 71 | font-size: 120%; 72 | display: inline-block; 73 | border-radius: 10rem; 74 | &:hover { 75 | opacity: 1; 76 | box-shadow: inset 0 0 10rem rgba(0, 0, 0, 0.25); 77 | } 78 | } 79 | 80 | #pricing { 81 | background-color: whitesmoke; 82 | padding: 2.5vh 0 5vh 0; 83 | margin-top: 7.5vh; 84 | .container { 85 | display: flex; 86 | margin-top: -7.5vh; 87 | } 88 | .container > div { 89 | flex: 1 0 0; 90 | background-color: #fff; 91 | margin: 0 1rem; 92 | box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1); 93 | border-radius: 0.5rem; 94 | text-align: center; 95 | padding: 1.5rem 0; 96 | display: flex; 97 | flex-direction: column; 98 | align-items: center; 99 | justify-content: space-between; 100 | } 101 | h3 { 102 | margin: 0; 103 | + div { 104 | font-size: 150%; 105 | font-weight: bold; 106 | color: #66029c; 107 | } 108 | } 109 | .button { 110 | margin-top: 0.5rem; 111 | background-color: #ebd3f5; 112 | color: inherit; 113 | + p { 114 | margin: 0.5rem 0 0 0; 115 | text-align: center; 116 | max-width: 100%; 117 | font-size: 80%; 118 | opacity: 0.75; 119 | } 120 | } 121 | .recommended .button { 122 | background: #66029c; 123 | color: #fff; 124 | } 125 | ul { 126 | margin: 2rem 0; 127 | padding: 0; 128 | list-style: none; 129 | } 130 | li { 131 | margin: 1rem 0; 132 | } 133 | p { 134 | font-size: 90%; 135 | max-width: 80%; 136 | } 137 | } 138 | 139 | .q { 140 | display: inline-block; 141 | background-color: #ffe600; 142 | width: 1rem; 143 | height: 1rem; 144 | line-height: 1rem; 145 | margin-top: -0.1rem; 146 | font-size: 0.75rem; 147 | font-weight: bold; 148 | vertical-align: middle; 149 | border-radius: 100%; 150 | text-decoration: none; 151 | margin-left: 0.5rem; 152 | transform: scale(1.25); 153 | } 154 | 155 | #faq { 156 | background-color: whitesmoke; 157 | padding: 2.5rem 0; 158 | details + details { 159 | margin-top: 1rem; 160 | } 161 | details { 162 | max-width: 600px; 163 | } 164 | } 165 | 166 | .cta { 167 | background-color: #ffe600; 168 | padding: 5vh 0 10vh 0; 169 | .container > div { 170 | max-width: 500px; 171 | } 172 | } 173 | 174 | .doodle { 175 | position: absolute; 176 | right: -10vw; 177 | bottom: -5vh; 178 | max-width: 550px; 179 | } 180 | header .doodle { 181 | bottom: -10vh; 182 | } 183 | 184 | footer { 185 | background-color: #000; 186 | color: #fff; 187 | font-size: 80%; 188 | padding: 2.5vh 0; 189 | .container { 190 | display: flex; 191 | justify-content: space-between; 192 | } 193 | a { 194 | color: inherit; 195 | } 196 | nav { 197 | display: flex; 198 | a + a { 199 | margin-left: 2rem; 200 | } 201 | } 202 | } 203 | 204 | .message { 205 | padding: 1.5vh 0; 206 | background-color: #e2650d; 207 | color: #fff; 208 | h2 { 209 | margin-bottom: 0.5rem; 210 | } 211 | p { 212 | margin: 0; 213 | } 214 | .container { 215 | display: flex; 216 | align-items: center; 217 | p { 218 | margin-right: 2rem; 219 | flex: 1 0 0; 220 | } 221 | } 222 | a.button { 223 | font-size: 100%; 224 | padding: 0.5rem 1rem; 225 | } 226 | } 227 | 228 | #features { 229 | .container { 230 | display: flex; 231 | padding: 5vh 0; 232 | > div { 233 | flex: 1 0 0; 234 | } 235 | } 236 | textarea { 237 | height: 18rem; 238 | line-height: 2rem; 239 | font-size: 2rem; 240 | padding: 1rem; 241 | font: inherit; 242 | width: 85%; 243 | box-sizing: border-box; 244 | border: 1px solid gray; 245 | border-radius: 0.5rem; 246 | resize: vertical; 247 | } 248 | .button { 249 | font-size: 100%; 250 | padding: 0.5rem 1rem; 251 | margin-top: 0.5rem; 252 | &:last-child { 253 | background-color: #ebd3f5; 254 | color: inherit; 255 | margin-left: 0.5rem; 256 | } 257 | } 258 | ul { 259 | margin: 0; 260 | padding: 0; 261 | list-style: none; 262 | } 263 | li { 264 | span:first-child { 265 | display: inline-block; 266 | background-color: #fff; 267 | box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.1); 268 | width: 3.5rem; 269 | height: 3.5rem; 270 | text-align: center; 271 | line-height: 3.5rem; 272 | vertical-align: middle; 273 | border-radius: 100%; 274 | margin-right: 1rem; 275 | } 276 | img { 277 | width: 2.5rem; 278 | margin-top: 0.5rem; 279 | height: 2.5rem; 280 | } 281 | } 282 | li + li { 283 | margin-top: 1rem; 284 | } 285 | } 286 | 287 | @media (max-width: 900px) { 288 | header nav, 289 | .message .container, 290 | #pricing .container, 291 | footer .container, 292 | #features .container { 293 | display: block; 294 | } 295 | #pricing .container > div:not(:first-child) { 296 | margin-top: 1rem; 297 | } 298 | header nav > div { 299 | margin-top: 1rem; 300 | } 301 | .doodle { 302 | position: static; 303 | margin-top: 5vh; 304 | margin-bottom: -10vh; 305 | } 306 | .container { 307 | max-width: 90%; 308 | } 309 | #features .container li span:first-child { 310 | zoom: 0.75; 311 | } 312 | #features { 313 | textarea { 314 | width: 100%; 315 | } 316 | ul { 317 | margin-top: 2rem; 318 | } 319 | } 320 | } 321 | 322 | @media (prefers-reduced-motion: no-preference) { 323 | html { 324 | scroll-behavior: smooth; 325 | } 326 | } 327 | 328 | ::selection { 329 | background-color: #f1c40f; 330 | } 331 | -------------------------------------------------------------------------------- /website/app.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnandChowdhary/puppet/2c8c826cbe2310bcb9e2206b20aee2d52802a152/website/app.ts -------------------------------------------------------------------------------- /website/assets/api.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /website/assets/coffee.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /website/assets/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /website/assets/ifttt.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /website/assets/schedule.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /website/assets/scrape.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /website/assets/slack.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /website/assets/sleek.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /website/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Puppet · Open source no-code, natural language web automation 8 | 9 | 10 | 11 | 15 | 16 | 17 |
18 |
19 |

20 | 🎉 We're on Product Hunt! 21 | $10 coupon code: 22 | GOLDENKITTY 23 |

24 | 27 |
28 |
29 |
30 |
31 | 42 |
43 |

No-code, natural language web automation

44 |

45 | Puppet is the open-source no-code browser-based automation tool, 46 | powered by Google Chrome. Easily automate your workflows, scrape 47 | websites, and more. 48 |

49 | 52 |
53 | 54 |
55 |
56 |
57 |
58 |
59 |
60 |

Try it live

61 | 71 | Run Puppet job → 72 | Usage docs 73 |
74 |
75 |
    76 |
  • 77 | 78 | 79 | 80 | Scrape websites, automate your workflows 81 |
  • 82 |
  • 83 | 84 | 88 | 89 | Based on leading browser Google Chrome 90 |
  • 91 |
  • 92 | 93 | 94 | 95 | Scheduled jobs run every hour, day, or week 96 |
  • 97 |
  • 98 | 99 | 100 | 101 | Send Slack notifications, emails, and SMS 102 |
  • 103 |
  • 104 | 105 | 106 | 107 | Easy-to-use developer API 108 |
  • 109 |
  • 110 | 111 | 112 | 113 | IFTTT webhook integrations 114 |
  • 115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |

Self-hosted

124 |
Open source
125 |
126 |

127 | Download the source code on GitHub and host it yourself, or use 128 | the NPM package in your projects. 129 |

130 |
131 | Download source 132 |

MIT licensed

133 |
134 |
135 | 152 |
153 |
154 |

Puppet Master

155 |
$9/month
156 |
157 |
    158 |
  • Unlimited usage
  • 159 |
  • Unlimited job duration
  • 160 |
  • Unlimited job history
  • 161 |
  • Unlimited Secrets ?
  • 162 |
  • Send emails and SMS
  • 163 |
164 |
165 | Get started → 166 |

7 days free

167 |
168 |
169 |
170 |
171 |
172 |
173 |

Recipes

174 |
    175 |
  • Save a webpage on the Internet Archive
  • 176 |
  • Send yourself daily weather on Slack
  • 177 |
  • Download stories from an Instagram user
  • 178 |
  • Monitor website uptime and email if down
  • 179 |
  • Audit website performance with Lighthouse
  • 180 |
  • Follow hashtag users on Twitter (with Secrets)
  • 181 |
  • Take screenshots of different viewports
  • 182 |
183 |
184 |
185 |
186 |
187 |

Frequently Asked Questions

188 |
189 | What are Secrets? 190 | Secrets are a form of encrypted text storage that allow you to store 191 | sensitive information. For example, you can use Secrets to store 192 | your username and password to use Puppet when logging in to web 193 | services. Secrets are encrypted with AES-256 using a unique key 194 | specific to you. 195 |
196 |
197 | Do you offer discounts to students and teachers? 198 | If you have an educational email address, you receive the Puppet 199 | Master plan for free. Just sign up with your school/university email 200 | and it will be activated. If you have an unrecognized institution 201 | domain, contact us with your ID and we'll set you up. 202 |
203 |
204 | Do you offer discounts to nonprofits? 205 | If you're representing a registered nonprofit in your jurisdiction 206 | (like 501/c in the US), let us know and we'll give you the Puppet 207 | Master plan for free. 208 |
209 |
210 | How unlimited is unlimited, really? 211 |
    212 |
  1. 213 | Unlimited fair usage: As long as you don't crash our servers, 214 | you can use as many jobs as you like. We have a rate limit of 215 | 100 requests per minute. We'll let you know if we're not 216 | comfortable with your usage, but this has never happened before. 217 |
  2. 218 |
  3. 219 | In the Puppet Master plan, we don't cap the job duration, but 220 | any running jobs will stop if we're running maintenance or 221 | updating our APIs. You should be fine as long as you don't run a 222 | job longer than tens of hours. This has also never happened 223 | before. 224 |
  4. 225 |
226 |
227 |
228 | Can we request an SLA? 229 | We're happy to provide 99.99% uptime SLAs to Puppet Master users for 230 | an additional fee of $100/month. You can purchase this addon when 231 | you sign up. 232 |
233 |
234 | Do you offer enterprise options or managed services? 237 | We're happy to offer a custom solution for your enterprise. This can 238 | be a managed service or increased rate limits on our platform. 239 | Contact us to discuss pricing. 240 |
241 |
242 |
243 |
244 |
245 |
246 |
247 |

248 | Seriously—unlimited web automation jobs for the price of 1 cup 249 | of coffee per month. 250 |

251 |
252 | Get for $4/month → 253 |
254 |
255 | 256 |
257 |
258 | 271 | 272 | 277 | 278 | 279 | --------------------------------------------------------------------------------