"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const fs_1 = require("fs"); const util_1 = require("util"); const axios_1 = require("axios"); const CallableInstance = require("callable-instance"); const html_entities_1 = require("html-entities"); const pngjs_1 = require("pngjs"); const puppeteer = require("playwright"); const sharp = require("sharp"); const temp = require("temp"); const loggers_1 = require("./loggers"); const koishi_1 = require("./koishi"); const utils_1 = require("./utils"); const twitter_1 = require("./twitter"); 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: ZHType('图片'), video: ZHType('视频'), }; const logger = loggers_1.getLogger('webshot'); class Webshot extends CallableInstance { constructor(wsUrl, mode, getCookies, onready) { super('webshot'); this.connect = (onready) => axios_1.default.get(this.wsUrl) .then(res => { logger.info(`received websocket endpoint: ${JSON.stringify(res.data)}`); const browserType = Object.keys(res.data)[0]; return puppeteer[browserType] .connect({ wsEndpoint: res.data[browserType] }); }) .then(browser => this.browser = browser) .then(() => { logger.info('launched puppeteer browser'); if (onready) return onready(); }) .catch(error => this.reconnect(error, onready)); this.reconnect = (error, onready) => { logger.error(`connection error, reason: ${error}`); logger.warn('trying to reconnect in 2.5s...'); return util_1.promisify(setTimeout)(2500) .then(() => this.connect(onready)); }; this.performOnNewPage = (action, zoomFactor = 2, reconnectOnError = true) => this.browser.newPage({ bypassCSP: true, deviceScaleFactor: zoomFactor, locale: 'ja-JP', timezoneId: 'Asia/Tokyo', userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.99 Safari/537.36', }).then(action) .catch(error => { if (reconnectOnError) { return this.reconnect(error) .then(() => this.performOnNewPage(action, zoomFactor, reconnectOnError)); } throw error; }); this.renderWebshot = (url, height, webshotDelay, ...morePostProcessings) => { temp.track(); const jpeg = (data) => data.pipe(sharp()).jpeg({ quality: 90, trellisQuantisation: true }); const sharpToFile = (pic) => new Promise(resolve => { const webshotTempFilePath = temp.path({ suffix: '.jpg' }); pic.toFile(webshotTempFilePath).then(() => resolve(`file://${webshotTempFilePath}`)); }); const promise = new Promise((resolve, reject) => { const width = 720; const zoomFactor = 2; logger.info(`shooting ${width}*${height} webshot for ${url}`); this.performOnNewPage(page => { const startTime = new Date().getTime(); const getTimerTime = () => new Date().getTime() - startTime; const getTimeout = () => twitter_1.WebshotHelpers.isWaitingForLogin ? 0 : Math.max(500, webshotDelay - getTimerTime()); page.setViewportSize({ width: width / zoomFactor, height: height / zoomFactor, }).then(() => page.context().addCookies(this.getCookies())) .then(() => page.goto(url, { waitUntil: 'load', timeout: getTimeout() })) .then(() => twitter_1.WebshotHelpers.handleCookieConsent(page)) .then(() => ((next) => Promise.race([ twitter_1.WebshotHelpers.handleLogin(page) .then(() => page.goto(url, { waitUntil: 'load', timeout: getTimeout() })) .then(next), next(), ]))(() => util_1.promisify(setTimeout)(2000).then(() => page.waitForSelector('article', { timeout: getTimeout() })))) .catch((err) => { if (err.name !== 'TimeoutError') throw err; logger.warn(`navigation timed out at ${getTimerTime()} ms`); return null; }) .then(() => page.addStyleTag({ content: 'nav,footer,main>*>*+*,header+div,header~div>div>div+div,main button,canvas,main section,main section+div>ul>:not(div),' + 'main section+div>ul>div [role="button"],header~div [tabindex="0"]>*>[tabindex="-1"]~div{display:none!important} ' + 'section+div{overflow:hidden} section+*>*{position:relative!important} article{border-bottom:1px solid!important} ' + 'main section+div>ul>div>li{padding:6px 2px 12px!important}', })) .then(() => page.addStyleTag({ content: '*{font-family:-apple-system,".Helvetica Neue DeskInterface",Hiragino Sans,Hiragino Sans GB,sans-serif!important}', })) .then(() => page.evaluate(() => { let time; time = document.querySelector('div>div>time'); if (time) time.parentElement.parentElement.style.display = 'none'; time = document.querySelector('main section~div>a>time'); if (time) { time.innerHTML = time.title + ' ' + new Date(time.dateTime).toLocaleTimeString().slice(0, -3); time.parentElement.parentElement.style.margin = '-24px 2px 12px'; const element = time.parentElement.parentElement.nextElementSibling; if (element) element.style.display = 'none'; } })) .then(() => utils_1.chainPromises(morePostProcessings.map(func => () => func(page)))) .then(() => util_1.promisify(setTimeout)(getTimeout())) .then(() => page.screenshot()) .then(screenshot => { new pngjs_1.PNG({ filterType: 4, deflateLevel: 0, }).on('parsed', function () { const idx = (x, y) => (this.width * y + x) << 2; let boundary = null; for (let y = this.height - 1; y > this.height - 3840; y -= zoomFactor) { if (this.data[idx(zoomFactor, y)] <= 38 && this.data[idx(zoomFactor, y)] === this.data[idx(this.width - zoomFactor, y)] && this.data[idx(zoomFactor, y + zoomFactor)] === this.data[idx(zoomFactor, y - 2 * zoomFactor)]) { boundary = y - 1; break; } } if (boundary !== null) { logger.info(`found boundary at ${boundary}, cropping image`); this.data = this.data.slice(0, idx(this.width, boundary)); this.height = boundary; sharpToFile(jpeg(this.pack())).then(path => { logger.info(`finished webshot for ${url}`); resolve({ path, boundary }); }); } else if (height >= 8 * 1920) { logger.warn('too large, consider as a bug, returning'); sharpToFile(jpeg(this.pack())).then(path => { resolve({ path, boundary: 0 }); }); } else { logger.info('unable to find boundary, try shooting a larger image'); resolve({ path: '', boundary }); } }).parse(screenshot); }) .catch(err => { if (err instanceof Error && err.name !== 'TimeoutError') throw err; logger.error(`error shooting webshot for ${url}, could not load web page of tweet`); resolve({ path: '', boundary: 0 }); }) .finally(() => { page.close(); }); }, zoomFactor, false) .catch(reject); }); return promise.then(data => { if (data.boundary === null) { return this.renderWebshot(url, height + 3840, webshotDelay, ...morePostProcessings); } else return data.path; }).catch(error => this.reconnect(error) .then(() => this.renderWebshot(url, height, webshotDelay, ...morePostProcessings))); }; this.fetchMedia = (url) => new Promise((resolve, reject) => { logger.info(`fetching ${url}`); axios_1.default({ 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}`); reject(); }); }).then(data => (ext => { const mediaTempFilePath = temp.path({ suffix: `.${ext}` }); fs_1.writeFileSync(mediaTempFilePath, Buffer.from(data)); const path = `file://${mediaTempFilePath}`; switch (ext) { case 'jpg': case 'png': return koishi_1.Message.Image(path); case 'mp4': return koishi_1.Message.Video(path); } logger.warn('unable to find MIME type of fetched media, failing this fetch'); throw Error(); })(/\/.*\.(.+?)\?/.exec(url)[1])); if (this.mode = mode) { if (onready) onready(); } else { this.getCookies = getCookies; this.wsUrl = wsUrl; this.connect(() => onready && onready(this.performOnNewPage)); } } webshot(lazyMediaItems, callback, webshotDelay) { let grandPromise = Promise.resolve(); lazyMediaItems.forEach(lazyItem => grandPromise = grandPromise.then(lazyItem.item).then(item => { var _a; let promise = Promise.resolve(); promise = promise.then(() => { logger.info(`working on ${item.user.username}/${item.code}`); }); let messageChain = ''; const author = `${item.user.full_name} (@${item.user.username}):\n`; const text = ((_a = item.caption) === null || _a === void 0 ? void 0 : _a.text) || ''; if (this.mode > 0) messageChain += (author + xmlEntities.decode(text)); if (this.mode === 0) { const url = twitter_1.linkBuilder({ postUrlSegment: item.code }); promise = promise.then(() => this.renderWebshot(url, 3840, webshotDelay, page => page.addStyleTag({ content: 'header>div>div+div{font-size:12px; line-height:15px; padding-top:0!important}' + `header>div>div+div::before{content:"${item.user.full_name}"; color:#8e8e8e; font-weight:bold}`, }))) .then(fileurl => { if (fileurl) return koishi_1.Message.Image(fileurl); return author + text; }) .then(msg => { if (msg) messageChain += msg; }); } const type = (mediaItem) => mediaItem.video_versions ? 'video' : 'photo'; const fetchBestCandidate = (candidates, mediaType) => { const url = candidates .sort((var1, var2) => var2.width + ((var2 === null || var2 === void 0 ? void 0 : var2.type) || 0) - var1.width - ((var1 === null || var1 === void 0 ? void 0 : var1.type) || 0)) .map(variant => variant.url)[0]; const altMessage = `\n[失败的${typeInZH[mediaType].type}:${url}]`; return this.fetchMedia(url) .catch(error => { logger.warn('unable to fetch media, sending plain text instead...'); return altMessage; }) .then(msg => { messageChain += msg; }); }; if (1 - this.mode % 2) promise = promise.then(() => { if (item.carousel_media) { return utils_1.chainPromises(item.carousel_media.map(carouselItem => () => fetchBestCandidate(carouselItem.video_versions || carouselItem.image_versions2.candidates, type(carouselItem)))); } else if (item.video_versions) { return fetchBestCandidate(item.video_versions, type(item)); } else if (item.image_versions2) { return fetchBestCandidate(item.image_versions2.candidates, type(item)); } }); promise.then(() => { logger.info(`done working on ${item.user.username}/${item.code}, message chain:`); logger.info(JSON.stringify(koishi_1.Message.ellipseBase64(messageChain))); callback(messageChain, xmlEntities.decode(text), author); }); return promise; })); return grandPromise; } } exports.default = Webshot;