webshot.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. import { writeFileSync } from 'fs';
  2. import { Readable } from 'stream';
  3. import { promisify } from 'util';
  4. import axios from 'axios';
  5. import * as CallableInstance from 'callable-instance';
  6. import { XmlEntities } from 'html-entities';
  7. import { PNG } from 'pngjs';
  8. import * as puppeteer from 'playwright';
  9. import * as sharp from 'sharp';
  10. import * as temp from 'temp';
  11. import { getLogger } from './loggers';
  12. import { Message } from './koishi';
  13. import { chainPromises } from './utils';
  14. import { browserLogin, linkBuilder, MediaItem } from './twitter';
  15. const xmlEntities = new XmlEntities();
  16. const ZHType = (type: string) => new class extends String {
  17. public type = super.toString();
  18. public toString = () => `[${super.toString()}]`;
  19. }(type);
  20. const typeInZH = {
  21. photo: ZHType('图片'),
  22. video: ZHType('视频'),
  23. };
  24. const logger = getLogger('webshot');
  25. export type Page = puppeteer.Page;
  26. export type Cookies = puppeteer.Cookie[];
  27. class Webshot extends CallableInstance<[MediaItem[], (...args) => void, number], Promise<void>> {
  28. private browser: puppeteer.Browser;
  29. private mode: number;
  30. private wsUrl: string;
  31. private getCookies: () => Cookies;
  32. constructor(wsUrl: string, mode: number, getCookies: () => Cookies, onready?: (...args) => void) {
  33. super('webshot');
  34. // tslint:disable-next-line: no-conditional-assignment
  35. // eslint-disable-next-line no-cond-assign
  36. if (this.mode = mode) {
  37. onready();
  38. } else {
  39. this.getCookies = getCookies;
  40. this.wsUrl = wsUrl;
  41. this.connect(onready);
  42. }
  43. }
  44. private connect = (onready?: (...args) => void): Promise<void> =>
  45. axios.get<{[key in 'chromium' | 'firefox' | 'webkit']?: string}>(this.wsUrl)
  46. .then(res => {
  47. logger.info(`received websocket endpoint: ${JSON.stringify(res.data)}`);
  48. const browserType = Object.keys(res.data)[0] as keyof typeof res.data;
  49. return (puppeteer[browserType] as puppeteer.BrowserType<puppeteer.Browser>)
  50. .connect({wsEndpoint: res.data[browserType]});
  51. })
  52. .then(browser => this.browser = browser)
  53. .then(() => {
  54. logger.info('launched puppeteer browser');
  55. if (onready) return onready();
  56. })
  57. .catch(error => this.reconnect(error, onready));
  58. private reconnect = (error, onready?: (...args) => void) => {
  59. logger.error(`connection error, reason: ${error}`);
  60. logger.warn('trying to reconnect in 2.5s...');
  61. return promisify(setTimeout)(2500)
  62. .then(() => this.connect(onready));
  63. };
  64. private renderWebshot = (url: string, height: number, webshotDelay: number): Promise<string> => {
  65. temp.track();
  66. const jpeg = (data: Readable) => data.pipe(sharp()).jpeg({quality: 90, trellisQuantisation: true});
  67. const sharpToFile = (pic: sharp.Sharp) => new Promise<string>(resolve => {
  68. const webshotTempFilePath = temp.path({suffix: '.jpg'});
  69. pic.toFile(webshotTempFilePath).then(() => resolve(`file://${webshotTempFilePath}`));
  70. });
  71. const promise = new Promise<{ path: string, boundary: null | number }>((resolve, reject) => {
  72. const width = 720;
  73. const zoomFactor = 2;
  74. logger.info(`shooting ${width}*${height} webshot for ${url}`);
  75. this.browser.newPage({
  76. bypassCSP: true,
  77. deviceScaleFactor: zoomFactor,
  78. locale: 'ja-JP',
  79. timezoneId: 'Asia/Tokyo',
  80. userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.99 Safari/537.36',
  81. })
  82. .then(page => {
  83. const startTime = new Date().getTime();
  84. const getTimerTime = () => new Date().getTime() - startTime;
  85. const getTimeout = () => Math.max(500, webshotDelay - getTimerTime());
  86. page.setViewportSize({
  87. width: width / zoomFactor,
  88. height: height / zoomFactor,
  89. }).then(() => page.context().addCookies(this.getCookies()))
  90. .then(() => page.goto(url, {waitUntil: 'load', timeout: getTimeout()}))
  91. .then(() =>
  92. (<T>(next: Promise<T>) => Promise.race([
  93. page.click('button:has-text("すべて許可")').then(() => browserLogin(page))
  94. .then(() => page.goto(url, {waitUntil: 'load', timeout: getTimeout()}))
  95. .then(() => next),
  96. page.click('button:has-text("すべて許可")').then(() => next),
  97. next,
  98. ]))(page.waitForSelector('article', {timeout: getTimeout()}))
  99. )
  100. .catch((err: Error): Promise<puppeteer.ElementHandle<Element> | null> => {
  101. if (err.name !== 'TimeoutError') throw err;
  102. logger.warn(`navigation timed out at ${getTimerTime()} ms`);
  103. return null;
  104. })
  105. // hide header, "more options" button, like and retweet count
  106. .then(() => page.addStyleTag({content:
  107. 'nav,footer,header+div,header+div+div>div>div+div,header div div div+div,' +
  108. 'article section,article section+div>ul>:not(div),article section+div~div,article button,canvas{display:none!important} ' +
  109. 'section+div{overflow:hidden} section+*>*{position:relative!important} article{border-bottom:1px solid!important}',
  110. }))
  111. .then(() => page.addStyleTag({
  112. content: '*{font-family:-apple-system,".Helvetica Neue DeskInterface",Hiragino Sans,Hiragino Sans GB,sans-serif!important}',
  113. }))
  114. .then(() => page.screenshot())
  115. .then(screenshot => {
  116. new PNG({
  117. filterType: 4,
  118. deflateLevel: 0,
  119. }).on('parsed', function () {
  120. const idx = (x: number, y: number) => (this.width * y + x) << 2;
  121. let boundary: number = null;
  122. for (let y = this.height - 1; y > this.height - 1920; y -= zoomFactor) {
  123. if (
  124. this.data[idx(zoomFactor, y)] <= 38 &&
  125. this.data[idx(zoomFactor, y)] === this.data[idx(this.width - zoomFactor, y)] &&
  126. this.data[idx(zoomFactor, y + zoomFactor)] === this.data[idx(zoomFactor, y - 2 * zoomFactor)]
  127. ) {
  128. boundary = y;
  129. break;
  130. }
  131. }
  132. if (boundary !== null) {
  133. logger.info(`found boundary at ${boundary}, cropping image`);
  134. this.data = this.data.slice(0, idx(this.width, boundary));
  135. this.height = boundary;
  136. sharpToFile(jpeg(this.pack())).then(path => {
  137. logger.info(`finished webshot for ${url}`);
  138. resolve({path, boundary});
  139. });
  140. } else if (height >= 8 * 1920) {
  141. logger.warn('too large, consider as a bug, returning');
  142. sharpToFile(jpeg(this.pack())).then(path => {
  143. resolve({path, boundary: 0});
  144. });
  145. } else {
  146. logger.info('unable to find boundary, try shooting a larger image');
  147. resolve({path: '', boundary});
  148. }
  149. }).parse(screenshot);
  150. })
  151. .catch(err => {
  152. if (err instanceof Error && err.name !== 'TimeoutError') throw err;
  153. logger.error(`error shooting webshot for ${url}, could not load web page of tweet`);
  154. resolve({path: '', boundary: 0});
  155. })
  156. .finally(() => { page.close(); });
  157. })
  158. .catch(reject);
  159. });
  160. return promise.then(data => {
  161. if (data.boundary === null) return this.renderWebshot(url, height + 1920, webshotDelay);
  162. else return data.path;
  163. }).catch(error => this.reconnect(error)
  164. .then(() => this.renderWebshot(url, height, webshotDelay))
  165. );
  166. };
  167. private fetchMedia = (url: string): Promise<string> => new Promise<ArrayBuffer>((resolve, reject) => {
  168. logger.info(`fetching ${url}`);
  169. axios({
  170. method: 'get',
  171. url,
  172. responseType: 'arraybuffer',
  173. timeout: 150000,
  174. }).then(res => {
  175. if (res.status === 200) {
  176. logger.info(`successfully fetched ${url}`);
  177. resolve(res.data);
  178. } else {
  179. logger.error(`failed to fetch ${url}: ${res.status}`);
  180. reject();
  181. }
  182. }).catch (err => {
  183. logger.error(`failed to fetch ${url}: ${err instanceof Error ? err.message : err}`);
  184. reject();
  185. });
  186. }).then(data =>
  187. (ext => {
  188. const mediaTempFilePath = temp.path({suffix: `.${ext}`});
  189. writeFileSync(mediaTempFilePath, Buffer.from(data));
  190. const path = `file://${mediaTempFilePath}`;
  191. switch (ext) {
  192. case 'jpg':
  193. case 'png':
  194. return Message.Image(path);
  195. case 'mp4':
  196. return Message.Video(path);
  197. }
  198. logger.warn('unable to find MIME type of fetched media, failing this fetch');
  199. throw Error();
  200. })(/\/.*\.(.+?)\?/.exec(url)[1])
  201. );
  202. public webshot(
  203. mediaItems: MediaItem[],
  204. callback: (msgs: string, text: string, author: string) => void,
  205. webshotDelay: number
  206. ): Promise<void> {
  207. let promise = new Promise<void>(resolve => {
  208. resolve();
  209. });
  210. mediaItems.forEach(item => {
  211. promise = promise.then(() => {
  212. logger.info(`working on ${item.user.username}/${item.code}`);
  213. });
  214. let messageChain = '';
  215. // text processing
  216. const author = `${item.user.full_name} (@${item.user.username}):\n`;
  217. const text = item.caption.text;
  218. if (this.mode > 0) messageChain += (author + xmlEntities.decode(text));
  219. // invoke webshot
  220. if (this.mode === 0) {
  221. const url = linkBuilder({postUrlSegment: item.code});
  222. promise = promise.then(() => this.renderWebshot(url, 1920, webshotDelay))
  223. .then(fileurl => {
  224. if (fileurl) return Message.Image(fileurl);
  225. return author + text;
  226. })
  227. .then(msg => {
  228. if (msg) messageChain += msg;
  229. });
  230. }
  231. // fetch extra entities
  232. const type = (mediaItem): keyof typeof typeInZH =>
  233. (mediaItem as MediaItem).video_versions ? 'video' : 'photo';
  234. const fetchBestCandidate =(
  235. candidates: (Partial<typeof item.video_versions[0]> & typeof item.image_versions2.candidates[0])[],
  236. mediaType: keyof typeof typeInZH
  237. ) => {
  238. const url = candidates
  239. .sort((var1, var2) => var2.width + (var2?.type || 0) - var1.width - (var1?.type || 0))
  240. .map(variant => variant.url)[0]; // largest media
  241. const altMessage = `\n[失败的${typeInZH[mediaType].type}:${url}]`;
  242. return this.fetchMedia(url)
  243. .catch(error => {
  244. logger.warn('unable to fetch media, sending plain text instead...');
  245. return altMessage;
  246. })
  247. .then(msg => { messageChain += msg; });
  248. };
  249. // tslint:disable-next-line: curly
  250. // eslint-disable-next-line curly
  251. if (1 - this.mode % 2) promise = promise.then(() => {
  252. if (item.carousel_media) {
  253. return chainPromises(item.carousel_media.map(carouselItem =>
  254. fetchBestCandidate(
  255. (carouselItem as unknown as MediaItem).video_versions ||
  256. carouselItem.image_versions2.candidates,
  257. type(carouselItem)
  258. )
  259. ));
  260. } else if (item.video_versions) {
  261. return fetchBestCandidate(item.video_versions, type(item));
  262. } else if (item.image_versions2) {
  263. return fetchBestCandidate(item.image_versions2.candidates, type(item));
  264. }
  265. });
  266. promise.then(() => {
  267. logger.info(`done working on ${item.user.username}/${item.code}, message chain:`);
  268. logger.info(JSON.stringify(Message.ellipseBase64(messageChain)));
  269. callback(messageChain, xmlEntities.decode(text), author);
  270. });
  271. });
  272. return promise;
  273. }
  274. }
  275. export default Webshot;