const querystring = require('querystring'); const got = require('@/utils/got'); const cheerio = require('cheerio'); const { fallback, queryToBoolean, queryToInteger } = require('@/utils/readable-social'); const weiboUtils = { formatTitle: (html) => html .replace(/]*?alt=["']?([^>]+?)["']?\s[^>]*?\/?><\/span>/g, '$1') // 表情转换 .replace(/(]*>)<\/span>/g, '') // 去掉所有图标 .replace(//g, '[图片]') // impossible to have inline script in weibo posts, but CodeQL complains about it // Dismiss it through the UI: https://github.com/github/codeql/issues/11427 .replace(/<[^<]*>/g, '') .replace(/\n/g, ' ') .trim(), formatExtended: (ctx, status, uid = undefined, params = {}, picsPrefixes = []) => { // `uid = undefined` to explicitly mark it as optional, avoiding IDEs prompting warnings // undefined and strings like "1" is also safely parsed, so no if branch is needed const routeParams = querystring.parse(ctx.params.routeParams); const mergedParams = { readable: fallback(params.readable, queryToBoolean(routeParams.readable), false), authorNameBold: fallback(params.authorNameBold, queryToBoolean(routeParams.authorNameBold), false), showAuthorInTitle: fallback(params.showAuthorInTitle, queryToBoolean(routeParams.showAuthorInTitle), false), showAuthorInDesc: fallback(params.showAuthorInDesc, queryToBoolean(routeParams.showAuthorInDesc), false), showAuthorAvatarInDesc: fallback(params.showAuthorAvatarInDesc, queryToBoolean(routeParams.showAuthorAvatarInDesc), false), showAtBeforeAuthor: fallback(params.showAtBeforeAuthor, null, false), showEmojiForRetweet: fallback(params.showEmojiForRetweet, queryToBoolean(routeParams.showEmojiForRetweet), false), showRetweetTextInTitle: fallback(params.showRetweetTextInTitle, queryToBoolean(routeParams.showRetweetTextInTitle), true), addLinkForPics: fallback(params.addLinkForPics, queryToBoolean(routeParams.addLinkForPics), false), showTimestampInDescription: fallback(params.showTimestampInDescription, queryToBoolean(routeParams.showTimestampInDescription), false), widthOfPics: fallback(params.widthOfPics, queryToInteger(routeParams.widthOfPics), -1), heightOfPics: fallback(params.heightOfPics, queryToInteger(routeParams.heightOfPics), -1), sizeOfAuthorAvatar: fallback(params.sizeOfAuthorAvatar, queryToInteger(routeParams.sizeOfAuthorAvatar), 48), showEmojiInDescription: fallback(params.showEmojiInDescription, queryToInteger(routeParams.showEmojiInDescription), true), showLinkIconInDescription: fallback(params.showLinkIconInDescription, queryToInteger(routeParams.showLinkIconInDescription), true), preferMobileLink: fallback(params.preferMobileLink, queryToBoolean(routeParams.preferMobileLink), false), }; params = mergedParams; const { readable, authorNameBold, showAuthorInTitle, showAuthorInDesc, showAuthorAvatarInDesc, showAtBeforeAuthor, showEmojiForRetweet, showRetweetTextInTitle, addLinkForPics, showTimestampInDescription, widthOfPics, heightOfPics, sizeOfAuthorAvatar, showEmojiInDescription, showLinkIconInDescription, preferMobileLink, } = params; let retweeted = ''; // 长文章的处理 let htmlNewLineUnreplaced = (status.longText && status.longText.longTextContent) || status.text || ''; // 表情图标转换为文字 if (!showEmojiInDescription) { htmlNewLineUnreplaced = htmlNewLineUnreplaced.replace(/]*?alt=["']?([^>]+?)["']?\s[^>]*?\/><\/span>/g, '$1'); } // 去掉链接的图标,保留 a 标签链接 if (!showLinkIconInDescription) { htmlNewLineUnreplaced = htmlNewLineUnreplaced.replace(/(]*>)]*><\/span>[^<>]*?([^<>]*?)<\/span><\/a>/g, '$1$2'); } // 去掉乱七八糟的图标 // 不需要,上述的替换应该已经把所有的图标都替换掉了,且这条 regex 会破坏上述替换不发生时的输出 // htmlNewLineUnreplaced = htmlNewLineUnreplaced.replace(/(]*?>)<\/span>/g, ''); // 将行内图标的高度设置为一行,改善阅读体验。但有些阅读器删除了 style 属性,无法生效 // 不需要,微博已经作此设置 // htmlNewLineUnreplaced = htmlNewLineUnreplaced.replace(/(?<=)/g, '
'); htmlNewLineUnreplaced = htmlNewLineUnreplaced.replace(/全文<\/a>/g, ''); // 处理外部链接 htmlNewLineUnreplaced = htmlNewLineUnreplaced.replace(/https:\/\/weibo\.cn\/sinaurl\/.*?&u=(http.*?")/g, (match, p1) => decodeURIComponent(p1)); let html = htmlNewLineUnreplaced.replace(/\n/g, '
'); // 添加用户名和头像 if (showAuthorInDesc) { let usernameAndAvatar = `
`; if (showAuthorAvatarInDesc) { usernameAndAvatar += ``; } let name = status.user.screen_name; if (showAtBeforeAuthor) { name = '@' + name; } if (authorNameBold) { usernameAndAvatar += `${name}: `; } else { usernameAndAvatar += `${name}: `; } html = usernameAndAvatar + html; } // status.pics can be either an array or an object: // array: [ object, object, ... ] // object: { '0': object, '1': object, ... } // REALLY AMAZING data structure if (status.pics && !Array.isArray(status.pics) && typeof status.pics === 'object') { status.pics = Object.values(status.pics); } // 添加文章头图,此处不需要回落到被转发的微博,后续处理被转发的微博时,还会执行到这里 if (status.page_info && status.page_info.type === 'article' && status.page_info.page_pic && status.page_info.page_pic.url) { // 如果以后后续流程会用到其他字段,记得修改这里 const pagePic = { large: { url: status.page_info.page_pic.url, }, }; // 文章微博一般不会有配图,但也有可能有:https://weibo.com/6882481489/Lh85BkS3m if (status.pics) { status.pics.push(pagePic); } else { status.pics = [pagePic]; } } // drop live photo const livePhotoCount = status.pics ? status.pics.filter((pic) => pic.type === 'livephotos').length : 0; const pics = status.pics && status.pics.filter((pic) => pic.type !== 'livephotos'); // 添加微博配图 if (pics) { if (readable) { html += '
'; } // 一些RSS Reader会识别所有标签作为内含图片显示,我们不想要头像也作为内含图片之一 // 让所有配图在description的最前面再次出现一次,但宽高设为0 let picsPrefix = ''; pics.forEach((item) => { picsPrefix += ``; }); picsPrefixes.push(picsPrefix); pics.forEach((item) => { if (addLinkForPics) { html += ''; } let style = ''; html += '= 0) { html += ` width="${widthOfPics}"`; style += `width: ${widthOfPics}px;`; } if (heightOfPics >= 0) { html += ` height="${heightOfPics}"`; style += `height: ${heightOfPics}px;`; } html += ` style="${style}"` + ' src="' + item.large.url + '">'; if (addLinkForPics) { html += ''; } if (!readable) { html += '

'; } htmlNewLineUnreplaced += ''; }); } // 处理转发的微博 if (status.retweeted_status) { if (readable) { html += `
`; } else { html += `
- 转发 `; } if (!status.retweeted_status.user) { // 当转发的微博被删除时user为null status.retweeted_status.user = { profile_image_url: '', screen_name: '[原微博不可访问]', id: 'sorry', }; } // 插入转发的微博 const retweetedParams = Object.assign({}, params); retweetedParams.showAuthorInDesc = true; retweetedParams.showAuthorAvatarInDesc = false; retweetedParams.showAtBeforeAuthor = true; retweeted += weiboUtils.formatExtended(ctx, status.retweeted_status, undefined, retweetedParams, picsPrefixes).description; html += retweeted; if (readable) { html += `
原博:https://weibo.com/${status.retweeted_status.user.id}/${status.retweeted_status.bid}`; } if (showTimestampInDescription) { html += `
` + new Date(status.retweeted_status.created_at).toLocaleString() + ``; } if (readable) { html += `
`; } html += '
'; } if (showAuthorInDesc && showAuthorAvatarInDesc) { html = picsPrefixes.join('') + html; } let title = ''; if (showAuthorInTitle) { title += status.user.screen_name + ': '; } if (!status.retweeted_status || showRetweetTextInTitle) { title += weiboUtils.formatTitle(htmlNewLineUnreplaced); } if (status.retweeted_status) { title += showEmojiForRetweet ? '🔁 ' : ' - 转发 '; title += weiboUtils.formatTitle(retweeted); } if (livePhotoCount > 0) { title += ' '; title += new Array(livePhotoCount + 1).join('[Live Photo]'); } if (status.page_info && status.page_info === 'video') { title += ' [视频]'; } uid = uid || status.user?.id; const bid = status.bid || status.id; const guid = uid ? `https://weibo.com/${uid}/${bid}` : `https://m.weibo.cn/status/${bid}`; const link = preferMobileLink ? `https://m.weibo.cn/status/${bid}` : guid; const author = status.user?.screen_name; const pubDate = status.created_at; return { description: html, title, link, guid, author, pubDate }; }, getShowData: async (uid, bid) => { const link = `https://m.weibo.cn/statuses/show?id=${bid}`; const itemResponse = await got.get(link, { headers: { Referer: `https://m.weibo.cn/u/${uid}`, 'MWeibo-Pwa': 1, 'X-Requested-With': 'XMLHttpRequest', 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', }, }); return itemResponse.data.data; }, formatVideo: (itemDesc, status) => { const pageInfo = status.page_info; const livePhotos = status.pics && status.pics.filter((pic) => pic.type === 'livephotos' && pic.videoSrc); let video = '
'; let anyVideo = false; if (livePhotos) { livePhotos.forEach((livePhoto) => { video += ``; anyVideo = true; }); } if (pageInfo && pageInfo.type === 'video') { const pagePic = pageInfo.page_pic; const posterUrl = pagePic ? pagePic.url : ''; const pageUrl = pageInfo.page_url; // video page url const mediaInfo = pageInfo.media_info || {}; // stream_url, stream_url_hd; deprecated: mp4_720p_mp4, mp4_hd_url, mp4_sd_url const urls = pageInfo.urls || {}; // mp4_720p_mp4, mp4_hd_mp4, hevc_mp4_hd, mp4_ld_mp4 const video720p = urls.mp4_720p_mp4 || mediaInfo.mp4_720p_mp4 || ''; const videoHd = urls.mp4_hd_mp4 || mediaInfo.mp4_hd_url || mediaInfo.stream_url_hd || ''; const videoHdHevc = urls.hevc_mp4_hd || ''; const videoLd = urls.mp4_ld_mp4 || mediaInfo.mp4_sd_url || mediaInfo.stream_url || ''; const hasVideo = video720p || videoHd || videoHdHevc || videoLd; if (hasVideo) { video += `'; anyVideo = true; } } if (anyVideo) { itemDesc += video; } return itemDesc; }, formatArticle: async (ctx, itemDesc, status) => { const pageInfo = status.page_info; if (pageInfo && pageInfo.type === 'article' && pageInfo.page_url) { const pageUrl = pageInfo.page_url; const articleIdMatch = pageUrl.match(/id=(\d+)/); if (!articleIdMatch) { return itemDesc; } const articleId = articleIdMatch[1]; const link = `https://card.weibo.com/article/m/aj/detail?id=${articleId}`; const response = await ctx.cache.tryGet(link, async () => { const _response = await got.get(link, { headers: { Referer: `https://card.weibo.com/article/m/show/id/${articleId}`, 'MWeibo-Pwa': 1, 'X-Requested-With': 'XMLHttpRequest', 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', }, }); return _response.data; }); // cache it! const article = response.data; if (article && article.title && article.content) { const title = article.title; const content = article.content; const summary = article.summary; const createAt = article.create_at; const readCount = article.read_count; const isOriginal = article.is_original; const isArticleNonFree = article.is_article_free; // 微博起错了字段名,它为 1 时才是收费文章 // 许多微博文章都给文字设置了白色背景,这里也只好使用白色背景了 let html = '

'; html += '
'; html += `

${title}

`; // 加入标题 // 加入文章信息 const iconStyle = 'display: inline-block;margin-inline: 0.25rem;width: 2.25rem; height: 1.125rem; background: #eee; border-radius: 2px; box-sizing: border-box; text-align: center; line-height: 1.0625rem; font-size: 0.75rem; color: #aaa;'; let articleMeta = '

'; if (isArticleNonFree) { articleMeta += `试读 `; } if (isOriginal) { articleMeta += `原创 `; } articleMeta += `发布时间: ${createAt} `; // 发布时间 articleMeta += `阅读量: ${readCount} `; // 阅读量 articleMeta += '

'; html += articleMeta; if (summary) { html += `

${summary}

`; // 摘要 } html += '
'; // 分割线 // 正文处理,加入一些在微博文章页的 CSS 中定义的不可或缺的样式 const $ = cheerio.load(content); $('p').each((_, elem) => { elem = $(elem); let style = elem.attr('style') || ''; style = 'margin: 0;padding: 0;border: 0;' + style; elem.attr('style', style); }); $('.image').each((_, elem) => { elem = $(elem); let style = elem.attr('style') || ''; style = 'display: table;text-align: center;margin-left: auto;margin-right: auto;clear: both;min-width: 50px;' + style; elem.attr('style', style); }); $('img').each((_, elem) => { elem = $(elem); let style = elem.attr('style') || ''; style = 'display: block;max-width: 100%;margin-left: auto;margin-right: auto;min-width: 50px;' + style; elem.attr('style', style); }); const contentHtml = $.html(); html += `
${contentHtml}
`; // 正文 html += '
'; itemDesc += html; } } return itemDesc; }, formatComments: async (ctx, itemDesc, status) => { if (status && status.comments_count && status.id && status.mid) { const id = status.id; const mid = status.mid; const link = `https://m.weibo.cn/comments/hotflow?id=${id}&mid=${mid}&max_id_type=0`; const response = await ctx.cache.tryGet(link, async () => { const _response = await got.get(link, { headers: { Referer: `https://m.weibo.cn/detail/${id}`, 'MWeibo-Pwa': 1, 'X-Requested-With': 'XMLHttpRequest', 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', }, }); return _response.data; }); if (response.data && response.data.data) { const comments = response.data.data; itemDesc += `
`; itemDesc += '

热门评论

'; comments.forEach((comment) => { itemDesc += '

'; itemDesc += `${comment.user.screen_name}: ${comment.text}`; if (comment.comments) { itemDesc += '

'; comment.comments.forEach((comment) => { itemDesc += '
'; itemDesc += `${comment.user.screen_name}: ${comment.text}`; itemDesc += '
'; }); itemDesc += '
'; } itemDesc += '

'; }); itemDesc += '
'; } } return itemDesc; }, sinaimgTvax: (() => { // https://datatracker.ietf.org/doc/html/rfc1808#section-2.4.3 const regex = /(?<=\/\/)wx(?=[1-4]\.sinaimg\.cn\/)/gi; // const prefixes = ['tva', 'tvax']; // let cnt = 0; // const replace = (html) => { // cnt = (cnt + 1) % 2; // return html.replace(regex, prefixes[cnt]); // }; const replace = (html) => html.replace(regex, 'tvax'); // enforce `tvax` as `tva` has a strict WAF const replaceKV = (obj, keys) => { keys.forEach((key) => { if (obj[key]) { obj[key] = replace(obj[key]); } }); }; const dataKeys = ['description', 'image']; const itemKeys = ['description']; return (data) => { if (data) { replaceKV(data, dataKeys); if (data.item) { data.item.forEach((item) => { replaceKV(item, itemKeys); }); } } return data; }; })(), }; module.exports = weiboUtils;