|
@@ -1,3 +1,4 @@
|
|
|
|
+import { writeFileSync } from 'fs';
|
|
import { Readable } from 'stream';
|
|
import { Readable } from 'stream';
|
|
import { promisify } from 'util';
|
|
import { promisify } from 'util';
|
|
|
|
|
|
@@ -7,10 +8,10 @@ import { XmlEntities } from 'html-entities';
|
|
import { PNG } from 'pngjs';
|
|
import { PNG } from 'pngjs';
|
|
import * as puppeteer from 'playwright';
|
|
import * as puppeteer from 'playwright';
|
|
import * as sharp from 'sharp';
|
|
import * as sharp from 'sharp';
|
|
|
|
+import * as temp from 'temp';
|
|
|
|
|
|
-import gifski from './gifski';
|
|
|
|
import { getLogger } from './loggers';
|
|
import { getLogger } from './loggers';
|
|
-import { Message, MessageChain } from './mirai';
|
|
|
|
|
|
+import { Message } from './koishi';
|
|
import { MediaEntity, Tweets } from './twitter';
|
|
import { MediaEntity, Tweets } from './twitter';
|
|
import { chainPromises } from './utils';
|
|
import { chainPromises } from './utils';
|
|
|
|
|
|
@@ -29,9 +30,7 @@ const typeInZH = {
|
|
|
|
|
|
const logger = getLogger('webshot');
|
|
const logger = getLogger('webshot');
|
|
|
|
|
|
-class Webshot extends CallableInstance<[
|
|
|
|
- Tweets, (...args) => Promise<any>, (...args) => void, number
|
|
|
|
-], Promise<void>> {
|
|
|
|
|
|
+class Webshot extends CallableInstance<[Tweets, (...args) => void, number], Promise<void>> {
|
|
|
|
|
|
private browser: puppeteer.Browser;
|
|
private browser: puppeteer.Browser;
|
|
private mode: number;
|
|
private mode: number;
|
|
@@ -76,11 +75,13 @@ class Webshot extends CallableInstance<[
|
|
};
|
|
};
|
|
|
|
|
|
private renderWebshot = (url: string, height: number, webshotDelay: number): Promise<string> => {
|
|
private renderWebshot = (url: string, height: number, webshotDelay: number): Promise<string> => {
|
|
|
|
+ temp.track();
|
|
const jpeg = (data: Readable) => data.pipe(sharp()).jpeg({quality: 90, trellisQuantisation: true});
|
|
const jpeg = (data: Readable) => data.pipe(sharp()).jpeg({quality: 90, trellisQuantisation: true});
|
|
- const sharpToBase64 = (pic: sharp.Sharp) => new Promise<string>(resolve => {
|
|
|
|
- pic.toBuffer().then(buffer => resolve(`data:image/jpeg;base64,${buffer.toString('base64')}`));
|
|
|
|
|
|
+ const sharpToFile = (pic: sharp.Sharp) => new Promise<string>(resolve => {
|
|
|
|
+ const webshotTempFilePath = temp.path({suffix: '.jpg'});
|
|
|
|
+ pic.toFile(webshotTempFilePath).then(() => resolve(`file://${webshotTempFilePath}`));
|
|
});
|
|
});
|
|
- const promise = new Promise<{ base64: string, boundary: null | number }>((resolve, reject) => {
|
|
|
|
|
|
+ const promise = new Promise<{ path: string, boundary: null | number }>((resolve, reject) => {
|
|
const width = 720;
|
|
const width = 720;
|
|
const zoomFactor = 2;
|
|
const zoomFactor = 2;
|
|
logger.info(`shooting ${width}*${height} webshot for ${url}`);
|
|
logger.info(`shooting ${width}*${height} webshot for ${url}`);
|
|
@@ -102,7 +103,10 @@ class Webshot extends CallableInstance<[
|
|
.then(() => page.goto(url, {waitUntil: 'load', timeout: getTimeout()}))
|
|
.then(() => page.goto(url, {waitUntil: 'load', timeout: getTimeout()}))
|
|
// hide header, "more options" button, like and retweet count
|
|
// hide header, "more options" button, like and retweet count
|
|
.then(() => page.addStyleTag({
|
|
.then(() => page.addStyleTag({
|
|
- content: 'header{display:none!important}path[d=\'M20.207 7.043a1 1 0 0 0-1.414 0L12 13.836 5.207 7.043a1 1 0 0 0-1.414 1.414l7.5 7.5a.996.996 0 0 0 1.414 0l7.5-7.5a1 1 0 0 0 0-1.414z\'],div[role=\'button\']{display: none;}',
|
|
|
|
|
|
+ content: 'header{display:none!important}div[role=\'button\'],[data-testid="tweet"]+*>[class*=" "]+*{display:none}',
|
|
|
|
+ }))
|
|
|
|
+ .then(() => page.addStyleTag({
|
|
|
|
+ content: '*{font-family:-apple-system,".Helvetica Neue DeskInterface",Hiragino Sans,Hiragino Sans GB,sans-serif!important}',
|
|
}))
|
|
}))
|
|
.then(() => page.addStyleTag({
|
|
.then(() => page.addStyleTag({
|
|
content: '*{font-family:-apple-system,".Helvetica Neue DeskInterface",Hiragino Sans,Hiragino Sans GB,sans-serif!important}',
|
|
content: '*{font-family:-apple-system,".Helvetica Neue DeskInterface",Hiragino Sans,Hiragino Sans GB,sans-serif!important}',
|
|
@@ -166,7 +170,7 @@ class Webshot extends CallableInstance<[
|
|
// eslint-disable-next-line @typescript-eslint/no-shadow
|
|
// eslint-disable-next-line @typescript-eslint/no-shadow
|
|
const idx = (x: number, y: number) => (this.width * y + x) << 2;
|
|
const idx = (x: number, y: number) => (this.width * y + x) << 2;
|
|
let boundary: number = null;
|
|
let boundary: number = null;
|
|
- let x = zoomFactor * 2;
|
|
|
|
|
|
+ const x = zoomFactor * 2;
|
|
for (let y = 0; y < this.height; y += zoomFactor) {
|
|
for (let y = 0; y < this.height; y += zoomFactor) {
|
|
if (
|
|
if (
|
|
this.data[idx(x, y)] !== 255 &&
|
|
this.data[idx(x, y)] !== 255 &&
|
|
@@ -186,63 +190,25 @@ class Webshot extends CallableInstance<[
|
|
this.data = this.data.slice(0, idx(this.width, boundary));
|
|
this.data = this.data.slice(0, idx(this.width, boundary));
|
|
this.height = boundary;
|
|
this.height = boundary;
|
|
|
|
|
|
- boundary = null;
|
|
|
|
- x = Math.floor(16 * zoomFactor);
|
|
|
|
- let flag = false;
|
|
|
|
- let cnt = 0;
|
|
|
|
- for (let y = this.height - 1 - zoomFactor; y >= 0; y -= zoomFactor) {
|
|
|
|
- if ((this.data[idx(x, y)] === 255) === flag) {
|
|
|
|
- cnt++;
|
|
|
|
- flag = !flag;
|
|
|
|
- } else continue;
|
|
|
|
-
|
|
|
|
- // line above the "comment", "retweet", "like", "share" button row
|
|
|
|
- if (cnt === 2) {
|
|
|
|
- boundary = y + 1;
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- // if there are a "retweet" count and "like" count row, this will be the line above it
|
|
|
|
- if (cnt === 4) {
|
|
|
|
- const b = y + 1;
|
|
|
|
- if (Math.abs(this.height - boundary - (boundary - b)) <= 3 * zoomFactor) {
|
|
|
|
- boundary = b;
|
|
|
|
- }
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- // if "retweet" count and "like" count are two rows, this will be the line above the first
|
|
|
|
- if (cnt === 6) {
|
|
|
|
- const c = y + 1;
|
|
|
|
- if (Math.abs(this.height - boundary - 2 * (boundary - c)) <= 3 * zoomFactor) {
|
|
|
|
- boundary = c;
|
|
|
|
- break;
|
|
|
|
- }
|
|
|
|
- }
|
|
|
|
- }
|
|
|
|
- if (boundary !== null) {
|
|
|
|
- logger.info(`found boundary at ${boundary}, trimming image`);
|
|
|
|
- this.data = this.data.slice(0, idx(this.width, boundary));
|
|
|
|
- this.height = boundary;
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- sharpToBase64(jpeg(this.pack())).then(base64 => {
|
|
|
|
|
|
+ sharpToFile(jpeg(this.pack())).then(path => {
|
|
logger.info(`finished webshot for ${url}`);
|
|
logger.info(`finished webshot for ${url}`);
|
|
- resolve({base64, boundary});
|
|
|
|
|
|
+ resolve({path, boundary});
|
|
});
|
|
});
|
|
} else if (height >= 8 * 1920) {
|
|
} else if (height >= 8 * 1920) {
|
|
logger.warn('too large, consider as a bug, returning');
|
|
logger.warn('too large, consider as a bug, returning');
|
|
- sharpToBase64(jpeg(this.pack())).then(base64 => {
|
|
|
|
- resolve({base64, boundary: 0});
|
|
|
|
|
|
+ sharpToFile(jpeg(this.pack())).then(path => {
|
|
|
|
+ resolve({path, boundary: 0});
|
|
});
|
|
});
|
|
} else {
|
|
} else {
|
|
logger.info('unable to find boundary, try shooting a larger image');
|
|
logger.info('unable to find boundary, try shooting a larger image');
|
|
- resolve({base64: '', boundary});
|
|
|
|
|
|
+ resolve({path: '', boundary});
|
|
}
|
|
}
|
|
}).parse(screenshot);
|
|
}).parse(screenshot);
|
|
})
|
|
})
|
|
.catch(err => {
|
|
.catch(err => {
|
|
if (err instanceof Error && err.name !== 'TimeoutError') throw err;
|
|
if (err instanceof Error && err.name !== 'TimeoutError') throw err;
|
|
logger.error(`error shooting webshot for ${url}, could not load web page of tweet`);
|
|
logger.error(`error shooting webshot for ${url}, could not load web page of tweet`);
|
|
- resolve({base64: '', boundary: 0});
|
|
|
|
|
|
+ resolve({path: '', boundary: 0});
|
|
})
|
|
})
|
|
.finally(() => { page.close(); });
|
|
.finally(() => { page.close(); });
|
|
})
|
|
})
|
|
@@ -250,72 +216,51 @@ class Webshot extends CallableInstance<[
|
|
});
|
|
});
|
|
return promise.then(data => {
|
|
return promise.then(data => {
|
|
if (data.boundary === null) return this.renderWebshot(url, height + 1920, webshotDelay);
|
|
if (data.boundary === null) return this.renderWebshot(url, height + 1920, webshotDelay);
|
|
- else return data.base64;
|
|
|
|
|
|
+ else return data.path;
|
|
}).catch(error => this.reconnect(error)
|
|
}).catch(error => this.reconnect(error)
|
|
.then(() => this.renderWebshot(url, height, webshotDelay))
|
|
.then(() => this.renderWebshot(url, height, webshotDelay))
|
|
);
|
|
);
|
|
};
|
|
};
|
|
|
|
|
|
- private fetchMedia = (url: string): Promise<string> => {
|
|
|
|
- const gif = (data: ArrayBuffer) => {
|
|
|
|
- const matchDims = /\/(\d+)x(\d+)\//.exec(url);
|
|
|
|
- if (matchDims) {
|
|
|
|
- const [ width, height ] = matchDims.slice(1).map(Number);
|
|
|
|
- const factor = width + height > 1600 ? 0.375 : 0.5;
|
|
|
|
- return gifski(data, width * factor);
|
|
|
|
- }
|
|
|
|
- return gifski(data);
|
|
|
|
- };
|
|
|
|
-
|
|
|
|
- return new Promise<ArrayBuffer>((resolve, reject) => {
|
|
|
|
- logger.info(`fetching ${url}`);
|
|
|
|
- axios({
|
|
|
|
- method: 'get',
|
|
|
|
- url,
|
|
|
|
- responseType: 'arraybuffer',
|
|
|
|
- timeout: 150000,
|
|
|
|
- }).then(res => {
|
|
|
|
- if (res.status === 200) {
|
|
|
|
- logger.info(`successfully fetched ${url}`);
|
|
|
|
- resolve(res.data);
|
|
|
|
- } else {
|
|
|
|
- logger.error(`failed to fetch ${url}: ${res.status}`);
|
|
|
|
- reject();
|
|
|
|
- }
|
|
|
|
- }).catch (err => {
|
|
|
|
- logger.error(`failed to fetch ${url}: ${err instanceof Error ? err.message : err}`);
|
|
|
|
|
|
+ private fetchMedia = (url: string): Promise<string> => new Promise<ArrayBuffer>((resolve, reject) => {
|
|
|
|
+ logger.info(`fetching ${url}`);
|
|
|
|
+ axios({
|
|
|
|
+ method: 'get',
|
|
|
|
+ url,
|
|
|
|
+ responseType: 'arraybuffer',
|
|
|
|
+ timeout: 150000,
|
|
|
|
+ }).then(res => {
|
|
|
|
+ if (res.status === 200) {
|
|
|
|
+ logger.info(`successfully fetched ${url}`);
|
|
|
|
+ resolve(res.data);
|
|
|
|
+ } else {
|
|
|
|
+ logger.error(`failed to fetch ${url}: ${res.status}`);
|
|
reject();
|
|
reject();
|
|
- });
|
|
|
|
- }).then(data => (async ext => {
|
|
|
|
|
|
+ }
|
|
|
|
+ }).catch (err => {
|
|
|
|
+ logger.error(`failed to fetch ${url}: ${err instanceof Error ? err.message : err}`);
|
|
|
|
+ reject();
|
|
|
|
+ });
|
|
|
|
+ }).then(data =>
|
|
|
|
+ (ext => {
|
|
|
|
+ const mediaTempFilePath = temp.path({suffix: `.${ext}`});
|
|
|
|
+ writeFileSync(mediaTempFilePath, Buffer.from(data));
|
|
|
|
+ const path = `file://${mediaTempFilePath}`;
|
|
switch (ext) {
|
|
switch (ext) {
|
|
case 'jpg':
|
|
case 'jpg':
|
|
- return {mimetype: 'image/jpeg', data};
|
|
|
|
case 'png':
|
|
case 'png':
|
|
- return {mimetype: 'image/png', data};
|
|
|
|
|
|
+ return Message.Image(path);
|
|
case 'mp4':
|
|
case 'mp4':
|
|
- try {
|
|
|
|
- return {mimetype: 'image/gif', data: await gif(data)};
|
|
|
|
- } catch (err) {
|
|
|
|
- logger.error(err);
|
|
|
|
- throw Error(err);
|
|
|
|
- }
|
|
|
|
|
|
+ return Message.Video(path);
|
|
}
|
|
}
|
|
|
|
+ logger.warn('unable to find MIME type of fetched media, failing this fetch');
|
|
|
|
+ throw Error();
|
|
})(((/\?format=([a-z]+)&/.exec(url)) ?? (/.*\/.*\.([^?]+)/.exec(url)))[1])
|
|
})(((/\?format=([a-z]+)&/.exec(url)) ?? (/.*\/.*\.([^?]+)/.exec(url)))[1])
|
|
- .catch(() => {
|
|
|
|
- logger.warn('unable to find MIME type of fetched media, failing this fetch');
|
|
|
|
- throw Error();
|
|
|
|
- })
|
|
|
|
- ).then(typedData => `data:${typedData.mimetype};base64,${Buffer.from(typedData.data).toString('base64')}`
|
|
|
|
- );
|
|
|
|
- };
|
|
|
|
|
|
+ );
|
|
|
|
|
|
public webshot(
|
|
public webshot(
|
|
tweets: Tweets,
|
|
tweets: Tweets,
|
|
- uploader: (
|
|
|
|
- img: ReturnType<typeof Message.Image>,
|
|
|
|
- lastResort: (...args) => ReturnType<typeof Message.Plain>
|
|
|
|
- ) => Promise<ReturnType<typeof Message.Image | typeof Message.Plain>>,
|
|
|
|
- callback: (msgs: MessageChain, text: string, author: string) => void,
|
|
|
|
|
|
+ callback: (msgs: string, text: string, author: string) => void,
|
|
webshotDelay: number
|
|
webshotDelay: number
|
|
): Promise<void> {
|
|
): Promise<void> {
|
|
let promise = new Promise<void>(resolve => {
|
|
let promise = new Promise<void>(resolve => {
|
|
@@ -326,7 +271,7 @@ class Webshot extends CallableInstance<[
|
|
logger.info(`working on ${twi.user.screen_name}/${twi.id_str}`);
|
|
logger.info(`working on ${twi.user.screen_name}/${twi.id_str}`);
|
|
});
|
|
});
|
|
const originTwi = twi.retweeted_status || twi;
|
|
const originTwi = twi.retweeted_status || twi;
|
|
- const messageChain: MessageChain = [];
|
|
|
|
|
|
+ let messageChain = '';
|
|
|
|
|
|
// text processing
|
|
// text processing
|
|
let author = `${twi.user.name} (@${twi.user.screen_name}):\n`;
|
|
let author = `${twi.user.name} (@${twi.user.screen_name}):\n`;
|
|
@@ -345,7 +290,7 @@ class Webshot extends CallableInstance<[
|
|
text = text.replace(new RegExp(media.url, 'gm'), this.mode === 1 ? typeInZH[media.type] : '');
|
|
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) messageChain += (author + xmlEntities.decode(text));
|
|
});
|
|
});
|
|
|
|
|
|
// invoke webshot
|
|
// invoke webshot
|
|
@@ -361,12 +306,12 @@ class Webshot extends CallableInstance<[
|
|
};
|
|
};
|
|
};
|
|
};
|
|
promise = promise.then(() => this.renderWebshot(url, 1920, webshotDelay))
|
|
promise = promise.then(() => this.renderWebshot(url, 1920, webshotDelay))
|
|
- .then(base64url => {
|
|
|
|
- if (base64url) return uploader(Message.Image('', base64url, url), () => Message.Plain(author + text));
|
|
|
|
- return Message.Plain(author + text);
|
|
|
|
|
|
+ .then(fileurl => {
|
|
|
|
+ if (fileurl) return Message.Image(fileurl);
|
|
|
|
+ return author + text;
|
|
})
|
|
})
|
|
.then(msg => {
|
|
.then(msg => {
|
|
- if (msg) messageChain.push(msg);
|
|
|
|
|
|
+ if (msg) messageChain += msg;
|
|
});
|
|
});
|
|
}
|
|
}
|
|
// fetch extra entities
|
|
// fetch extra entities
|
|
@@ -384,16 +329,13 @@ class Webshot extends CallableInstance<[
|
|
.sort((var1, var2) => var2.bitrate - var1.bitrate)
|
|
.sort((var1, var2) => var2.bitrate - var1.bitrate)
|
|
.map(variant => variant.url)[0]; // largest video
|
|
.map(variant => variant.url)[0]; // largest video
|
|
}
|
|
}
|
|
- const altMessage = Message.Plain(`\n[失败的${typeInZH[media.type as keyof typeof typeInZH].type}:${url}]`);
|
|
|
|
|
|
+ const altMessage = `\n[失败的${typeInZH[media.type as keyof typeof typeInZH].type}:${url}]`;
|
|
return this.fetchMedia(url)
|
|
return this.fetchMedia(url)
|
|
- .then(base64url =>
|
|
|
|
- uploader(Message.Image('', base64url, media.type === 'photo' ? url : `${url} as gif`), () => altMessage)
|
|
|
|
- )
|
|
|
|
.catch(error => {
|
|
.catch(error => {
|
|
logger.warn('unable to fetch media, sending plain text instead...');
|
|
logger.warn('unable to fetch media, sending plain text instead...');
|
|
return altMessage;
|
|
return altMessage;
|
|
})
|
|
})
|
|
- .then(msg => { messageChain.push(msg); });
|
|
|
|
|
|
+ .then(msg => { messageChain += msg; });
|
|
}));
|
|
}));
|
|
}
|
|
}
|
|
});
|
|
});
|
|
@@ -405,7 +347,7 @@ class Webshot extends CallableInstance<[
|
|
.filter(urlObj => urlObj.indices[0] < originTwi.display_text_range[1])
|
|
.filter(urlObj => urlObj.indices[0] < originTwi.display_text_range[1])
|
|
.map(urlObj => `\n\ud83d\udd17 ${urlObj.expanded_url}`);
|
|
.map(urlObj => `\n\ud83d\udd17 ${urlObj.expanded_url}`);
|
|
if (urls.length) {
|
|
if (urls.length) {
|
|
- messageChain.push(Message.Plain(urls.join('')));
|
|
|
|
|
|
+ messageChain += urls.join('');
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
@@ -413,14 +355,14 @@ class Webshot extends CallableInstance<[
|
|
// refer to quoted tweet, if any
|
|
// refer to quoted tweet, if any
|
|
if (originTwi.is_quote_status) {
|
|
if (originTwi.is_quote_status) {
|
|
promise = promise.then(() => {
|
|
promise = promise.then(() => {
|
|
- messageChain.push(
|
|
|
|
- Message.Plain(`\n回复此命令查看引用的推文:\n/twitter_view ${originTwi.quoted_status_permalink.expanded}`)
|
|
|
|
- );
|
|
|
|
|
|
+ const match = /\/status\/(\d+)/.exec(originTwi.quoted_status_permalink?.expanded);
|
|
|
|
+ const blockQuoteIdStr = match ? match[1] : originTwi.quoted_status?.id_str;
|
|
|
|
+ if (blockQuoteIdStr) messageChain += `\n回复此命令查看引用的推文:\n/twitter_view ${blockQuoteIdStr}`;
|
|
});
|
|
});
|
|
}
|
|
}
|
|
promise.then(() => {
|
|
promise.then(() => {
|
|
logger.info(`done working on ${twi.user.screen_name}/${twi.id_str}, message chain:`);
|
|
logger.info(`done working on ${twi.user.screen_name}/${twi.id_str}, message chain:`);
|
|
- logger.info(JSON.stringify(messageChain));
|
|
|
|
|
|
+ logger.info(JSON.stringify(Message.ellipseBase64(messageChain)));
|
|
callback(messageChain, xmlEntities.decode(text), author);
|
|
callback(messageChain, xmlEntities.decode(text), author);
|
|
});
|
|
});
|
|
});
|
|
});
|