├── README.md ├── .gitignore ├── .eslintrc.js ├── tslint.json ├── src ├── common │ ├── browser │ │ ├── api │ │ │ ├── like-post.ts │ │ │ ├── follow-post.ts │ │ │ ├── comment-post.ts │ │ │ ├── index.ts │ │ │ ├── find-posts.ts │ │ │ ├── get-user-info.ts │ │ │ ├── authenticate.ts │ │ │ ├── get-following.ts │ │ │ ├── unfollow-user.ts │ │ │ └── get-post-info.ts │ │ └── index.ts │ ├── scheduler │ │ ├── index.ts │ │ └── jobs.ts │ ├── utils │ │ └── index.ts │ ├── wit │ │ └── index.ts │ ├── interfaces │ │ └── index.ts │ └── scraper │ │ └── scraper.js ├── index.ts └── config.ts ├── tsconfig.json └── package.json /README.md: -------------------------------------------------------------------------------- 1 | # instagrambot 2 | Code for the blog post. 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | build 4 | *.jpg 5 | .env 6 | .DS_Store 7 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'airbnb-base', 3 | env: { 4 | browser: true, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint-config-airbnb"], 3 | "jsRules": {}, 4 | "rules": { 5 | "align": false, 6 | "ter-arrow-parens": false, 7 | "prefer-array-literal": false 8 | }, 9 | "rulesDirectory": [] 10 | } 11 | -------------------------------------------------------------------------------- /src/common/browser/api/like-post.ts: -------------------------------------------------------------------------------- 1 | import { LikePost } from 'src/common/interfaces'; 2 | 3 | const likePost: LikePost = async function likePost(page, post) { 4 | if (post.isLiked) { 5 | return page; 6 | } 7 | 8 | await page.click(post.likeSelector); 9 | await page.waitFor(2500); 10 | await page.waitForSelector(post.unlikeSelector); 11 | return page; 12 | }; 13 | 14 | export { likePost }; 15 | -------------------------------------------------------------------------------- /src/common/browser/api/follow-post.ts: -------------------------------------------------------------------------------- 1 | import { FollowPost } from 'src/common/interfaces'; 2 | 3 | const followPost: FollowPost = async function followPost(page, post) { 4 | if (post.isFollowed) { 5 | return page; 6 | } 7 | 8 | await page.click(post.followSelector); 9 | await page.waitFor(1500); 10 | await page.waitForSelector(post.unfollowSelector); 11 | return page; 12 | }; 13 | 14 | export { followPost }; 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "removeComments": true, 5 | "preserveConstEnums": true, 6 | "outDir": "./build", 7 | "sourceMap": true, 8 | "baseUrl": ".", 9 | "paths": { 10 | "src/*": ["./src/*"] 11 | }, 12 | "pretty": true, 13 | "target": "ESNext" 14 | }, 15 | "include": ["src/**/*"], 16 | "exclude": ["node_modules", "**/*.spec.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /src/common/browser/api/comment-post.ts: -------------------------------------------------------------------------------- 1 | import { CommentPost } from 'src/common/interfaces'; 2 | 3 | const commentPost: CommentPost = async function commentPost(page, post, message) { 4 | await page.click(post.commentButtonSelector); 5 | await page.waitForSelector(post.commentSelector); 6 | await page.type(post.commentSelector, message, { delay: 200 }); 7 | 8 | await page.keyboard.press('Enter'); 9 | 10 | await page.waitFor(2500); 11 | 12 | return page; 13 | }; 14 | 15 | export { commentPost }; 16 | -------------------------------------------------------------------------------- /src/common/browser/api/index.ts: -------------------------------------------------------------------------------- 1 | import { authenticate } from './authenticate'; 2 | import { getFollowing } from './get-following'; 3 | import { findPosts } from './find-posts'; 4 | import { commentPost } from './comment-post'; 5 | import { likePost } from './like-post'; 6 | import { followPost } from './follow-post'; 7 | import { getUserInfo } from './get-user-info'; 8 | import { getPostInfo } from './get-post-info'; 9 | import { unfollowUser } from './unfollow-user'; 10 | 11 | export { 12 | getUserInfo, 13 | getFollowing, 14 | findPosts, 15 | commentPost, 16 | followPost, 17 | likePost, 18 | getPostInfo, 19 | unfollowUser, 20 | authenticate, 21 | }; 22 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import 'module-alias/register'; 2 | import * as dotenv from 'dotenv'; 3 | 4 | dotenv.config(); 5 | 6 | import { createBrowser } from 'src/common/browser'; 7 | import { auth as authConfig } from 'src/config'; 8 | import { schedule, jobs } from 'src/common/scheduler'; 9 | 10 | (async () => { 11 | try { 12 | const browser = await createBrowser(); 13 | await browser.authenticate(authConfig); 14 | 15 | schedule( 16 | [ 17 | new jobs.FollowJob([[0, 5], [11, 14], [17, 21], [22, 23]]), 18 | new jobs.UnfollowJob([[6, 10], [13, 18], [21, 24]]), 19 | ], 20 | browser, 21 | ); 22 | } catch (e) { 23 | console.log(e); 24 | } 25 | })(); 26 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | const config = { 2 | auth: { 3 | username: process.env.USERNAME || '', 4 | password: process.env.PASSWORD || '', 5 | }, 6 | job: { 7 | hashtags: ['like4like', 'follow4follow', 'followforfollow', 'likeforlike'], 8 | numberOfPosts: Number(process.env.NUMBER_OF_POSTS) || 20, 9 | unfollow: Number(process.env.NUMBER_OF_UNFOLLOW) || 20, 10 | commentProbability: 65, 11 | }, 12 | wit: { 13 | accessToken: process.env.WIT_TOKEN || '', 14 | expectedConfidence: 0.7, 15 | }, 16 | comments: { 17 | happy: [ 18 | 'Great', 19 | 'Wow', 20 | 'Damn', 21 | 'Nice', 22 | 'Cool!', 23 | 'Awesome', 24 | 'Beautiful', 25 | 'Amazing', 26 | 'Perfect', 27 | 'Wonderful', 28 | ], 29 | sad: ['damn', 'im gonna cry', 'nooo', 'why', 'omg', 'oh God'], 30 | }, 31 | }; 32 | 33 | export = config; 34 | -------------------------------------------------------------------------------- /src/common/browser/api/find-posts.ts: -------------------------------------------------------------------------------- 1 | import { FindPosts } from 'src/common/interfaces'; 2 | 3 | const findPosts: FindPosts = async function findPosts(hashtag, numberOfPosts = 12) { 4 | return this.getPage(`/explore/tags/${hashtag}`, async page => { 5 | if (numberOfPosts > 12) { 6 | await page.evaluate(async posts => { 7 | const { scraper } = window as any; 8 | 9 | await scraper.scrollPageTimes({ times: Math.ceil((posts - 12) / 12) + 1 }); 10 | }, numberOfPosts); 11 | } 12 | 13 | // waitFor render 14 | await page.waitFor(1500); 15 | 16 | return page.evaluate(posts => { 17 | const { scraper } = window as any; 18 | 19 | return scraper 20 | .find({ selector: 'a[href^="/p/"]', count: posts + 9 }) 21 | .slice(9) 22 | .map(el => el.getAttr('href')); 23 | }, numberOfPosts); 24 | }); 25 | }; 26 | 27 | export { findPosts }; 28 | -------------------------------------------------------------------------------- /src/common/scheduler/index.ts: -------------------------------------------------------------------------------- 1 | import * as nodeschedule from 'node-schedule'; 2 | import * as moment from 'moment'; 3 | import { Browser } from 'src/common/interfaces'; 4 | import { jobs, Job } from './jobs'; 5 | 6 | const getMilisecondsFromMinutes = (minutes: number) => minutes * 60000; 7 | 8 | const schedule = (jobs: Job[], browser: Browser) => 9 | nodeschedule.scheduleJob('0 * * * *', () => { 10 | const hour = moment().hour(); 11 | 12 | console.log('Executing jobs for', moment().format('dddd, MMMM Do YYYY, h:mm:ss a')); 13 | 14 | return jobs.forEach(job => { 15 | if (!job.validateRanges(hour)) { 16 | return null; 17 | } 18 | 19 | const delayedMinutes = Math.floor(Math.random() * 30); 20 | 21 | const cb = () => job.execute(browser); 22 | 23 | return setTimeout(cb, getMilisecondsFromMinutes(delayedMinutes)); 24 | }); 25 | }); 26 | 27 | export { schedule, jobs }; 28 | -------------------------------------------------------------------------------- /src/common/browser/api/get-user-info.ts: -------------------------------------------------------------------------------- 1 | import * as numeral from 'numeral'; 2 | import { GetUserInfo } from 'src/common/interfaces'; 3 | 4 | const getUserInfo: GetUserInfo = async function getUserInfo(username) { 5 | return this.getPage(`/${username}`, async (page) => { 6 | const [posts, followers, following] = await page.evaluate(() => { 7 | const { scraper } = window as any; 8 | 9 | return scraper 10 | .find({ 11 | selector: 'li span', 12 | }) 13 | .slice(1) 14 | .map(el => el.text()); 15 | }); 16 | 17 | const isFollowed = await page.evaluate(() => { 18 | const { scraper } = window as any; 19 | 20 | const followButton = scraper.findOneWithText({ selector: 'button', text: 'Follow' }); 21 | 22 | if (followButton) { 23 | return false; 24 | } 25 | 26 | return true; 27 | }); 28 | 29 | return { 30 | isFollowed, 31 | username, 32 | following: numeral(following).value(), 33 | followers: numeral(followers).value(), 34 | posts: numeral(posts).value(), 35 | }; 36 | }); 37 | }; 38 | 39 | export { getUserInfo }; 40 | -------------------------------------------------------------------------------- /src/common/utils/index.ts: -------------------------------------------------------------------------------- 1 | const getRandomNumber = (min: number, max: number) => 2 | Math.floor(Math.random() * (max - min + 1)) + min; 3 | 4 | function getRandomItem(arr: T[]): T { 5 | return arr[getRandomNumber(0, arr.length - 1)]; 6 | } 7 | 8 | const getMilisecondsFromSeconds = (seconds: number): number => seconds * 1000; 9 | 10 | const probability = (chance: number): boolean => getRandomNumber(0, 100) <= chance; 11 | 12 | const waitFor = (timeout: number): Promise => 13 | new Promise(resolve => { 14 | setTimeout(resolve, timeout); 15 | }); 16 | 17 | function reduceAsync( 18 | arr: T[], 19 | fn: (result: R, curr: T, index: number) => R, 20 | waitForSeconds: number, 21 | startValue: R | undefined = undefined, 22 | ): Promise { 23 | return Promise.resolve( 24 | arr.reduce(async (prev: Promise, curr: T, index: number) => { 25 | const result = await prev; 26 | await waitFor(getMilisecondsFromSeconds(waitForSeconds)); 27 | return Promise.resolve(fn(result, curr, index)); 28 | }, Promise.resolve(startValue)), 29 | ); 30 | } 31 | 32 | export { 33 | getRandomItem, 34 | getMilisecondsFromSeconds, 35 | probability, 36 | waitFor, 37 | reduceAsync, 38 | getRandomNumber, 39 | }; 40 | -------------------------------------------------------------------------------- /src/common/browser/api/authenticate.ts: -------------------------------------------------------------------------------- 1 | import { Authenticate } from 'src/common/interfaces'; 2 | 3 | const authenticate: Authenticate = function authenticate({ username, password }) { 4 | return this.getPage('/accounts/login', async page => { 5 | await page.waitForSelector('input[name="username"]'); 6 | 7 | const usernameInput = await page.$('input[name="username"]'); 8 | const passwordInput = await page.$('input[name="password"]'); 9 | 10 | await usernameInput.type(username, { delay: 100 }); 11 | await passwordInput.type(password, { delay: 100 }); 12 | 13 | const logInButtonSelector = await page.evaluate(() => { 14 | const { scraper } = window as any; 15 | 16 | const logInButton = scraper.findOneWithText({ 17 | selector: 'button', 18 | text: 'Log in', 19 | }); 20 | 21 | if (!logInButton) { 22 | return ''; 23 | } 24 | 25 | return logInButton 26 | .setscraperAttr('logInButton', 'logInButton') 27 | .getSelectorByscraperAttr('logInButton'); 28 | }); 29 | 30 | if (!logInButtonSelector) { 31 | throw new Error('Failed to auth'); 32 | } 33 | 34 | const logInButton = await page.$(logInButtonSelector); 35 | 36 | await logInButton.click(); 37 | 38 | await page.waitFor(2000); 39 | }); 40 | }; 41 | 42 | export { authenticate }; 43 | -------------------------------------------------------------------------------- /src/common/browser/api/get-following.ts: -------------------------------------------------------------------------------- 1 | import { GetFollowing } from 'src/common/interfaces'; 2 | 3 | const getFollowing: GetFollowing = async function getFollowing(username) { 4 | return this.getPage(`/${username}`, async (page) => { 5 | let buttonOfFollowingList = await page.$(`a[href="/${username}/following/"]`); 6 | 7 | if (!buttonOfFollowingList) { 8 | const buttonOfFollowingListSelector = await page.evaluate(() => { 9 | const { scraper } = window as any; 10 | const el = scraper.findOne({ 11 | selector: 'a', 12 | where: el => el.text().includes('following'), 13 | }); 14 | 15 | if (!el) { 16 | return ''; 17 | } 18 | 19 | return el 20 | .setscraperAttr('buttonOfFollowingList', 'buttonOfFollowingList') 21 | .getSelectorByscraperAttr('buttonOfFollowingList'); 22 | }); 23 | 24 | if (buttonOfFollowingListSelector) { 25 | buttonOfFollowingList = await page.$(buttonOfFollowingListSelector); 26 | } 27 | } 28 | 29 | await buttonOfFollowingList.click(); 30 | await page.waitFor(1000); 31 | 32 | const following = await page.evaluate(async () => { 33 | const { scraper } = window as any; 34 | 35 | return scraper 36 | .find({ 37 | selector: 'a[title].notranslate', 38 | }) 39 | .slice(0, 20) 40 | .map(el => el.getAttr('title')); 41 | }); 42 | 43 | return following; 44 | }); 45 | }; 46 | 47 | export { getFollowing }; 48 | -------------------------------------------------------------------------------- /src/common/browser/api/unfollow-user.ts: -------------------------------------------------------------------------------- 1 | import { UnFollowUser } from 'src/common/interfaces'; 2 | 3 | const unfollowUser: UnFollowUser = async function unfollowUser(username) { 4 | return this.getPage(`/${username}`, async (page) => { 5 | const unfollowButtonSelector = await page.evaluate(() => { 6 | const { scraper } = window as any; 7 | let el = scraper.findOneWithText({ 8 | selector: 'button', 9 | text: 'Following', 10 | }); 11 | 12 | if (!el) { 13 | el = scraper.findOne({ 14 | selector: 'button._qv64e._t78yp._r9b8f._njrw0', 15 | }); 16 | } 17 | 18 | if (!el) { 19 | return ''; 20 | } 21 | 22 | return el 23 | .setscraperAttr('following', 'following') 24 | .getSelectorByscraperAttr('following'); 25 | }); 26 | 27 | if (!unfollowButtonSelector) { 28 | return null; 29 | } 30 | 31 | const unfollowButton = await page.$(unfollowButtonSelector); 32 | await unfollowButton.click(); 33 | 34 | const confirmUnfollowButtonSelector = await page.evaluate(() => { 35 | const { scraper } = window as any; 36 | 37 | return scraper 38 | .findOneWithText({ selector: 'button', text: 'Unfollow' }) 39 | .setscraperAttr('confirmUnfollowButton', 'confirmUnfollowButton') 40 | .getSelectorByscraperAttr('confirmUnfollowButton'); 41 | }); 42 | 43 | (await page.$(confirmUnfollowButtonSelector)).click(); 44 | 45 | await page.waitFor(1500); 46 | }); 47 | }; 48 | 49 | export { unfollowUser }; 50 | -------------------------------------------------------------------------------- /src/common/browser/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as puppeteer from 'puppeteer'; 3 | import * as api from './api'; 4 | import { GetPage, CloseBrowser, CreateBrowser } from 'src/common/interfaces'; 5 | 6 | const getUrl = (endpoint: string): string => `https://www.instagram.com${endpoint}`; 7 | 8 | const createBrowser: CreateBrowser = async () => { 9 | let browser = await puppeteer.launch({ 10 | headless: true, 11 | args: ['--lang=en-US,en'], 12 | }); 13 | 14 | const getPage: GetPage = async function getPage(endpoint, fn) { 15 | let page: puppeteer.Page; 16 | let result; 17 | 18 | try { 19 | const url = getUrl(endpoint); 20 | console.log(url); 21 | page = await browser.newPage(); 22 | 23 | await page.goto(url, { waitUntil: 'load' }); 24 | 25 | page.on('console', msg => { 26 | const leng = msg.args().length; 27 | for (let i = 0; i < leng; i += 1) { 28 | console.log(`${i}: ${msg.args()[i]}`); 29 | } 30 | }); 31 | 32 | await page.addScriptTag({ 33 | path: path.join(__dirname, '../../../src/common/scraper/scraper.js'), 34 | }); 35 | 36 | result = await fn(page); 37 | await page.close(); 38 | } catch (e) { 39 | if (page) { 40 | await page.close(); 41 | } 42 | 43 | throw e; 44 | } 45 | 46 | return result; 47 | }; 48 | 49 | const close: CloseBrowser = async function close() { 50 | await browser.close(); 51 | browser = null; 52 | }; 53 | 54 | return { 55 | getPage, 56 | close, 57 | ...api, 58 | }; 59 | }; 60 | 61 | export { createBrowser }; 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "instagrambot", 3 | "version": "1.0.0", 4 | "description": "Instagram bot", 5 | "main": "build/index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/maciejcieslar/instagrambot.git" 9 | }, 10 | "keywords": [], 11 | "author": "maciejcieslar", 12 | "license": "MIT", 13 | "bugs": { 14 | "url": "https://github.com/maciejcieslar/instagrambot/issues" 15 | }, 16 | "homepage": "https://github.com/maciejcieslar/instagrambot#readme", 17 | "scripts": { 18 | "start": "tsc && node ./build/index.js", 19 | "start-dev": "nodemon -e ts --exec \"npm run dev\"", 20 | "dev": "rm -rf \"./build\" && killall Chromium || echo \"No chromium instances killed\" && npm run start" 21 | }, 22 | "_moduleAliases": { 23 | "src": "./build" 24 | }, 25 | "devDependencies": { 26 | "@types/bluebird": "^3.5.20", 27 | "@types/dotenv": "^4.0.2", 28 | "@types/lodash": "^4.14.104", 29 | "@types/node-emoji": "^1.8.0", 30 | "@types/node-schedule": "^1.2.2", 31 | "@types/node-wit": "^4.2.2", 32 | "@types/numeral": "0.0.22", 33 | "@types/puppeteer": "^1.5.0", 34 | "eslint": "^4.18.2", 35 | "eslint-config-airbnb-base": "^13.0.0", 36 | "eslint-plugin-import": "^2.13.0", 37 | "module-alias": "^2.0.6", 38 | "node-emoji": "^1.8.1", 39 | "nodemon": "^1.17.5", 40 | "tslint": "^5.9.1", 41 | "tslint-config-airbnb": "^5.7.0", 42 | "typescript": "^2.9.2" 43 | }, 44 | "dependencies": { 45 | "bluebird": "^3.5.1", 46 | "dotenv": "^5.0.1", 47 | "lodash": "^4.17.5", 48 | "moment": "^2.21.0", 49 | "node-schedule": "^1.3.0", 50 | "node-wit": "^5.0.0", 51 | "numeral": "^2.0.6", 52 | "puppeteer": "^1.5.0" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/common/wit/index.ts: -------------------------------------------------------------------------------- 1 | import { Wit } from 'node-wit'; 2 | import * as _ from 'lodash'; 3 | import * as emoji from 'node-emoji'; 4 | import { Intent } from 'src/common/interfaces'; 5 | import { wit as witConfig, comments as commentsConfig } from 'src/config'; 6 | import { getRandomItem } from 'src/common/utils'; 7 | 8 | const client = new Wit(_.pick(witConfig, ['accessToken'])); 9 | 10 | const getIntentFromMessage = async (message: string, context: any = {}): Promise => { 11 | if (!message || message.length > 280) { 12 | return { 13 | confidence: 0, 14 | value: '', 15 | }; 16 | } 17 | 18 | const result = await client.message(message, context); 19 | 20 | return _.get(result, ['entities', 'intent', '0']); 21 | }; 22 | 23 | const intentToCategory = { 24 | happy_description: 'happy', 25 | sad_description: 'sad', 26 | }; 27 | 28 | const emojis = { 29 | happy: [ 30 | 'smiley', 31 | 'smirk', 32 | 'pray', 33 | 'rocket', 34 | 'kissing_closed_eyes', 35 | 'sunglasses', 36 | 'heart_eyes', 37 | 'joy', 38 | 'relieved', 39 | 'wink', 40 | 'innocent', 41 | 'smiling_imp', 42 | 'sweat_smile', 43 | 'blush', 44 | 'yum', 45 | 'triumph', 46 | ], 47 | sad: ['cry', 'sob', 'cold_sweat', 'disappointed', 'worried'], 48 | }; 49 | 50 | const getRandomEmoji = (type: string) => { 51 | const emojisForType: string[] = emojis[type]; 52 | 53 | if (!emojisForType) { 54 | return ''; 55 | } 56 | 57 | const emojiName = getRandomItem(emojisForType); 58 | 59 | return emoji.get(emojiName); 60 | }; 61 | 62 | const getRandomEmojis = (count: number, type: string) => 63 | new Array(count) 64 | .fill(type) 65 | .map(getRandomEmoji) 66 | .join(''); 67 | 68 | const shouldPostComment = (intent: Intent): boolean => 69 | intent.confidence >= witConfig.expectedConfidence; 70 | 71 | const generateComment = (category: string): string => { 72 | // const randomEmojis = getRandomEmojis(getRandomNumber(0, 2), category); 73 | const randomEmojis = ''; 74 | 75 | return [getRandomItem(commentsConfig[category]), randomEmojis].join(' '); 76 | }; 77 | 78 | const getMessageBasedOnIntent = (intent: Intent): string => { 79 | const category: string = intentToCategory[intent.value]; 80 | 81 | if (!category || !shouldPostComment(intent)) { 82 | return ''; 83 | } 84 | 85 | return generateComment(category); 86 | }; 87 | 88 | export { client, getIntentFromMessage, getMessageBasedOnIntent, shouldPostComment }; 89 | -------------------------------------------------------------------------------- /src/common/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | import * as puppeteer from 'puppeteer'; 2 | 3 | interface Post { 4 | likes: number; 5 | comments: string[]; 6 | isLiked: boolean; 7 | isFollowed: boolean; 8 | likeSelector: string; 9 | followSelector: string; 10 | unlikeSelector: string; 11 | unfollowSelector: string; 12 | commentSelector: string; 13 | commentButtonSelector: string; 14 | description: string; 15 | author: string; 16 | postIntent: Intent; 17 | } 18 | 19 | interface UserInfo { 20 | username: string; 21 | following: number; 22 | followers: number; 23 | posts: number; 24 | isFollowed: boolean; 25 | } 26 | 27 | interface UserCredentials { 28 | username: string; 29 | password: string; 30 | } 31 | 32 | interface Browser { 33 | getPage: GetPage; 34 | close: CloseBrowser; 35 | getUserInfo: GetUserInfo; 36 | getFollowing: GetFollowing; 37 | findPosts: FindPosts; 38 | commentPost: CommentPost; 39 | followPost: FollowPost; 40 | likePost: LikePost; 41 | getPostInfo: GetPostInfo; 42 | unfollowUser: UnFollowUser; 43 | authenticate: Authenticate; 44 | } 45 | 46 | interface Intent { 47 | confidence: number; 48 | value: string; 49 | } 50 | 51 | type GetPage = (url: string, callback: (page: puppeteer.Page) => Promise) => any; 52 | 53 | type CloseBrowser = () => Promise; 54 | 55 | type GetUserInfo = (this: Browser, username: string) => Promise; 56 | 57 | type GetFollowing = (this: Browser, username: string) => Promise; 58 | 59 | type FindPosts = (this: Browser, hashtag: string, numberOfPosts: number) => Promise; 60 | 61 | type CommentPost = ( 62 | this: Browser, 63 | page: puppeteer.Page, 64 | post: Post, 65 | message: string, 66 | ) => Promise; 67 | 68 | type FollowPost = (this: Browser, page: puppeteer.Page, post: Post) => Promise; 69 | 70 | type LikePost = (this: Browser, page: puppeteer.Page, post: Post) => Promise; 71 | 72 | type GetPostInfo = (this: Browser, page: puppeteer.Page) => Promise; 73 | 74 | type UnFollowUser = (this: Browser, username: string) => Promise; 75 | 76 | type Authenticate = (this: Browser, credentials: UserCredentials) => Promise; 77 | 78 | type CreateBrowser = () => Promise; 79 | 80 | export { 81 | Post, 82 | UserInfo, 83 | UserCredentials, 84 | Browser, 85 | Intent, 86 | GetPage, 87 | CloseBrowser, 88 | GetUserInfo, 89 | GetFollowing, 90 | FindPosts, 91 | CommentPost, 92 | FollowPost, 93 | LikePost, 94 | GetPostInfo, 95 | UnFollowUser, 96 | Authenticate, 97 | CreateBrowser, 98 | }; 99 | -------------------------------------------------------------------------------- /src/common/scheduler/jobs.ts: -------------------------------------------------------------------------------- 1 | import { Browser } from 'src/common/interfaces'; 2 | import { reduceAsync, getRandomItem, probability } from 'src/common/utils'; 3 | import { job as jobConfig, auth as authConfig } from 'src/config'; 4 | import { getMessageBasedOnIntent } from 'src/common/wit'; 5 | 6 | class Job { 7 | private ranges: number[][] = []; 8 | 9 | public execute(browser: Browser) { 10 | throw new Error('Execute must be provided.'); 11 | } 12 | 13 | public constructor(ranges: number[][]) { 14 | this.ranges = ranges; 15 | } 16 | 17 | public validateRanges(hour: number): boolean { 18 | return Boolean( 19 | this.ranges.find( 20 | (range): boolean => { 21 | const [startHour, endHour] = range; 22 | 23 | return hour >= startHour && hour <= endHour; 24 | }, 25 | ), 26 | ); 27 | } 28 | } 29 | 30 | class FollowJob extends Job { 31 | public async execute(browser: Browser) { 32 | try { 33 | const hashtag = getRandomItem(jobConfig.hashtags); 34 | const postsUrls = await browser.findPosts(hashtag, jobConfig.numberOfPosts); 35 | 36 | await reduceAsync( 37 | postsUrls, 38 | async (prev, url) => 39 | browser.getPage(url, async page => { 40 | try { 41 | const post = await browser.getPostInfo(page); 42 | 43 | await browser.likePost(page, post); 44 | console.log('liked'); 45 | 46 | await browser.followPost(page, post); 47 | console.log('followed'); 48 | 49 | const message = getMessageBasedOnIntent(post.postIntent); 50 | 51 | if (probability(jobConfig.commentProbability) && message) { 52 | await browser.commentPost(page, post, message); 53 | console.log('commented'); 54 | } 55 | } catch (e) { 56 | console.log('Failed to like/follow/comment.'); 57 | console.log(e); 58 | } 59 | }), 60 | 35, 61 | ); 62 | console.log('FollowJob executed successfully.'); 63 | } catch (e) { 64 | console.log('FollowJob failed to execute.'); 65 | console.log(e); 66 | } 67 | } 68 | } 69 | 70 | class UnfollowJob extends Job { 71 | public async execute(browser: Browser) { 72 | try { 73 | const following = await browser.getFollowing(authConfig.username); 74 | 75 | await reduceAsync( 76 | following, 77 | async (result, username) => { 78 | await browser.unfollowUser(username); 79 | }, 80 | 35, 81 | ); 82 | 83 | console.log('UnfollowJob executed successfully'); 84 | } catch (e) { 85 | console.log('UnfollowJob failed to execute.'); 86 | console.log(e); 87 | } 88 | } 89 | } 90 | 91 | const jobs = { 92 | UnfollowJob, 93 | FollowJob, 94 | }; 95 | 96 | export { Job, jobs }; 97 | -------------------------------------------------------------------------------- /src/common/browser/api/get-post-info.ts: -------------------------------------------------------------------------------- 1 | import * as numeral from 'numeral'; 2 | import { GetPostInfo } from 'src/common/interfaces'; 3 | import { getIntentFromMessage } from 'src/common/wit'; 4 | 5 | const getPostInfo: GetPostInfo = async function getPostInfo(page) { 6 | const { likeSelector = '', isLiked = false, unlikeSelector = '' } = 7 | (await page.evaluate(() => { 8 | const { scraper } = window as any; 9 | 10 | const likeSpan = scraper.findOne({ 11 | selector: 'span', 12 | where: (el) => el.html() === 'Like', 13 | }); 14 | 15 | if (!likeSpan) { 16 | const unlikeSpan = scraper.findOne({ 17 | selector: 'span', 18 | where: (el) => el.html() === 'Unlike', 19 | }); 20 | const unlikeSelector = unlikeSpan 21 | .parent() 22 | .setscraperAttr('unlikeHeart', 'unlikeHeart') 23 | .getSelectorByscraperAttr('unlikeHeart'); 24 | 25 | return { 26 | unlikeSelector, 27 | isLiked: true, 28 | likeSelector: unlikeSelector, 29 | }; 30 | } 31 | 32 | const likeSelector = likeSpan 33 | .parent() 34 | .setscraperAttr('likeHeart', 'likeHeart') 35 | .getSelectorByscraperAttr('likeHeart'); 36 | 37 | return { 38 | likeSelector, 39 | isLiked: false, 40 | unlikeSelector: likeSelector, 41 | }; 42 | })) || {}; 43 | 44 | const { followSelector = '', isFollowed = false, unfollowSelector = '' } = 45 | (await page.evaluate(() => { 46 | const { scraper } = window as any; 47 | 48 | const followButton = scraper.findOneWithText({ 49 | selector: 'button', 50 | text: 'Follow', 51 | }); 52 | 53 | if (!followButton) { 54 | const unfollowButton = scraper.findOneWithText({ 55 | selector: 'button', 56 | text: 'Following', 57 | }); 58 | /** 59 | * @author Rosario Gueli 60 | * @description This hotfix fixes the problem had where running this code on a page which is owned by the 61 | * currenly logged in user, for example to like or comment this page. Since it's our page, we 62 | * can't find a follow/unfollow button here, so the following code was generating an error and 63 | * stopped the execution of the script. Now, the below code has been made conditional based on 64 | * the unfollowButton selector above. If it doesn't find the follow/unfollow button, ignore it. 65 | */ 66 | const unfollowSelector = unfollowButton ? unfollowButton 67 | .setscraperAttr('unfollowButton', 'unfollowButton') 68 | .getSelectorByscraperAttr('unfollowButton') : ''; 69 | 70 | return { 71 | unfollowSelector, 72 | isFollowed: true, 73 | followSelector: unfollowSelector, 74 | }; 75 | } 76 | 77 | const followSelector = followButton 78 | .setscraperAttr('followButton', 'followButton') 79 | .getSelectorByscraperAttr('followButton'); 80 | 81 | return { 82 | followSelector, 83 | isFollowed: false, 84 | unfollowSelector: followSelector, 85 | }; 86 | })) || {}; 87 | 88 | const likes = numeral( 89 | await page.evaluate(() => { 90 | const { scraper } = window as any; 91 | const el = scraper.findOne({ selector: 'a[href$="/liked_by/"] > span' }); 92 | 93 | if (!el) { 94 | return 0; 95 | } 96 | 97 | return el.text(); 98 | }), 99 | ).value(); 100 | 101 | const { description = '', comments = [] } = await page.evaluate(() => { 102 | const { scraper } = window as any; 103 | const comments = scraper 104 | .find({ selector: 'div > ul > li > span' }) 105 | .map((el) => el.text()); 106 | 107 | return { 108 | description: comments[0], 109 | comments: comments.slice(1), 110 | }; 111 | }); 112 | 113 | const author = await page.evaluate(() => { 114 | const { scraper } = window as any; 115 | 116 | return scraper.findOne({ selector: 'a[title].notranslate' }).text(); 117 | }); 118 | 119 | const commentButtonSelector = await page.evaluate(() => { 120 | const { scraper } = window as any; 121 | 122 | return scraper 123 | .findOneWithText({ selector: 'span', text: 'Comment' }) 124 | .parent() 125 | .setscraperAttr('comment', 'comment') 126 | .getSelectorByscraperAttr('comment'); 127 | }); 128 | 129 | const commentSelector = 'textarea[autocorrect="off"]'; 130 | 131 | const postIntent = await getIntentFromMessage(description); 132 | 133 | return { 134 | likeSelector, 135 | unlikeSelector, 136 | isLiked, 137 | isFollowed, 138 | followSelector, 139 | unfollowSelector, 140 | likes, 141 | comments, 142 | description, 143 | author, 144 | commentSelector, 145 | commentButtonSelector, 146 | postIntent, 147 | }; 148 | }; 149 | 150 | export { getPostInfo }; 151 | -------------------------------------------------------------------------------- /src/common/scraper/scraper.js: -------------------------------------------------------------------------------- 1 | const scraper = ((window, document) => { 2 | const capitalize = str => str.slice(0, 1).toUpperCase() + str.slice(1); 3 | 4 | const waitFor = ms => new Promise(resolve => setTimeout(() => { 5 | resolve(); 6 | }, ms)); 7 | 8 | const getFormattedDataName = key => `scraper${capitalize(key)}`; 9 | 10 | const getDataAttrName = (key) => { 11 | const reg = /[A-Z]{1}/g; 12 | const name = key.replace(reg, match => `-${match[0].toLowerCase()}`); 13 | 14 | return `data-${name}`; 15 | }; 16 | 17 | // in dataset like { scraperXYZ: 123 } 18 | // in query 'div[data-scraper-x-y-z="123"]'; 19 | 20 | class Element { 21 | constructor(el) { 22 | if (!(el instanceof HTMLElement)) { 23 | throw new TypeError('Element must be instance of HTMLElement.'); 24 | } 25 | 26 | this.el = el; 27 | } 28 | 29 | /** 30 | * @author Rosario Gueli 31 | * @description IG has updated some of the elements structure, for example when using the comment function 32 | * the scraper could not find the button by its internal text, throwing the error: 33 | * "Error: Evaluation failed: TypeError: Cannot read property 'parent' of null" 34 | * This is because IG have placed the text of this button inside the aria-label attribute of the span, 35 | * the below hotfix is used in those areas where if text value was not found, gives this function 36 | * second chance and try the element arial-label attibute: 37 | * XPath Example to find the Comment button: span[contains(@aria-label, "Comment")] 38 | * 39 | */ 40 | aria_label(){ 41 | return String(this.getAttr('aria-label')).trim(); 42 | } 43 | 44 | text() { 45 | let res = String(this.el.textContent).trim(); 46 | 47 | if(!res){ 48 | res = this.aria_label(); 49 | } 50 | 51 | return res; 52 | } 53 | 54 | get() { 55 | return this.el; 56 | } 57 | 58 | html() { 59 | let res = this.el.innerHTML; 60 | 61 | if(!res){ 62 | res = this.aria_label(); 63 | } 64 | 65 | return res; 66 | } 67 | 68 | dimensions() { 69 | return JSON.parse(JSON.stringify(this.el.getBoundingClientRect())); 70 | } 71 | 72 | clone() { 73 | return new Element(this.el.cloneNode(true)); 74 | } 75 | 76 | scrollIntoView() { 77 | this.el.scrollIntoView(true); 78 | return this.el; 79 | } 80 | 81 | setAttr(key, value) { 82 | this.el.setAttribute(key, value); 83 | return this; 84 | } 85 | 86 | getAttr(key) { 87 | return this.el.getAttribute(key); 88 | } 89 | 90 | getTag() { 91 | return this.el.tagName.toLowerCase(); 92 | } 93 | 94 | setscraperAttr(key, value) { 95 | const scraperKey = getFormattedDataName(key); 96 | this.el.dataset[scraperKey] = value; 97 | return this; 98 | } 99 | 100 | parent() { 101 | return new Element(this.el.parentNode); 102 | } 103 | 104 | setClass(className) { 105 | this.el.classList.add(className); 106 | return this; 107 | } 108 | 109 | getscraperAttr(key) { 110 | return this.el.dataset[getFormattedDataName(key)]; 111 | } 112 | 113 | getSelectorByscraperAttr(key) { 114 | const scraperValue = this.getscraperAttr(key); 115 | const scraperKey = getDataAttrName(getFormattedDataName(key)); 116 | const tagName = this.getTag(); 117 | 118 | return `${tagName}[${scraperKey}="${scraperValue}"]`; 119 | } 120 | } 121 | 122 | const find = ({ selector, where = () => true, count }) => { 123 | if (count === 0) { 124 | return []; 125 | } 126 | 127 | const elements = Array.from(document.querySelectorAll(selector)); 128 | 129 | let sliceArgs = [0, count || elements.length]; 130 | 131 | if (count < 0) { 132 | sliceArgs = [count]; 133 | } 134 | 135 | return elements 136 | .map(el => new Element(el)) 137 | .filter(where) 138 | .slice(...sliceArgs); 139 | }; 140 | 141 | const findOne = ({ selector, where }) => find({ selector, where, count: 1 })[0] || null; 142 | 143 | const getDocumentDimensions = () => { 144 | const height = Math.max( 145 | document.documentElement.clientHeight, 146 | document.body.scrollHeight, 147 | document.documentElement.scrollHeight, 148 | document.body.offsetHeight, 149 | document.documentElement.offsetHeight, 150 | ); 151 | 152 | const width = Math.max( 153 | document.documentElement.clientWidth, 154 | document.body.scrollWidth, 155 | document.documentElement.scrollWidth, 156 | document.body.offsetWidth, 157 | document.documentElement.offsetWidth, 158 | ); 159 | 160 | return { 161 | height, 162 | width, 163 | }; 164 | }; 165 | 166 | const scrollToPosition = { 167 | top: () => { 168 | window.scrollTo(0, 0); 169 | return true; 170 | }, 171 | bottom: () => { 172 | const { height } = getDocumentDimensions(); 173 | 174 | window.scrollTo(0, height); 175 | return true; 176 | }, 177 | }; 178 | 179 | const scrollPageTimes = async ({ times = 0, direction = 'bottom' }) => { 180 | if (!times || times < 1) { 181 | return true; 182 | } 183 | 184 | await new Array(times).fill(0).reduce(async (prev) => { 185 | await prev; 186 | scrollToPosition[direction](); 187 | await waitFor(2000); 188 | }, Promise.resolve()); 189 | 190 | return true; 191 | }; 192 | 193 | const findOneWithText = ({ selector, text }) => { 194 | const lowerText = text.toLowerCase(); 195 | 196 | return findOne({ 197 | selector, 198 | where: (el) => { 199 | const textContent = el.text().toLowerCase(); 200 | 201 | return lowerText === textContent; 202 | }, 203 | }); 204 | }; 205 | 206 | return { 207 | Element, 208 | findOneWithText, 209 | scrollPageTimes, 210 | scrollToPosition, 211 | getDocumentDimensions, 212 | find, 213 | findOne, 214 | waitFor, 215 | }; 216 | })(window, document); 217 | 218 | window.scraper = scraper; 219 | --------------------------------------------------------------------------------