微博 [ 图片 | 视频 ] 下载

下载微博(weibo.com)的图片和视频。(支持LivePhoto、短视频、动/静图(9+),可以打包下载)

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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);
})();