├── .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 | [](https://github.com/AnandChowdhary/puppet/actions)
6 | [](https://travis-ci.org/AnandChowdhary/puppet)
7 | [](https://coveralls.io/github/AnandChowdhary/puppet?branch=master)
8 | [](https://libraries.io/npm/puppet)
9 | [](https://github.com/AnandChowdhary/puppet/blob/master/LICENSE)
10 | [](https://snyk.io/test/npm/puppet)
11 | [](https://github.com/AnandChowdhary/node.ts)
12 | [](https://unpkg.com/browse/puppet/dist/index.d.ts)
13 | [](https://www.npmjs.com/package/puppet)
14 | [](https://www.npmjs.com/package/puppet)
15 | [](https://github.com/AnandChowdhary/puppet/graphs/contributors)
16 | [](https://github.com/semantic-release/semantic-release)
17 |
18 | [](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 |
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 |
32 |
33 |
34 | Puppet
35 |
36 |
41 |
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 |
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 |
134 |
135 |
136 |
137 |
Puppet Starter
138 |
$4/month
139 |
140 |
141 | Unlimited usage
142 | 5 minute long jobs
143 | 7-day job history
144 | No Secrets ?
145 | No emails or SMS
146 |
147 |
151 |
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 |
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 |
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 |
218 |
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 |
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 |
254 |
255 |
256 |
257 |
258 |
271 |
272 |
277 |
278 |
279 |
--------------------------------------------------------------------------------