blob: df795b188ae5dcb29346cf5d62db61f0dffb248a [file] [log] [blame]
Philipp Le30eb6132022-07-04 12:59:37 +02001/*
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
9import { IgApiClient, IgLoginTwoFactorRequiredError } from 'instagram-private-api';
10import Bluebird = require("bluebird");
11import inquirer = require ("inquirer");
12import date = require("date-and-time");
13import fs = require("fs");
14import path = require("path");
15import https = require("https");
16
17export 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
53export class UserStory extends MediaItem {
54 constructor(id: string, taken_at: number, url: string) {
55 super(id, taken_at, url);
56 }
57}
58
59export 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
113export 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
211export 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
219export 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}