Mike L преди 4 години
родител
ревизия
f18d6fffa2
променени са 8 файла, в които са добавени 294 реда и са изтрити 630 реда
  1. 9 22
      dist/command.js
  2. 12 12
      dist/koishi.js
  3. 108 104
      dist/twitter.js
  4. 13 167
      dist/webshot.js
  5. 11 22
      src/command.ts
  6. 12 12
      src/koishi.ts
  7. 120 111
      src/twitter.ts
  8. 9 180
      src/webshot.ts

+ 9 - 22
dist/command.js

@@ -6,7 +6,6 @@ const path = require("path");
 const datetime_1 = require("./datetime");
 const loggers_1 = require("./loggers");
 const twitter_1 = require("./twitter");
-const utils_1 = require("./utils");
 const logger = loggers_1.getLogger('command');
 function parseCmd(message) {
     message = message.trim();
@@ -47,17 +46,15 @@ function sub(chat, args, reply, lock, lockfile) {
     if (!matched) {
         return reply(`订阅链接格式错误:
 示例:
-https://www.instagram.com/tomoyo_kurosawa_/
-https://www.instagram.com/p/B6GHRSmgV-7/`);
+https://www.instagram.com/tomoyo_kurosawa_/`);
     }
-    let offset = '0';
     const subscribeTo = (link, config = {}) => {
-        const { id, msg = `已为此聊天订阅 ${link}` } = config;
+        const { id, msg = `已为此聊天订阅 ${link} 的 Instagram 故事` } = config;
         if (id) {
             lock.feed.push(link);
             lock.threads[link] = {
                 id,
-                offset,
+                offset: '0',
                 subscribers: [],
                 updatedAt: '',
             };
@@ -83,17 +80,7 @@ https://www.instagram.com/p/B6GHRSmgV-7/`);
         const link = twitter_1.linkBuilder(matched);
         subscribeTo(link, { id: Number(userName.split(':')[1]) });
     };
-    if (matched.postUrlSegment) {
-        offset = utils_1.BigNumOps.plus(twitter_1.urlSegmentToId(matched.postUrlSegment), '-1');
-        delete matched.postUrlSegment;
-        twitter_1.getPostOwner(matched.postUrlSegment).then(userName => {
-            if (!tryFindSub(userName))
-                newSub(userName);
-        }).catch((parsedErr) => {
-            reply(parsedErr.message);
-        });
-    }
-    else if (!tryFindSub(matched.userName)) {
+    if (!tryFindSub(matched.userName)) {
         twitter_1.ScreenNameNormalizer.normalizeLive(matched.userName).then(userName => {
             if (!userName)
                 return reply(`找不到用户 ${matched.userName.replace(/^@?(.*)$/, '@$1')}。`);
@@ -109,7 +96,7 @@ function unsub(chat, args, reply, lock, lockfile) {
         return reply('请先添加机器人为好友。');
     }
     if (args.length === 0) {
-        return reply('找不到要退订的链接。');
+        return reply('找不到要退订推特故事的链接。');
     }
     const match = (_a = twitter_1.parseLink(args[0])) === null || _a === void 0 ? void 0 : _a.userName;
     if (!match) {
@@ -117,12 +104,12 @@ function unsub(chat, args, reply, lock, lockfile) {
     }
     const [link, index] = linkFinder(match, chat, lock);
     if (index === -1)
-        return list(chat, args, msg => reply('您没有订阅此链接。\n' + msg), lock);
+        return list(chat, args, msg => reply('您没有订阅此链接的推特故事。\n' + msg), lock);
     else {
         lock.threads[link].subscribers.splice(index, 1);
         fs.writeFileSync(path.resolve(lockfile), JSON.stringify(lock));
         logger.warn(`chat ${JSON.stringify(chat)} has unsubscribed ${link}`);
-        return reply(`已为此聊天退订 ${link}`);
+        return reply(`已为此聊天退订 ${link} 的 Instagram 故事`);
     }
 }
 exports.unsub = unsub;
@@ -143,12 +130,12 @@ function view(chat, args, reply) {
     if (args.length === 0) {
         return reply('找不到要查看的链接。');
     }
-    const match = twitter_1.isValidUrlSegment(args[0]) && args[0] || ((_a = twitter_1.parseLink(args[0])) === null || _a === void 0 ? void 0 : _a.postUrlSegment);
+    const match = (_a = twitter_1.parseLink(args[0])) === null || _a === void 0 ? void 0 : _a.userName;
     if (!match) {
         return reply('链接格式有误。');
     }
     try {
-        twitter_1.sendPost(match, chat);
+        twitter_1.sendAllStories(match, chat);
     }
     catch (e) {
         reply('机器人尚未加载完毕,请稍后重试。');

+ 12 - 12
dist/koishi.js

@@ -149,29 +149,29 @@ class default_1 {
                 const cmdObj = command_1.parseCmd(session.content);
                 const reply = (msg) => __awaiter(this, void 0, void 0, function* () { return session.sendQueued(msg); });
                 switch (cmdObj.cmd) {
-                    case 'instagram_view':
-                    case 'instagram_get':
+                    case 'igstory_view':
+                    case 'igstory_get':
                         command_1.view(chat, cmdObj.args, reply);
                         break;
-                    case 'instagram_sub':
-                    case 'instagram_subscribe':
+                    case 'igstory_sub':
+                    case 'igstory_subscribe':
                         this.botInfo.sub(chat, cmdObj.args, reply);
                         break;
-                    case 'instagram_unsub':
-                    case 'instagram_unsubscribe':
+                    case 'igstory_unsub':
+                    case 'igstory_unsubscribe':
                         this.botInfo.unsub(chat, cmdObj.args, reply);
                         break;
                     case 'ping':
-                    case 'instagram':
+                    case 'igstory':
                         this.botInfo.list(chat, cmdObj.args, reply);
                         break;
                     case 'help':
                         if (cmdObj.args.length === 0) {
-                            reply(`Instagram 搬运机器人:
-/instagram - 查询当前聊天中的 Instagram 动态订阅
-/instagram_subscribe〈链接|用户名〉- 订阅 Instagram 媒体搬运
-/instagram_unsubscribe〈链接|用户名〉- 退订 Instagram 媒体搬运
-/instagram_view〈链接〉- 查看媒体
+                            reply(`Instagram 故事搬运机器人:
+/igstory - 查询当前聊天中的 Instagram Stories 动态订阅
+/igstory_subscribe〈链接|用户名〉- 订阅 Instagram Stories 搬运
+/igstory_unsubscribe〈链接|用户名〉- 退订 Instagram Stories 媒体搬运
+/igstory_view〈链接〉- 查看该用户所有 Stories
 ${chat.chatType === "temp" ?
                                 '\n(当前游客模式下无法使用订阅功能,请先添加本账号为好友。)' : ''}`);
                         }

+ 108 - 104
dist/twitter.js

@@ -9,21 +9,18 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
     });
 };
 Object.defineProperty(exports, "__esModule", { value: true });
-exports.sendPost = exports.getPostOwner = exports.browserLogin = exports.ScreenNameNormalizer = exports.SessionManager = exports.urlSegmentToId = exports.idToUrlSegment = exports.isValidUrlSegment = exports.parseLink = exports.linkBuilder = void 0;
+exports.sendAllStories = exports.ScreenNameNormalizer = exports.SessionManager = exports.parseLink = exports.linkBuilder = void 0;
 const fs = require("fs");
 const path = require("path");
-const instagram_id_to_url_segment_1 = require("instagram-id-to-url-segment");
-Object.defineProperty(exports, "idToUrlSegment", { enumerable: true, get: function () { return instagram_id_to_url_segment_1.instagramIdToUrlSegment; } });
-Object.defineProperty(exports, "urlSegmentToId", { enumerable: true, get: function () { return instagram_id_to_url_segment_1.urlSegmentToInstagramId; } });
 const instagram_private_api_1 = require("instagram-private-api");
 const loggers_1 = require("./loggers");
 const koishi_1 = require("./koishi");
 const utils_1 = require("./utils");
 const webshot_1 = require("./webshot");
 const parseLink = (link) => {
-    let match = /instagram\.com\/p\/([A-Za-z0-9\-_]+)/.exec(link);
+    let match = /instagram\.com\/stories\/([^\/?#]+)\/(\d+)/.exec(link);
     if (match)
-        return { postUrlSegment: match[1] };
+        return { userName: ScreenNameNormalizer.normalize(match[1]).split(':')[0], storyId: match[2] };
     match =
         /instagram\.com\/([^\/?#]+)/.exec(link) ||
             /^([^\/?#]+)$/.exec(link);
@@ -32,13 +29,12 @@ const parseLink = (link) => {
     return;
 };
 exports.parseLink = parseLink;
-const isValidUrlSegment = (input) => /^[A-Za-z0-9\-_]+$/.test(input);
-exports.isValidUrlSegment = isValidUrlSegment;
 const linkBuilder = (config) => {
-    if (config.userName)
+    if (!config.userName)
+        return;
+    if (!config.storyId)
         return `https://www.instagram.com/${config.userName}/`;
-    if (config.postUrlSegment)
-        return `https://www.instagram.com/p/${config.postUrlSegment}/`;
+    return `https://www.instagram.com/stories/${config.userName}/${config.storyId}/`;
 };
 exports.linkBuilder = linkBuilder;
 class SessionManager {
@@ -98,14 +94,10 @@ class ScreenNameNormalizer {
 }
 exports.ScreenNameNormalizer = ScreenNameNormalizer;
 ScreenNameNormalizer.normalize = (username) => `${username.toLowerCase().replace(/^@/, '')}:`;
-let browserLogin = (page) => Promise.reject();
-exports.browserLogin = browserLogin;
-let getPostOwner = (segmentId) => Promise.reject();
-exports.getPostOwner = getPostOwner;
-let sendPost = (segmentId, receiver) => {
+let sendAllStories = (segmentId, receiver) => {
     throw Error();
 };
-exports.sendPost = sendPost;
+exports.sendAllStories = sendAllStories;
 const logger = loggers_1.getLogger('instagram');
 const maxTrials = 3;
 const retryInterval = 1500;
@@ -135,19 +127,27 @@ const retryOnError = (doWork, onRetry) => new Promise(resolve => {
 class default_1 {
     constructor(opt) {
         this.launch = () => {
-            this.webshot = new webshot_1.default(this.wsUrl, this.mode, () => this.webshotCookies, () => setTimeout(this.work, this.workInterval * 1000));
+            this.webshot = new webshot_1.default(this.wsUrl, this.mode, () => {
+                setTimeout(this.workForAll, this.workInterval * 1000);
+                setTimeout(() => {
+                    this.work();
+                    setInterval(this.workForAll, this.workInterval * 10000);
+                }, this.workInterval * 1200);
+            });
+        };
+        this.queryUser = (rawUserName) => {
+            const username = ScreenNameNormalizer.normalize(rawUserName).split(':')[0];
+            if (username in this.cache) {
+                return Promise.resolve(`${username}:${this.cache[username].user.pk}`);
+            }
+            return this.client.user.searchExact(username)
+                .then(user => {
+                this.cache[user.username] = { user, stories: {} };
+                return `${user.username}:${user.pk}`;
+            });
         };
-        this.queryUser = (username) => this.client.user.searchExact(username)
-            .then(user => `${user.username}:${user.pk}`);
         this.workOnMedia = (mediaItems, sendMedia) => this.webshot(mediaItems, sendMedia, this.webshotDelay);
-        this.urlSegmentToId = instagram_id_to_url_segment_1.urlSegmentToInstagramId;
-        this.getMedia = (segmentId, sender) => this.client.media.info(instagram_id_to_url_segment_1.urlSegmentToInstagramId(segmentId))
-            .then(media => {
-            const mediaItem = media.items[0];
-            logger.debug(`api returned media post ${JSON.stringify(mediaItem)} for query id=${segmentId}`);
-            return this.workOnMedia([mediaItem], sender);
-        });
-        this.sendMedia = (source, ...to) => (msg, text, author) => {
+        this.sendStories = (source, ...to) => (msg, text, author) => {
             to.forEach(subscriber => {
                 logger.info(`pushing data${source ? ` of ${koishi_1.Message.ellipseBase64(source)}` : ''} to ${JSON.stringify(subscriber)}`);
                 retryOnError(() => this.bot.sendTo(subscriber, msg), (_, count, terminate) => {
@@ -162,14 +162,48 @@ class default_1 {
                 });
             });
         };
+        this.cache = {};
+        this.workForAll = () => {
+            const idToUserMap = {};
+            Promise.all(Object.entries(this.lock.threads).map(entry => {
+                const id = entry[1].id;
+                const userName = parseLink(entry[0]).userName;
+                logger.debug(`preparing to add user @${userName} to next pull task...`);
+                if (userName in this.cache)
+                    return Promise.resolve(idToUserMap[id] = this.cache[userName].user);
+                return this.client.user.info(id).then(user => {
+                    logger.debug(`initialized cache item for user ${user.full_name} (@${userName})`);
+                    this.cache[userName] = { user, stories: {} };
+                    return idToUserMap[id] = user;
+                });
+            }))
+                .then(() => {
+                logger.debug(`pulling stories for users: ${Object.values(idToUserMap).map(user => user.username)}`);
+                this.client.feed.reelsMedia({
+                    userIds: Object.keys(idToUserMap),
+                }).items()
+                    .then(storyItems => storyItems.forEach(item => {
+                    if (!(item.pk in this.cache[idToUserMap[item.user.pk].username].stories)) {
+                        this.cache[idToUserMap[item.user.pk].username].stories[item.pk] = item;
+                    }
+                }))
+                    .catch((error) => {
+                    if (error instanceof instagram_private_api_1.IgNetworkError) {
+                        logger.warn(`error on fetching stories for all: ${JSON.stringify(error.cause)}`);
+                    }
+                    else {
+                        logger.error(`unhandled error on fetching media for all: ${error}`);
+                    }
+                });
+            });
+        };
         this.work = () => {
             const lock = this.lock;
+            logger.debug(`current cache: ${JSON.stringify(this.cache)}`);
             if (this.workInterval < 1)
                 this.workInterval = 1;
             if (lock.feed.length === 0) {
-                setTimeout(() => {
-                    this.work();
-                }, this.workInterval * 1000);
+                setTimeout(this.work, this.workInterval * 1000);
                 return;
             }
             if (lock.workon >= lock.feed.length)
@@ -185,38 +219,20 @@ class default_1 {
                 return;
             }
             const currentFeed = lock.feed[lock.workon];
-            logger.debug(`pulling feed ${currentFeed}`);
+            logger.debug(`searching for new items from ${currentFeed} in cache`);
             const promise = new Promise(resolve => {
                 const match = /https:\/\/www\.instagram\.com\/([^\/]+)/.exec(currentFeed);
                 if (match) {
-                    const feed = this.client.feed.user(lock.threads[currentFeed].id);
+                    const cachedFeed = this.cache[match[1]];
+                    if (!cachedFeed) {
+                        setTimeout(this.work, this.workInterval * 1000);
+                        resolve([]);
+                    }
                     const newer = (item) => utils_1.BigNumOps.compare(item.pk, lock.threads[currentFeed].offset) > 0;
-                    const fetchMore = () => new Promise(fetch => {
-                        feed.request().then(response => {
-                            if (response.items.length === 0)
-                                return fetch([]);
-                            if (response.items.every(newer)) {
-                                fetchMore().then(fetched => fetch(response.items.concat(fetched)));
-                            }
-                            else
-                                fetch(response.items.filter(newer));
-                        }, (error) => {
-                            if (error instanceof instagram_private_api_1.IgNetworkError) {
-                                logger.warn(`error on fetching media for ${currentFeed}: ${JSON.stringify(error.cause)}`);
-                                if (!(error instanceof instagram_private_api_1.IgNotFoundError))
-                                    return;
-                                lock.threads[currentFeed].subscribers.forEach(subscriber => {
-                                    logger.info(`sending notfound message of ${currentFeed} to ${JSON.stringify(subscriber)}`);
-                                    this.bot.sendTo(subscriber, `链接 ${currentFeed} 指向的用户或列表不存在,请退订。`).catch();
-                                });
-                            }
-                            else {
-                                logger.error(`unhandled error on fetching media for ${currentFeed}: ${JSON.stringify(error)}`);
-                            }
-                            fetch([]);
-                        });
-                    });
-                    fetchMore().then(resolve);
+                    resolve(Object.values(cachedFeed.stories)
+                        .filter(newer)
+                        .map(story => (Object.assign(Object.assign({}, story), { user: cachedFeed.user })))
+                        .sort((i1, i2) => utils_1.BigNumOps.compare(i2.pk, i1.pk)));
                 }
             });
             promise.then((mediaItems) => {
@@ -234,7 +250,7 @@ class default_1 {
                 }
                 if (currentThread.offset === '0')
                     mediaItems.splice(1);
-                return this.workOnMedia(mediaItems, this.sendMedia(`thread ${currentFeed}`, ...currentThread.subscribers))
+                return this.workOnMedia(mediaItems, this.sendStories(`thread ${currentFeed}`, ...currentThread.subscribers))
                     .then(updateDate).then(updateOffset);
             })
                 .then(() => {
@@ -251,59 +267,47 @@ class default_1 {
         this.client = new instagram_private_api_1.IgApiClient();
         this.session = new SessionManager(this.client, opt.sessionLockfile, opt.credentials);
         this.lockfile = opt.lockfile;
-        this.webshotCookiesLockfile = opt.webshotCookiesLockfile;
         this.lock = opt.lock;
         this.workInterval = opt.workInterval;
         this.bot = opt.bot;
         this.webshotDelay = opt.webshotDelay;
         this.mode = opt.mode;
         this.wsUrl = opt.wsUrl;
-        const cookiesFilePath = path.resolve(this.webshotCookiesLockfile);
-        if (fs.existsSync(cookiesFilePath)) {
-            try {
-                this.webshotCookies = JSON.parse(fs.readFileSync(cookiesFilePath, 'utf8'));
-                logger.info(`loaded webshot cookies from file ${this.webshotCookiesLockfile}`);
-            }
-            catch (err) {
-                logger.warn(`failed to load webshot cookies from file ${this.webshotCookiesLockfile}: `, err);
-                logger.warn('cookies will be saved to this file when needed');
-            }
-        }
-        exports.browserLogin = (page) => {
-            logger.warn('blocked by login dialog, trying to log in manually...');
-            return page.type('input[name="username"]', opt.credentials[0])
-                .then(() => page.type('input[name="password"]', opt.credentials[1]))
-                .then(() => page.click('button[type="submit"]'))
-                .then(() => page.click('button:has-text("情報を保存")'))
-                .then(() => page.waitForSelector('img[data-testid="user-avatar"]', { timeout: this.webshotDelay }))
-                .then(() => page.context().cookies())
-                .then(cookies => {
-                this.webshotCookies = cookies;
-                logger.info('successfully logged in, saving cookies to file...');
-                fs.writeFileSync(path.resolve(this.webshotCookiesLockfile), JSON.stringify(cookies, null, 2), 'utf-8');
+        ScreenNameNormalizer._queryUser = this.queryUser;
+        exports.sendAllStories = (rawUserName, receiver) => {
+            const sender = this.sendStories(`instagram stories for ${rawUserName}`, receiver);
+            this.queryUser(rawUserName)
+                .then(userNameId => {
+                const [userName, userId] = userNameId.split(':');
+                if (userName in this.cache && Object.keys(this.cache[userName].stories).length > 0) {
+                    return Promise.resolve(Object.values(this.cache[userName].stories)
+                        .map(story => (Object.assign(Object.assign({}, story), { user: this.cache[userName].user })))
+                        .sort((i1, i2) => utils_1.BigNumOps.compare(i2.pk, i1.pk)));
+                }
+                return this.client.feed.reelsMedia({ userIds: [userId] }).items()
+                    .then(storyItems => {
+                    storyItems.forEach(item => {
+                        if (!(item.pk in this.cache[userName].stories)) {
+                            this.cache[userName].stories[item.pk] = item;
+                        }
+                    });
+                    if (storyItems.length === 0)
+                        this.bot.sendTo(receiver, `当前用户 (@${userName}) 没有可用的推特故事。`);
+                    return storyItems;
+                });
             })
-                .catch((err) => {
-                if (err.name === 'TimeoutError')
-                    logger.warn('navigation timed out, assuming login has failed');
-                throw err;
+                .then(storyItems => this.workOnMedia(storyItems, sender))
+                .catch((error) => {
+                if (error instanceof instagram_private_api_1.IgNetworkError) {
+                    logger.warn(`error on fetching stories for ${rawUserName}: ${JSON.stringify(error.cause)}`);
+                    this.bot.sendTo(receiver, `获取 Fleets 时出现错误:原因: ${error.cause}`);
+                }
+                else {
+                    logger.error(`unhandled error on fetching media for ${rawUserName}: ${error}`);
+                    this.bot.sendTo(receiver, `获取 Fleets 时发生未知错误: ${error}`);
+                }
             });
         };
-        ScreenNameNormalizer._queryUser = this.queryUser;
-        const parseMediaError = (err) => {
-            if (!(err instanceof instagram_private_api_1.IgResponseError && err.text === 'Media not found or unavailable')) {
-                logger.warn(`error retrieving instagram media: ${err.message}`);
-                return `获取媒体时出现错误:${err.message}`;
-            }
-            return '找不到请求的媒体,它可能已被删除。';
-        };
-        exports.getPostOwner = (segmentId) => this.client.media.info(instagram_id_to_url_segment_1.urlSegmentToInstagramId(segmentId))
-            .then(media => media.items[0].user)
-            .then(user => `${user.username}:${user.pk}`)
-            .catch((err) => { throw Error(parseMediaError(err)); });
-        exports.sendPost = (segmentId, receiver) => {
-            this.getMedia(segmentId, this.sendMedia(`instagram media ${segmentId}`, receiver))
-                .catch((err) => { this.bot.sendTo(receiver, parseMediaError(err)); });
-        };
     }
 }
 exports.default = default_1;

+ 13 - 167
dist/webshot.js

@@ -1,18 +1,12 @@
 "use strict";
 Object.defineProperty(exports, "__esModule", { value: true });
 const fs_1 = require("fs");
-const util_1 = require("util");
 const axios_1 = require("axios");
 const CallableInstance = require("callable-instance");
 const html_entities_1 = require("html-entities");
-const pngjs_1 = require("pngjs");
-const puppeteer = require("playwright");
-const sharp = require("sharp");
 const temp = require("temp");
 const loggers_1 = require("./loggers");
 const koishi_1 = require("./koishi");
-const utils_1 = require("./utils");
-const twitter_1 = require("./twitter");
 const xmlEntities = new html_entities_1.XmlEntities();
 const ZHType = (type) => new class extends String {
     constructor() {
@@ -27,130 +21,8 @@ const typeInZH = {
 };
 const logger = loggers_1.getLogger('webshot');
 class Webshot extends CallableInstance {
-    constructor(wsUrl, mode, getCookies, onready) {
+    constructor(_wsUrl, _mode, onready) {
         super('webshot');
-        this.connect = (onready) => axios_1.default.get(this.wsUrl)
-            .then(res => {
-            logger.info(`received websocket endpoint: ${JSON.stringify(res.data)}`);
-            const browserType = Object.keys(res.data)[0];
-            return puppeteer[browserType]
-                .connect({ wsEndpoint: res.data[browserType] });
-        })
-            .then(browser => this.browser = browser)
-            .then(() => {
-            logger.info('launched puppeteer browser');
-            if (onready)
-                return onready();
-        })
-            .catch(error => this.reconnect(error, onready));
-        this.reconnect = (error, onready) => {
-            logger.error(`connection error, reason: ${error}`);
-            logger.warn('trying to reconnect in 2.5s...');
-            return util_1.promisify(setTimeout)(2500)
-                .then(() => this.connect(onready));
-        };
-        this.renderWebshot = (url, height, webshotDelay) => {
-            temp.track();
-            const jpeg = (data) => data.pipe(sharp()).jpeg({ quality: 90, trellisQuantisation: true });
-            const sharpToFile = (pic) => new Promise(resolve => {
-                const webshotTempFilePath = temp.path({ suffix: '.jpg' });
-                pic.toFile(webshotTempFilePath).then(() => resolve(`file://${webshotTempFilePath}`));
-            });
-            const promise = new Promise((resolve, reject) => {
-                const width = 720;
-                const zoomFactor = 2;
-                logger.info(`shooting ${width}*${height} webshot for ${url}`);
-                this.browser.newPage({
-                    bypassCSP: true,
-                    deviceScaleFactor: zoomFactor,
-                    locale: 'ja-JP',
-                    timezoneId: 'Asia/Tokyo',
-                    userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.99 Safari/537.36',
-                })
-                    .then(page => {
-                    const startTime = new Date().getTime();
-                    const getTimerTime = () => new Date().getTime() - startTime;
-                    const getTimeout = () => Math.max(500, webshotDelay - getTimerTime());
-                    page.setViewportSize({
-                        width: width / zoomFactor,
-                        height: height / zoomFactor,
-                    }).then(() => page.context().addCookies(this.getCookies()))
-                        .then(() => page.goto(url, { waitUntil: 'load', timeout: getTimeout() }))
-                        .then(() => ((next) => Promise.race([
-                        page.click('button:has-text("すべて許可")').then(() => twitter_1.browserLogin(page))
-                            .then(() => page.goto(url, { waitUntil: 'load', timeout: getTimeout() }))
-                            .then(() => next),
-                        page.click('button:has-text("すべて許可")').then(() => next),
-                        next,
-                    ]))(page.waitForSelector('article', { timeout: getTimeout() })))
-                        .catch((err) => {
-                        if (err.name !== 'TimeoutError')
-                            throw err;
-                        logger.warn(`navigation timed out at ${getTimerTime()} ms`);
-                        return null;
-                    })
-                        .then(() => page.addStyleTag({ content: 'nav,footer,header+div,header+div+div>div>div+div,header div div div+div,' +
-                            'article section,article section+div>ul>:not(div),article section+div~div,article button,canvas{display:none!important} ' +
-                            'section+div{overflow:hidden} section+*>*{position:relative!important} article{border-bottom:1px solid!important}',
-                    }))
-                        .then(() => page.addStyleTag({
-                        content: '*{font-family:-apple-system,".Helvetica Neue DeskInterface",Hiragino Sans,Hiragino Sans GB,sans-serif!important}',
-                    }))
-                        .then(() => page.screenshot())
-                        .then(screenshot => {
-                        new pngjs_1.PNG({
-                            filterType: 4,
-                            deflateLevel: 0,
-                        }).on('parsed', function () {
-                            const idx = (x, y) => (this.width * y + x) << 2;
-                            let boundary = null;
-                            for (let y = this.height - 1; y > this.height - 1920; y -= zoomFactor) {
-                                if (this.data[idx(zoomFactor, y)] <= 38 &&
-                                    this.data[idx(zoomFactor, y)] === this.data[idx(this.width - zoomFactor, y)] &&
-                                    this.data[idx(zoomFactor, y + zoomFactor)] === this.data[idx(zoomFactor, y - 2 * zoomFactor)]) {
-                                    boundary = y;
-                                    break;
-                                }
-                            }
-                            if (boundary !== null) {
-                                logger.info(`found boundary at ${boundary}, cropping image`);
-                                this.data = this.data.slice(0, idx(this.width, boundary));
-                                this.height = boundary;
-                                sharpToFile(jpeg(this.pack())).then(path => {
-                                    logger.info(`finished webshot for ${url}`);
-                                    resolve({ path, boundary });
-                                });
-                            }
-                            else if (height >= 8 * 1920) {
-                                logger.warn('too large, consider as a bug, returning');
-                                sharpToFile(jpeg(this.pack())).then(path => {
-                                    resolve({ path, boundary: 0 });
-                                });
-                            }
-                            else {
-                                logger.info('unable to find boundary, try shooting a larger image');
-                                resolve({ path: '', boundary });
-                            }
-                        }).parse(screenshot);
-                    })
-                        .catch(err => {
-                        if (err instanceof Error && err.name !== 'TimeoutError')
-                            throw err;
-                        logger.error(`error shooting webshot for ${url}, could not load web page of tweet`);
-                        resolve({ path: '', boundary: 0 });
-                    })
-                        .finally(() => { page.close(); });
-                })
-                    .catch(reject);
-            });
-            return promise.then(data => {
-                if (data.boundary === null)
-                    return this.renderWebshot(url, height + 1920, webshotDelay);
-                else
-                    return data.path;
-            }).catch(error => this.reconnect(error)
-                .then(() => this.renderWebshot(url, height, webshotDelay)));
-        };
         this.fetchMedia = (url) => new Promise((resolve, reject) => {
             logger.info(`fetching ${url}`);
             axios_1.default({
@@ -185,14 +57,7 @@ class Webshot extends CallableInstance {
             logger.warn('unable to find MIME type of fetched media, failing this fetch');
             throw Error();
         })(/\/.*\.(.+?)\?/.exec(url)[1]));
-        if (this.mode = mode) {
-            onready();
-        }
-        else {
-            this.getCookies = getCookies;
-            this.wsUrl = wsUrl;
-            this.connect(onready);
-        }
+        onready();
     }
     webshot(mediaItems, callback, webshotDelay) {
         let promise = new Promise(resolve => {
@@ -204,22 +69,8 @@ class Webshot extends CallableInstance {
             });
             let messageChain = '';
             const author = `${item.user.full_name} (@${item.user.username}):\n`;
-            const text = item.caption.text;
-            if (this.mode > 0)
-                messageChain += (author + xmlEntities.decode(text));
-            if (this.mode === 0) {
-                const url = twitter_1.linkBuilder({ postUrlSegment: item.code });
-                promise = promise.then(() => this.renderWebshot(url, 1920, webshotDelay))
-                    .then(fileurl => {
-                    if (fileurl)
-                        return koishi_1.Message.Image(fileurl);
-                    return author + text;
-                })
-                    .then(msg => {
-                    if (msg)
-                        messageChain += msg;
-                });
-            }
+            const date = `${new Date(item.taken_at * 1000)}\n`;
+            messageChain += author + date;
             const type = (mediaItem) => mediaItem.video_versions ? 'video' : 'photo';
             const fetchBestCandidate = (candidates, mediaType) => {
                 const url = candidates
@@ -233,23 +84,18 @@ class Webshot extends CallableInstance {
                 })
                     .then(msg => { messageChain += msg; });
             };
-            if (1 - this.mode % 2)
-                promise = promise.then(() => {
-                    if (item.carousel_media) {
-                        return utils_1.chainPromises(item.carousel_media.map(carouselItem => fetchBestCandidate(carouselItem.video_versions ||
-                            carouselItem.image_versions2.candidates, type(carouselItem))));
-                    }
-                    else if (item.video_versions) {
-                        return fetchBestCandidate(item.video_versions, type(item));
-                    }
-                    else if (item.image_versions2) {
-                        return fetchBestCandidate(item.image_versions2.candidates, type(item));
-                    }
-                });
+            promise = promise.then(() => {
+                if (item.video_versions) {
+                    return fetchBestCandidate(item.video_versions, type(item));
+                }
+                else if (item.image_versions2) {
+                    return fetchBestCandidate(item.image_versions2.candidates, type(item));
+                }
+            });
             promise.then(() => {
                 logger.info(`done working on ${item.user.username}/${item.code}, message chain:`);
                 logger.info(JSON.stringify(koishi_1.Message.ellipseBase64(messageChain)));
-                callback(messageChain, xmlEntities.decode(text), author);
+                callback(messageChain, xmlEntities.decode(item.caption), author);
             });
         });
         return promise;

+ 11 - 22
src/command.ts

@@ -7,10 +7,9 @@ import * as path from 'path';
 import { relativeDate } from './datetime';
 import { getLogger } from './loggers';
 import {
-  getPostOwner, sendPost, ScreenNameNormalizer as normalizer,
-  isValidUrlSegment, linkBuilder, parseLink, urlSegmentToId
+  sendAllStories, ScreenNameNormalizer as normalizer,
+  linkBuilder, parseLink
 } from './twitter';
-import { BigNumOps } from './utils';
 
 const logger = getLogger('command');
 
@@ -62,17 +61,15 @@ function sub(chat: IChat, args: string[], reply: (msg: string) => any,
   if (!matched) {
     return reply(`订阅链接格式错误:
 示例:
-https://www.instagram.com/tomoyo_kurosawa_/
-https://www.instagram.com/p/B6GHRSmgV-7/`);
+https://www.instagram.com/tomoyo_kurosawa_/`);
   }
-  let offset = '0';
   const subscribeTo = (link: string, config: {id?: number, msg?: string} = {}) => {
-    const {id, msg = `已为此聊天订阅 ${link}`} = config;
+    const {id, msg = `已为此聊天订阅 ${link} 的 Instagram 故事`} = config;
     if (id) {
       lock.feed.push(link);
       lock.threads[link] = {
         id,
-        offset,
+        offset: '0',
         subscribers: [],
         updatedAt: '',
       };
@@ -92,15 +89,7 @@ https://www.instagram.com/p/B6GHRSmgV-7/`);
     const link = linkBuilder(matched);
     subscribeTo(link, {id: Number(userName.split(':')[1])});
   };
-  if (matched.postUrlSegment) {
-    offset = BigNumOps.plus(urlSegmentToId(matched.postUrlSegment), '-1');
-    delete matched.postUrlSegment;
-    getPostOwner(matched.postUrlSegment).then(userName => {
-      if (!tryFindSub(userName)) newSub(userName);
-    }).catch((parsedErr: Error) => {
-      reply(parsedErr.message);
-    });
-  } else if (!tryFindSub(matched.userName)) {
+  if (!tryFindSub(matched.userName)) {
     normalizer.normalizeLive(matched.userName).then(userName => {
       if (!userName) return reply(`找不到用户 ${matched.userName.replace(/^@?(.*)$/, '@$1')}。`);
       else newSub(userName);
@@ -115,19 +104,19 @@ function unsub(chat: IChat, args: string[], reply: (msg: string) => any,
     return reply('请先添加机器人为好友。');
   }
   if (args.length === 0) {
-    return reply('找不到要退订的链接。');
+    return reply('找不到要退订推特故事的链接。');
   }
   const match = parseLink(args[0])?.userName;
   if (!match) {
     return reply('链接格式有误。');
   }
   const [link, index] = linkFinder(match, chat, lock);
-  if (index === -1) return list(chat, args, msg => reply('您没有订阅此链接。\n' + msg), lock);
+  if (index === -1) return list(chat, args, msg => reply('您没有订阅此链接的推特故事。\n' + msg), lock);
   else {
     lock.threads[link].subscribers.splice(index, 1);
     fs.writeFileSync(path.resolve(lockfile), JSON.stringify(lock));
     logger.warn(`chat ${JSON.stringify(chat)} has unsubscribed ${link}`);
-    return reply(`已为此聊天退订 ${link}`);
+    return reply(`已为此聊天退订 ${link} 的 Instagram 故事`);
   }
 }
 
@@ -148,12 +137,12 @@ function view(chat: IChat, args: string[], reply: (msg: string) => any): void {
   if (args.length === 0) {
     return reply('找不到要查看的链接。');
   }
-  const match = isValidUrlSegment(args[0]) && args[0] || parseLink(args[0])?.postUrlSegment;
+  const match = parseLink(args[0])?.userName;
   if (!match) {
     return reply('链接格式有误。');
   }
   try {
-    sendPost(match, chat);
+    sendAllStories(match, chat);
   } catch (e) {
     reply('机器人尚未加载完毕,请稍后重试。');
   }

+ 12 - 12
src/koishi.ts

@@ -166,29 +166,29 @@ export default class {
       const cmdObj = parseCmd(session.content);
       const reply = async msg => session.sendQueued(msg);
       switch (cmdObj.cmd) {
-        case 'instagram_view':
-        case 'instagram_get':
+        case 'igstory_view':
+        case 'igstory_get':
           view(chat, cmdObj.args, reply);
           break;
-        case 'instagram_sub':
-        case 'instagram_subscribe':
+        case 'igstory_sub':
+        case 'igstory_subscribe':
           this.botInfo.sub(chat, cmdObj.args, reply);
           break;
-        case 'instagram_unsub':
-        case 'instagram_unsubscribe':
+        case 'igstory_unsub':
+        case 'igstory_unsubscribe':
           this.botInfo.unsub(chat, cmdObj.args, reply);
           break;
         case 'ping':
-        case 'instagram':
+        case 'igstory':
           this.botInfo.list(chat, cmdObj.args, reply);
           break;
         case 'help':
           if (cmdObj.args.length === 0) {
-            reply(`Instagram 搬运机器人:
-/instagram - 查询当前聊天中的 Instagram 动态订阅
-/instagram_subscribe〈链接|用户名〉- 订阅 Instagram 媒体搬运
-/instagram_unsubscribe〈链接|用户名〉- 退订 Instagram 媒体搬运
-/instagram_view〈链接〉- 查看媒体
+            reply(`Instagram 故事搬运机器人:
+/igstory - 查询当前聊天中的 Instagram Stories 动态订阅
+/igstory_subscribe〈链接|用户名〉- 订阅 Instagram Stories 搬运
+/igstory_unsubscribe〈链接|用户名〉- 退订 Instagram Stories 媒体搬运
+/igstory_view〈链接〉- 查看该用户所有 Stories
 ${chat.chatType === ChatType.Temp ?
     '\n(当前游客模式下无法使用订阅功能,请先添加本账号为好友。)' : ''
 }`);

+ 120 - 111
src/twitter.ts

@@ -1,25 +1,22 @@
 import * as fs from 'fs';
 import * as path from 'path';
-import {
-  instagramIdToUrlSegment as idToUrlSegment,
-  urlSegmentToInstagramId as urlSegmentToId
-} from 'instagram-id-to-url-segment';
 import {
   IgApiClient,
-  IgClientError, IgExactUserNotFoundError, IgNetworkError, IgNotFoundError, IgResponseError,
-  MediaInfoResponseItemsItem, UserFeedResponseItemsItem
+  IgClientError, IgExactUserNotFoundError,
+  IgNetworkError,
+  ReelsMediaFeedResponseItem, UserFeedResponseUser
 } from 'instagram-private-api';
 import { RequestError } from 'request-promise/errors';
 
 import { getLogger } from './loggers';
 import QQBot, { Message } from './koishi';
 import { BigNumOps } from './utils';
-import Webshot, { Cookies, Page } from './webshot';
+import Webshot from './webshot';
 
-const parseLink = (link: string): {userName?: string, postUrlSegment?: string} => {
+const parseLink = (link: string): {userName?: string, storyId?: string} => {
   let match =
-    /instagram\.com\/p\/([A-Za-z0-9\-_]+)/.exec(link);
-  if (match) return {postUrlSegment: match[1]};
+    /instagram\.com\/stories\/([^\/?#]+)\/(\d+)/.exec(link);
+  if (match) return {userName: ScreenNameNormalizer.normalize(match[1]).split(':')[0], storyId: match[2]};
   match =
     /instagram\.com\/([^\/?#]+)/.exec(link) ||
     /^([^\/?#]+)$/.exec(link);
@@ -27,14 +24,13 @@ const parseLink = (link: string): {userName?: string, postUrlSegment?: string} =
   return;
 };
 
-const isValidUrlSegment = (input: string) => /^[A-Za-z0-9\-_]+$/.test(input);
-
 const linkBuilder = (config: ReturnType<typeof parseLink>): string => {
-  if (config.userName) return `https://www.instagram.com/${config.userName}/`;
-  if (config.postUrlSegment) return `https://www.instagram.com/p/${config.postUrlSegment}/`;
+  if (!config.userName) return;
+  if (!config.storyId) return `https://www.instagram.com/${config.userName}/`;
+  return `https://www.instagram.com/stories/${config.userName}/${config.storyId}/`;
 };
 
-export {linkBuilder, parseLink, isValidUrlSegment, idToUrlSegment, urlSegmentToId};
+export {linkBuilder, parseLink};
 
 interface IWorkerOption {
   sessionLockfile: string;
@@ -116,15 +112,11 @@ export class ScreenNameNormalizer {
   }
 }
 
-export let browserLogin = (page: Page): Promise<void> => Promise.reject();
-
-export let getPostOwner = (segmentId: string): Promise<string> => Promise.reject();
-
-export let sendPost = (segmentId: string, receiver: IChat): void => {
+export let sendAllStories = (segmentId: string, receiver: IChat): void => {
   throw Error();
 };
 
-export type MediaItem = MediaInfoResponseItemsItem & UserFeedResponseItemsItem;
+export type MediaItem = ReelsMediaFeedResponseItem;
 
 const logger = getLogger('instagram');
 const maxTrials = 3;
@@ -163,8 +155,6 @@ export default class {
   private workInterval: number;
   private bot: QQBot;
   private webshotDelay: number;
-  private webshotCookies: Cookies;
-  private webshotCookiesLockfile: string;
   private webshot: Webshot;
   private mode: number;
   private wsUrl: string;
@@ -175,7 +165,6 @@ export default class {
     this.client = new IgApiClient();
     this.session = new SessionManager(this.client, opt.sessionLockfile, opt.credentials);
     this.lockfile = opt.lockfile;
-    this.webshotCookiesLockfile = opt.webshotCookiesLockfile;
     this.lock = opt.lock;
     this.workInterval = opt.workInterval;
     this.bot = opt.bot;
@@ -183,82 +172,75 @@ export default class {
     this.mode = opt.mode;
     this.wsUrl = opt.wsUrl;
 
-    const cookiesFilePath = path.resolve(this.webshotCookiesLockfile);
-    if (fs.existsSync(cookiesFilePath)) {
-      try {
-        this.webshotCookies = JSON.parse(fs.readFileSync(cookiesFilePath, 'utf8')) as Cookies;
-        logger.info(`loaded webshot cookies from file ${this.webshotCookiesLockfile}`);
-      } catch (err) {
-        logger.warn(`failed to load webshot cookies from file ${this.webshotCookiesLockfile}: `, err);
-        logger.warn('cookies will be saved to this file when needed');
-      }
-    }
-
-    browserLogin = (page) => {
-      logger.warn('blocked by login dialog, trying to log in manually...');
-      return page.type('input[name="username"]', opt.credentials[0])
-        .then(() => page.type('input[name="password"]', opt.credentials[1]))
-        .then(() => page.click('button[type="submit"]'))
-        .then(() => page.click('button:has-text("情報を保存")'))
-        .then(() => page.waitForSelector('img[data-testid="user-avatar"]', {timeout: this.webshotDelay}))
-        .then(() => page.context().cookies())
-        .then(cookies => {
-          this.webshotCookies = cookies;
-          logger.info('successfully logged in, saving cookies to file...');
-          fs.writeFileSync(path.resolve(this.webshotCookiesLockfile), JSON.stringify(cookies, null, 2), 'utf-8');
+    ScreenNameNormalizer._queryUser = this.queryUser;
+    sendAllStories = (rawUserName, receiver) => {
+      const sender = this.sendStories(`instagram stories for ${rawUserName}`, receiver);
+      this.queryUser(rawUserName)
+        .then(userNameId => {
+          const [userName, userId] = userNameId.split(':');
+          if (userName in this.cache && Object.keys(this.cache[userName].stories).length > 0) {
+            return Promise.resolve(
+              Object.values(this.cache[userName].stories)
+                .map(story => ({...story, user: this.cache[userName].user}))
+                .sort((i1, i2) => BigNumOps.compare(i2.pk, i1.pk))
+            );
+          }
+          return this.client.feed.reelsMedia({userIds: [userId]}).items()
+            .then(storyItems => {
+              storyItems.forEach(item => {
+                if (!(item.pk in this.cache[userName].stories)) {
+                  this.cache[userName].stories[item.pk] = item;
+                }
+              });
+              if (storyItems.length === 0) this.bot.sendTo(receiver, `当前用户 (@${userName}) 没有可用的推特故事。`);
+              return storyItems;
+            });
         })
-        .catch((err: Error) => {
-          if (err.name === 'TimeoutError') logger.warn('navigation timed out, assuming login has failed');
-          throw err;
+        .then(storyItems => this.workOnMedia(storyItems, sender))
+        .catch((error: IgClientError & Partial<RequestError>) => {
+          if (error instanceof IgNetworkError) {
+            logger.warn(`error on fetching stories for ${rawUserName}: ${JSON.stringify(error.cause)}`);
+            this.bot.sendTo(receiver, `获取 Fleets 时出现错误:原因: ${error.cause}`); 
+          } else {
+            logger.error(`unhandled error on fetching media for ${rawUserName}: ${error}`);
+            this.bot.sendTo(receiver, `获取 Fleets 时发生未知错误: ${error}`); 
+          }
         });
     };
-    ScreenNameNormalizer._queryUser = this.queryUser;
-    const parseMediaError = (err: IgClientError) => {
-      if (!(err instanceof IgResponseError && err.text === 'Media not found or unavailable')) {
-        logger.warn(`error retrieving instagram media: ${err.message}`);
-        return `获取媒体时出现错误:${err.message}`;
-      }
-      return '找不到请求的媒体,它可能已被删除。';
-    };
-    getPostOwner = (segmentId) => 
-      this.client.media.info(urlSegmentToId(segmentId))
-        .then(media => media.items[0].user)
-        .then(user => `${user.username}:${user.pk}`)
-        .catch((err: IgClientError) => { throw Error(parseMediaError(err)); });
-    sendPost = (segmentId, receiver) => {
-      this.getMedia(segmentId, this.sendMedia(`instagram media ${segmentId}`, receiver))
-        .catch((err: IgClientError) => { this.bot.sendTo(receiver, parseMediaError(err)); });
-    };
   }
 
   public launch = () => {
     this.webshot = new Webshot(
       this.wsUrl,
       this.mode,
-      () => this.webshotCookies,
-      () => setTimeout(this.work, this.workInterval * 1000)
+      () => {
+        setTimeout(this.workForAll, this.workInterval * 1000);
+        setTimeout(() => {
+          this.work();
+          setInterval(this.workForAll, this.workInterval * 10000);
+        }, this.workInterval * 1200);
+      }
     );
   };
 
-  public queryUser = (username: string) => this.client.user.searchExact(username)
-    .then(user => `${user.username}:${user.pk}`);
+  public queryUser = (rawUserName: string) => {
+    const username = ScreenNameNormalizer.normalize(rawUserName).split(':')[0];
+    if (username in this.cache) {
+      return Promise.resolve(`${username}:${this.cache[username].user.pk}`);
+    }
+    return this.client.user.searchExact(username)
+      .then(user => {
+        this.cache[user.username] = {user, stories: {}};
+        return `${user.username}:${user.pk}`;
+      });
+  };
 
   private workOnMedia = (
     mediaItems: MediaItem[],
     sendMedia: (msg: string, text: string, author: string) => void
   ) => this.webshot(mediaItems, sendMedia, this.webshotDelay);
 
-  public urlSegmentToId = urlSegmentToId;
-
-  public getMedia = (segmentId: string, sender: (msg: string, text: string, author: string) => void) =>
-    this.client.media.info(urlSegmentToId(segmentId))
-      .then(media => {
-        const mediaItem = media.items[0] as MediaItem;
-        logger.debug(`api returned media post ${JSON.stringify(mediaItem)} for query id=${segmentId}`);
-        return this.workOnMedia([mediaItem], sender);
-      });
-
-  private sendMedia = (source?: string, ...to: IChat[]) => (msg: string, text: string, author: string) => {
+  private sendStories = (source?: string, ...to: IChat[]) => (msg: string, text: string, author: string) => {
     to.forEach(subscriber => {
       logger.info(`pushing data${source ? ` of ${Message.ellipseBase64(source)}` : ''} to ${JSON.stringify(subscriber)}`);
       retryOnError(
@@ -275,13 +257,52 @@ export default class {
     });
   };
 
+  private cache: {
+    [userName: string]: {
+      user: UserFeedResponseUser,
+      stories: {[storyId: string]: MediaItem},
+    },
+  } = {};
+
+  private workForAll = () => {
+    const idToUserMap: {[id: number]: UserFeedResponseUser} = {};
+    Promise.all(Object.entries(this.lock.threads).map(entry => {
+      const id = entry[1].id;
+      const userName = parseLink(entry[0]).userName;
+      logger.debug(`preparing to add user @${userName} to next pull task...`);
+      if (userName in this.cache) return Promise.resolve(idToUserMap[id] = this.cache[userName].user);
+      return this.client.user.info(id).then(user => {
+        logger.debug(`initialized cache item for user ${user.full_name} (@${userName})`);
+        this.cache[userName] = {user, stories: {}};
+        return idToUserMap[id] = user as UserFeedResponseUser;
+      });
+    }))
+      .then(() => {
+        logger.debug(`pulling stories for users: ${Object.values(idToUserMap).map(user => user.username)}`);
+        this.client.feed.reelsMedia({
+          userIds: Object.keys(idToUserMap),
+        }).items()
+          .then(storyItems => storyItems.forEach(item => {
+            if (!(item.pk in this.cache[idToUserMap[item.user.pk].username].stories)) {
+              this.cache[idToUserMap[item.user.pk].username].stories[item.pk] = item;
+            }
+          }))
+          .catch((error: IgClientError & Partial<RequestError>) => {
+            if (error instanceof IgNetworkError) {
+              logger.warn(`error on fetching stories for all: ${JSON.stringify(error.cause)}`);
+            } else {
+              logger.error(`unhandled error on fetching media for all: ${error}`);
+            }
+          });
+      });
+  };
+
   public work = () => {
     const lock = this.lock;
+    logger.debug(`current cache: ${JSON.stringify(this.cache)}`);
     if (this.workInterval < 1) this.workInterval = 1;
     if (lock.feed.length === 0) {
-      setTimeout(() => {
-        this.work();
-      }, this.workInterval * 1000);
+      setTimeout(this.work, this.workInterval * 1000);
       return;
     }
     if (lock.workon >= lock.feed.length) lock.workon = 0;
@@ -297,35 +318,23 @@ export default class {
     }
 
     const currentFeed = lock.feed[lock.workon];
-    logger.debug(`pulling feed ${currentFeed}`);
+    logger.debug(`searching for new items from ${currentFeed} in cache`);
 
-    const promise = new Promise<UserFeedResponseItemsItem[]>(resolve => {
+    const promise = new Promise<MediaItem[]>(resolve => {
       const match = /https:\/\/www\.instagram\.com\/([^\/]+)/.exec(currentFeed);
       if (match) {
-        const feed = this.client.feed.user(lock.threads[currentFeed].id);
-        const newer = (item: UserFeedResponseItemsItem) =>
+        const cachedFeed = this.cache[match[1]];
+        if (!cachedFeed) {
+          setTimeout(this.work, this.workInterval * 1000);
+          resolve([]);
+        }
+        const newer = (item: MediaItem) =>
           BigNumOps.compare(item.pk, lock.threads[currentFeed].offset) > 0;
-        const fetchMore = () => new Promise<UserFeedResponseItemsItem[]>(fetch => {
-          feed.request().then(response => {
-            if (response.items.length === 0) return fetch([]);
-            if (response.items.every(newer)) {
-              fetchMore().then(fetched => fetch(response.items.concat(fetched)));
-            } else fetch(response.items.filter(newer));
-          }, (error: IgClientError & Partial<RequestError>) => {
-            if (error instanceof IgNetworkError) {
-              logger.warn(`error on fetching media for ${currentFeed}: ${JSON.stringify(error.cause)}`);
-              if (!(error instanceof IgNotFoundError)) return;
-              lock.threads[currentFeed].subscribers.forEach(subscriber => {
-                logger.info(`sending notfound message of ${currentFeed} to ${JSON.stringify(subscriber)}`);
-                this.bot.sendTo(subscriber, `链接 ${currentFeed} 指向的用户或列表不存在,请退订。`).catch();
-              });
-            } else {
-              logger.error(`unhandled error on fetching media for ${currentFeed}: ${JSON.stringify(error)}`);
-            }
-            fetch([]);
-          });
-        });
-        fetchMore().then(resolve);
+        resolve(Object.values(cachedFeed.stories)
+          .filter(newer)
+          .map(story => ({...story, user: cachedFeed.user}))
+          .sort((i1, i2) => BigNumOps.compare(i2.pk, i1.pk))
+        );
       }
     });
 
@@ -341,7 +350,7 @@ export default class {
       if (currentThread.offset === '-1') { updateOffset(); return; }
       if (currentThread.offset === '0') mediaItems.splice(1);
 
-      return this.workOnMedia(mediaItems, this.sendMedia(`thread ${currentFeed}`, ...currentThread.subscribers))
+      return this.workOnMedia(mediaItems, this.sendStories(`thread ${currentFeed}`, ...currentThread.subscribers))
         .then(updateDate).then(updateOffset);
     })
       .then(() => {

+ 9 - 180
src/webshot.ts

@@ -1,19 +1,13 @@
 import { writeFileSync } from 'fs';
-import { Readable } from 'stream';
-import { promisify } from 'util';
 
 import axios from 'axios';
 import * as CallableInstance from 'callable-instance';
 import { XmlEntities } from 'html-entities';
-import { PNG } from 'pngjs';
-import * as puppeteer from 'playwright';
-import * as sharp from 'sharp';
 import * as temp from 'temp';
 
 import { getLogger } from './loggers';
 import { Message } from './koishi';
-import { chainPromises } from './utils';
-import { browserLogin, linkBuilder, MediaItem } from './twitter';
+import { MediaItem } from './twitter';
 
 const xmlEntities = new XmlEntities();
 
@@ -29,156 +23,12 @@ const typeInZH = {
 
 const logger = getLogger('webshot');
 
-export type Page = puppeteer.Page;
-export type Cookies = puppeteer.Cookie[];
-
 class Webshot extends CallableInstance<[MediaItem[], (...args) => void, number], Promise<void>> {
-
-  private browser: puppeteer.Browser;
-  private mode: number;
-  private wsUrl: string;
-  private getCookies: () => Cookies;
-
-  constructor(wsUrl: string, mode: number, getCookies: () => Cookies, onready?: (...args) => void) {
+  constructor(_wsUrl: string, _mode: number, onready?: (...args) => void) {
     super('webshot');
-    // tslint:disable-next-line: no-conditional-assignment
-    // eslint-disable-next-line no-cond-assign
-    if (this.mode = mode) {
-      onready();
-    } else {
-      this.getCookies = getCookies;
-      this.wsUrl = wsUrl;
-      this.connect(onready);
-    }
+    onready();
   }
 
-  private connect = (onready?: (...args) => void): Promise<void> =>
-    axios.get<{[key in 'chromium' | 'firefox' | 'webkit']?: string}>(this.wsUrl)
-      .then(res => {
-        logger.info(`received websocket endpoint: ${JSON.stringify(res.data)}`);
-        const browserType = Object.keys(res.data)[0] as keyof typeof res.data;
-        return (puppeteer[browserType] as puppeteer.BrowserType<puppeteer.Browser>)
-          .connect({wsEndpoint: res.data[browserType]});
-      })
-      .then(browser => this.browser = browser)
-      .then(() => {
-        logger.info('launched puppeteer browser');
-        if (onready) return onready();
-      })
-      .catch(error => this.reconnect(error, onready));
-
-  private reconnect = (error, onready?: (...args) => void) => {
-    logger.error(`connection error, reason: ${error}`);
-    logger.warn('trying to reconnect in 2.5s...');
-    return promisify(setTimeout)(2500)
-      .then(() => this.connect(onready));
-  };
-
-  private renderWebshot = (url: string, height: number, webshotDelay: number): Promise<string> => {
-    temp.track();
-    const jpeg = (data: Readable) => data.pipe(sharp()).jpeg({quality: 90, trellisQuantisation: true});
-    const sharpToFile = (pic: sharp.Sharp) => new Promise<string>(resolve => {
-      const webshotTempFilePath = temp.path({suffix: '.jpg'});
-      pic.toFile(webshotTempFilePath).then(() => resolve(`file://${webshotTempFilePath}`));
-    });
-    const promise = new Promise<{ path: string, boundary: null | number }>((resolve, reject) => {
-      const width = 720;
-      const zoomFactor = 2;
-      logger.info(`shooting ${width}*${height} webshot for ${url}`);
-      this.browser.newPage({
-        bypassCSP: true,
-        deviceScaleFactor: zoomFactor,
-        locale: 'ja-JP',
-        timezoneId: 'Asia/Tokyo',
-        userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.99 Safari/537.36',
-      })
-        .then(page => {
-          const startTime = new Date().getTime();
-          const getTimerTime = () => new Date().getTime() - startTime;
-          const getTimeout = () => Math.max(500, webshotDelay - getTimerTime());
-          page.setViewportSize({
-            width: width / zoomFactor,
-            height: height / zoomFactor,
-          }).then(() => page.context().addCookies(this.getCookies()))
-            .then(() => page.goto(url, {waitUntil: 'load', timeout: getTimeout()}))
-            .then(() =>
-              (<T>(next: Promise<T>) => Promise.race([
-                page.click('button:has-text("すべて許可")').then(() => browserLogin(page))
-                  .then(() => page.goto(url, {waitUntil: 'load', timeout: getTimeout()}))
-                  .then(() => next),
-                page.click('button:has-text("すべて許可")').then(() => next),
-                next,
-              ]))(page.waitForSelector('article', {timeout: getTimeout()}))
-            )
-            .catch((err: Error): Promise<puppeteer.ElementHandle<Element> | null> => {
-              if (err.name !== 'TimeoutError') throw err;
-              logger.warn(`navigation timed out at ${getTimerTime()} ms`);
-              return null;
-            })
-            // hide header, "more options" button, like and retweet count
-            .then(() => page.addStyleTag({content:
-              'nav,footer,header+div,header+div+div>div>div+div,header div div div+div,' +
-              'article section,article section+div>ul>:not(div),article section+div~div,article button,canvas{display:none!important} ' +
-              'section+div{overflow:hidden} section+*>*{position:relative!important} article{border-bottom:1px solid!important}',
-            }))
-            .then(() => page.addStyleTag({
-              content: '*{font-family:-apple-system,".Helvetica Neue DeskInterface",Hiragino Sans,Hiragino Sans GB,sans-serif!important}',
-            }))
-            .then(() => page.screenshot())
-            .then(screenshot => {
-              new PNG({
-                filterType: 4,
-                deflateLevel: 0,
-              }).on('parsed', function () {
-                const idx = (x: number, y: number) => (this.width * y + x) << 2;
-                let boundary: number = null;
-                for (let y = this.height - 1; y > this.height - 1920; y -= zoomFactor) {
-                  if (
-                    this.data[idx(zoomFactor, y)] <= 38 &&
-                    this.data[idx(zoomFactor, y)] === this.data[idx(this.width - zoomFactor, y)] &&
-                    this.data[idx(zoomFactor, y + zoomFactor)] === this.data[idx(zoomFactor, y - 2 * zoomFactor)]
-                  ) {
-                    boundary = y;
-                    break;
-                  }
-                }
-                if (boundary !== null) {
-                  logger.info(`found boundary at ${boundary}, cropping image`);
-                  this.data = this.data.slice(0, idx(this.width, boundary));
-                  this.height = boundary;
-
-                  sharpToFile(jpeg(this.pack())).then(path => {
-                    logger.info(`finished webshot for ${url}`);
-                    resolve({path, boundary});
-                  });
-                } else if (height >= 8 * 1920) {
-                  logger.warn('too large, consider as a bug, returning');
-                  sharpToFile(jpeg(this.pack())).then(path => {
-                    resolve({path, boundary: 0});
-                  });
-                } else {
-                  logger.info('unable to find boundary, try shooting a larger image');
-                  resolve({path: '', boundary});
-                }
-              }).parse(screenshot);
-            })
-            .catch(err => {
-              if (err instanceof Error && err.name !== 'TimeoutError') throw err;
-              logger.error(`error shooting webshot for ${url}, could not load web page of tweet`);
-              resolve({path: '', boundary: 0});
-            })
-            .finally(() => { page.close(); });
-        })
-        .catch(reject);
-    });
-    return promise.then(data => {
-      if (data.boundary === null) return this.renderWebshot(url, height + 1920, webshotDelay);
-      else return data.path;
-    }).catch(error => this.reconnect(error)
-      .then(() => this.renderWebshot(url, height, webshotDelay))
-    );
-  };
-
   private fetchMedia = (url: string): Promise<string> => new Promise<ArrayBuffer>((resolve, reject) => {
     logger.info(`fetching ${url}`);
     axios({
@@ -231,21 +81,9 @@ class Webshot extends CallableInstance<[MediaItem[], (...args) => void, number],
 
       // text processing
       const author = `${item.user.full_name} (@${item.user.username}):\n`;
-      const text = item.caption.text;
-      if (this.mode > 0) messageChain += (author + xmlEntities.decode(text));
+      const date = `${new Date(item.taken_at * 1000)}\n`;
+      messageChain += author + date;
 
-      // invoke webshot
-      if (this.mode === 0) {
-        const url = linkBuilder({postUrlSegment: item.code});
-        promise = promise.then(() => this.renderWebshot(url, 1920, webshotDelay))
-          .then(fileurl => {
-            if (fileurl) return Message.Image(fileurl);
-            return author + text;
-          })
-          .then(msg => {
-            if (msg) messageChain += msg;
-          });
-      }
       // fetch extra entities
       const type = (mediaItem): keyof typeof typeInZH =>
         (mediaItem as MediaItem).video_versions ? 'video' : 'photo';
@@ -264,18 +102,9 @@ class Webshot extends CallableInstance<[MediaItem[], (...args) => void, number],
           })
           .then(msg => { messageChain += msg; });
       };
-      // tslint:disable-next-line: curly
-      // eslint-disable-next-line curly
-      if (1 - this.mode % 2) promise = promise.then(() => {
-        if (item.carousel_media) {
-          return chainPromises(item.carousel_media.map(carouselItem =>
-            fetchBestCandidate(
-              (carouselItem as unknown as MediaItem).video_versions || 
-              carouselItem.image_versions2.candidates,
-              type(carouselItem)
-            )
-          ));
-        } else if (item.video_versions) {
+
+      promise = promise.then(() => {
+        if (item.video_versions) {
           return fetchBestCandidate(item.video_versions, type(item));
         } else if (item.image_versions2) {
           return fetchBestCandidate(item.image_versions2.candidates, type(item));
@@ -284,7 +113,7 @@ class Webshot extends CallableInstance<[MediaItem[], (...args) => void, number],
       promise.then(() => {
         logger.info(`done working on ${item.user.username}/${item.code}, message chain:`);
         logger.info(JSON.stringify(Message.ellipseBase64(messageChain)));
-        callback(messageChain, xmlEntities.decode(text), author);
+        callback(messageChain, xmlEntities.decode(item.caption), author);
       });
     });
     return promise;