| Philipp Le | 30eb613 | 2022-07-04 12:59:37 +0200 | [diff] [blame^] | 1 | /* |
| 2 | * SPDX-License-Identifier: MPL-2.0 |
| 3 | * Copyright (c) 2022 Philipp Le <philipp@philipple.de>. |
| 4 | * This Source Code Form is subject to the terms of the Mozilla Public |
| 5 | * License, v. 2.0. If a copy of the MPL was not distributed with this |
| 6 | * file, You can obtain one at https://mozilla.org/MPL/2.0/. |
| 7 | */ |
| 8 | |
| 9 | import { IgApiClient, IgLoginTwoFactorRequiredError } from 'instagram-private-api'; |
| 10 | import Bluebird = require("bluebird"); |
| 11 | import inquirer = require ("inquirer"); |
| 12 | import date = require("date-and-time"); |
| 13 | import fs = require("fs"); |
| 14 | import path = require("path"); |
| 15 | import https = require("https"); |
| 16 | |
| 17 | export class MediaItem { |
| 18 | id: string = ""; |
| 19 | taken_at: Date = new Date(); |
| 20 | date_str: string = ""; |
| 21 | url: string = ""; |
| 22 | filename: string = ""; |
| 23 | |
| 24 | constructor(id: string, taken_at: number, url: string) { |
| 25 | this.id = id; |
| 26 | this.taken_at = new Date(taken_at * 1000); |
| 27 | this.date_str = date.format(this.taken_at, "YYYYMMDD"); |
| 28 | this.url = url; |
| 29 | const filename = url.split('#').shift()?.split('?').shift()?.split('/').pop(); |
| 30 | if (filename) |
| 31 | this.filename = filename; |
| 32 | else |
| 33 | this.filename = ""; |
| 34 | } |
| 35 | |
| 36 | public async fetch(dest_dir: string): Promise<void> { |
| 37 | const dest_path = path.join(dest_dir, this.filename); |
| 38 | console.log(`Downloading ${this.url} => ${dest_path}`); |
| 39 | const file = fs.createWriteStream(dest_path); |
| 40 | await new Promise((resolve) => { |
| 41 | https.get(this.url, (response) => { |
| 42 | response.pipe(file); |
| 43 | file.on("finish", () => { |
| 44 | file.close(); |
| 45 | resolve(response); |
| 46 | }); |
| 47 | }) |
| 48 | }); |
| 49 | console.log(`Completed downloading ${this.url} => ${dest_path}`); |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | export class UserStory extends MediaItem { |
| 54 | constructor(id: string, taken_at: number, url: string) { |
| 55 | super(id, taken_at, url); |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | export class StoryCollection { |
| 60 | users: Map<string, UserStory[]> = new Map<string, UserStory[]>(); |
| 61 | |
| 62 | public length(): number { |
| 63 | let cntr = 0; |
| 64 | this.users.forEach(() => cntr++); |
| 65 | return cntr; |
| 66 | } |
| 67 | |
| 68 | public async download(base_dir: string): Promise<void> { |
| 69 | await Bluebird.map(this.users.entries(), async (entry) => { |
| 70 | const user = entry[0]; |
| 71 | const story_list = entry[1]; |
| 72 | |
| 73 | const user_dir = path.join(base_dir, user); |
| 74 | await this.mkdir(user_dir); |
| 75 | |
| 76 | await Bluebird.map(story_list, async (story) => { |
| 77 | const story_dir = path.join(user_dir, story.date_str); |
| 78 | await this.mkdir(story_dir); |
| 79 | await story.fetch(story_dir); |
| 80 | }); |
| 81 | |
| 82 | await this.dump_story_json(user_dir, story_list); |
| 83 | }); |
| 84 | } |
| 85 | |
| 86 | private async mkdir(dir: string): Promise<void> { |
| 87 | await new Promise((resolve) => { |
| 88 | fs.mkdir(dir, {recursive: true}, (err) => { |
| 89 | if (err) |
| 90 | console.error(err) |
| 91 | else |
| 92 | console.log(`Created directory ${dir}`) |
| 93 | resolve(null); |
| 94 | }); |
| 95 | }); |
| 96 | } |
| 97 | |
| 98 | private async dump_story_json(dest_dir: string, stories: UserStory[]): Promise<void> { |
| 99 | const d = date.format(new Date, "YYYY-MM-DDTHHmmssZ", true); |
| 100 | const dest_path = path.join(dest_dir, `info_${d}.json`); |
| 101 | await new Promise((resolve) => { |
| 102 | fs.writeFile(dest_path, JSON.stringify(stories), (err) => { |
| 103 | if (err) |
| 104 | console.error(err) |
| 105 | else |
| 106 | console.log(`Written info to ${dest_path}`) |
| 107 | resolve(null); |
| 108 | }); |
| 109 | }); |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | export class Client |
| 114 | { |
| 115 | ig = new IgApiClient(); |
| 116 | |
| 117 | constructor(seed?: string) { |
| 118 | if (seed === undefined) |
| 119 | seed = `ig-story-dl-${date.format(new Date(), "YYYYMMDDHH")}` |
| 120 | this.ig.state.generateDevice(seed); |
| 121 | } |
| 122 | |
| 123 | public async login(username: string) { |
| 124 | await this.ig.simulate.preLoginFlow(); |
| 125 | |
| 126 | const { passwd } = await inquirer.prompt([ |
| 127 | { |
| 128 | type: 'password', |
| 129 | name: 'passwd', |
| 130 | message: `Enter password for ${username}`, |
| 131 | }, |
| 132 | ]); |
| 133 | |
| 134 | await Bluebird.try(() => this.ig.account.login(username, passwd)).catch( |
| 135 | IgLoginTwoFactorRequiredError, |
| 136 | async err => { |
| 137 | const {username, totp_two_factor_on, two_factor_identifier} = err.response.body.two_factor_info; |
| 138 | const verificationMethod: string = totp_two_factor_on ? '0' : '0'; |
| 139 | const { code } = await inquirer.prompt([ |
| 140 | { |
| 141 | type: 'input', |
| 142 | name: 'code', |
| 143 | message: `Enter code received via ${verificationMethod === '1' ? 'SMS' : 'TOTP'}`, |
| 144 | }, |
| 145 | ]); |
| 146 | return this.ig.account.twoFactorLogin({ |
| 147 | username, |
| 148 | verificationCode: code, |
| 149 | twoFactorIdentifier: two_factor_identifier, |
| 150 | verificationMethod, // '1' = SMS (default), '0' = TOTP (google auth for example) |
| 151 | trustThisDevice: '0', |
| 152 | }); |
| 153 | }, |
| 154 | ).catch(e => console.error('An error occurred while processing two factor auth', e, e.stack)); |
| 155 | |
| 156 | console.log(`Logged in as ${username}`); |
| 157 | } |
| 158 | |
| 159 | public async logout() { |
| 160 | await this.ig.account.logout(); |
| 161 | console.log(`Logged out`); |
| 162 | } |
| 163 | |
| 164 | public async get_stories(users: string[]): Promise<StoryCollection> { |
| 165 | let stories = new StoryCollection(); |
| 166 | |
| 167 | await Bluebird.map(users, async (user) => { |
| 168 | let user_stories: UserStory[] = []; |
| 169 | |
| 170 | const target_user = await this.ig.user.searchExact(user); |
| 171 | const reels_feed = this.ig.feed.reelsMedia({ |
| 172 | userIds: [target_user.pk] |
| 173 | }); |
| 174 | console.info(`Fetch stories for ${target_user.pk} (${target_user.username})`) |
| 175 | const story_items = await reels_feed.items(); |
| 176 | story_items.forEach((item) => { |
| 177 | console.info(`${target_user.pk} (${target_user.username}) story item: ${item.id}`) |
| 178 | if (item.image_versions2) { |
| 179 | item.image_versions2.candidates.forEach((pic) => { |
| 180 | user_stories.push( |
| 181 | new UserStory( |
| 182 | item.id, |
| 183 | item.taken_at, |
| 184 | pic.url |
| 185 | ) |
| 186 | ); |
| 187 | console.info(`${target_user.pk} (${target_user.username})->${item.id}: ${pic.url}`) |
| 188 | }); |
| 189 | } |
| 190 | if (item.video_versions) { |
| 191 | item.video_versions.forEach((vid) => { |
| 192 | user_stories.push( |
| 193 | new UserStory( |
| 194 | item.id, |
| 195 | item.taken_at, |
| 196 | vid.url |
| 197 | ) |
| 198 | ); |
| 199 | console.info(`${target_user.pk} (${target_user.username})->${item.id}: ${vid.url}`) |
| 200 | }); |
| 201 | } |
| 202 | }); |
| 203 | |
| 204 | stories.users.set(user, user_stories); |
| 205 | }); |
| 206 | |
| 207 | return stories; |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | export function json_replacer(key: any, value: any) { |
| 212 | if (value instanceof Map) { |
| 213 | return Array.from(value.entries()); |
| 214 | } else { |
| 215 | return value; |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | export class Sleeper { |
| 220 | running: boolean = false; |
| 221 | |
| 222 | poll_interval: number = 1000; |
| 223 | |
| 224 | constructor(poll_interval: number = 1000) { |
| 225 | this.poll_interval = poll_interval; |
| 226 | } |
| 227 | |
| 228 | public async sleep_ms(ms: number): Promise<void> { |
| 229 | this.running = true; |
| 230 | for (let remaining_ms = ms; (remaining_ms > 0) && this.running; remaining_ms -= this.poll_interval) { |
| 231 | await new Promise((resolve) => { |
| 232 | setTimeout(resolve, (remaining_ms > this.poll_interval) ? this.poll_interval : remaining_ms) |
| 233 | }); |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | public cancel() { |
| 238 | this.running = false; |
| 239 | } |
| 240 | } |