Pārlūkot izejas kodu

delete readme, some experiments

Mike L 4 gadi atpakaļ
vecāks
revīzija
1c84ade4fa
10 mainītis faili ar 245 papildinājumiem un 298 dzēšanām
  1. 0 100
      README.md
  2. 1 0
      config.example.json
  3. 1 0
      dist/main.js
  4. 10 6
      dist/mirai.js
  5. 4 10
      dist/twitter.js
  6. 98 80
      dist/webshot.js
  7. 1 0
      src/main.ts
  8. 16 12
      src/mirai.ts
  9. 10 11
      src/twitter.ts
  10. 104 79
      src/webshot.ts

+ 0 - 100
README.md

@@ -1,100 +0,0 @@
-# CQHTTP Twitter Bot
-
-[![npm](https://img.shields.io/npm/v/cqhttp-twitter-bot.svg)](https://www.npmjs.com/package/cqhttp-twitter-bot)
-[![npm](https://img.shields.io/npm/dt/cqhttp-twitter-bot.svg)](https://www.npmjs.com/package/cqhttp-twitter-bot)
-[![GitHub issues](https://img.shields.io/github/issues/rikakomoe/cqhttp-twitter-bot.svg)](https://github.com/rikakomoe/cqhttp-twitter-bot/issues)
-[![npm](https://img.shields.io/npm/l/cqhttp-twitter-bot.svg)](https://www.npmjs.com/package/cqhttp-twitter-bot)
-
-是一个可以订阅 Twitter 并转发到 QQ 的 Bot。
-
-## 这个项目已经停止维护
-
-+ 因为我已经没有在用它了,所以能不能用是随缘的
-+ Issue 反馈是欢迎的,但不保证能够及时处理
-+ 代码不多,欢迎阅读/fork,如果有问题我不能及时修理,你其实可以自己试试 :)
-
-## 安装
-
-```bash
-npm i -g cqhttp-twitter-bot
-```
-
-当然还需要配合 [coolq-http-api](https://github.com/richardchien/coolq-http-api) 和 [酷Q](https://cqp.cc/) 才能工作。  
-它们是什么?  
-观察它们的文档:[https://cqhttp.cc/](https://cqhttp.cc/) [https://cqp.cc/t/15124](https://cqp.cc/t/15124)
-
-## 食用
-
-```bash
-cqhttp-twitter-bot config.json
-```
-
-## 配置
-
-它会从命令传入的 JSON 配置文件里读取配置,配置说明如下
-
-| 配置项 | 说明 | 默认 |
-| --- | --- | --- |
-| cq_ws_host | CQHTTP Websocket 服务端地址 | 127.0.0.1 |
-| cq_ws_port | CQHTTP Websocket 服务端口 | 6700 |
-| cq_access_token | CQHTTP access_token | (空) |
-| twitter_consumer_key | Twitter App consumer_key | (必填) |
-| twitter_consumer_secret |  Twitter App consumer_secret | (必填) |
-| twitter_access_token_key | Twitter App access_token_key | (必填) |
-| twitter_access_token_secret | Twitter App access_token_secret | (必填) |
-| mode | 工作模式,0 为图文模式,1 为纯文本模式。图文模式必须使用 [酷Q Pro](https://cqp.cc/t/14901)。 | 0 |
-| work_interval | 对单个订阅两次拉取更新的最少间隔时间(秒) | 60 |
-| webshot_delay | 抓取网页截图时等待网页加载的延迟时长(毫秒) | 5000 |
-| lockfile | 本地保存订阅信息以便下次启动时恢复 | subscriber.lock |
-| loglevel | 日志调试等级 | info |
-| redis | 启用 Redis | false |
-| redis_host | Redis Host | 127.0.0.1 |
-| redis_port | Redis Port | 6379 |
-| redis_expire_time | Redis Key 过期时间(秒) | 43200 |
-
-示例文件在 `config.example.json`
-
-## 命令
-
-Bot 启动了以后就可以在 QQ 里用命令了。命令有:
-
-- /twitter - 列出当前会话的订阅
-- /twitter_sub [链接] - 订阅
-- /twitter_unsub [链接] - 退订
-
-链接可以是一个个人的时间轴或者是列表,支持下面几种格式:
-
-个人:
-  + https://twitter.com/Saito_Shuka
-  + https://mobile.twitter.com/Saito_Shuka
-  + Saito_Shuka
-
-列表:
-  + https://twitter.com/rikakomoe/lists/lovelive
-  + https://mobile.twitter.com/rikakomoe/lists/lovelive
-  + rikakomoe/lovelive
-
-## 其他说明
-
-1. Twitter 这两个(时间轴和列表) API 对单个应用的限制是 900次/15min,
-也就是最快可以 1s 一次。这个 Bot 的工作方式是轮流拉取,即:
-每次从队首拿出任务,完成后放到队尾。在不达到 1s 一次的前提下,
-总体请求速度会随着订阅量的增加而加快:例如当 work_interval 设置为 60 时,
-如果只有 1 个订阅,那么每分钟只有 1 个请求。如果有 2 个订阅,每分钟则有 2 个请求。
-如果有 70 个订阅,每分钟仍然只有 60 个请求。
-
-2. 上面说的每分钟之类指的是休眠的时长,工作时间不算在内。因此实际的 API 调用
-频率要比这个低。
-
-3. webshot_delay 如果设成 0 的话肯定不行的,会出现正在加载的界面。这个具体多
-少最合适可以自己试,5 秒应该是比较保险了。
-
-4. 如果启用了 Redis,同一聊天中 Redis 过期时间内的重复推文会被去重,
-如同一列表中不同人转推等。
-
-5. 原创和转推的推文如果有图片的话会另外拉取一波图片附加在消息里。
-如果是视频的话则是封面图。“带评论转推”并不是转推,相当于是分享的链接,
-跟从一些其他网站分享的链接是类似的。
-这种情况不会拉取图片过来,链接也不会有(因为一般都被墙了)。
-
-6. 怎么查看翻译?QQ 点开图片,长按 -> 提取图中文字 -> 译

+ 1 - 0
config.example.json

@@ -2,6 +2,7 @@
   "mirai_access_token": "",
   "mirai_http_host": "127.0.0.1",
   "mirai_http_port": 8080,
+  "mirai_http_base_dir": ".",
   "mirai_bot_qq": 10000,
   "twitter_consumer_key": "",
   "twitter_consumer_secret": "",

+ 1 - 0
dist/main.js

@@ -136,6 +136,7 @@ const worker = new twitter_1.default({
     workInterval: config.work_interval,
     bot: qq,
     webshotDelay: config.webshot_delay,
+    webshotOutDir: config.mirai_http_base_dir + '/images',
     mode: config.mode,
 });
 worker.launch();

+ 10 - 6
dist/mirai.js

@@ -21,16 +21,19 @@ const ChatTypeMap = {
 };
 class default_1 {
     constructor(opt) {
-        this.sendTo = (subscriber, msg) => {
+        this.sendTo = (subscriber, msg) => (() => {
             switch (subscriber.chatType) {
                 case 'group':
-                    return this.bot.api.sendGroupMessage(msg, subscriber.chatID)
-                        .catch(reason => logger.error(`error pushing data to ${subscriber.chatID}, reason: ${reason}`));
+                    return this.bot.api.sendGroupMessage(msg, subscriber.chatID);
                 case 'private':
-                    return this.bot.api.sendFriendMessage(msg, subscriber.chatID)
-                        .catch(reason => logger.error(`error pushing data to ${subscriber.chatID}, reason: ${reason}`));
+                    return this.bot.api.sendFriendMessage(msg, subscriber.chatID);
             }
-        };
+        })()
+            .then(response => {
+            logger.info(`pushing data to ${subscriber.chatID} was successful, response:`);
+            logger.info(response);
+        })
+            .catch(reason => logger.error(`error pushing data to ${subscriber.chatID}, reason: ${reason}`));
         this.initBot = () => {
             this.bot = new mirai_ts_1.default({
                 authKey: this.botInfo.access_token,
@@ -71,6 +74,7 @@ class default_1 {
                 }
             });
         };
+        // TODO doesn't work if connection is dropped after connection
         this.listen = (logMsg) => {
             if (logMsg !== '') {
                 logger.warn((logMsg !== null && logMsg !== void 0 ? logMsg : 'Listening...'));

+ 4 - 10
dist/twitter.js

@@ -1,18 +1,15 @@
 "use strict";
 Object.defineProperty(exports, "__esModule", { value: true });
 const fs = require("fs");
-const html_entities_1 = require("html-entities");
 const path = require("path");
-const sha1 = require("sha1");
 const Twitter = require("twitter");
 const loggers_1 = require("./loggers");
 const webshot_1 = require("./webshot");
 const logger = loggers_1.getLogger('twitter');
-const entities = new html_entities_1.XmlEntities();
 class default_1 {
     constructor(opt) {
         this.launch = () => {
-            this.webshot = new webshot_1.default(() => setTimeout(this.work, this.workInterval * 1000));
+            this.webshot = new webshot_1.default(this.webshotOutDir, this.mode, () => setTimeout(this.work, this.workInterval * 1000));
         };
         this.work = () => {
             const lock = this.lock;
@@ -95,14 +92,10 @@ class default_1 {
                 }
                 if (lock.threads[lock.feed[lock.workon]].offset === 0)
                     tweets.splice(1);
-                return this.webshot(this.mode, tweets, (msg, text, author) => {
+                return this.webshot(tweets, msg => {
                     lock.threads[lock.feed[lock.workon]].subscribers.forEach(subscriber => {
                         logger.info(`pushing data of thread ${lock.feed[lock.workon]} to ${JSON.stringify(subscriber)}`);
-                        let hash = JSON.stringify(subscriber) + text.replace(/\s+/gm, '');
-                        logger.debug(hash);
-                        hash = sha1(hash);
-                        logger.debug(hash);
-                        this.bot.sendTo(subscriber, this.mode === 0 ? msg : author + entities.decode(entities.decode(text)));
+                        this.bot.sendTo(subscriber, msg);
                     });
                 }, this.webshotDelay)
                     .then(() => {
@@ -132,6 +125,7 @@ class default_1 {
         this.workInterval = opt.workInterval;
         this.bot = opt.bot;
         this.webshotDelay = opt.webshotDelay;
+        this.webshotOutDir = opt.webshotOutDir;
         this.mode = opt.mode;
     }
 }

+ 98 - 80
dist/webshot.js

@@ -9,31 +9,33 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
     });
 };
 Object.defineProperty(exports, "__esModule", { value: true });
+const axios_1 = require("axios");
 const CallableInstance = require("callable-instance");
 const fs_1 = require("fs");
+const html_entities_1 = require("html-entities");
 const message_1 = require("mirai-ts/dist/message");
 const pngjs_1 = require("pngjs");
 const puppeteer = require("puppeteer");
-// import * as read from 'read-all-stream';
 const loggers_1 = require("./loggers");
+const writeOutTo = (path, data) => __awaiter(void 0, void 0, void 0, function* () {
+    yield new Promise(resolve => data.pipe(fs_1.createWriteStream(path)).on('close', resolve));
+    return path;
+});
+const xmlEntities = new html_entities_1.XmlEntities();
 const typeInZH = {
     photo: '[图片]',
     video: '[视频]',
     animated_gif: '[GIF]',
 };
 const logger = loggers_1.getLogger('webshot');
-const tempDir = '/tmp/mirai-twitter-bot/pics/';
-const mkTempDir = () => { if (!fs_1.existsSync(tempDir))
-    fs_1.mkdirSync(tempDir, { recursive: true }); };
-const writeTempFile = (url, png) => __awaiter(void 0, void 0, void 0, function* () {
-    const path = tempDir + url.replace(/[:\/]/g, '_') + '.png';
-    yield new Promise(resolve => png.pipe(fs_1.createWriteStream(path)).on('close', resolve));
-    return path;
-});
+const mkdirP = dir => { if (!fs_1.existsSync(dir))
+    fs_1.mkdirSync(dir, { recursive: true }); };
+const baseName = path => path.split(/[/\\]/).slice(-1)[0];
 class Webshot extends CallableInstance {
-    constructor(onready) {
+    constructor(outDir, mode, onready) {
         super('webshot');
         this.renderWebshot = (url, height, webshotDelay) => {
+            const writeOutPic = (pic) => writeOutTo(`${this.outDir}/${url.replace(/[:\/]/g, '_')}.png`, pic);
             const promise = new Promise(resolve => {
                 const width = 600;
                 logger.info(`shooting ${width}*${height} webshot for ${url}`);
@@ -57,14 +59,13 @@ class Webshot extends CallableInstance {
                     }))
                         .then(() => page.screenshot())
                         .then(screenshot => {
-                        mkTempDir();
                         new pngjs_1.PNG({
                             filterType: 4,
                         }).on('parsed', function () {
                             // remove comment area
                             let boundary = null;
                             let x = 0;
-                            for (let y = 0; y < this.height - 3; y++) {
+                            for (let y = 0; y < this.height; y++) {
                                 const idx = (this.width * y + x) << 2;
                                 if (this.data[idx] !== 255) {
                                     boundary = y;
@@ -88,11 +89,11 @@ class Webshot extends CallableInstance {
                                     else
                                         continue;
                                     // line above the "comment", "retweet", "like", "share" button row
-                                    if (cnt === 2) {
+                                    if (cnt === 6) {
                                         boundary = y + 1;
                                     }
                                     // if there are a "retweet" count and "like" count row, this will be the line above it
-                                    if (cnt === 4) {
+                                    if (cnt === 8) {
                                         const b = y + 1;
                                         if (this.height - b <= 200)
                                             boundary = b;
@@ -104,15 +105,14 @@ class Webshot extends CallableInstance {
                                     this.data = this.data.slice(0, (this.width * boundary) << 2);
                                     this.height = boundary;
                                 }
-                                writeTempFile(url, this.pack()).then(data => {
+                                writeOutPic(this.pack()).then(data => {
                                     logger.info(`finished webshot for ${url}`);
                                     resolve({ data, boundary });
                                 });
                             }
                             else if (height >= 8 * 1920) {
                                 logger.warn('too large, consider as a bug, returning');
-                                writeTempFile(url, this.pack()).then(data => {
-                                    logger.info(`finished webshot for ${url}`);
+                                writeOutPic(this.pack()).then(data => {
                                     resolve({ data, boundary: 0 });
                                 });
                             }
@@ -132,45 +132,54 @@ class Webshot extends CallableInstance {
                     return data.data;
             });
         };
-        puppeteer.launch({
-            args: [
-                '--no-sandbox',
-                '--disable-setuid-sandbox',
-                '--disable-dev-shm-usage',
-                '--disable-accelerated-2d-canvas',
-                '--no-first-run',
-                '--no-zygote',
-                '--single-process',
-                '--disable-gpu',
-                '--lang=ja-JP,ja',
-            ]
-        })
-            .then(browser => this.browser = browser)
-            .then(() => {
-            logger.info('launched puppeteer browser');
-            if (onready)
-                onready();
-        });
+        this.fetchImage = (url, tag) => new Promise(resolve => {
+            logger.info(`fetching ${url}`);
+            axios_1.default({
+                method: 'get',
+                url,
+                responseType: 'stream',
+            }).then(res => {
+                if (res.status === 200) {
+                    logger.info(`successfully fetched ${url}`);
+                    resolve(res.data);
+                }
+                else {
+                    logger.error(`failed to fetch ${url}: ${res.status}`);
+                    resolve();
+                }
+            }).catch(err => {
+                logger.error(`failed to fetch ${url}: ${err.message}`);
+                resolve();
+            });
+        }).then(data => writeOutTo(`${this.outDir}/${tag}${baseName(url)}`, data));
+        mkdirP(this.outDir = outDir);
+        // tslint:disable-next-line: no-conditional-assignment
+        if (this.mode = mode) {
+            onready();
+        }
+        else {
+            puppeteer.launch({
+                args: [
+                    '--no-sandbox',
+                    '--disable-setuid-sandbox',
+                    '--disable-dev-shm-usage',
+                    '--disable-accelerated-2d-canvas',
+                    '--no-first-run',
+                    '--no-zygote',
+                    '--single-process',
+                    '--disable-gpu',
+                    '--lang=ja-JP,ja',
+                ]
+            })
+                .then(browser => this.browser = browser)
+                .then(() => {
+                logger.info('launched puppeteer browser');
+                if (onready)
+                    onready();
+            });
+        }
     }
-    // private fetchImage = (url: string): Promise<string> =>
-    //   new Promise<string>(resolve => {
-    //     logger.info(`fetching ${url}`);
-    //     https.get(url, res => {
-    //       if (res.statusCode === 200) {
-    //         read(res, 'base64').then(data => {
-    //           logger.info(`successfully fetched ${url}`);
-    //           resolve(data);
-    //         });
-    //       } else {
-    //         logger.error(`failed to fetch ${url}: ${res.statusCode}`);
-    //         resolve();
-    //       }
-    //     }).on('error', (err) => {
-    //       logger.error(`failed to fetch ${url}: ${err.message}`);
-    //       resolve();
-    //     });
-    //   })
-    webshot(mode, tweets, callback, webshotDelay) {
+    webshot(tweets, callback, webshotDelay) {
         let promise = new Promise(resolve => {
             resolve();
         });
@@ -180,18 +189,45 @@ class Webshot extends CallableInstance {
             });
             const originTwi = twi.retweeted_status || twi;
             const messageChain = [];
-            if (mode === 0) {
+            // text processing
+            let author = `${twi.user.name} (@${twi.user.screen_name}):\n`;
+            if (twi.retweeted_status)
+                author += `RT @${twi.retweeted_status.user.screen_name}: `;
+            let text = originTwi.full_text;
+            promise = promise.then(() => {
+                if (originTwi.entities && originTwi.entities.urls && originTwi.entities.urls.length) {
+                    originTwi.entities.urls.forEach(url => {
+                        text = text.replace(new RegExp(url.url, 'gm'), url.expanded_url);
+                    });
+                }
+                if (originTwi.extended_entities) {
+                    originTwi.extended_entities.media.forEach(media => {
+                        text = text.replace(new RegExp(media.url, 'gm'), this.mode === 1 ? typeInZH[media.type] : '');
+                    });
+                }
+                if (this.mode > 0)
+                    messageChain.push(message_1.default.Plain(author + xmlEntities.decode(text)));
+            });
+            // invoke webshot
+            if (this.mode === 0) {
                 const url = `https://mobile.twitter.com/${twi.user.screen_name}/status/${twi.id_str}`;
                 promise = promise.then(() => this.renderWebshot(url, 1920, webshotDelay))
                     .then(webshotFilePath => {
                     if (webshotFilePath)
-                        messageChain.push(message_1.default.Image('', `file://${webshotFilePath}`));
+                        messageChain.push(message_1.default.Image('', '', baseName(webshotFilePath)));
                 });
+                // fetch extra images
+            }
+            else if (1 - this.mode % 2) {
                 if (originTwi.extended_entities) {
-                    originTwi.extended_entities.media.forEach(media => {
-                        messageChain.push(message_1.default.Image('', media.media_url_https));
-                    });
+                    promise = promise.then(() => originTwi.extended_entities.media.forEach(media => {
+                        this.fetchImage(media.media_url_https, `${twi.user.screen_name}-${twi.id_str}--`)
+                            .then(path => messageChain.push(message_1.default.Image('', '', baseName(path))));
+                    }));
                 }
+                // append URLs, if any
+            }
+            else if (this.mode === 0) {
                 if (originTwi.entities && originTwi.entities.urls && originTwi.entities.urls.length) {
                     promise = promise.then(() => {
                         const urls = originTwi.entities.urls
@@ -204,26 +240,8 @@ class Webshot extends CallableInstance {
                 }
             }
             promise.then(() => {
-                let text = originTwi.full_text;
-                if (originTwi.entities && originTwi.entities.urls && originTwi.entities.urls.length) {
-                    originTwi.entities.urls.forEach(url => {
-                        text = text.replace(new RegExp(url.url, 'gm'), url.expanded_url);
-                    });
-                }
-                if (originTwi.extended_entities) {
-                    originTwi.extended_entities.media.forEach(media => {
-                        text = text.replace(new RegExp(media.url, 'gm'), typeInZH[media.type]);
-                    });
-                }
-                text = text.replace(/&/gm, '&amp;')
-                    .replace(/\[/gm, '&#91;')
-                    .replace(/\]/gm, '&#93;');
-                let author = `${twi.user.name} (@${twi.user.screen_name}):\n`;
-                if (twi.retweeted_status)
-                    author += `RT @${twi.retweeted_status.user.screen_name}: `;
-                author = author.replace(/&/gm, '&amp;')
-                    .replace(/\[/gm, '&#91;')
-                    .replace(/\]/gm, '&#93;');
+                logger.info(`done working on ${twi.user.screen_name}/${twi.id_str}, message chain:`);
+                logger.info(JSON.stringify(messageChain));
                 callback(messageChain, text, author);
             });
         });

+ 1 - 0
src/main.ts

@@ -145,6 +145,7 @@ const worker = new Worker({
   workInterval: config.work_interval,
   bot: qq,
   webshotDelay: config.webshot_delay,
+  webshotOutDir: config.mirai_http_base_dir + '/images',
   mode: config.mode,
 });
 worker.launch();

+ 16 - 12
src/mirai.ts

@@ -27,18 +27,21 @@ export default class {
   private botInfo: IQQProps;
   public bot: Mirai;
 
-  public sendTo = (subscriber: IChat, msg) => {
-    switch (subscriber.chatType) {
-      case 'group':
-        return this.bot.api.sendGroupMessage(msg, subscriber.chatID)
-        .catch(reason => 
-          logger.error(`error pushing data to ${subscriber.chatID}, reason: ${reason}`));
-      case 'private':
-        return this.bot.api.sendFriendMessage(msg, subscriber.chatID)
-        .catch(reason => 
-          logger.error(`error pushing data to ${subscriber.chatID}, reason: ${reason}`));
-    }
-  }
+  public sendTo = (subscriber: IChat, msg) =>
+    (() => {
+      switch (subscriber.chatType) {
+        case 'group':
+          return this.bot.api.sendGroupMessage(msg, subscriber.chatID);
+        case 'private':
+          return this.bot.api.sendFriendMessage(msg, subscriber.chatID);
+      }
+    })()
+    .then(response => {
+      logger.info(`pushing data to ${subscriber.chatID} was successful, response:`);
+      logger.info(response);
+    })
+    .catch(reason =>
+      logger.error(`error pushing data to ${subscriber.chatID}, reason: ${reason}`))
 
   private initBot = () => {
     this.bot = new Mirai({
@@ -81,6 +84,7 @@ export default class {
     });
 }
 
+  // TODO doesn't work if connection is dropped after connection
   private listen = (logMsg?: string) => {
     if (logMsg !== '') {
       logger.warn(logMsg ?? 'Listening...');

+ 10 - 11
src/twitter.ts

@@ -1,7 +1,5 @@
 import * as fs from 'fs';
-import { XmlEntities } from 'html-entities';
 import * as path from 'path';
-import * as sha1 from 'sha1';
 import * as Twitter from 'twitter';
 
 import { getLogger } from './loggers';
@@ -14,6 +12,7 @@ interface IWorkerOption {
   bot: QQBot;
   workInterval: number;
   webshotDelay: number;
+  webshotOutDir: string;
   consumer_key: string;
   consumer_secret: string;
   access_token_key: string;
@@ -23,8 +22,6 @@ interface IWorkerOption {
 
 const logger = getLogger('twitter');
 
-const entities = new XmlEntities();
-
 export default class {
 
   private client;
@@ -33,6 +30,7 @@ export default class {
   private workInterval: number;
   private bot: QQBot;
   private webshotDelay: number;
+  private webshotOutDir: string;
   private webshot: Webshot;
   private mode: number;
 
@@ -48,11 +46,16 @@ export default class {
     this.workInterval = opt.workInterval;
     this.bot = opt.bot;
     this.webshotDelay = opt.webshotDelay;
+    this.webshotOutDir = opt.webshotOutDir;
     this.mode = opt.mode;
   }
 
   public launch = () => {
-    this.webshot = new Webshot(() => setTimeout(this.work, this.workInterval * 1000));
+    this.webshot = new Webshot(
+      this.webshotOutDir,
+      this.mode,
+      () => setTimeout(this.work, this.workInterval * 1000)
+    );
   }
 
   public work = () => {
@@ -132,14 +135,10 @@ export default class {
         return;
       }
       if (lock.threads[lock.feed[lock.workon]].offset === 0) tweets.splice(1);
-      return (this.webshot as any)(this.mode, tweets, (msg, text, author) => {
+      return (this.webshot as any)(tweets, msg => {
         lock.threads[lock.feed[lock.workon]].subscribers.forEach(subscriber => {
           logger.info(`pushing data of thread ${lock.feed[lock.workon]} to ${JSON.stringify(subscriber)}`);
-          let hash = JSON.stringify(subscriber) + text.replace(/\s+/gm, '');
-          logger.debug(hash);
-          hash = sha1(hash);
-          logger.debug(hash);
-          this.bot.sendTo(subscriber, this.mode === 0 ? msg : author + entities.decode(entities.decode(text)));
+          this.bot.sendTo(subscriber, msg);
         });
       }, this.webshotDelay)
         .then(() => {

+ 104 - 79
src/webshot.ts

@@ -1,15 +1,23 @@
+import axios from 'axios';
 import * as CallableInstance from 'callable-instance';
 import { createWriteStream, existsSync, mkdirSync } from 'fs';
-// import * as https from 'https';
+import { XmlEntities } from 'html-entities';
 import { MessageType } from 'mirai-ts';
 import Message from 'mirai-ts/dist/message';
 import { PNG } from 'pngjs';
 import * as puppeteer from 'puppeteer';
 import { Browser } from 'puppeteer';
-// import * as read from 'read-all-stream';
+import { Stream } from 'stream';
 
 import { getLogger } from './loggers';
 
+const writeOutTo = async (path: string, data: PNG | Stream) => {
+  await new Promise(resolve => data.pipe(createWriteStream(path)).on('close', resolve));
+  return path;
+};
+
+const xmlEntities = new XmlEntities();
+
 const typeInZH = {
   photo: '[图片]',
   video: '[视频]',
@@ -18,42 +26,46 @@ const typeInZH = {
 
 const logger = getLogger('webshot');
 
-const tempDir = '/tmp/mirai-twitter-bot/pics/';
-const mkTempDir = () => { if (!existsSync(tempDir)) mkdirSync(tempDir, {recursive: true}); };
-const writeTempFile = async (url: string, png: PNG) => {
-  const path = tempDir + url.replace(/[:\/]/g, '_') + '.png';
-  await new Promise(resolve => png.pipe(createWriteStream(path)).on('close', resolve));
-  return path;
-};
+const mkdirP = dir => { if (!existsSync(dir)) mkdirSync(dir, {recursive: true}); };
+const baseName = path => path.split(/[/\\]/).slice(-1)[0];
 
 type MessageChain = MessageType.MessageChain;
 
 class Webshot extends CallableInstance<[number], Promise<void>> {
 
   private browser: Browser;
+  private outDir: string;
+  private mode: number;
 
-  constructor(onready?: () => any) {
+  constructor(outDir: string, mode: number, onready?: () => any) {
     super('webshot');
-    puppeteer.launch({
-      args: [
-        '--no-sandbox',
-        '--disable-setuid-sandbox',
-        '--disable-dev-shm-usage',
-        '--disable-accelerated-2d-canvas',
-        '--no-first-run',
-        '--no-zygote',
-        '--single-process', // <- this one doesn't works in Windows
-        '--disable-gpu',
-        '--lang=ja-JP,ja',
-      ]})
-    .then(browser => this.browser = browser)
-    .then(() => {
-      logger.info('launched puppeteer browser');
-      if (onready) onready();
-    });
+    mkdirP(this.outDir = outDir);
+    // tslint:disable-next-line: no-conditional-assignment
+    if (this.mode = mode) {
+      onready();
+    } else {
+      puppeteer.launch({
+        args: [
+          '--no-sandbox',
+          '--disable-setuid-sandbox',
+          '--disable-dev-shm-usage',
+          '--disable-accelerated-2d-canvas',
+          '--no-first-run',
+          '--no-zygote',
+          '--single-process', // <- this one doesn't works in Windows
+          '--disable-gpu',
+          '--lang=ja-JP,ja',
+        ]})
+      .then(browser => this.browser = browser)
+      .then(() => {
+        logger.info('launched puppeteer browser');
+        if (onready) onready();
+      });
+    }
   }
 
   private renderWebshot = (url: string, height: number, webshotDelay: number): Promise<string> => {
+    const writeOutPic = (pic: PNG) => writeOutTo(`${this.outDir}/${url.replace(/[:\/]/g, '_')}.png`, pic);
     const promise = new Promise<{ data: string, boundary: null | number }>(resolve => {
       const width = 600;
       logger.info(`shooting ${width}*${height} webshot for ${url}`);
@@ -77,14 +89,13 @@ class Webshot extends CallableInstance<[number], Promise<void>> {
             }))
             .then(() => page.screenshot())
             .then(screenshot => {
-              mkTempDir();
               new PNG({
                 filterType: 4,
               }).on('parsed', function () {
                 // remove comment area
                 let boundary = null;
                 let x = 0;
-                for (let y = 0; y < this.height - 3; y++) {
+                for (let y = 0; y < this.height; y++) {
                   const idx = (this.width * y + x) << 2;
                   if (this.data[idx] !== 255) {
                     boundary = y;
@@ -108,12 +119,12 @@ class Webshot extends CallableInstance<[number], Promise<void>> {
                     } else continue;
 
                     // line above the "comment", "retweet", "like", "share" button row
-                    if (cnt === 2) {
+                    if (cnt === 6) {
                       boundary = y + 1;
                     }
 
                     // if there are a "retweet" count and "like" count row, this will be the line above it
-                    if (cnt === 4) {
+                    if (cnt === 8) {
                       const b = y + 1;
                       if (this.height - b <= 200) boundary = b;
                       break;
@@ -125,14 +136,13 @@ class Webshot extends CallableInstance<[number], Promise<void>> {
                     this.height = boundary;
                   }
 
-                  writeTempFile(url, this.pack()).then(data => {
+                  writeOutPic(this.pack()).then(data => {
                     logger.info(`finished webshot for ${url}`);
                     resolve({data, boundary});
                   });
                 } else if (height >= 8 * 1920) {
                   logger.warn('too large, consider as a bug, returning');
-                  writeTempFile(url, this.pack()).then(data => {
-                    logger.info(`finished webshot for ${url}`);
+                  writeOutPic(this.pack()).then(data => {
                     resolve({data, boundary: 0});
                   });
                 } else {
@@ -150,28 +160,30 @@ class Webshot extends CallableInstance<[number], Promise<void>> {
     });
   }
 
-  // private fetchImage = (url: string): Promise<string> =>
-  //   new Promise<string>(resolve => {
-  //     logger.info(`fetching ${url}`);
-  //     https.get(url, res => {
-  //       if (res.statusCode === 200) {
-  //         read(res, 'base64').then(data => {
-  //           logger.info(`successfully fetched ${url}`);
-  //           resolve(data);
-  //         });
-  //       } else {
-  //         logger.error(`failed to fetch ${url}: ${res.statusCode}`);
-  //         resolve();
-  //       }
-  //     }).on('error', (err) => {
-  //       logger.error(`failed to fetch ${url}: ${err.message}`);
-  //       resolve();
-  //     });
-  //   })
+  private fetchImage = (url: string, tag: string): Promise<string> =>
+    new Promise<Stream>(resolve => {
+      logger.info(`fetching ${url}`);
+      axios({
+        method: 'get',
+        url,
+        responseType: 'stream',
+      }).then(res => {
+        if (res.status === 200) {
+            logger.info(`successfully fetched ${url}`);
+            resolve(res.data);
+        } else {
+          logger.error(`failed to fetch ${url}: ${res.status}`);
+          resolve();
+        }
+      }).catch (err => {
+        logger.error(`failed to fetch ${url}: ${err.message}`);
+        resolve();
+      });
+    }).then(data => writeOutTo(`${this.outDir}/${tag}${baseName(url)}`, data))
 
   public webshot(
-    mode, tweets,
-    callback: (pics: MessageChain, text: string, author: string) => void,
+    tweets,
+    callback: (msgs: MessageChain, text: string, author: string) => void,
     webshotDelay: number
   ): Promise<void> {
     let promise = new Promise<void>(resolve => {
@@ -183,17 +195,48 @@ class Webshot extends CallableInstance<[number], Promise<void>> {
       });
       const originTwi = twi.retweeted_status || twi;
       const messageChain: MessageChain = [];
-      if (mode === 0) {
+
+      // text processing
+      let author = `${twi.user.name} (@${twi.user.screen_name}):\n`;
+      if (twi.retweeted_status) author += `RT @${twi.retweeted_status.user.screen_name}: `;
+
+      let text = originTwi.full_text;
+
+      promise = promise.then(() => {
+        if (originTwi.entities && originTwi.entities.urls && originTwi.entities.urls.length) {
+          originTwi.entities.urls.forEach(url => {
+            text = text.replace(new RegExp(url.url, 'gm'), url.expanded_url);
+          });
+        }
+        if (originTwi.extended_entities) {
+          originTwi.extended_entities.media.forEach(media => {
+            text = text.replace(new RegExp(media.url, 'gm'), this.mode === 1 ? typeInZH[media.type] : '');
+          });
+        }
+        if (this.mode > 0) messageChain.push(Message.Plain(author + xmlEntities.decode(text)));
+      });
+
+      // invoke webshot
+      if (this.mode === 0) {
         const url = `https://mobile.twitter.com/${twi.user.screen_name}/status/${twi.id_str}`;
         promise = promise.then(() => this.renderWebshot(url, 1920, webshotDelay))
           .then(webshotFilePath => {
-            if (webshotFilePath) messageChain.push(Message.Image('', `file://${webshotFilePath}`));
+            if (webshotFilePath) messageChain.push(Message.Image('', '', baseName(webshotFilePath)));
           });
+
+      // fetch extra images
+      } else if (1 - this.mode % 2) {
         if (originTwi.extended_entities) {
-          originTwi.extended_entities.media.forEach(media => {
-            messageChain.push(Message.Image('', media.media_url_https));
-          });
+          promise = promise.then(() => originTwi.extended_entities.media.forEach(media => {
+            this.fetchImage(media.media_url_https, `${twi.user.screen_name}-${twi.id_str}--`)
+            .then(path =>
+              messageChain.push(Message.Image('', '', baseName(path)))
+            );
+          }));
         }
+
+      // append URLs, if any
+      } else if (this.mode === 0) {
         if (originTwi.entities && originTwi.entities.urls && originTwi.entities.urls.length) {
           promise = promise.then(() => {
             const urls = originTwi.entities.urls
@@ -206,31 +249,13 @@ class Webshot extends CallableInstance<[number], Promise<void>> {
         }
       }
       promise.then(() => {
-        let text = originTwi.full_text;
-        if (originTwi.entities && originTwi.entities.urls && originTwi.entities.urls.length) {
-          originTwi.entities.urls.forEach(url => {
-            text = text.replace(new RegExp(url.url, 'gm'), url.expanded_url);
-          });
-        }
-        if (originTwi.extended_entities) {
-          originTwi.extended_entities.media.forEach(media => {
-            text = text.replace(new RegExp(media.url, 'gm'), typeInZH[media.type]);
-          });
-        }
-        text = text.replace(/&/gm, '&amp;')
-          .replace(/\[/gm, '&#91;')
-          .replace(/\]/gm, '&#93;');
-        let author = `${twi.user.name} (@${twi.user.screen_name}):\n`;
-        if (twi.retweeted_status) author += `RT @${twi.retweeted_status.user.screen_name}: `;
-        author = author.replace(/&/gm, '&amp;')
-          .replace(/\[/gm, '&#91;')
-          .replace(/\]/gm, '&#93;');
+        logger.info(`done working on ${twi.user.screen_name}/${twi.id_str}, message chain:`);
+        logger.info(JSON.stringify(messageChain));
         callback(messageChain, text, author);
       });
     });
     return promise;
   }
-
 }
 
 export default Webshot;