|
@@ -1,15 +1,23 @@
|
|
|
+import axios from 'axios';
|
|
|
import * as CallableInstance from 'callable-instance';
|
|
|
import { createWriteStream, existsSync, mkdirSync } from 'fs';
|
|
|
-
|
|
|
+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 { 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',
|
|
|
- '--disable-gpu',
|
|
|
- '--lang=ja-JP,ja',
|
|
|
- ]})
|
|
|
- .then(browser => this.browser = browser)
|
|
|
- .then(() => {
|
|
|
- logger.info('launched puppeteer browser');
|
|
|
- if (onready) onready();
|
|
|
- });
|
|
|
+ mkdirP(this.outDir = outDir);
|
|
|
+
|
|
|
+ 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 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 () {
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
- if (cnt === 2) {
|
|
|
+ if (cnt === 6) {
|
|
|
boundary = y + 1;
|
|
|
}
|
|
|
|
|
|
|
|
|
- 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, 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) {
|
|
|
+
|
|
|
+
|
|
|
+ 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)));
|
|
|
+ });
|
|
|
+
|
|
|
+
|
|
|
+ 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)));
|
|
|
});
|
|
|
+
|
|
|
+
|
|
|
+ } 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)))
|
|
|
+ );
|
|
|
+ }));
|
|
|
}
|
|
|
+
|
|
|
+
|
|
|
+ } 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, '&')
|
|
|
- .replace(/\[/gm, '[')
|
|
|
- .replace(/\]/gm, ']');
|
|
|
- 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, '&')
|
|
|
- .replace(/\[/gm, '[')
|
|
|
- .replace(/\]/gm, ']');
|
|
|
+ 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;
|