| /* |
| * SPDX-License-Identifier: MPL-2.0 |
| * Copyright (c) 2022 Philipp Le <philipp@philipple.de>. |
| * This Source Code Form is subject to the terms of the Mozilla Public |
| * License, v. 2.0. If a copy of the MPL was not distributed with this |
| * file, You can obtain one at https://mozilla.org/MPL/2.0/. |
| */ |
| |
| import { IgApiClient, IgLoginTwoFactorRequiredError } from 'instagram-private-api'; |
| import Bluebird = require("bluebird"); |
| import inquirer = require ("inquirer"); |
| import date = require("date-and-time"); |
| import fs = require("fs"); |
| import path = require("path"); |
| import https = require("https"); |
| |
| export class MediaItem { |
| id: string = ""; |
| taken_at: Date = new Date(); |
| date_str: string = ""; |
| url: string = ""; |
| filename: string = ""; |
| |
| constructor(id: string, taken_at: number, url: string) { |
| this.id = id; |
| this.taken_at = new Date(taken_at * 1000); |
| this.date_str = date.format(this.taken_at, "YYYYMMDD"); |
| this.url = url; |
| const filename = url.split('#').shift()?.split('?').shift()?.split('/').pop(); |
| if (filename) |
| this.filename = filename; |
| else |
| this.filename = ""; |
| } |
| |
| public async fetch(dest_dir: string): Promise<void> { |
| const dest_path = path.join(dest_dir, this.filename); |
| console.log(`Downloading ${this.url} => ${dest_path}`); |
| const file = fs.createWriteStream(dest_path); |
| await new Promise((resolve) => { |
| https.get(this.url, (response) => { |
| response.pipe(file); |
| file.on("finish", () => { |
| file.close(); |
| resolve(response); |
| }); |
| }) |
| }); |
| console.log(`Completed downloading ${this.url} => ${dest_path}`); |
| } |
| } |
| |
| export class UserStory extends MediaItem { |
| constructor(id: string, taken_at: number, url: string) { |
| super(id, taken_at, url); |
| } |
| } |
| |
| export class StoryCollection { |
| users: Map<string, UserStory[]> = new Map<string, UserStory[]>(); |
| |
| public length(): number { |
| let cntr = 0; |
| this.users.forEach(() => cntr++); |
| return cntr; |
| } |
| |
| public async download(base_dir: string): Promise<void> { |
| await Bluebird.map(this.users.entries(), async (entry) => { |
| const user = entry[0]; |
| const story_list = entry[1]; |
| |
| const user_dir = path.join(base_dir, user); |
| await this.mkdir(user_dir); |
| |
| await Bluebird.map(story_list, async (story) => { |
| const story_dir = path.join(user_dir, story.date_str); |
| await this.mkdir(story_dir); |
| await story.fetch(story_dir); |
| }); |
| |
| await this.dump_story_json(user_dir, story_list); |
| }); |
| } |
| |
| private async mkdir(dir: string): Promise<void> { |
| await new Promise((resolve) => { |
| fs.mkdir(dir, {recursive: true}, (err) => { |
| if (err) |
| console.error(err) |
| else |
| console.log(`Created directory ${dir}`) |
| resolve(null); |
| }); |
| }); |
| } |
| |
| private async dump_story_json(dest_dir: string, stories: UserStory[]): Promise<void> { |
| const d = date.format(new Date, "YYYY-MM-DDTHHmmssZ", true); |
| const dest_path = path.join(dest_dir, `info_${d}.json`); |
| await new Promise((resolve) => { |
| fs.writeFile(dest_path, JSON.stringify(stories), (err) => { |
| if (err) |
| console.error(err) |
| else |
| console.log(`Written info to ${dest_path}`) |
| resolve(null); |
| }); |
| }); |
| } |
| } |
| |
| export class Client |
| { |
| ig = new IgApiClient(); |
| |
| constructor(seed?: string) { |
| if (seed === undefined) |
| seed = `ig-story-dl-${date.format(new Date(), "YYYYMMDDHH")}` |
| this.ig.state.generateDevice(seed); |
| } |
| |
| public async login(username: string) { |
| await this.ig.simulate.preLoginFlow(); |
| |
| const { passwd } = await inquirer.prompt([ |
| { |
| type: 'password', |
| name: 'passwd', |
| message: `Enter password for ${username}`, |
| }, |
| ]); |
| |
| await Bluebird.try(() => this.ig.account.login(username, passwd)).catch( |
| IgLoginTwoFactorRequiredError, |
| async err => { |
| const {username, totp_two_factor_on, two_factor_identifier} = err.response.body.two_factor_info; |
| const verificationMethod: string = totp_two_factor_on ? '0' : '0'; |
| const { code } = await inquirer.prompt([ |
| { |
| type: 'input', |
| name: 'code', |
| message: `Enter code received via ${verificationMethod === '1' ? 'SMS' : 'TOTP'}`, |
| }, |
| ]); |
| return this.ig.account.twoFactorLogin({ |
| username, |
| verificationCode: code, |
| twoFactorIdentifier: two_factor_identifier, |
| verificationMethod, // '1' = SMS (default), '0' = TOTP (google auth for example) |
| trustThisDevice: '0', |
| }); |
| }, |
| ).catch(e => console.error('An error occurred while processing two factor auth', e, e.stack)); |
| |
| console.log(`Logged in as ${username}`); |
| } |
| |
| public async logout() { |
| await this.ig.account.logout(); |
| console.log(`Logged out`); |
| } |
| |
| public async get_stories(users: string[]): Promise<StoryCollection> { |
| let stories = new StoryCollection(); |
| |
| await Bluebird.map(users, async (user) => { |
| let user_stories: UserStory[] = []; |
| |
| const target_user = await this.ig.user.searchExact(user); |
| const reels_feed = this.ig.feed.reelsMedia({ |
| userIds: [target_user.pk] |
| }); |
| console.info(`Fetch stories for ${target_user.pk} (${target_user.username})`) |
| const story_items = await reels_feed.items(); |
| story_items.forEach((item) => { |
| console.info(`${target_user.pk} (${target_user.username}) story item: ${item.id}`) |
| if (item.image_versions2) { |
| item.image_versions2.candidates.forEach((pic) => { |
| user_stories.push( |
| new UserStory( |
| item.id, |
| item.taken_at, |
| pic.url |
| ) |
| ); |
| console.info(`${target_user.pk} (${target_user.username})->${item.id}: ${pic.url}`) |
| }); |
| } |
| if (item.video_versions) { |
| item.video_versions.forEach((vid) => { |
| user_stories.push( |
| new UserStory( |
| item.id, |
| item.taken_at, |
| vid.url |
| ) |
| ); |
| console.info(`${target_user.pk} (${target_user.username})->${item.id}: ${vid.url}`) |
| }); |
| } |
| }); |
| |
| stories.users.set(user, user_stories); |
| }); |
| |
| return stories; |
| } |
| } |
| |
| export function json_replacer(key: any, value: any) { |
| if (value instanceof Map) { |
| return Array.from(value.entries()); |
| } else { |
| return value; |
| } |
| } |
| |
| export class Sleeper { |
| running: boolean = false; |
| |
| poll_interval: number = 1000; |
| |
| constructor(poll_interval: number = 1000) { |
| this.poll_interval = poll_interval; |
| } |
| |
| public async sleep_ms(ms: number): Promise<void> { |
| this.running = true; |
| for (let remaining_ms = ms; (remaining_ms > 0) && this.running; remaining_ms -= this.poll_interval) { |
| await new Promise((resolve) => { |
| setTimeout(resolve, (remaining_ms > this.poll_interval) ? this.poll_interval : remaining_ms) |
| }); |
| } |
| } |
| |
| public cancel() { |
| this.running = false; |
| } |
| } |