feat: Implemented fetching stories
Signed-off-by: Philipp Le <philipp@philipple.de>
Change-Id: I3841b5ca69f304c52c6bc6f32d3a0329b0547db4
diff --git a/src/config.example.ts b/src/config.example.ts
new file mode 100644
index 0000000..bbb587f
--- /dev/null
+++ b/src/config.example.ts
@@ -0,0 +1,23 @@
+
+// Put username here
+export const username = "";
+
+export const story_config = {
+ users: [
+ // Add users to watch here
+ ],
+ destination: "download",
+ email: {
+ smtp: {
+ host: "", // SMTP host
+ port: 587,
+ user: "", // SMTP user
+ password: "", // SMTP password
+ starttls: true
+ },
+ from: "",
+ to: "",
+ subject: ""
+ },
+ sleep_sec: (2 * 3600)
+};
diff --git a/src/story.ts b/src/story.ts
new file mode 100644
index 0000000..2163555
--- /dev/null
+++ b/src/story.ts
@@ -0,0 +1,62 @@
+/*
+ * 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 nodemailer = require("nodemailer");
+
+import { username, story_config } from "./config";
+import {Client, StoryCollection, Sleeper} from "./utils"
+
+async function send_email_report(stories: StoryCollection) {
+ if ((stories.length() > 0) && (story_config.email)) {
+ const smtp = nodemailer.createTransport({
+ host: story_config.email.smtp.host,
+ port: story_config.email.smtp.port,
+ auth: {
+ user: story_config.email.smtp.user,
+ pass: story_config.email.smtp.password
+ },
+ requireTLS: story_config.email.smtp.starttls
+ });
+ let body = "IG Stories have been downloaded\n\nDetails:\n";
+ stories.users.forEach((stories, user) => {
+ if (stories.length > 0) {
+ body += ` Fetched ${stories.length} items for ${user}`
+ }
+ });
+ await smtp.sendMail({
+ from: story_config.email.from,
+ to: story_config.email.to,
+ subject: story_config.email.subject,
+ text: body
+ });
+ smtp.close();
+ }
+}
+
+const client = new Client();
+(async () => {
+ await client.login(username);
+ try {
+ const sleeper = new Sleeper();
+
+ let running = true;
+ process.on("SIGINT", (code) => {
+ running = false;
+ sleeper.cancel();
+ });
+
+ while (running) {
+ const stories = await client.get_stories(story_config.users);
+ await stories.download(story_config.destination);
+ await send_email_report(stories);
+ await sleeper.sleep_ms(story_config.sleep_sec * 1000);
+ }
+ } finally {
+ await client.logout();
+ }
+})();
diff --git a/src/utils.ts b/src/utils.ts
new file mode 100644
index 0000000..df795b1
--- /dev/null
+++ b/src/utils.ts
@@ -0,0 +1,240 @@
+/*
+ * 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;
+ }
+}