下载微博(weibo.com)的图片和视频。(支持LivePhoto、短视频、动/静图(9+),可以打包下载)
// ==UserScript== // @name 微博 [ 图片 | 视频 ] 下载 // @namespace http://tampermonkey.net/ // @version 2.4.6 // @description 下载微博(weibo.com)的图片和视频。(支持LivePhoto、短视频、动/静图(9+),可以打包下载) // @author Mr.Po // @match https://weibo.com/* // @match https://www.weibo.com/* // @match https://d.weibo.com/* // @match https://s.weibo.com/* // @require https://cdnjs.cloudflare.com/ajax/libs/jquery/1.11.0/jquery.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.2.0/jszip.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/1.3.8/FileSaver.min.js // @require https://cdn.staticfile.org/mustache.js/3.1.0/mustache.min.js // @resource iconError https://cdn.jsdelivr.net/gh/Mr-Po/weibo-resource-download/out/media/error.png // @resource iconSuccess https://cdn.jsdelivr.net/gh/Mr-Po/weibo-resource-download/out/media/success.png // @resource iconInfo https://cdn.jsdelivr.net/gh/Mr-Po/weibo-resource-download/out/media/info.png // @resource iconExtract https://cdn.jsdelivr.net/gh/Mr-Po/weibo-resource-download/out/media/extract.png // @resource iconZip https://cdn.jsdelivr.net/gh/Mr-Po/weibo-resource-download/out/media/zip.png // @connect sinaimg.cn // @connect miaopai.com // @connect video.qq.com // @connect youku.com // @connect weibo.com // @grant GM_notification // @grant GM_setClipboard // @grant GM_download // @grant GM_xmlhttpRequest // @grant GM_getResourceURL // @grant GM_setValue // @grant GM_getValue // ==/UserScript== // @更新日志 // v2.4.6 2021-06-25 1、更新支持广告类视频解析。 // v2.4.5 2021-06-08 1、修复来自腾讯的视频,无法解析的bug。 // v2.4.4 2021-04-05 1、修复某些视频无法解析的bug。 // v2.4.3 2020-09-18 1、更新视频链接解析方式,支持1080P+(需自身是微博会员)。 // v2.4.2 2020-08-11 1、新增“操作提示”开关;2、更新jquery来源。 // v2.4.1 2020-06-28 1、修复使用“resource_id”命名时,出现重复后缀的bug。 // v2.4 2020-05-06 1、新增wb_root_*命名参数。 // v2.3.1 2020-04-27 1、优化图标资源加载。 // v2.3 2020-04-27 1、修复视频下载未默认最高清晰度的bug;2、修复逐个下载最多10张的bug;3、修复部分情况下,图片重复的bug。 // v2.2 2020-01-12 1、更新9+图片解析策略。 // v2.1 2019-12-19 1、支持9+图片下载。 // v2.0 2019-06-23 1、重构代码逻辑;2、优化自定义命名方式。 // v1.1 2019-05-24 1、新增支持热门微博、微博搜索;2、新增可选文件命名方式。 // v1.0 2019-05-23 1、支持LivePhoto、短视频、动/静图,可以打包下载。 (function() { 'use strict'; /*jshint esversion: 8 */ class Config { /********************* ↓ 用户可配置区域 ↓ *********************/ /** * 媒体类型 * 【不推荐】直接在此修改数据,应前往【储存】中修改。 * * 此方法的返回值,影响资源名称中的 @mdeia_type 参数值 */ static get mediaType() { return Config.getValue("mediaType", () => { return { picture: "P", livePhoto: "L", video: "V" }; }); } /** * 得到资源名称 * 【不推荐】直接在此修改数据,应前往【储存】中修改。 * * 此方法的返回值,影响资源名称 * * 默认的:${wb_user_name}-${wb_id}-${no} * 会生成:小米商城-4375413591293810-01 * * 若改为:微博-${media_type}-${wb_user_name}-${wb_id}-${no} * 会生成:微博-P-小米商城-4375413591293810-01 * * @param {字符串} wb_user_name 微博用户名(如:小米商城) * @param {字符串} wb_user_id 微博用户ID(如:5578564422) * @param {字符串} wb_id 微博ID(如:4375413591293810) * @param {字符串} wb_url 微博地址(如:1871821935_Ilt7yCnvt)https://weibo.com/ * @param {字符串} resource_id 资源原始名称(如:0065x5rwly1g3c6exw0a2j30u012utyg) * @param {字符串} no 序号(如:01) * @param {字符串} mdeia_type 媒体类型(如:P) * @param {字符串} wb_root_user_name 微博根用户名 * @param {字符串} wb_root_user_id 微博根用户ID * @param {字符串} wb_root_url 微博根链接 * @param {字符串} wb_root_id 微博根ID * * @return {字符串} 由以上字符串组合而成的名称 */ static getResourceName(wb_user_name, wb_user_id, wb_id, wb_url, resource_id, no, mdeia_type, wb_root_user_name, wb_root_user_id, wb_root_url, wb_root_id) { const template = Config.getValue("resourceName", () => "{{wb_root_user_name}}-{{wb_root_id}}-{{no}}" ); return Mustache.render(template, { wb_user_name: wb_user_name, wb_user_id: wb_user_id, wb_id: wb_id, wb_url: wb_url, resource_id: resource_id, no: no, mdeia_type: mdeia_type, wb_root_user_name: wb_root_user_name, wb_root_user_id: wb_root_user_id, wb_root_url: wb_root_url, wb_root_id: wb_root_id }); } /** * 得到打包名称 * 【不推荐】直接在此修改数据,应前往【储存】中修改。 * * 此方法的返回值,影响打包名称 * * 默认的:${wb_user_name}-${wb_id} * 会生成:小米商城-4375413591293810 * * 若改为:压缩包-${wb_user_name}-${wb_id} * 会生成:压缩包-小米商城-4375413591293810 * * * @param {字符串} wb_user_name 微博用户名(如:小米商城) * @param {字符串} wb_user_id 微博用户ID(如:5578564422) * @param {字符串} wb_id 微博ID(如:4375413591293810) * @param {字符串} wb_url 微博地址(如:1871821935_Ilt7yCnvt) * @param {字符串} wb_root_user_name 微博根用户名 * @param {字符串} wb_root_user_id 微博根用户ID * @param {字符串} wb_root_url 微博根链接 * @param {字符串} wb_root_id 微博根ID * * @return {字符串} 由以上字符串组合而成的名称 */ static getZipName(wb_user_name, wb_user_id, wb_id, wb_url, wb_root_user_name, wb_root_user_id, wb_root_url, wb_root_id) { const template = Config.getValue("zipName", () => "{{wb_root_user_name}}-{{wb_root_id}}" ); return Mustache.render(template, { wb_user_name: wb_user_name, wb_user_id: wb_user_id, wb_id: wb_id, wb_url: wb_url, wb_root_user_name: wb_root_user_name, wb_root_user_id: wb_root_user_id, wb_root_url: wb_root_url, wb_root_id: wb_root_id }); } /** * 最大等待请求时间(超时时间),单位:毫秒 *【不推荐】直接在此修改数据,应前往【储存】中修改。 * * 若经常请求超时,可适当增大此值 * * @type {Number} */ static get maxRequestTime() { return Config.getValue("maxRequestTime", () => 8000); } /** * 每隔 space 毫秒检查一次,是否有新的微博被加载出来 *【不推荐】直接在此修改数据,应前往【储存】中修改。 * * 此值越小,检查越快;过小会造成浏览器卡顿 * @type {Number} */ static get space() { return Config.getValue("space", () => 5000); } /** * 是否开启操作提示 * 【不推荐】直接在此修改数据,应前往【储存】中修改。 * * 启用后,右下角会有弹窗对操作进行反馈。 * @type {Boolean}[true/false] */ static get isTip() { return JSON.parse(Config.getValue("tip", () => true)); } /********************* ↑ 用户可配置区域 ↑ *********************/ /** * 是否启用调试模式 * 【不推荐】直接在此修改数据,应前往【储存】中修改。 * * 启用后,浏览器控制台会显示此脚本运行时的调试数据 * @type {Boolean}[true/false] */ static get isDebug() { return JSON.parse(Config.getValue("debug", () => false)); } /** * 已添加增强扩展的item,会追加此类 * 【不推荐】直接在此修改数据,应前往【储存】中修改。 * * @type 字符串 */ static get handledWeiBoCardClass() { return Config.getValue("handledWeiBoCardClass", () => "weibo_383402_extend"); } /** * 得到值 * @param {字符串} name 键 * @param {方法} fun 缺省产生值的方法 * @return {值} 值 */ static getValue(name, fun) { let value = Config.properties[name]; // 本地map中不存在(此处不能用‘非’,因为false会进入) if (value == undefined) { value = GM_getValue(name, null); // 储存中也不存在(此处不能用‘非’,因为false会进入) if (value == undefined) { value = fun(); GM_setValue(name, value); } // 记录到本地map中 Config.properties[name] = value; } return value; } } Config.properties = new Map(); /*jshint esversion: 6 */ /** * 接口 */ class Interface { /** * 构造函数 * @param {字符串} name 接口名 * @param {字符串数组} methods 该接口所包含的所有方法 */ constructor(name, methods) { //判断接口的参数个数(第一个为接口对象,第二个为参数数组) if (arguments.length != 2) { throw new Error('创建的接口对象参数必须为两个,第二个为方法数组!'); } // 判断第二个参数是否为数组 if(!Array.isArray(methods)){ throw new Error('参数2必须为字符串数组!'); } //接口对象引用名 this.name = name; //自己的属性 this.methods = []; //定义一个内置的空数组对象 等待接受methods里的元素(方法名称) //判断数组是否中的元素是否为string的字符串 for (var i = 0; i < methods.length; i++) { //判断方法数组里面是否为string(字符串)的属性 if (typeof methods[i] != 'string') { throw new Error('方法名必须是string类型的!'); } //把他放在接口对象中的methods中(把接口方法名放在Interface对象的数组中) this.methods.push(methods[i]); } } /** * 实现 * @param {对象} obj 待实现接口的对象 * @param {接口} I 接口对象 * @param {对象} proxy 接口的实现 * @return {对象} 扩展后的当前对象 */ static impl(obj, I, proxy) { if (I.constructor != Interface) { throw new Error("参数2不是一个接口!"); } // 校验实现是否实现了接口的每一个方法 for (var i = 0; i < I.methods.length; i++) { // 方法名 var methodName = I.methods[i]; //判断obj中是否实现了接口的方法和methodName是方法(而不是属性) if (!proxy[methodName] || typeof proxy[methodName] != 'function') { throw new Error('有接口的方法没实现'); } // 将代理中的方法渡给obj obj[methodName] = proxy[methodName]; } } } /*jshint esversion: 6 */ class Link { /** * 构造函数 * * @param {字符串} name 名称 * @param {字符串} src 地址 */ constructor(name, src) { this.name = name; this.src = src; } } /*jshint esversion: 6 */ /** * 微博解析器接口 */ const WeiBoResolver = new Interface("SearchWeiBoResolver", [ "getOperationButton", // 得到操作按钮[↓] "getOperationList", // 根据操作按钮,得到操作列表 "get9PhotoImgs", // 返回九宫格图片的img$控件数组(自带后缀) "get9PhotoOver", // 得到超过部分的图片的id数组(无后缀) "getLivePhotoContainer", "getWeiBoCard", // 这条微博(若为转发微博,则取根微博) "getWeiBoInfo", // 这条微博(发布者)信息 "getRootWeiBoInfo", // 这条微博(【根】发布者)信息 "getWeiBoId", // 此条微博的ID "getWeiBoUserId", // 微博发送者的Id "getWeiBoUserName", // 微博发送者的名称 "getWeiBoUrl", // 此条微博的地址 "getProgressContainer", "getVideoBox", "geiVideoSrc" ]); /*jshint esversion: 6 */ /** * 搜索微博 - 解析器 */ const SearchWeiBoResolver = {}; Interface.impl(SearchWeiBoResolver, WeiBoResolver, { getOperationButton: () => $(`div .menu a:not(.${Config.handledWeiBoCardClass})`), getOperationList: $operationButton => $operationButton.parents(".menu").find("ul"), get9PhotoImgs: $ul => $ul.parents(".card-wrap").find(".media.media-piclist img"), get9PhotoOver: $ul => new Promise((resolve, reject) => { resolve([]); }), //搜索微博不会展示9+图片 getLivePhotoContainer: $ul => $(null), getWeiBoCard: ($ul, isRoot) => { const $content = $ul.parents(".content"); const $card_content = $content.find(".card-comment"); let $content_node; if ($card_content.length == 1 && isRoot) { // 这是转发微博 && 需要根 $content_node = $card_content; } else { $content_node = $content; } return $content_node; }, getWeiBoInfo: $ul => { return SearchWeiBoResolver.getWeiBoCard($ul, false).find("a.name").first(); }, getRootWeiBoInfo: $ul => { return SearchWeiBoResolver.getWeiBoCard($ul, true).find("a.name").first(); }, getWeiBoId: ($ul, $info, isRoot) => { const action_data = $info.parents(".card-wrap") .find(".card-act li:eq(1) a").attr("action-data"); const rootmid_regex = action_data.match(/&rootmid=(\d+)&/); let mid; if (rootmid_regex && isRoot) { // 这是转发微博 && 需要根 mid = rootmid_regex[1]; } else { mid = action_data.match(/[&\d]mid=(\d+)&/)[1]; } Core.log(`得到根【${isRoot}】的微博ID为:${mid}`); return mid; }, getWeiBoUserId: ($ul, $info, isRoot) => { const user_id = $info.attr("href").match(/weibo\.com\/[u\/]{0,2}(\d+)/)[1].trim(); Core.log(`得到根【${isRoot}】的微博用户ID为:${user_id}`); return user_id; }, getWeiBoUserName: ($ul, $info, isRoot) => { let name = $info.attr("nick-name"); // 不存在 if (!name) { name = $info.text(); } name = name.trim(); Core.log(`得到根【${isRoot}】的名称为:${name}`); return name; }, getWeiBoUrl: ($ul, isRoot) => { const $card = SearchWeiBoResolver.getWeiBoCard($ul, isRoot); const $froms = $card.find(".from"); let $a; if ($froms.length == 2) { // 转发微博 if (isRoot) { // 需要根 $a = $($froms[0]).find("a"); } else { $a = $($froms[1]).find("a"); } } else { $a = $($froms[0]).find("a"); } const url = $a.attr("href").match(/weibo\.com\/(\d+\/\w+)\?/)[1].trim(); Core.log(`得到根【${isRoot}】微博的地址为:${url}`); return url.replace("\/", "_"); }, getProgressContainer: $sub => $sub.parents(".card-wrap").find("a.name").first().parent(), getVideoBox: $ul => $ul.parents(".card-wrap").find(".WB_video_h5").first(), geiVideoSrc: $box => { let src = $box.attr("action-data").match(/video_src=([\w\/\.%]+)/)[1]; src = decodeURIComponent(decodeURIComponent(src)); if (src.indexOf("http") != 0) { src = `https:${src}`; } return src; } }); /*jshint esversion: 8 */ /** * 我的微博(含:我的微博、他人微博、我的收藏、热门微博) - 解析器 */ const MyWeiBoResolver = {}; Interface.impl(MyWeiBoResolver, WeiBoResolver, { getOperationButton: () => $(`div .screen_box i.ficon_arrow_down:not(.${Config.handledWeiBoCardClass})`), getOperationList: $operationButton => $operationButton.parents(".screen_box").find("ul"), get9PhotoImgs: $ul => $ul.parents(".WB_feed_detail").find("li.WB_pic img"), get9PhotoOver: ($ul) => { return new Promise((resolve, reject) => { const $box = $ul.parents(".WB_feed_detail").find(".WB_media_a"); const action_data = $box.attr("action-data"); const over9pic = action_data.match(/over9pic=1&/); if (over9pic) { // 存在超9图 const ids_regex = action_data.match(/pic_ids=([\w,]+)&/); if (ids_regex) { // 得到图片ids_regex const ids = ids_regex[1].split(","); // 已知所有图片 if (ids.length > 9) { // 用户已手动触发加载over resolve(ids.splice(9)); } else { // 只知前面9张,用户未手动触发加载over Core.log("未知超过部分图片!"); const mid_regex = action_data.match(/mid=([\d,]+)&/); if (mid_regex) { // 找到mid const mid = mid_regex[1]; // 请求未显示的图片id GM_xmlhttpRequest({ method: 'GET', url: `https://weibo.com/aj/mblog/getover9pic?ajwvr=6&mid=${mid}&__rnd=${Date.now()}`, timeout: Config.maxRequestTime, responseType: "json", onload: function(res) { resolve(res.response.data.pids); }, onerror: function(e) { console.error(e); reject("请求未展示图片发生错误"); }, ontimeout: function() { reject("请求未展示图片超时!"); } }); } else { // 未找到mid reject("未能找到此条微博的mid!"); } } } else { reject("获取图片ids失败!"); } } else { // 图片数量未超9张 resolve([]); } }); }, getLivePhotoContainer: $ul => $ul.parents(".WB_feed_detail").find(".WB_media_a"), getWeiBoCard: ($ul, isRoot) => { // 此条微博 const $box = $ul.parents("div.WB_feed_detail"); // 根微博 const $box_expand = $box.find(".WB_feed_expand"); let $box_node = $box; // 这是一条转发微博 && 并且需要取根 if ($box_expand.length == 1 && isRoot) { $box_node = $box_expand; } return $box_node; }, getWeiBoInfo: $ul => { return MyWeiBoResolver.getWeiBoCard($ul, false).find("div.WB_detail div.WB_info a").first(); }, getRootWeiBoInfo: $ul => { return MyWeiBoResolver.getWeiBoCard($ul, true).find("div.WB_info a").first(); }, getWeiBoId: ($ul, $info, isRoot) => { const id_regex = $info.attr("suda-uatrack").match(/value=\w+:(\d+)/); let id; if (id_regex) { // 我的微博、他人微博(转发)、我的收藏、热门微博 id = id_regex[1].trim(); } else { // 他人微博 id = $ul.parents(".WB_feed_detail").parents(".WB_cardwrap").attr("mid").trim(); } Core.log(`得到根【${isRoot}】的微博ID为:${id}`); return id; }, getWeiBoUserId: ($ul, $info, isRoot) => { const user_id = $info.attr("usercard").match(/id=(\d+)/)[1].trim(); Core.log(`得到根【${isRoot}】的微博用户ID为:${user_id}`); return user_id; }, getWeiBoUserName: ($ul, $info, isRoot) => { // 适用于根微博 let name = $info.attr("nick-name"); // 不存在 if (!name) { name = $info.text(); } name = name.trim(); Core.log(`得到根【${isRoot}】的名称为:${name}`); return name; }, getWeiBoUrl: ($ul, isRoot) => { const $li_forward = $ul .parents(".WB_feed_detail") .parents("div.WB_cardwrap") .find(".WB_feed_handle .WB_row_line li:eq(1) a"); const action_data = $li_forward.attr("action-data"); const rooturl_regex = action_data.match(/rooturl=https:\/\/weibo\.com\/(\d+\/\w+)&/); let url; if (rooturl_regex && isRoot) { // 这是转发微博 && 需要根 url = rooturl_regex[1].trim(); } else { url = action_data.match(/&url=https:\/\/weibo\.com\/(\d+\/\w+)&/)[1].trim(); } Core.log(`得到根【${isRoot}】微博的地址为:${url}`); return url.replace("\/", "_"); }, getProgressContainer: $sub => $sub.parents("div.WB_feed_detail").find("div.WB_info").first(), getVideoBox: $ul => $ul.parents(".WB_feed_detail").find(".WB_video,.WB_video_a,.li_story,.WB_video_h5_v2 .WB_feed_spec_pic"), geiVideoSrc: $box => { const video_sources = $box.attr("video-sources"); // 多清晰度源 const sources = video_sources.split("&"); Core.log(sources); // 尝试从 quality_label_list 中,获取视频地址 const sources_filter = sources.filter(it => it.trim().indexOf("quality_label_list") == 0); if (sources_filter != null && sources_filter.length > 0) { Core.log("尝试使用:quality_label_list,进行视频地址解析..."); const quality_label_list = sources_filter[0].trim(); // 解码 const source = decodeURIComponent(quality_label_list); const json = source.substring(source.indexOf("=") + 1).trim(); // 存在质量列表的值 if (json.length > 0) { const $urls = JSON.parse(json); Core.log($urls); // 逐步下调清晰度,当前用户为未登录或非vip时,1080P+的地址为空 for (let i = 0; i < $urls.length; i++) { const $url = $urls[i]; const src = $url.url.trim(); // 是一个链接 if (src.indexOf("http") == 0) { Core.log(`得到一个有效链接,${$url.quality_label}:${src}`); return src; } } } else Core.log("仅存在quality_label_list的key,却无value!"); } else console.log("无法找到quality_label_list!"); Core.log("即将使用缺省方式,进行视频地址解析..."); // 逐步下调清晰度【兼容旧版,防止 quality_label_list API变动,或quality_label_list的值不存在】 for (let i = sources.length - 2; i >= 0; i--) { const source = sources[i].trim(); const index = source.indexOf("="); const key = source.substring(0, index).trim(); const value = source.substring(index + 1).trim(); if (value.length > 0) { // 解码 const src = decodeURIComponent(decodeURIComponent(value)); // 是一个链接 if (src.indexOf("http") == 0) { Core.log(`得到一个有效链接,${key}:${src}`); return src; } } } return null; } }); /*jshint esversion: 8 */ /** * 图片处理器(含:LivePhoto) */ class PictureHandler { /** * 处理图片,如果需要 */ static async handlePictureIfNeed($ul) { const $button = Core.putButton($ul, "图片解析中...", null); try { const resolver = Core.getWeiBoResolver(); const photo_9_ids = resolver.get9PhotoImgs($ul).map(function(i, it) { const parts = $(it).attr("src").split("/"); return parts[parts.length - 1]; }).get(); Core.log("九宫格图片:"); Core.log(photo_9_ids); const photo_9_over_ids = await resolver.get9PhotoOver($ul).catch(e => { Tip.error(e); return []; }); Core.log("未展示图片:"); Core.log(photo_9_over_ids); const photo_ids = photo_9_ids.concat(photo_9_over_ids); Core.log("总图片:"); Core.log(photo_ids); // 得到大图片 let $links = await PictureHandler.convertLargePhoto($ul, photo_ids); Core.log(`此Item有图:${$links.length}`); // 判断图片是否存在 if ($links.length > 0) { // 得到LivePhoto的链接 const lp_links = PictureHandler.getLivePhoto($ul, $links.length); // 存在LivePhoto if (lp_links) { $links = $($links.get().concat(lp_links)); } Core.handleCopy($ul, $links); PictureHandler.handleDownload($ul, $links); PictureHandler.handleDownloadZip($ul, $links); } } catch (e) { console.error(e); Tip.error(e.message); Core.putButton($ul, "图片解析失败", null); } finally { Core.removeButton($ul, $button); } } /** * 提取LivePhoto的地址 * @param {$标签对象} $owner ul或li * @return {字符串数组} LivePhoto地址集,可能为null */ static extractLivePhotoSrc($owner) { const action_data = $owner.attr("action-data"); if (action_data) { const urlsRegex = action_data.match(/pic_video=([\w:,]+)/); if (urlsRegex) { const urls = urlsRegex[1].split(",").map(function(it, i) { return it.split(":")[1]; }); return urls; } } return null; } /** * 得到LivePhoto链接集 * * @param {$标签对象} $ul 操作列表 * @param {整数} start 下标开始的位置 * @return {Link数组} 链接集,可能为null */ static getLivePhoto($ul, start) { const $box = Core.getWeiBoResolver().getLivePhotoContainer($ul); let srcs; // 仅有一张LivePhoto if ($box.hasClass('WB_media_a_m1')) { srcs = PictureHandler.extractLivePhotoSrc($box.find(".WB_pic")); } else { srcs = PictureHandler.extractLivePhotoSrc($box); } // 判断是否存在LivePhoto的链接 if (srcs) { srcs = srcs.map(function(it, i) { var src = `https://video.weibo.com/media/play?livephoto=//us.sinaimg.cn/${it}.mov&KID=unistore,videomovSrc`; var name = Core.getResourceName($ul, `https://weibo.com/${it}.mp4`, i + start, Config.mediaType.livePhoto); return new Link(name, src); }); } return srcs; } // 处理下载 static handleDownload($ul, $links) { Core.putButton($ul, "逐个下载图片", function() { PictureHandler.downloadByLinks(0, $links.length - 1, $links); }); } /** * 通过$links进行依次下载 * * @param {数字} index 下标 * @param {数字} lastIndex 尾下标 * @param {$数组} $links 包含名称与链接的$数组 */ static downloadByLinks(index, lastIndex, $links) { const it = $links[index]; GM_download({ url: it.src, name: it.name, onload: () => { if (index <= lastIndex) { PictureHandler.downloadByLinks(index + 1, lastIndex, $links); } } }); } /** * 处理打包下载 */ static handleDownloadZip($ul, $links) { Core.putButton($ul, "打包下载图片", function() { ZipHandler.startZip($ul, $links); }); } /** * 转换为大图链接 * * @param {$控件} $ul 操作列表 * @param {数组} photo_ids 图片id数组(可能无后缀) * @return {Link数组} 链接集,可能为null */ static async convertLargePhoto($ul, photo_ids) { const server = Core.getLargeImageServer($ul); Core.log(`获取到服务器:${server}`); let photo_ids_fix = await Promise.all($(photo_ids).map(function(i, it) { return new Promise((resolve, reject) => { // 判断是否存在后缀 if (it.indexOf(".") != -1) { // 存在 resolve(it); } else { // 不存在 // 请求,不打开流,只需要头信息 GM_xmlhttpRequest({ method: 'GET', url: `http://${server}.sinaimg.cn/thumb150/${it}`, timeout: Config.maxRequestTime, responseType: "blob", onload: function(res) { const postfix_regex = res.responseHeaders.match(/content-type: image\/(\w+)/); // 找到,且图片类型为git if (postfix_regex && postfix_regex[1] == "gif") { resolve(`${it}.gif`); } else { // 未找到,或图片类型为:jpeg resolve(`${it}.jpg`); } }, onerror: function(e) { console.error(e); reject("请求图片格式发生错误!"); }, ontimeout: function() { reject("请求图片格式超时!"); } }); } }).catch(e => { Tip.error(e); return `${it}.jpg`; }); }).get()); // 去除重复 photo_ids_fix = Array.from(new Set(photo_ids_fix)); Core.log("总图片(fix):"); Core.log(photo_ids_fix); return $(photo_ids_fix).map((i, it) => { // 替换为大图链接 const src = `http://${server}.sinaimg.cn/large/${it}`; Core.log(src); const name = Core.getResourceName($ul, src, i, Config.mediaType.picture); return new Link(name, src); }); } } /*jshint esversion: 8 */ /** * 视频处理器 */ class VideoHandler { /** * 处理视频如果需要 * @param {$标签对象} $ul 操作列表 */ static async handleVideoIfNeed($ul) { const $button = Core.putButton($ul, "视频解析中...", null); try { const $box = Core.getWeiBoResolver().getVideoBox($ul); // 不存在视频 if ($box.length === 0) { return; } // 得到视频类型 const type = VideoHandler.getVideoType($box); let $link; if (type === "feedvideo") { // 短视屏(秒拍、梨视频、优#) $link = VideoHandler.getBlowVideoLink($box); } else if (type === "feedlive") { // 直播回放 //TODO 暂不支持 } else if (type === "story") { // 微博故事 $link = VideoHandler.getWeiboStoryLink($box); } else if (type === "adFeedVideo") { // 广告视频(无清晰度选择) $link = VideoHandler.getAdVideoLink($box); } else { console.warn(`未知的类型:${type}`); } // 是否存在视频链接 if ($link) { Core.handleCopy($ul, $([$link])); const fun = () => VideoHandler.downloadVideo($box, $link); Core.putButton($ul, "下载当前视频", fun); } } catch (e) { console.error(e); Tip.error(e.message); Core.putButton($ul, "视频解析失败", null); } finally { Core.removeButton($ul, $button); } } /** * 得到视频类型 * @param {$标签对象} $box 视频容器 * @return {字符串} 视频类型[video、live] */ static getVideoType($box) { const typeRegex = $box.attr("action-data").match(/type=(\w+)&/); return typeRegex[1]; } /** * 得到微博故事视频Link * * @param {$标签对象} $box 视频box * * @return {Link} 链接对象 */ static getWeiboStoryLink($box) { const action_data = $box.attr("action-data"); const urlRegex = action_data.match(/gif_url=([\w%.]+)&/); const url = urlRegex[1]; let src = decodeURIComponent(decodeURIComponent(url)); const name = Core.getResourceName($box, src.split("?")[0], 0, Config.mediaType.video); if (src.indexOf("//") === 0) { src = "https:" + src; } return new Link(name, src); } /** * 得到广告视频Link * @param {$标签对象} $box 视频box * * @return {Link} 链接对象 */ static getAdVideoLink($box) { const src = SearchWeiBoResolver.geiVideoSrc($box); name = Core.getResourceName($box, src.split("?")[0], 0, Config.mediaType.video); Core.log(`download:${name}=${src}`); return new Link(name, src); } /** * 得到#燃视频Link * * @param {$标签对象} $box 视频box * * @return {Link} 链接对象 */ static getBlowVideoLink($box) { let src, name; try { src = Core.getWeiBoResolver().geiVideoSrc($box); if (!src) { // 未找到合适的视频地址 throw new Error("未能找到视频地址!"); } name = Core.getResourceName($box, src.split("?")[0], 0, Config.mediaType.video); Core.log(`download:${name}=${src}`); } catch (e) { console.error(e); throw new Error("未能找到视频地址!"); } return new Link(name, src); } /** * 下载直播回放 * @param {$标签对象} $li 视频box */ static downloadLiveVCRVideo($ul, $li) { // TODO 暂不支持 } /** * 下载视频 * * @param {$标签对象} $box 视频box * @param {$对象} $link Link对象 */ static downloadVideo($box, $link) { Tip.info("即将开始下载..."); const progress = ZipHandler.bornProgress($box); GM_download({ url: $link.src, name: $link.name, onprogress: function(p) { const value = p.loaded / p.total; progress.value = value; }, onerror: function(e) { console.error(e); Tip.error("视频下载出错!"); } }); } } /*jshint esversion: 6 */ class ZipHandler { /** * 生成一个进度条 * @param {$标签对象} $sub card的子节点 * @param {int} max 最大值 * @return {标签对象} 进度条 */ static bornProgress($sub) { const $div = Core.getWeiBoResolver().getProgressContainer($sub); // 尝试获取进度条 let $progress = $div.find('progress'); // 进度条不存在时,生成一个 if ($progress.length === 0) { $progress = $("<progress max='1' style='margin-left:10px;' />"); $div.append($progress); } else { // 已存在时,重置value $progress[0].value = 0; } return $progress[0]; } /** * 开始打包 * @param {$数组} $links 图片地址集 */ static startZip($ul, $links) { Tip.tip("正在提取,请稍候...", "iconExtract"); const progress = ZipHandler.bornProgress($ul); const zip = new JSZip(); const names = []; $links.each(function(i, it) { const name = it.name; GM_xmlhttpRequest({ method: 'GET', url: it.src, timeout: Config.maxRequestTime, responseType: "blob", onload: function(response) { zip.file(name, response.response); ZipHandler.downloadZipIfComplete($ul, progress, name, zip, names, $links.length); }, onerror: function(e) { console.error(e); Tip.error(`第${(i + 1)}个对象,获取失败!`); ZipHandler.downloadZipIfComplete($ul, progress, name, zip, names, $links.length); }, ontimeout: function() { Tip.error(`第${(i + 1)}个对象,请求超时!`); ZipHandler.downloadZipIfComplete($ul, progress, name, zip, names, $links.length); } }); }); } /** * 下载打包,如果完成 */ static downloadZipIfComplete($ul, progress, name, zip, names, length) { names.push(name); const value = names.length / length; progress.value = value; if (names.length === length) { Tip.tip("正在打包,请稍候...", "iconZip"); zip.generateAsync({ type: "blob" }, function(metadata) { progress.value = metadata.percent / 100; }).then(function(content) { Tip.success("打包完成,即将开始下载!"); const zipName = Core.getZipName($ul); saveAs(content, `${zipName}.zip`); }); } } } /*jshint esversion: 6 */ /** * 提示 */ class Tip { static tip(text, iconName) { if (Config.isTip) { GM_notification({ text: text, image: GM_getResourceURL(iconName), timeout: 3000, }); } } static info(text) { Tip.tip(text, "iconInfo"); } static error(text) { Tip.tip(text, "iconError"); } static success(text) { Tip.tip(text, "iconSuccess"); } } /*jshint esversion: 8 */ /** * 核心 */ class Core { /** * 处理微博卡片 */ static handleWeiBoCard() { // 查找未被扩展的操作按钮 const $operationButtons = Core.getWeiBoResolver().getOperationButton(); // 存在未被扩展的操作按钮 if ($operationButtons.length > 0) { console.info(`找到未被扩展的操作按钮:${$operationButtons.length}`); $operationButtons.one("click", event => Core.resolveWeiBoCard($(event.currentTarget)) ); $operationButtons.addClass(Config.handledWeiBoCardClass); } } /** * 解析 微博卡片 * 仅在初次点击 操作按钮[↓] 时,触发 * * @param {$标签对象} $operationButton 操作按钮 */ static resolveWeiBoCard($operationButton) { const weiboResolver = Core.getWeiBoResolver(); const $ul = weiboResolver.getOperationList($operationButton); PictureHandler.handlePictureIfNeed($ul); VideoHandler.handleVideoIfNeed($ul); } /** * 得到微博解析器 */ static getWeiBoResolver() { let resolver; // 微博搜索 if (window.location.href.indexOf("https://s.weibo.com") === 0) { resolver = SearchWeiBoResolver; } else { // 我的微博、他人微博、我的收藏、热门微博 resolver = MyWeiBoResolver; } return resolver; } /** * 添加按钮 * @param {$标签对象} $ul 操作列表 * @param {字符串} name 按钮名称 * @param {方法} op 按钮操作 * * @return {$控件} 按钮 */ static putButton($ul, name, op) { const $li = $(`<li><a href='javascript:void(0)'>—> ${name} <—</a></li>`); $li.click(op); $ul.append($li); return $li; } /** * 移除按钮 * @param {$标签对象} $ul 操作列表 * @param {$控件} $button 按钮 */ static removeButton($ul, $button) { $ul.find(`li a:contains(${$button.text()})`).remove(); } /** * 处理拷贝 * * @param {$对象} $ul 操作列表 * @param {$数组} $links Link数组 */ static handleCopy($ul, $links) { Core.putButton($ul, "复制资源链接", function() { const link = $links.get().map(function(it, i) { return it.src; }).join("\n"); GM_setClipboard(link, "text"); Tip.success("链接地址已复制到剪贴板!"); }); } /** * 得到打包名称 * * @param {$标签对象} $ul 操作列表 * @return {字符串} 压缩包名称(不含后缀) */ static getZipName($ul) { const weiBoResolver = Core.getWeiBoResolver(); const $info = weiBoResolver.getWeiBoInfo($ul); const wb_id = weiBoResolver.getWeiBoId($ul, $info, false); const wb_user_id = weiBoResolver.getWeiBoUserId($ul, $info, false); const wb_user_name = weiBoResolver.getWeiBoUserName($ul, $info, false); const wb_url = weiBoResolver.getWeiBoUrl($ul, false); const $root_info = weiBoResolver.getRootWeiBoInfo($ul); const wb_root_id = weiBoResolver.getWeiBoId($ul, $root_info, true); const wb_root_user_id = weiBoResolver.getWeiBoUserId($ul, $root_info, true); const wb_root_user_name = weiBoResolver.getWeiBoUserName($ul, $root_info, true); const wb_root_url = weiBoResolver.getWeiBoUrl($ul, true); const name = Config.getZipName( wb_user_name, wb_user_id, wb_id, wb_url, wb_root_user_name, wb_root_user_id, wb_root_url, wb_root_id ); return name; } /** * 得到资源原始名称(不含后缀) * @param {字符串} path 路径 * @return {字符串} 名称(不含后缀) */ static getPathName(path) { const start = path.lastIndexOf("/") + 1; const end = path.lastIndexOf("."); let name; if (end > start) { name = path.substring(start, end); } else { name = path.substring(start); } Core.log(`截得名称为:${name}`); return name; } /** * 得到后缀 * @param {字符串} path 路径 * @param {字符串} media_type 媒体类型 * * @return {字符串} 后缀(含.) */ static getPathPostfix(path, media_type) { let postfix = path.substring(path.lastIndexOf(".") + 1).toLowerCase(); Core.log(`截得后缀为:${postfix}`); // 媒体类型为图片 if (media_type == Config.mediaType.picture) { const pics = ["jpg", "jpeg", "gif", "png", "bmp", "tif"]; // 此格式的后缀不是一个常见格式,可能是解析错误导致 // 也可能就是一个冷门格式,但此格式若使用GM进行下载,则会受到限制 if ($.inArray(postfix, pics) == -1) { console.warn(`不能识别的【${media_type}】格式:${postfix},Ta即将被覆盖为${pics[0]}。`); postfix = pics[0]; } } else if (media_type == Config.mediaType.video || media_type == Config.mediaType.livePhoto) { // 媒体类型为视频 const vids = ["mp4", "wmv", "avi", "ts", "mov"]; if ($.inArray(postfix, vids) == -1) { console.warn(`不能识别的【${media_type}】格式:${postfix},Ta即将被覆盖为${vids[0]}。`); postfix = vids[0]; } } return `.${postfix}`; } /** * 得到资源名称 * * @param {$标签对象} $ul 操作列表 * @param {字符串} src 资源地址 * @param {整数} index 序号 * @param {字符串} media_type 媒体类型 * * @return {字符串} 资源名称(含后缀) */ static getResourceName($ul, src, index, media_type) { const weiBoResolver = Core.getWeiBoResolver(); const resource_id = Core.getPathName(src); const $info = weiBoResolver.getWeiBoInfo($ul); const wb_id = weiBoResolver.getWeiBoId($ul, $info, false); const wb_user_id = weiBoResolver.getWeiBoUserId($ul, $info, false); const wb_user_name = weiBoResolver.getWeiBoUserName($ul, $info, false); const wb_url = weiBoResolver.getWeiBoUrl($ul, false); const $root_info = weiBoResolver.getRootWeiBoInfo($ul); const wb_root_id = weiBoResolver.getWeiBoId($ul, $root_info, true); const wb_root_user_id = weiBoResolver.getWeiBoUserId($ul, $root_info, true); const wb_root_user_name = weiBoResolver.getWeiBoUserName($ul, $root_info, true); const wb_root_url = weiBoResolver.getWeiBoUrl($ul, true); // 修正,从1开始 index++; // 补齐位数:01、02、03... if (index.toString().length === 1) { index = "0" + index.toString(); } const no = index; const postfix = Core.getPathPostfix(src, media_type); const name = Config.getResourceName( wb_user_name, wb_user_id, wb_id, wb_url, resource_id, no, media_type, wb_root_user_name, wb_root_user_id, wb_root_url, wb_root_id) + postfix; return name; } /** * 记录日志 * @param {字符串} msg 日志内容 */ static log(msg) { if (Config.isDebug) { console.log(msg); } } /** * 得到大图服务器 * * @param {$控件} $ul 操作列表 * @return {字符串} 服务器 */ static getLargeImageServer($ul) { const weiBoResolver = Core.getWeiBoResolver(); const $imgs = weiBoResolver.get9PhotoImgs($ul); const src = $($imgs[0]).attr("src"); let server; if (src) { const server_regex = src.match(/(wx\d)\.sinaimg\.cn/); if (server_regex) { server = server_regex[1]; } } if (!server) { // 缺省服务器 server = "wx2"; } return server; } } setInterval(Core.handleWeiBoCard, Config.space); })();