├── .gitignore ├── src ├── util │ ├── spinner.js │ ├── prompt.js │ └── crawler.js └── index.js ├── package.json ├── LICENSE ├── README.md └── .eslintrc.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | gifs 3 | .DS_Store 4 | .vscode 5 | -------------------------------------------------------------------------------- /src/util/spinner.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const CLI = require('clui'); 4 | const Spinner = CLI.Spinner; 5 | 6 | const spinner = new Spinner(' Fetching Awesome content... ', ['🌕','🌔','🌓','🌒','🌑','🌘','🌗','🌖']); 7 | 8 | module.exports = spinner; -------------------------------------------------------------------------------- /src/util/prompt.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const inquirer = require('inquirer'); 4 | 5 | inquirer.registerPrompt('autocomplete', require('inquirer-autocomplete-prompt')); 6 | 7 | const showPosts = (titles, text) => { 8 | if(titles.length === 0){ 9 | console.error("¯\\_(ツ)_/¯ No " + text + " found. Please try again."); 10 | process.exit(1); 11 | } 12 | 13 | return inquirer.prompt([{ 14 | type: 'rawlist', 15 | name: 'title', 16 | message: '🌎 Here are your ' + text + ':', 17 | choices: titles, 18 | paginated: true 19 | }]) 20 | } 21 | 22 | module.exports = { 23 | showPosts 24 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "product-hunt-cli", 3 | "version": "1.0.0", 4 | "description": "A package to access product hunt from CLI", 5 | "main": "src/index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/sunilkumarc/product-hunt-cli.git" 9 | }, 10 | "author": "Sarthology", 11 | "license": "MIT", 12 | "bugs": { 13 | "url": "https://github.com/sunilkumarc/product-hunt-cli/issues" 14 | }, 15 | "homepage": "https://github.com/sunilkumarc/product-hunt-cli#readme", 16 | "dependencies": { 17 | "algoliasearch": "^3.32.0", 18 | "chalk": "^2.4.2", 19 | "cheerio": "^1.0.0-rc.2", 20 | "clui": "^0.3.6", 21 | "commander": "^2.19.0", 22 | "esc-exit": "^2.0.1", 23 | "inquirer": "^6.2.1", 24 | "inquirer-autocomplete-prompt": "^1.0.1", 25 | "opn": "^5.4.0", 26 | "x-ray": "^2.3.3" 27 | }, 28 | "devDependencies": { 29 | "eslint": "^5.12.1", 30 | "eslint-plugin-prettier": "^3.0.1", 31 | "prettier": "^1.16.1" 32 | }, 33 | "preferGlobal": true, 34 | "bin": { 35 | "ph": "src/index.js" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 phtocli 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 | ## Product Hunt CLI 2 | A command line tool to access product hunt's products right from your terminal. 3 | Inspired by Team Xenox's [Dev.To CLI](https://teamxenox.github.io/devtocli/) 4 | 5 | ## Usage 6 | #### 1. Check products from home page 7 | ![Alt Text](http://g.recordit.co/BLNgHRZojy.gif) 8 | 9 | #### 2. Check latest products 10 | ![Alt Text](http://g.recordit.co/kgGQHru1RZ.gif) 11 | 12 | #### 3. Check upcoming products 13 | ![Alt Text](http://g.recordit.co/V0nTuMJyiL.gif) 14 | 15 | #### 4. Check all jobs 16 | ![Alt Text](http://g.recordit.co/DSPFda66Fk.gif) 17 | 18 | #### 5. Check remote jobs 19 | ![Alt Text](http://g.recordit.co/CurgO7EZrW.gif) 20 | 21 | #### 6. Check products by topic 22 | ![Alt Text](http://g.recordit.co/Or3na1BIGI.gif) 23 | 24 | #### 7. Search for products 25 | ![Alt Text](http://g.recordit.co/zYHh6AX6p2.gif) 26 | 27 | #### 8. Search for products by author 28 | ![Alt Text](http://g.recordit.co/d2nLeFn4QG.gif) 29 | 30 | ## Prerequisites 31 | Before running this locally you must have these installed 32 | - Node 33 | - npm 34 | 35 | ## Installing 36 | It's built in node so the process to start this is really easy 37 | 1. `npm install -g product-hunt-cli` 38 | 2. `ph` 39 | 40 | ## License 41 | MIT License 42 | 43 | ## Author 44 | [Sunil Kumar C](https://twitter.com/sunilc_) -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "es6": true 5 | }, 6 | "extends": ["plugin:prettier/recommended"], 7 | "parserOptions": { 8 | "ecmaVersion": 2016 9 | }, 10 | "rules": { 11 | "prettier/prettier": "error", 12 | "strict": "error", 13 | "no-console": "off", 14 | "no-eval": "error", 15 | "no-implied-eval": "error", 16 | "no-implicit-globals": "error", 17 | "no-control-regex":"off", 18 | "no-empty-function": "error", 19 | "no-unused-vars": "warn", 20 | "no-undefined": "error", 21 | "no-undef-init": "error", 22 | "no-param-reassign": "warn", 23 | "no-unneeded-ternary": "error", 24 | "no-nested-ternary": "error", 25 | "no-throw-literal": "error", 26 | "no-tabs": "error", 27 | "indent": [ 28 | "error", 29 | 4 30 | ], 31 | "quotes": [ 32 | "error", 33 | "single" 34 | ], 35 | "semi": [ 36 | "error", 37 | "always" 38 | ], 39 | "block-scoped-var": "warn", 40 | "prefer-const": "warn", 41 | "prefer-arrow-callback": "error", 42 | "prefer-template": "error", 43 | "default-case": "warn", 44 | "require-await": "error", 45 | "yoda": "warn", 46 | "no-new-func": "error", 47 | "no-new-wrappers": "error", 48 | "handle-callback-err": "error" 49 | }, 50 | "plugins": ["prettier"] 51 | } 52 | -------------------------------------------------------------------------------- /src/util/crawler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Xray = require('x-ray'); 4 | const algoliasearch = require('algoliasearch'); 5 | const client = algoliasearch('YE5Y9R600C', 'OTU1YjU5MWNlZTk1MjQ0YmExOTRjZmY4NDM2ZTM2YWZiYTM2ODA2NThhMzNjMDkzYTEzYjFmNDY0MDcwNjRkOHJlc3RyaWN0SW5kaWNlcz1zZWFyY2hhYmxlc19wcm9kdWN0aW9uJTJDVGFnX3Byb2R1Y3Rpb24lMkNvcmRlcmVkX2FydGljbGVzX3Byb2R1Y3Rpb24lMkNvcmRlcmVkX2FydGljbGVzX2J5X3B1Ymxpc2hlZF9hdF9wcm9kdWN0aW9uJTJDb3JkZXJlZF9hcnRpY2xlc19ieV9wb3NpdGl2ZV9yZWFjdGlvbnNfY291bnRfcHJvZHVjdGlvbiUyQ29yZGVyZWRfY29tbWVudHNfcHJvZHVjdGlvbg=='); 6 | const index = client.initIndex('searchables_production'); 7 | 8 | const xray = Xray({ 9 | filters: { 10 | trim: function (value) { 11 | return typeof value === 'string' ? value.trim() : value 12 | }, 13 | removeNewLine: function (value) { 14 | return value.replace(/\s\s+/g, ' '); 15 | } 16 | } 17 | }); 18 | 19 | const fetchHome = () => { 20 | return xray('https://www.producthunt.com/', '.content_2d8bd .postsList_b2208 li', [{ 21 | title: '.link_523b9 .content_31491 h3 | trim', 22 | description: '.link_523b9 .content_31491 p | trim', 23 | author: 'h4 a | trim', 24 | link: '.link_523b9@href', 25 | upvotes: '.voteButtonWrap_4c515 .buttonContainer_b6eb3 span', 26 | tag: ['.tags .tag | trim'] 27 | }]).then(data => { 28 | return formatTitle(data); 29 | }); 30 | } 31 | 32 | const fetchSearchResults = (query) => { 33 | return xray('https://www.producthunt.com/search?q=' + query, '.content_111fa .postsList_b2208 li', [{ 34 | title: '.link_523b9 .content_31491 h3 | trim', 35 | description: '.link_523b9 .content_31491 p | trim', 36 | link: '.link_523b9@href', 37 | upvotes: '.voteButtonWrap_4c515 .buttonContainer_b6eb3 span', 38 | tag: ['.tags .tag | trim'] 39 | }]).then(data => { 40 | return formatTitle(data); 41 | }); 42 | } 43 | 44 | const fetchTopics = (query) => { 45 | return xray('https://www.producthunt.com/topics', '.content_2d8bd .item_56e23', [{ 46 | title: '.info_d7201 span | trim', 47 | link: '.info_d7201@href', 48 | tag: ['.tags .tag | trim'] 49 | }]).then(data => { 50 | return formatTitle(data); 51 | }); 52 | } 53 | 54 | const fetchJobs = () => { 55 | return xray('https://www.producthunt.com/jobs', '.container_2ae19 .white_7a18a .item_f6569', [{ 56 | title: '.itemInfos_0b720 a h3 | trim', 57 | position: '.itemInfos_0b720 a span | trim', 58 | posted: 'div time | trim', 59 | location: '.right_488cb span | trim', 60 | link: 'a@href', 61 | tag: ['.tags .tag | trim'] 62 | }]).then(data => { 63 | return formatTitle(data); 64 | }); 65 | } 66 | 67 | const fetchRemoteJobs = () => { 68 | return xray('https://www.producthunt.com/jobs?remote_ok=true', '.container_2ae19 .white_7a18a .item_f6569', [{ 69 | title: '.itemInfos_0b720 a h3 | trim', 70 | position: '.itemInfos_0b720 a span | trim', 71 | posted: 'div time | trim', 72 | location: '.right_488cb span | trim', 73 | link: 'a@href', 74 | tag: ['.tags .tag | trim'] 75 | }]).then(data => { 76 | return formatTitle(data); 77 | }); 78 | } 79 | 80 | const fetchUpcoming = () => { 81 | return xray('https://www.producthunt.com/upcoming', '.item_6a520', [{ 82 | title: '.link_2a415 .title_39c87 | trim', 83 | description: '.link_2a415 .tagline_ce810 | trim', 84 | link: '.link_2a415@href', 85 | tag: ['.tags .tag | trim'] 86 | }]).then(data => { 87 | return formatTitle(data); 88 | }); 89 | } 90 | 91 | const fetchLatest = () => { 92 | return xray('https://www.producthunt.com/newest', '.content_2d8bd .postsList_b2208 li', [{ 93 | title: '.link_523b9 .content_31491 h3 | trim', 94 | description: '.link_523b9 .content_31491 p | trim', 95 | author: 'h4 a | trim', 96 | link: '.link_523b9@href', 97 | upvotes: '.voteButtonWrap_4c515 .buttonContainer_b6eb3 span', 98 | tag: ['.tags .tag | trim'] 99 | }]).then(data => { 100 | return formatTitle(data); 101 | }); 102 | } 103 | 104 | const fetchByAuthor = (author) => { 105 | return xray('https://www.producthunt.com/@' + author + '/submitted', '.content_7c39f .postsList_b2208 li', [{ 106 | title: '.link_523b9 .content_31491 h3 | trim', 107 | description: '.link_523b9 .content_31491 p | trim', 108 | link: '.link_523b9@href', 109 | upvotes: '.voteButtonWrap_4c515 .buttonContainer_b6eb3 span', 110 | tag: ['.tags .tag | trim'] 111 | }]).then(data => { 112 | return formatTitle(data); 113 | }); 114 | } 115 | 116 | const formatTitle = (posts) => { 117 | return posts.map(post => { 118 | post.tag.forEach(tag => { 119 | if (post.title.indexOf(tag) > -1) post.title = post.title.split(tag)[1]; 120 | if (post.location == undefined) post.location = ''; 121 | }) 122 | return post; 123 | }); 124 | } 125 | 126 | module.exports = { 127 | fetchHome, 128 | fetchByAuthor, 129 | fetchJobs, 130 | fetchRemoteJobs, 131 | fetchLatest, 132 | fetchUpcoming, 133 | fetchSearchResults, 134 | fetchTopics 135 | }; -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | const program = require('commander'); 4 | const opn = require('opn'); 5 | const escExit = require('esc-exit'); 6 | 7 | const crawler = require('./util/crawler'); 8 | const countdown = require('./util/spinner'); 9 | const prompt = require('./util/prompt'); 10 | 11 | let articles; 12 | 13 | escExit(); 14 | 15 | const openBasicLink = (answers) => { 16 | opn(articles.find(data => data.title === answers.title).link); 17 | process.exit(); 18 | } 19 | 20 | const openLink = (answers) => { 21 | opn(articles.find(data => (data.title + ' (' + data.description + ')' + ' [' + data.upvotes + ']') === answers.title).link); 22 | process.exit(); 23 | } 24 | 25 | const openJobsLink = (answers) => { 26 | opn(articles.find(data => (data.title + ' [Position: ' + data.position + ', Location: ' + data.location + ', Posted: ' + data.posted + ']') === answers.title).link); 27 | process.exit(); 28 | } 29 | 30 | const openUpcomingLink = (answers) => { 31 | opn(articles.find(data => (data.title + ' | ' + data.description) === answers.title).link); 32 | process.exit(); 33 | } 34 | 35 | program 36 | .version('1.0.0') 37 | 38 | program 39 | .command("latest") 40 | .alias("l") 41 | .action(() => { 42 | countdown.start(); 43 | 44 | crawler.fetchLatest().then(data => { 45 | countdown.stop(); 46 | articles = data.filter(data => data.title != undefined); 47 | prompt.showPosts(articles.map(data => data.title + ' (' + data.description + ')' + ' [' + data.upvotes + ']'), 'latest posts').then(answers => { 48 | openLink(answers); 49 | }); 50 | }) 51 | }) 52 | 53 | program 54 | .command("upcoming") 55 | .alias("j") 56 | .action(() => { 57 | countdown.start(); 58 | crawler.fetchUpcoming().then(data => { 59 | countdown.stop(); 60 | articles = data.filter(data => data.title != undefined); 61 | prompt.showPosts(articles.map(data => data.title + ' | ' + data.description), 'upcoming posts').then(answers => { 62 | openUpcomingLink(answers); 63 | }); 64 | }) 65 | }) 66 | 67 | program 68 | .command("jobs") 69 | .alias("j") 70 | .action(() => { 71 | countdown.start(); 72 | crawler.fetchJobs().then(data => { 73 | countdown.stop(); 74 | articles = data.filter(data => data.title != undefined); 75 | prompt.showPosts(articles.map(data => data.title + ' [Position: ' + data.position + ', Location: ' + data.location + ', Posted: ' + data.posted + ']'), 'jobs').then(answers => { 76 | openJobsLink(answers); 77 | }); 78 | }) 79 | }) 80 | 81 | 82 | program 83 | .command("remote") 84 | .alias("r") 85 | .action(() => { 86 | countdown.start(); 87 | crawler.fetchRemoteJobs().then(data => { 88 | countdown.stop(); 89 | articles = data.filter(data => data.title != undefined); 90 | let loc = data.location; 91 | if (loc == undefined) loc = 'Remote'; 92 | prompt.showPosts(articles.map(data => data.title + ' [Position: ' + data.position + ', Location: ' + loc + ', Posted: ' + data.posted + ']'), 'remote jobs').then(answers => { 93 | openJobsLink(answers); 94 | }); 95 | }) 96 | }) 97 | 98 | program 99 | .command("topics") 100 | .alias("t") 101 | .action(() => { 102 | countdown.start(); 103 | crawler.fetchTopics().then(data => { 104 | countdown.stop(); 105 | articles = data.filter(data => data.title != undefined); 106 | prompt.showPosts(articles.map(data => data.title), 'topics').then(answers => { 107 | openBasicLink(answers); 108 | }); 109 | }) 110 | }) 111 | 112 | program 113 | .command("search ") 114 | .alias("s") 115 | .action((keyword) => { 116 | countdown.start(); 117 | crawler.fetchSearchResults(keyword).then(data => { 118 | countdown.stop(); 119 | articles = data.filter(data => data.title != undefined); 120 | prompt.showPosts(articles.map(data => data.title + ' (' + data.description + ')' + ' [' + data.upvotes + ']'), 'results').then(answers => { 121 | openLink(answers); 122 | }); 123 | }); 124 | }) 125 | 126 | program 127 | .command("author ") 128 | .alias("a") 129 | .action((keyword) => { 130 | countdown.start(); 131 | crawler.fetchByAuthor(keyword).then(data => { 132 | countdown.stop(); 133 | articles = data.filter(data => data.title != undefined); 134 | prompt.showPosts(articles.map(data => data.title + ' (' + data.description + ')' + ' [' + data.upvotes + ']'), 'posts').then(answers => { 135 | openLink(answers); 136 | }); 137 | }); 138 | }) 139 | 140 | program.on('command:*', function () { 141 | console.error('Invalid command: %s\nSee --help for a list of available commands.', program.args.join(' ')); 142 | process.exit(1); 143 | }); 144 | 145 | program.parse(process.argv); 146 | 147 | if (program.args.length === 0) { 148 | countdown.start(); 149 | crawler.fetchHome().then(data => { 150 | countdown.stop(); 151 | articles = data.filter(data => data.title != undefined); 152 | prompt.showPosts(articles.map(data => data.title + ' (' + data.description + ')' + ' [' + data.upvotes + ']'), 'posts').then(answers => { 153 | openLink(answers); 154 | }); 155 | }) 156 | } --------------------------------------------------------------------------------