Просмотр исходного кода

Merge branch 'koishi-redis-waiting' into mediaonly-koishi-redis-waiting

Mike L 3 лет назад
Родитель
Сommit
b1a3a8c141
9 измененных файлов с 131 добавлено и 155 удалено
  1. 4 4
      dist/command.js
  2. 8 8
      dist/koishi.js
  3. 1 1
      dist/loggers.js
  4. 6 6
      dist/main.js
  5. 2 2
      dist/redis.js
  6. 2 2
      dist/twitter.js
  7. 53 67
      dist/webshot.js
  8. 1 1
      package.json
  9. 54 64
      src/webshot.ts

+ 4 - 4
dist/command.js

@@ -7,7 +7,7 @@ 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');
+const logger = (0, loggers_1.getLogger)('command');
 function parseCmd(message) {
     message = message.trim();
     message = message.replace('\\\\', '\\0x5c');
@@ -161,7 +161,7 @@ function list(chat, _, reply, lock) {
     const links = [];
     Object.keys(lock.threads).forEach(key => {
         if (lock.threads[key].subscribers.find(({ chatID, chatType }) => chat.chatID === chatID && chat.chatType === chatType))
-            links.push(`${key} ${datetime_1.relativeDate(lock.threads[key].updatedAt)}`);
+            links.push(`${key} ${(0, datetime_1.relativeDate)(lock.threads[key].updatedAt)}`);
     });
     return reply('此聊天中订阅媒体推文的链接:\n' + links.join('\n'));
 }
@@ -193,7 +193,7 @@ last-1@sunflower930316,noreps=off,norts=on
         forceRefresh = { on: true, off: false }[optMatch[2]];
     }
     try {
-        twitter_1.sendTweet(match[1], chat, forceRefresh);
+        (0, twitter_1.sendTweet)(match[1], chat, forceRefresh);
     }
     catch (e) {
         reply('推特机器人尚未加载完毕,请稍后重试。');
@@ -236,7 +236,7 @@ function query(chat, args, reply) {
         return reply('查询数量上限参数为零、非数值或超出取值范围。');
     }
     try {
-        twitter_1.sendTimeline(conf, chat);
+        (0, twitter_1.sendTimeline)(conf, chat);
     }
     catch (e) {
         logger.error(`error querying timeline, error: ${e}`);

+ 8 - 8
dist/koishi.js

@@ -15,7 +15,7 @@ require("koishi-adapter-onebot");
 const command_1 = require("./command");
 const loggers_1 = require("./loggers");
 const utils_1 = require("./utils");
-const logger = loggers_1.getLogger('qqbot');
+const logger = (0, loggers_1.getLogger)('qqbot');
 const cqUrlFix = (factory) => (...args) => factory(...args).replace(/(?<=\[CQ:.*)url=(?=(base64|file|https?):\/\/)/, 'file=');
 exports.Message = {
     Image: cqUrlFix(koishi_1.segment.image),
@@ -51,7 +51,7 @@ class default_1 {
             var _a, _b;
             let wasEmpty = false;
             const queue = (_a = this.messageQueues)[_b = `${type}:${id}`] || (_a[_b] = (() => { wasEmpty = true; return []; })());
-            queue.push(() => koishi_1.sleep(200).then(resolver));
+            queue.push(() => (0, koishi_1.sleep)(200).then(resolver));
             logger.debug(`no. of message currently queued for ${type}:${id}: ${queue.length}`);
             if (wasEmpty)
                 this.next(type, id);
@@ -139,7 +139,7 @@ class default_1 {
                             .then(() => { logger.info(`accepted friend request from ${userString} (from group ${groupString})`); })
                             .catch(error => { logger.error(`error accepting friend request from ${userString}, error: ${error}`); });
                     }
-                    utils_1.chainPromises(groupList.map(groupItem => (done) => Promise.resolve(done ||
+                    (0, utils_1.chainPromises)(groupList.map(groupItem => (done) => Promise.resolve(done ||
                         this.bot.getGroupMember(groupItem.groupId, session.userId).then(() => {
                             groupString = `${groupItem.groupName}(${groupItem.groupId})`;
                             return session.bot.handleFriendRequest(session.messageId, true)
@@ -170,7 +170,7 @@ class default_1 {
             }));
             this.app.middleware((session) => __awaiter(this, void 0, void 0, function* () {
                 const chat = yield this.getChat(session);
-                const cmdObj = command_1.parseCmd(session.content);
+                const cmdObj = (0, command_1.parseCmd)(session.content);
                 const reply = (msg) => __awaiter(this, void 0, void 0, function* () {
                     const userString = `${session.username}(${session.userId})`;
                     return (chat.chatType === "group" ? this.sendToGroup : this.sendToUser)(chat.chatID.toString(), msg)
@@ -179,14 +179,14 @@ class default_1 {
                 switch (cmdObj.cmd) {
                     case 'twitterpic_view':
                     case 'twitterpic_get':
-                        command_1.view(chat, cmdObj.args, reply);
+                        (0, command_1.view)(chat, cmdObj.args, reply);
                         break;
                     case 'twitterpic_resendlast':
-                        command_1.resendLast(chat, cmdObj.args, reply);
+                        (0, command_1.resendLast)(chat, cmdObj.args, reply);
                         break;
                     case 'twitterpic_query':
                     case 'twitterpic_gettimeline':
-                        command_1.query(chat, cmdObj.args, reply);
+                        (0, command_1.query)(chat, cmdObj.args, reply);
                         break;
                     case 'twitterpic_sub':
                     case 'twitterpic_subscribe':
@@ -246,7 +246,7 @@ count 与 since/until 并用时,取二者中实际查询结果较少者
             }
             catch (err) {
                 logger.error(`error connecting to bot provider at ${this.app.options.server}, will retry in 2.5s...`);
-                yield koishi_1.sleep(2500);
+                yield (0, koishi_1.sleep)(2500);
                 yield this.listen('retry connecting...');
             }
         });

+ 1 - 1
dist/loggers.js

@@ -4,7 +4,7 @@ exports.setLogLevels = exports.getLogger = void 0;
 const log4js_1 = require("log4js");
 const loggers = [];
 function getLogger(category) {
-    const l = log4js_1.getLogger(category);
+    const l = (0, log4js_1.getLogger)(category);
     l.level = 'info';
     loggers.push(l);
     return l;

+ 6 - 6
dist/main.js

@@ -10,7 +10,7 @@ const command_1 = require("./command");
 const loggers_1 = require("./loggers");
 const koishi_1 = require("./koishi");
 const twitter_1 = require("./twitter");
-const logger = loggers_1.getLogger();
+const logger = (0, loggers_1.getLogger)();
 const sections = [
     {
         header: 'GoCQHTTP Twitter Bot',
@@ -69,7 +69,7 @@ optionalFields.forEach(key => {
         config[key] = exampleConfig[key];
     }
 });
-loggers_1.setLogLevels(config.loglevel);
+(0, loggers_1.setLogLevels)(config.loglevel);
 let lock;
 if (fs.existsSync(path.resolve(config.lockfile))) {
     try {
@@ -114,10 +114,10 @@ const qq = new koishi_1.default({
     host: config.cq_ws_host,
     port: config.cq_ws_port,
     bot_id: config.cq_bot_qq,
-    list: (c, a, cb) => command_1.list(c, a, cb, lock),
-    sub: (c, a, cb) => command_1.sub(c, a, cb, lock, config.lockfile),
-    unsub: (c, a, cb) => command_1.unsub(c, a, cb, lock, config.lockfile),
-    unsubAll: (c, a, cb) => command_1.unsubAll(c, a, cb, lock, config.lockfile),
+    list: (c, a, cb) => (0, command_1.list)(c, a, cb, lock),
+    sub: (c, a, cb) => (0, command_1.sub)(c, a, cb, lock, config.lockfile),
+    unsub: (c, a, cb) => (0, command_1.unsub)(c, a, cb, lock, config.lockfile),
+    unsubAll: (c, a, cb) => (0, command_1.unsubAll)(c, a, cb, lock, config.lockfile),
 });
 const worker = new twitter_1.default({
     consumerKey: config.twitter_consumer_key,

+ 2 - 2
dist/redis.js

@@ -2,7 +2,7 @@
 Object.defineProperty(exports, "__esModule", { value: true });
 const redis_1 = require("redis");
 const loggers_1 = require("./loggers");
-const logger = loggers_1.getLogger('redis');
+const logger = (0, loggers_1.getLogger)('redis');
 class default_1 {
     constructor(opt) {
         this.chatAsString = (chat) => `${chat.chatType}:${chat.chatID.toString()}`;
@@ -117,7 +117,7 @@ class default_1 {
                 return false;
             });
         };
-        this.client = redis_1.createClient({
+        this.client = (0, redis_1.createClient)({
             host: opt.redisHost,
             port: opt.redisPort,
         });

+ 2 - 2
dist/twitter.js

@@ -47,7 +47,7 @@ exports.sendTimeline = sendTimeline;
 const TWITTER_EPOCH = 1288834974657;
 const snowflake = (epoch) => Number.isNaN(epoch) ? undefined :
     utils_1.BigNumOps.lShift(String(epoch - 1 - TWITTER_EPOCH), 22);
-const logger = loggers_1.getLogger('twitter');
+const logger = (0, loggers_1.getLogger)('twitter');
 const maxTrials = 3;
 const retryInterval = 1500;
 const ordinal = (n) => {
@@ -396,7 +396,7 @@ class default_1 {
                 noreps: { on: true, off: false }[noreps],
                 norts: { on: true, off: false }[norts],
             })
-                .then(tweets => utils_1.chainPromises(tweets.map(tweet => () => this.bot.sendTo(receiver, `\
+                .then(tweets => (0, utils_1.chainPromises)(tweets.map(tweet => () => this.bot.sendTo(receiver, `\
 编号:${tweet.id_str}
 时间:${tweet.created_at}
 媒体:${tweet.extended_entities ? '有' : '无'}

+ 53 - 67
dist/webshot.js

@@ -25,7 +25,7 @@ const typeInZH = {
     video: ZHType('视频'),
     animated_gif: ZHType('GIF'),
 };
-const logger = loggers_1.getLogger('webshot');
+const logger = (0, loggers_1.getLogger)('webshot');
 class Webshot extends CallableInstance {
     constructor(wsUrl, mode, onready) {
         super('webshot');
@@ -46,15 +46,9 @@ class Webshot extends CallableInstance {
         this.reconnect = (error, onready) => {
             logger.error(`connection error, reason: ${error}`);
             logger.warn('trying to reconnect in 2.5s...');
-            return util_1.promisify(setTimeout)(2500)
+            return (0, util_1.promisify)(setTimeout)(2500)
                 .then(() => this.connect(onready));
         };
-        this.extendEntity = (media) => {
-            logger.info('not working on a tweet');
-        };
-        this.truncateLongThread = (atId) => {
-            logger.info('not working on a tweet');
-        };
         this.renderWebshot = (url, height, webshotDelay, ...morePostProcessings) => {
             temp.track();
             const jpeg = (data) => data.pipe(sharp()).jpeg({ quality: 90, trellisQuantisation: true });
@@ -112,7 +106,7 @@ class Webshot extends CallableInstance {
                         const count = sensitiveToggles.length;
                         if (count)
                             logger.info(`found ${count} sensitive ${count === 1 ? 'tweet' : 'tweets'} on page, uncollapsing...`);
-                        return utils_1.chainPromises(sensitiveToggles.filter(toggle => toggle.isVisible()).map(toggle => () => toggle.click()));
+                        return (0, utils_1.chainPromises)(sensitiveToggles.filter(toggle => toggle.isVisible()).map(toggle => () => toggle.click()));
                     })
                         .then(() => handle))
                         .then(handle => handle.$('[data-testid="tweet"]').then(owner => owner ? handle : null))
@@ -122,7 +116,7 @@ class Webshot extends CallableInstance {
                         logger.warn(`${err} (${getTimerTime()} ms)`);
                         return page.evaluate(() => document.documentElement.outerHTML).then(html => {
                             const path = temp.path({ suffix: '.html' });
-                            fs_1.writeFileSync(path, html);
+                            (0, fs_1.writeFileSync)(path, html);
                             logger.warn(`saved debug html to ${path}`);
                         }).then(() => page.screenshot()).then(screenshot => {
                             sharpToFile(sharp(screenshot).jpeg({ quality: 90 })).then(fileUri => {
@@ -130,53 +124,12 @@ class Webshot extends CallableInstance {
                             });
                         }).then(() => null);
                     })
-                        .then((handle) => {
+                        .then(handle => {
                         if (handle === null)
                             throw new puppeteer.errors.TimeoutError();
-                        return handle.evaluate(div => {
-                            try {
-                                const selector = '[data-testid="tweet"] :nth-child(2)>:first-child a';
-                                const getProfileUrl = () => (div.querySelector(selector) || { href: '' }).href;
-                                const ownerProfileUrl = getProfileUrl();
-                                const bottom = div;
-                                while (div = div.previousElementSibling) {
-                                    if (getProfileUrl() !== ownerProfileUrl || div === bottom.previousElementSibling)
-                                        continue;
-                                    const top = document.documentElement.scrollTop = window.scrollY + div.getBoundingClientRect().top;
-                                    if (top > 10)
-                                        return div.querySelector('article a[aria-label]').href.replace(/.*\/status\//, '');
-                                }
-                            }
-                            catch (_a) { }
-                            document.documentElement.scrollTop = 0;
-                        }).then(this.truncateLongThread).then(() => handle);
+                        return (0, utils_1.chainPromises)(morePostProcessings.map(func => () => func(page, handle)));
                     })
-                        .then(handle => handle.evaluate(div => {
-                        const cardImg = div.querySelector('div[data-testid^="card.layout"][data-testid$=".media"] img');
-                        if (typeof (cardImg === null || cardImg === void 0 ? void 0 : cardImg.getAttribute('src')) === 'string') {
-                            const match = /^(.*\/card_img\/(\d+)\/.+\?format=.*)&name=/.exec(cardImg === null || cardImg === void 0 ? void 0 : cardImg.getAttribute('src'));
-                            if (match) {
-                                const [media_url_https, id_str] = match.slice(1);
-                                return {
-                                    media_url: media_url_https.replace(/^https/, 'http'),
-                                    media_url_https,
-                                    url: '',
-                                    display_url: '',
-                                    expanded_url: '',
-                                    type: 'photo',
-                                    id: Number(id_str),
-                                    id_str,
-                                    sizes: undefined,
-                                };
-                            }
-                        }
-                    }))
-                        .then(cardImg => {
-                        if (cardImg)
-                            this.extendEntity(cardImg);
-                    })
-                        .then(() => utils_1.chainPromises(morePostProcessings.map(func => () => func(page))))
-                        .then(() => util_1.promisify(setTimeout)(getTimeout()))
+                        .then(() => (0, util_1.promisify)(setTimeout)(getTimeout()))
                         .then(() => page.evaluate(() => document.activeElement.blur()))
                         .then(() => page.screenshot())
                         .then(screenshot => {
@@ -236,7 +189,7 @@ class Webshot extends CallableInstance {
         };
         this.fetchMedia = (url) => new Promise((resolve, reject) => {
             logger.info(`fetching ${url}`);
-            axios_1.default({
+            (0, axios_1.default)({
                 method: 'get',
                 url,
                 responseType: 'arraybuffer',
@@ -258,7 +211,7 @@ class Webshot extends CallableInstance {
             var _a;
             return (ext => {
                 const mediaTempFilePath = temp.path({ suffix: `.${ext}` });
-                fs_1.writeFileSync(mediaTempFilePath, Buffer.from(data));
+                (0, fs_1.writeFileSync)(mediaTempFilePath, Buffer.from(data));
                 const path = `file://${mediaTempFilePath}`;
                 switch (ext) {
                     case 'jpg':
@@ -280,11 +233,8 @@ class Webshot extends CallableInstance {
         }
     }
     webshot(tweets, callback, webshotDelay) {
-        let promise = new Promise(resolve => {
-            resolve();
-        });
-        tweets.forEach((twi, index) => {
-            promise = promise.then(() => util_1.promisify(setTimeout)(webshotDelay / 4 * index)).then(() => {
+        const promises = tweets.map((twi, index) => {
+            let promise = (0, util_1.promisify)(setTimeout)(webshotDelay / 4 * index).then(() => {
                 logger.info(`working on ${twi.user.screen_name}/${twi.id_str}`);
             });
             const originTwi = twi.retweeted_status || twi;
@@ -311,20 +261,56 @@ class Webshot extends CallableInstance {
             });
             if (this.mode === 0) {
                 const url = `https://mobile.twitter.com/${twi.user.screen_name}/status/${twi.id_str}`;
-                this.extendEntity = (cardImg) => {
+                const extendEntity = (cardImg) => {
                     var _a, _b;
                     originTwi.extended_entities = Object.assign(Object.assign({}, originTwi.extended_entities), { media: [
                             ...(_b = (_a = originTwi.extended_entities) === null || _a === void 0 ? void 0 : _a.media) !== null && _b !== void 0 ? _b : [],
                             cardImg,
                         ] });
                 };
-                this.truncateLongThread = (atId) => {
+                const truncateLongThread = (atId) => {
                     if (!atId)
                         return;
                     logger.info(`thread too long, truncating at tweet ${atId}...`);
                     truncatedAt = atId;
                 };
-                promise = promise.then(() => this.renderWebshot(url, 1920, webshotDelay))
+                promise = promise.then(() => this.renderWebshot(url, 1920, webshotDelay, (_, tweetHandle) => tweetHandle.evaluate(div => {
+                    try {
+                        const selector = '[data-testid="tweet"] :nth-child(2)>:first-child a';
+                        const getProfileUrl = () => (div.querySelector(selector) || { href: '' }).href;
+                        const ownerProfileUrl = getProfileUrl();
+                        const bottom = div;
+                        while (div = div.previousElementSibling) {
+                            if (getProfileUrl() !== ownerProfileUrl || div === bottom.previousElementSibling)
+                                continue;
+                            const top = document.documentElement.scrollTop = window.scrollY + div.getBoundingClientRect().top;
+                            if (top > 10)
+                                return div.querySelector('article a[aria-label]').href.replace(/.*\/status\//, '');
+                        }
+                    }
+                    catch (_a) { }
+                    document.documentElement.scrollTop = 0;
+                }).then(truncateLongThread), (_, tweetHandle) => tweetHandle.evaluate(div => {
+                    const cardImg = div.querySelector('div[data-testid^="card.layout"][data-testid$=".media"] img');
+                    if (typeof (cardImg === null || cardImg === void 0 ? void 0 : cardImg.getAttribute('src')) === 'string') {
+                        const match = /^(.*\/card_img\/(\d+)\/.+\?format=.*)&name=/.exec(cardImg === null || cardImg === void 0 ? void 0 : cardImg.getAttribute('src'));
+                        if (match) {
+                            const [media_url_https, id_str] = match.slice(1);
+                            return {
+                                media_url: media_url_https.replace(/^https/, 'http'),
+                                media_url_https,
+                                url: '',
+                                display_url: '',
+                                expanded_url: '',
+                                type: 'photo',
+                                id: Number(id_str),
+                                id_str,
+                                sizes: undefined,
+                            };
+                        }
+                    }
+                }).then(cardImg => { if (cardImg)
+                    extendEntity(cardImg); })))
                     .then(fileurl => {
                     if (fileurl)
                         return koishi_1.Message.Image(fileurl);
@@ -338,7 +324,7 @@ class Webshot extends CallableInstance {
             if (1 - this.mode % 2)
                 promise = promise.then(() => {
                     if (originTwi.extended_entities) {
-                        return utils_1.chainPromises(originTwi.extended_entities.media.map(media => () => {
+                        return (0, utils_1.chainPromises)(originTwi.extended_entities.media.map(media => () => {
                             let url;
                             if (media.type === 'photo') {
                                 url = media.media_url_https.replace(/\.([a-z]+)$/, '?format=$1') + '&name=orig';
@@ -385,7 +371,7 @@ class Webshot extends CallableInstance {
                         messageChain += `\n回复此命令查看引用的推文:\n/twitter_view ${blockQuoteIdStr}`;
                 });
             }
-            promise.then(() => {
+            return promise.then(() => {
                 logger.info(`done working on ${twi.user.screen_name}/${twi.id_str}, message chain:`);
                 logger.info(JSON.stringify(koishi_1.Message.ellipseBase64(messageChain)));
                 let cacheId = twi.id_str;
@@ -394,7 +380,7 @@ class Webshot extends CallableInstance {
                 callback(cacheId, messageChain, xmlEntities.decode(text), author);
             });
         });
-        return promise;
+        return Promise.all(promises).then();
     }
 }
 exports.default = Webshot;

+ 1 - 1
package.json

@@ -44,7 +44,7 @@
     "sharp": "^0.25.4",
     "temp": "^0.9.1",
     "twitter": "^1.7.1",
-    "typescript": "^4.2.3"
+    "typescript": "^4.5.5"
   },
   "devDependencies": {
     "@types/command-line-usage": "^5.0.1",

+ 54 - 64
src/webshot.ts

@@ -70,17 +70,9 @@ class Webshot extends CallableInstance<[Tweet[], (...args) => void, number], Pro
       .then(() => this.connect(onready));
   };
 
-  private extendEntity = (media: MediaEntity) => {
-    logger.info('not working on a tweet');
-  };
-
-  private truncateLongThread = (atId: string) => {
-    logger.info('not working on a tweet');
-  };
-
   private renderWebshot = (
     url: string, height: number, webshotDelay: number,
-    ...morePostProcessings: ((page: puppeteer.Page) => Promise<any>)[]
+    ...morePostProcessings: ((page?: puppeteer.Page, handle?: puppeteer.ElementHandle) => Promise<any>)[]
   ): Promise<string> => {
     temp.track();
     const jpeg = (data: Readable) => data.pipe(sharp()).jpeg({quality: 90, trellisQuantisation: true});
@@ -155,7 +147,7 @@ class Webshot extends CallableInstance<[Tweet[], (...args) => void, number], Pro
               throw err;
               logger.warn(`${err} (${getTimerTime()} ms)`);
               return page.evaluate(() => document.documentElement.outerHTML).then(html => {
-                const path = temp.path({ suffix: '.html' });
+                const path = temp.path({suffix: '.html'});
                 writeFileSync(path, html);
                 logger.warn(`saved debug html to ${path}`);
               }).then(() => page.screenshot()).then(screenshot => {
@@ -164,52 +156,10 @@ class Webshot extends CallableInstance<[Tweet[], (...args) => void, number], Pro
                 });
               }).then(() => null);
             })
-            // scroll back at least 2 tweets revealing 2nd last tweet by owner in thread, or top of thread, if any
-            .then((handle: puppeteer.ElementHandle<HTMLDivElement>) => {
+            .then(handle => {
               if (handle === null) throw new puppeteer.errors.TimeoutError();
-              return handle.evaluate(div => {
-                try {
-                  const selector = '[data-testid="tweet"] :nth-child(2)>:first-child a';
-                  const getProfileUrl = () => (div.querySelector<HTMLAnchorElement>(selector) || {href: ''}).href;
-                  const ownerProfileUrl = getProfileUrl();
-                  const bottom = div;
-                  // eslint-disable-next-line no-cond-assign
-                  while (div = div.previousElementSibling as HTMLDivElement) {
-                    if (getProfileUrl() !== ownerProfileUrl || div === bottom.previousElementSibling) continue;
-                    const top = document.documentElement.scrollTop = window.scrollY + div.getBoundingClientRect().top;
-                    if (top > 10)
-                      return div.querySelector<HTMLAnchorElement>('article a[aria-label]').href.replace(/.*\/status\//, '');
-                  }
-                } catch {/* handle errors like none-found cases */}
-                document.documentElement.scrollTop = 0;
-              }).then(this.truncateLongThread).then(() => handle);
-            })
-            // scrape card image from main tweet
-            .then(handle => handle.evaluate(div => {
-              const cardImg = div.querySelector('div[data-testid^="card.layout"][data-testid$=".media"] img');
-              if (typeof cardImg?.getAttribute('src') === 'string') {
-                const match = /^(.*\/card_img\/(\d+)\/.+\?format=.*)&name=/.exec(cardImg?.getAttribute('src'));
-                if (match) {
-                  // tslint:disable-next-line: variable-name
-                  const [media_url_https, id_str] = match.slice(1);
-                  return {
-                    media_url: media_url_https.replace(/^https/, 'http'),
-                    media_url_https,
-                    url: '',
-                    display_url: '',
-                    expanded_url: '',
-                    type: 'photo',
-                    id: Number(id_str),
-                    id_str,
-                    sizes: undefined,
-                  };
-                }
-              }
-            }))
-            .then(cardImg => {
-              if (cardImg) this.extendEntity(cardImg); 
+              return chainPromises(morePostProcessings.map(func => () => func(page, handle)));
             })
-            .then(() => chainPromises(morePostProcessings.map(func => () => func(page))))
             .then(() => promisify(setTimeout)(getTimeout()))
             // hide highlight of retweet header
             .then(() => page.evaluate(() => (document.activeElement as unknown as HTMLOrSVGElement).blur()))
@@ -313,11 +263,8 @@ class Webshot extends CallableInstance<[Tweet[], (...args) => void, number], Pro
     callback: (cacheId: string, msgs: string, text: string, author: string) => void,
     webshotDelay: number
   ): Promise<void> {
-    let promise = new Promise<void>(resolve => {
-      resolve();
-    });
-    tweets.forEach((twi, index) => {
-      promise = promise.then(() => promisify(setTimeout)(webshotDelay / 4 * index)).then(() => {
+    const promises = tweets.map((twi, index) => {
+      let promise = promisify(setTimeout)(webshotDelay / 4 * index).then(() => {
         logger.info(`working on ${twi.user.screen_name}/${twi.id_str}`);
       });
       const originTwi = twi.retweeted_status || twi;
@@ -348,7 +295,7 @@ class Webshot extends CallableInstance<[Tweet[], (...args) => void, number], Pro
       // invoke webshot
       if (this.mode === 0) {
         const url = `https://mobile.twitter.com/${twi.user.screen_name}/status/${twi.id_str}`;
-        this.extendEntity = (cardImg: MediaEntity) => {
+        const extendEntity = (cardImg: MediaEntity) => {
           originTwi.extended_entities = {
             ...originTwi.extended_entities,
             media: [
@@ -357,12 +304,55 @@ class Webshot extends CallableInstance<[Tweet[], (...args) => void, number], Pro
             ],
           };
         };
-        this.truncateLongThread = (atId: string) => {
+        const truncateLongThread = (atId: string) => {
           if (!atId) return;
           logger.info(`thread too long, truncating at tweet ${atId}...`);
           truncatedAt = atId;
         };
-        promise = promise.then(() => this.renderWebshot(url, 1920, webshotDelay))
+
+        promise = promise.then(() => this.renderWebshot(url, 1920, webshotDelay,
+
+          // scroll back at least 2 tweets revealing 2nd last tweet by owner in thread, or top of thread
+          (_, tweetHandle: puppeteer.ElementHandle<HTMLDivElement>) => tweetHandle.evaluate(div => {
+            try {
+              const selector = '[data-testid="tweet"] :nth-child(2)>:first-child a';
+              const getProfileUrl = () => (div.querySelector<HTMLAnchorElement>(selector) || {href: ''}).href;
+              const ownerProfileUrl = getProfileUrl();
+              const bottom = div;
+              // eslint-disable-next-line no-cond-assign
+              while (div = div.previousElementSibling as HTMLDivElement) {
+                if (getProfileUrl() !== ownerProfileUrl || div === bottom.previousElementSibling) continue;
+                const top = document.documentElement.scrollTop = window.scrollY + div.getBoundingClientRect().top;
+                if (top > 10)
+                  return div.querySelector<HTMLAnchorElement>('article a[aria-label]').href.replace(/.*\/status\//, '');
+              }
+            } catch {/* handle errors like none-found cases */}
+            document.documentElement.scrollTop = 0;
+          }).then(truncateLongThread),
+
+          // scrape card image from main tweet
+          (_, tweetHandle: puppeteer.ElementHandle<HTMLDivElement>) => tweetHandle.evaluate(div => {
+            const cardImg = div.querySelector('div[data-testid^="card.layout"][data-testid$=".media"] img');
+            if (typeof cardImg?.getAttribute('src') === 'string') {
+              const match = /^(.*\/card_img\/(\d+)\/.+\?format=.*)&name=/.exec(cardImg?.getAttribute('src'));
+              if (match) {
+                // tslint:disable-next-line: variable-name
+                const [media_url_https, id_str] = match.slice(1);
+                return {
+                  media_url: media_url_https.replace(/^https/, 'http'),
+                  media_url_https,
+                  url: '',
+                  display_url: '',
+                  expanded_url: '',
+                  type: 'photo',
+                  id: Number(id_str),
+                  id_str,
+                  sizes: undefined,
+                };
+              }
+            }
+          }).then(cardImg => { if (cardImg) extendEntity(cardImg); })
+        ))
           .then(fileurl => {
             if (fileurl) return Message.Image(fileurl);
             return '[截图不可用] ' + author + text;
@@ -423,7 +413,7 @@ class Webshot extends CallableInstance<[Tweet[], (...args) => void, number], Pro
           if (blockQuoteIdStr) messageChain += `\n回复此命令查看引用的推文:\n/twitter_view ${blockQuoteIdStr}`;
         });
       }
-      promise.then(() => {
+      return promise.then(() => {
         logger.info(`done working on ${twi.user.screen_name}/${twi.id_str}, message chain:`);
         logger.info(JSON.stringify(Message.ellipseBase64(messageChain)));
         let cacheId = twi.id_str;
@@ -431,7 +421,7 @@ class Webshot extends CallableInstance<[Tweet[], (...args) => void, number], Pro
         callback(cacheId, messageChain, xmlEntities.decode(text), author);
       });
     });
-    return promise;
+    return Promise.all(promises).then();
   }
 }