Переглянути джерело

support forwarding videos (as GIF)

Mike L 4 роки тому
батько
коміт
86555476e0
7 змінених файлів з 152 додано та 38 видалено
  1. 6 4
      README.md
  2. 38 0
      dist/gifski.js
  3. 2 1
      dist/mirai.js
  4. 34 16
      dist/webshot.js
  5. 36 0
      src/gifski.ts
  6. 5 1
      src/mirai.ts
  7. 31 16
      src/webshot.ts

+ 6 - 4
README.md

@@ -8,6 +8,7 @@
 
 - 去除了 Redis
 - 图片使用 [sharp](https://github.com/lovell/sharp) 压缩为 JPEG
+- 视频使用 [gifski](https://github.com/ImageOptim/gifski) 压缩为 GIF(请务必下载并放到 `PATH` 下,推荐[这里](https://github.com/CL-Jeremy/gifski/releases/tag/1.0.1-unofficial)的最新修改版,注意从包管理器安装依赖)
 - 机器人的 QQ 号码必须手动填写
 
 ## 配置
@@ -16,7 +17,7 @@
 
 | 配置项 | 说明 | 默认 |
 | --- | --- | --- |
-| mirai_access_token | Mirai HTTP API authKey(需保持和插件一致,插件在未配置对应<br />项目时会在 console 给出当前设定值,请将该值填在此处) | (必填) |
+| mirai_access_token | Mirai HTTP API authKey(需与插件一致,插件若未<br />配置本项会在 console 显示生成值,请将其填入) | (必填) |
 | mirai_http_host | Mirai HTTP API 插件服务端地址 | 127.0.0.1 |
 | mirai_http_port | Mirai HTTP API 插件服务端口 | 8080 |
 | mirai_bot_qq | Mirai HTTP API 登录的目标机器人 QQ 号 | 10000(示例值,必填) |
@@ -24,8 +25,8 @@
 | 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 为纯文本模式,2 为文本附图模式 | 0 |
-| resume_on_start | 是否在启动时继续退出时的进度(拉取本应用非活动时期错过的推文) | false |
+| mode | 工作模式,0 为图文模式,1 为纯文本模式,2 为文<br />本附图模式 | 0 |
+| resume_on_start | 是否在启动时从退出时的进度继续(拉取本应用非活<br />动时期错过的推文) | false |
 | work_interval | 对单个订阅两次拉取更新的最少间隔时间(秒) | 60 |
 | webshot_delay | 抓取网页截图时等待网页加载的延迟时长(毫秒) | 5000 |
 | lockfile | 本地保存订阅信息以便下次启动时恢复 | subscriber.lock |
@@ -37,8 +38,9 @@
 
 - 原项目的列表订阅功能已失效
 - 好友消息的图片有可能会失效或直接无法接收(后者会被转换为 `[失败的图片:<地址>]` 格式,然后整条消息会以纯文本模式重发)
+- 视频为实验性功能,可能会有各种问题,比如超过大小后会被服务器二压,暂时请酌情自行处理
 
 ## Todo
 
-- 重新实现基于 hash 的文件缓存
+- 重新实现基于 hash 的文件缓存和转推媒体去重
 - 添加选项对时间线进行过滤

+ 38 - 0
dist/gifski.js

@@ -0,0 +1,38 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+const child_process_1 = require("child_process");
+const temp = require("temp");
+const loggers_1 = require("./loggers");
+const fs_1 = require("fs");
+const logger = loggers_1.getLogger('gifski');
+function default_1(data) {
+    const outputFilePath = temp.path({ suffix: '.gif' });
+    // temp.track();
+    try {
+        const inputFile = temp.openSync();
+        fs_1.writeSync(inputFile.fd, Buffer.from(data));
+        fs_1.closeSync(inputFile.fd);
+        logger.info(`saved video file to ${inputFile.path}, starting gif conversion...`);
+        const args = [
+            '--fps',
+            '12.5',
+            '--quiet',
+            '--quality',
+            '80',
+            '-o',
+            outputFilePath,
+            inputFile.path
+        ];
+        logger.info(` gifski ${args.join(' ')}`);
+        const gifskiInvocation = child_process_1.spawnSync('gifski', args, { encoding: 'utf8', timeout: 90000 });
+        if (gifskiInvocation.stderr)
+            throw Error(gifskiInvocation.stderr);
+        logger.info(`gif conversion succeeded, file path: ${outputFilePath}`);
+        return fs_1.readFileSync(outputFilePath).buffer;
+    }
+    catch (error) {
+        logger.error('error converting video to gif' + error ? `message: ${error}` : '');
+        throw Error('error converting video to gif');
+    }
+}
+exports.default = default_1;

+ 2 - 1
dist/mirai.js

@@ -71,7 +71,7 @@ class default_1 {
             }
             try {
                 this.bot.axios.defaults.timeout = timeout === -1 ? 0 : timeout;
-                logger.info(`uploading ${img.path}...`);
+                logger.info(`uploading ${JSON.stringify(exports.Message.Image(img.imageId, `${img.url.split(',')[0]},[...]`, img.path))}...`);
                 return this.bot.api.uploadImage('group', imgFile || img.path)
                     .then(response => {
                     logger.info(`uploading ${img.path} as group image was successful, response:`);
@@ -96,6 +96,7 @@ class default_1 {
                 host: this.botInfo.host,
                 port: this.botInfo.port,
             });
+            this.bot.axios.defaults.maxContentLength = Infinity;
             this.bot.on('message', (msg) => {
                 const chat = {
                     chatType: ChatTypeMap[msg.type],

+ 34 - 16
dist/webshot.js

@@ -6,13 +6,21 @@ const html_entities_1 = require("html-entities");
 const pngjs_1 = require("pngjs");
 const puppeteer = require("puppeteer");
 const sharp = require("sharp");
+const gifski_1 = require("./gifski");
 const loggers_1 = require("./loggers");
 const mirai_1 = require("./mirai");
 const xmlEntities = new html_entities_1.XmlEntities();
+const ZHType = (type) => new class extends String {
+    constructor() {
+        super(...arguments);
+        this.type = super.toString();
+        this.toString = () => `[${super.toString()}]`;
+    }
+}(type);
 const typeInZH = {
-    photo: '[图片]',
-    video: '[视频]',
-    animated_gif: '[GIF]',
+    photo: ZHType('图片'),
+    video: ZHType('视频'),
+    animated_gif: ZHType('GIF'),
 };
 const logger = loggers_1.getLogger('webshot');
 class Webshot extends CallableInstance {
@@ -153,7 +161,7 @@ class Webshot extends CallableInstance {
             }).catch(error => new Promise(resolve => this.reconnect(error, resolve))
                 .then(() => this.renderWebshot(url, height, webshotDelay)));
         };
-        this.fetchImage = (url) => new Promise(resolve => {
+        this.fetchMedia = (url) => new Promise(resolve => {
             logger.info(`fetching ${url}`);
             axios_1.default({
                 method: 'get',
@@ -179,10 +187,11 @@ class Webshot extends CallableInstance {
                         return 'image/jpeg';
                     case 'png':
                         return 'image/png';
-                    case 'gif':
+                    case 'mp4':
+                        data = gifski_1.default(data);
                         return 'image/gif';
                 }
-            })(url.match(/(\.[a-z]+):(.*)/)[1]);
+            })(url.split('/').slice(-1)[0].match(/\.([^:?&]+)/)[1]);
             return `data:${mimetype};base64,${Buffer.from(data).toString('base64')}`;
         });
         // tslint:disable-next-line: no-conditional-assignment
@@ -236,13 +245,27 @@ class Webshot extends CallableInstance {
                         messageChain.push(msg);
                 });
             }
-            // fetch extra images
+            // fetch extra entities
             if (1 - this.mode % 2) {
                 if (originTwi.extended_entities) {
                     originTwi.extended_entities.media.forEach(media => {
-                        const url = media.media_url_https + ':orig';
-                        promise = promise.then(() => this.fetchImage(url))
-                            .then(base64url => uploader(mirai_1.Message.Image('', base64url, url), () => mirai_1.Message.Plain(`[失败的图片:${url}]`)))
+                        let url;
+                        if (media.type === 'photo') {
+                            url = media.media_url_https + ':orig';
+                        }
+                        else {
+                            url = media.video_info.variants
+                                .filter(variant => variant.bitrate)
+                                .sort((var1, var2) => var1.bitrate - var2.bitrate)
+                                .map(variant => variant.url)[0]; // smallest video
+                        }
+                        const altMessage = mirai_1.Message.Plain(`[失败的${typeInZH[media.type].type}:${url}]`);
+                        promise = promise.then(() => this.fetchMedia(url))
+                            .then(base64url => uploader(mirai_1.Message.Image('', base64url, media.type === 'photo' ? url : `${url} as gif`), () => altMessage))
+                            .catch(error => {
+                            logger.warn('unable to fetch media, sending plain text instead...');
+                            return altMessage;
+                        })
                             .then(msg => {
                             messageChain.push(msg);
                         });
@@ -264,12 +287,7 @@ class Webshot extends CallableInstance {
             }
             promise.then(() => {
                 logger.info(`done working on ${twi.user.screen_name}/${twi.id_str}, message chain:`);
-                logger.info(JSON.stringify(messageChain.map(message => {
-                    if (message.type === 'Image' && message.url.startsWith('data:')) {
-                        return mirai_1.Message.Image(message.imageId, 'data:[...]', message.path);
-                    }
-                    return message;
-                })));
+                logger.info(JSON.stringify(messageChain));
                 callback(messageChain, xmlEntities.decode(text), author);
             });
         });

+ 36 - 0
src/gifski.ts

@@ -0,0 +1,36 @@
+import { spawnSync } from 'child_process';
+import * as temp from 'temp';
+
+import { getLogger } from './loggers';
+import { readFileSync, writeSync, closeSync } from 'fs';
+
+const logger = getLogger('gifski');
+
+export default function (data: ArrayBuffer) {
+    const outputFilePath = temp.path({suffix: '.gif'});
+    // temp.track();
+    try {
+      const inputFile = temp.openSync();
+      writeSync(inputFile.fd, Buffer.from(data));
+      closeSync(inputFile.fd);
+      logger.info(`saved video file to ${inputFile.path}, starting gif conversion...`)
+      const args = [
+        '--fps',
+        '12.5',
+        '--quiet',
+        '--quality',
+        '80',
+        '-o',
+        outputFilePath,
+        inputFile.path
+      ];
+      logger.info(` gifski ${args.join(' ')}`);
+      const gifskiInvocation = spawnSync('gifski', args, {encoding: 'utf8', timeout: 90000});
+      if (gifskiInvocation.stderr) throw Error(gifskiInvocation.stderr);
+      logger.info(`gif conversion succeeded, file path: ${outputFilePath}`)
+      return readFileSync(outputFilePath).buffer;
+    } catch (error) {
+      logger.error('error converting video to gif' + error ? `message: ${error}` : '');
+      throw Error('error converting video to gif');
+    }
+}

+ 5 - 1
src/mirai.ts

@@ -76,7 +76,9 @@ export default class {
     }
     try {
       this.bot.axios.defaults.timeout = timeout === -1 ? 0 : timeout;
-      logger.info(`uploading ${img.path}...`);
+      logger.info(`uploading ${JSON.stringify(
+        Message.Image(img.imageId, `${img.url.split(',')[0]},[...]`, img.path)
+      )}...`);
       return this.bot.api.uploadImage('group', imgFile || img.path)
       .then(response => { // workaround for https://github.com/mamoe/mirai/issues/194
         logger.info(`uploading ${img.path} as group image was successful, response:`);
@@ -102,6 +104,8 @@ export default class {
       port: this.botInfo.port,
     });
 
+    this.bot.axios.defaults.maxContentLength = Infinity;
+
     this.bot.on('message', (msg) => {
       const chat: IChat = {
         chatType: ChatTypeMap[msg.type],

+ 31 - 16
src/webshot.ts

@@ -7,16 +7,22 @@ import { Browser } from 'puppeteer';
 import * as sharp from 'sharp';
 import { Readable } from 'stream';
 
+import gifski from './gifski';
 import { getLogger } from './loggers';
 import { Message, MessageChain } from './mirai';
 import { Tweets } from './twitter';
 
 const xmlEntities = new XmlEntities();
 
+const ZHType = (type: string) => new class extends String {
+  public type = super.toString();
+  public toString = () => `[${super.toString()}]`;
+}(type);
+
 const typeInZH = {
-  photo: '[图片]',
-  video: '[视频]',
-  animated_gif: '[GIF]',
+  photo: ZHType('图片'),
+  video: ZHType('视频'),
+  animated_gif: ZHType('GIF'),
 };
 
 const logger = getLogger('webshot');
@@ -177,7 +183,7 @@ extends CallableInstance<
     );
   }
 
-  private fetchImage = (url: string): Promise<string> =>
+  private fetchMedia = (url: string): Promise<string> =>
     new Promise<ArrayBuffer>(resolve => {
       logger.info(`fetching ${url}`);
       axios({
@@ -203,10 +209,11 @@ extends CallableInstance<
             return 'image/jpeg';
           case 'png':
             return 'image/png';
-          case 'gif':
+          case 'mp4':
+            data = gifski(data);
             return 'image/gif';
         }
-      })(url.match(/(\.[a-z]+):(.*)/)[1]);
+      })(url.split('/').slice(-1)[0].match(/\.([^:?&]+)/)[1]);
       return `data:${mimetype};base64,${Buffer.from(data).toString('base64')}`;
     })
 
@@ -262,15 +269,28 @@ extends CallableInstance<
             if (msg) messageChain.push(msg);
           });
       }
-      // fetch extra images
+      // fetch extra entities
       if (1 - this.mode % 2) {
         if (originTwi.extended_entities) {
           originTwi.extended_entities.media.forEach(media => {
-            const url = media.media_url_https + ':orig';
-            promise = promise.then(() => this.fetchImage(url))
+            let url: string;
+            if (media.type === 'photo') {
+              url = media.media_url_https + ':orig';
+            } else {
+              url = media.video_info.variants
+                .filter(variant => variant.bitrate)
+                .sort((var1, var2) => var1.bitrate - var2.bitrate)
+                .map(variant => variant.url)[0]; // smallest video
+            }
+            const altMessage = Message.Plain(`[失败的${typeInZH[media.type].type}:${url}]`);
+            promise = promise.then(() => this.fetchMedia(url))
               .then(base64url =>
-                uploader(Message.Image('', base64url, url), () => Message.Plain(`[失败的图片:${url}]`))
+                uploader(Message.Image('', base64url, media.type === 'photo' ? url : `${url} as gif`), () => altMessage)
               )
+              .catch(error => {
+                logger.warn('unable to fetch media, sending plain text instead...');
+                return altMessage;
+              })
               .then(msg => {
                 messageChain.push(msg);
               });
@@ -292,12 +312,7 @@ extends CallableInstance<
       }
       promise.then(() => {
         logger.info(`done working on ${twi.user.screen_name}/${twi.id_str}, message chain:`);
-        logger.info(JSON.stringify(messageChain.map(message => {
-          if (message.type === 'Image' && message.url.startsWith('data:')) {
-            return Message.Image(message.imageId, 'data:[...]', message.path);
-          }
-          return message;
-        })));
+        logger.info(JSON.stringify(messageChain));
         callback(messageChain, xmlEntities.decode(text), author);
       });
     });