Greasy Fork is available in English.

微博 [ 图片 | 视频 ] 下载

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

Stan na 24-05-2019. Zobacz najnowsza wersja.

// ==UserScript==
// @name         微博 [ 图片 | 视频 ] 下载
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  下载微博(weibo.com)的图片和视频。(支持LivePhoto、短视频、动/静图,可以打包下载)
// @author       Mr.Po
// @match        https://weibo.com/*
// @match        https://www.weibo.com/*
// @match        https://d.weibo.com/*
// @match        https://s.weibo.com/*
// @require      https://code.jquery.com/jquery-1.11.0.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
// @resource iconError https://raw.githubusercontent.com/Mr-Po/weibo-resource-download/master/media/error.png
// @resource iconSuccess https://raw.githubusercontent.com/Mr-Po/weibo-resource-download/master/media/success.png
// @resource iconInfo https://raw.githubusercontent.com/Mr-Po/weibo-resource-download/master/media/info.png
// @resource iconExtract https://raw.githubusercontent.com/Mr-Po/weibo-resource-download/master/media/extract.png
// @resource iconZip https://raw.githubusercontent.com/Mr-Po/weibo-resource-download/master/media/zip.png
// @connect      sinaimg.cn
// @connect      miaopai.com
// @connect      youku.com
// @connect      weibo.com
// @grant        GM_notification
// @grant        GM_setClipboard
// @grant        GM_download
// @grant        GM_xmlhttpRequest
// @grant        GM_getResourceURL
// ==/UserScript==

(function() {
    'use strict';

    // TODO 直播回放

    /**
     * 资源命名策略
     *
     * 0:资源原始名称(如:0065x5rwly1g3c6exw0a2j30u012utyg.jpg)
     * 1:微博用户名-微博ID-序号(如:小米商城-4375413591293810-01.jpg)[缺省]
     * 2:微博用户ID-微博ID-序号(如:5578564422-4375413591293810-01.jpg)
     * 
     * @type 整数
     */
    var resourceNamingStrategy = 1;

    /**
     * 打包命名策略
     * 
     * 1:微博用户名-微博ID(如:小米商城-4375413591293810.zip)[缺省]
     * 2:微博用户ID-微博ID(如:5578564422-4375413591293810.zip)
     * 
     * @type 整数
     */
    var zipNamingStrategy = 1;

    /**
     * 命名连接符
     * 即:“微博用户名-微博ID-序号”,中的短横线“-”
     * @type {String}
     */
    var nameingSeparator = "-";

    /**
     * 最大等待请求时间(超时时间),单位:毫秒
     * 若经常请求超时,可适当增大此值
     * 
     * @type {Number}
     */
    var maxRequestTime = 8000;

    /**
     * 每隔 space 毫秒检查一次,是否有新的微博被加载出来
     * 此值越小,检查越快;过小会造成浏览器卡顿
     * @type {Number}
     */
    var space = 5000;

    /**
     * 是否启用调试模式
     * 启用后,浏览器控制台会显示此脚本运行时的调试数据
     * @type {Boolean}
     */
    var isDebug = false;

    var handledWeiBoCardClass = "weibo_383402_extend";

    /**
     * 搜索微博解析器
     * 
     * @type {Object}
     */
    var searchWeiBoResolver = {
        getOperationList: function() {
            return $("div .menu ul:not([class='" + handledWeiBoCardClass + "'])");
        },
        getPhoto: function($ul) {
            return $ul.parents(".card-wrap").find(".media.media-piclist img");
        },
        getLivePhotoContainer: function($ul) {
            return $(null);
        },
        getWeiBoId: function($ul) {

            var mid = $ul.parents(".card-wrap").attr("mid").trim();

            return mid;
        },
        getWeiBoUserId: function($ul) {

            var $a = $ul.parents(".card-wrap").find("a.name").first();

            var id = $a.attr("href").match(/weibo\.com\/(\d+)/)[1].trim();

            if (isDebug) {
                console.log("得到的微博ID为:" + id);
            }

            return id;
        },
        getWeiBoUserName: function($ul) {

            var name = $ul.parents(".card-wrap").find("a.name").first().text().trim();

            if (isDebug) {
                console.log("得到的名称为:" + name);
            }

            return name;
        },
        getProgressContainer: function($sub) {
            return $sub.parents(".card-wrap").find("a.name").first().parent();
        },
        getVideoBox: function($ul) {
            return $ul.parents(".card-wrap").find(".WB_video_h5").first();
        },
        geiVideoSrc: function($box) {

            var src = $box.attr("action-data").match(/video_src=([\w\/\.%]+)/)[1];

            src = decodeURIComponent(decodeURIComponent(src));

            if (src.indexOf("http") != 0) {
                src = "https:" + src;
            }

            return src;
        }
    };

    /**
     * 我的微博解析器(含:我的微博、他人微博、我的收藏、热门微博)
     * 
     * @type {Object}
     */
    var myWeiBoResolver = {
        getOperationList: function() {
            return $("div .screen_box ul:not([class='" + handledWeiBoCardClass + "'])");
        },
        getPhoto: function($ul) {
            return $ul.parents(".WB_feed_detail").find("li.WB_pic img");
        },
        getLivePhotoContainer: function($ul) {
            return $ul.parents(".WB_feed_detail").find(".WB_media_a");
        },
        getWeiBoId: function($ul) {

            var mid = $ul.parents(".WB_cardwrap").attr("mid").trim();

            return mid;
        },
        getWeiBoUserId: function($ul) {

            var $a = $ul.parents("div.WB_feed_detail").find("div.WB_info a").first();

            var id = $a.attr("usercard").match(/id=(\d+)/)[1].trim();

            if (isDebug) {
                console.log("得到的微博ID为:" + id);
            }

            return id;
        },
        getWeiBoUserName: function($ul) {

            var name = $ul.parents("div.WB_feed_detail").find("div.WB_info a").first().text().trim();

            if (isDebug) {
                console.log("得到的名称为:" + name);
            }

            return name;
        },
        getProgressContainer: function($sub) {
            return $sub.parents("div.WB_feed_detail").find("div.WB_info").first();
        },
        getVideoBox: function($ul) {
            return $ul.parents(".WB_feed_detail").find(".WB_video,.WB_video_a,.li_story");
        },
        geiVideoSrc: function($box) {

            var video_sources = $box.attr("video-sources");

            // 多清晰度源
            var sources = video_sources.split("&");

            if (isDebug) {
                console.log(sources);
            }

            var src;

            // 逐步下调清晰度
            for (var i = sources.length - 2; i >= 0; i -= 2) {

                if (sources[i].trim().split("=")[1].trim().length > 0) {

                    // 解码
                    var source = decodeURIComponent(decodeURIComponent(sources[i].trim()));

                    if (isDebug) {
                        console.log(source);
                    }

                    src = source.substring(source.indexOf("=") + 1);
                }
            }

            return src;
        }
    };

    /**
     * 处理微博卡片
     */
    function handleWeiBoCard() {

        // 查找未被扩展的box
        var $uls = getWeiBoResolver().getOperationList();

        if ($uls.length > 0) {

            console.info("找到未扩展的box:" + $uls.length);

            $uls.each(function(i, it) {

                handlePictureIfNeed($(it));

                handleVideoIfNeed($(it));
            });

            // 批量给这些box添加已扩展标记
            $uls.addClass(handledWeiBoCardClass);
        }
    }


    /**
     * 得到操作列表
     * @return {$标签对象}          操作列表
     */
    function getWeiBoResolver() {

        var resolver;

        // 微博搜索
        if (window.location.href.indexOf("https://s.weibo.com") === 0) {

            resolver = searchWeiBoResolver;

        } else { // 我的微博、他人微博、我的收藏、热门微博

            resolver = myWeiBoResolver;
        }

        return resolver;
    }

    /**
     * 处理图片,如果需要
     */
    function handlePictureIfNeed($ul) {

        // 得到大图片
        var $links = getLargePhoto($ul);

        if (isDebug) {
            console.log("此Item有图:" + $links.length);
        }

        // 判断图片是否存在
        if ($links.length > 0) {

            // 得到LivePhoto的链接
            var lp_links = getLivePhoto($ul, $links.length);

            // 存在LivePhoto
            if (lp_links) {

                $links = $($links.get().concat(lp_links));
            }

            handleCopy($ul, $links);

            handleDownload($ul, $links);

            handleDownloadZip($ul, $links);
        }
    }

    /**
     * 处理视频如果需要
     * @param  {$标签对象} $ul 操作列表
     */
    function handleVideoIfNeed($ul) {

        var $box = getWeiBoResolver().getVideoBox($ul);

        // 不存在视频
        if ($box.length === 0) {
            return;
        }

        var type = getVideoType($box);

        var fun;

        if (type === "feedvideo") { // 短视屏(秒拍、梨视频、优酷)

            fun = function() { downloadBlowVideo($box); };

        } else if (type === "feedlive") { // 直播回放

            //TODO 暂不支持

        } else if (type === "story") { // 微博故事

            fun = function() { downloadWeiboStory($box); };

        } else {

            console.warn("未知的类型:" + type);
        }

        if (fun) {

            putButton($ul, "下载当前视频", fun);
        }
    }

    /**
     * 提取LivePhoto的地址
     * @param  {$标签对象} $owner ul或li
     * @return {字符串数组}       LivePhoto地址集,可能为null
     */
    function extractLivePhotoSrc($owner) {

        var action_data = $owner.attr("action-data");

        if (action_data) {

            var urlsRegex = action_data.match(/pic_video=([\w:,]+)/);

            if (urlsRegex) {

                var urls = urlsRegex[1].split(",").map(function(it, i) {
                    return it.split(":")[1];
                });

                return urls;
            }
        }

        return null;
    }

    /**
     * 得到视频类型
     * @param  {$标签对象} $box 视频容器
     * @return {字符串}         视频类型[video、live]
     */
    function getVideoType($box) {

        // console.log($box);

        // console.log($box.attr("action-data"));

        var typeRegex = $box.attr("action-data").match(/type=(\w+)&/);

        // console.log(typeRegex);

        return typeRegex[1];
    }

    /**
     * 添加按钮
     * @param  {$标签对象} $ul  操作列表
     * @param  {字符串} name 按钮名称
     * @param  {方法} op   按钮操作
     */
    function putButton($ul, name, op) {

        var $li = $("<li><a href='javascript:void(0)'>—> " + name + " <—</a></li>");

        $li.click(op);

        $ul.append($li);
    }

    // 处理拷贝
    function handleCopy($ul, $links) {

        putButton($ul, "复制图片链接", function() {

            var link = $links.get().map(function(it, i) {
                return it.src;
            }).join("\n");

            GM_setClipboard(link, "text");

            tipSuccess("链接已复制到剪贴板!");
        });
    }

    // 处理下载
    function handleDownload($ul, $links) {

        putButton($ul, "逐个下载图片", function() {

            $links.each(function(i, it) {

                // console.log("name:" + it.name + ",src=" + it.src);

                GM_download(it.src, it.name);
            });
        });
    }

    /**
     * 处理打包下载
     */
    function handleDownloadZip($ul, $links) {

        putButton($ul, "打包下载图片", function() {

            startZip($ul, $links);
        });
    }

    /**
     * 下载微博故事视频
     * 
     * @param  {$标签对象} $box 视频box
     */
    function downloadWeiboStory($box) {

        var action_data = $box.attr("action-data");

        var urlRegex = action_data.match(/gif_url=([\w%.]+)&/);

        var url = urlRegex[1];

        var src = decodeURIComponent(decodeURIComponent(url));

        var name = getResourceName($box, src.split("?")[0], 0);

        if (src.indexOf("//") === 0) {
            src = "https:" + src;
        }

        downloadVideo($box, name, src);
    }

    /**
     * 下载酷燃视频
     * @param  {$标签对象} $box 视频box
     */
    function downloadBlowVideo($box) {

        var src, name;

        try {

            src = getWeiBoResolver().geiVideoSrc($box);

            if (!src) { // 未找到合适的视频地址

                tipError("未能找到视频地址!");

                throw new Error("未能找到视频地址!");
            }

            name = getResourceName($box, src.split("?")[0], 0);

            if (isDebug) {
                console.log("download:" + name + "=" + src);
            }

        } catch (e) {

            console.error(e);

            tipError("提取视频地址失败!");
        }


        downloadVideo($box, name, src);
    }

    /**
     * 下载直播回放
     * @param  {$标签对象} $li 视频box
     */
    function downloadLiveVCRVideo($ul, $li) {
        // TODO 暂不支持
    }

    /**
     * 下载视频
     * @param  {$标签对象} $box 视频box
     */
    function downloadVideo($box, name, src) {

        tipInfo("即将开始下载...");

        var progress = bornProgress($box);

        GM_download({
            url: src,
            name: name,
            onprogress: function(p) {

                var value = p.loaded / p.total;
                progress.value = value;
            },
            onerror: function(e) {

                console.error(e);

                tipError("视频下载出错!");
            }
        });
    }

    /**
     * 得到LivePhoto链接集
     * 
     * @param   {$标签对象} $ul     操作列表
     * @param   {整数}      start   下标开始的位置
     * @return  {Link数组}          链接集,可能为null
     */
    function getLivePhoto($ul, start) {

        var $box = getWeiBoResolver().getLivePhotoContainer($ul);

        var srcs;

        // 仅有一张LivePhoto
        if ($box.hasClass('WB_media_a_m1')) {

            srcs = extractLivePhotoSrc($box.find(".WB_pic"));

        } else {

            srcs = 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 = getResourceName($ul, "https://weibo.com/" + it + ".mp4", i + start);

                return bornLink(name, src);
            });
        }

        return srcs;
    }

    function bornLink(name, src) {
        return { name: name, src: src };
    }

    /**
     * 得到大图链接
     * 
     * @param  {$标签对象} $ul      操作列表
     * @return {Link数组}           链接集,可能为null
     */
    function getLargePhoto($ul) {

        // 得到每一个图片
        var links = getWeiBoResolver().getPhoto($ul).map(function(i, it) {

            var parts = $(it).attr("src").split("/");

            // 替换为大图链接
            var src = "http://wx2.sinaimg.cn/large/" + parts[parts.length - 1];

            if (isDebug) {
                console.log(src);
            }

            var name = getResourceName($ul, src, i);

            return bornLink(name, src);
        });

        return links;
    }

    /**
     * 得到打包名称
     * 
     * @param  {$标签对象} $ul      操作列表
     * @return {字符串}             压缩包名称(不含后缀)
     */
    function getZipName($ul) {

        var name;

        var weiBoResolver = getWeiBoResolver();

        // 2:微博用户ID-微博ID(如:5578564422-4375413591293810.zip)
        if (zipNamingStrategy === 2) {

            name = weiBoResolver.getWeiBoUserId($ul) + nameingSeparator + weiBoResolver.getWeiBoId($ul);

        } else { // 1:微博用户名-微博ID(如:小米商城-4375413591293810.zip)[缺省]

            name = weiBoResolver.getWeiBoUserName($ul) + nameingSeparator + weiBoResolver.getWeiBoId($ul);
        }

        return name;
    }

    /**
     * 得到资源名称
     * 
     * @param  {$标签对象} $ul      操作列表
     * @param  {字符串}    src      资源地址
     * @param  {整数}      index    序号
     * @return {字符串}             资源名称(含后缀)
     */
    function getResourceName($ul, src, index) {

        var name;

        // 0:资源原始名称(如:0065x5rwly1g3c6exw0a2j30u012utyg.jpg)
        if (resourceNamingStrategy === 0) {

            name = getPathName(src);

        } else {

            // 修正,从1开始
            index++;

            // 补齐位数:01、02、03...
            if (index.toString().length === 1) {
                index = "0" + index.toString();
            }

            var postfix = getPathPostfix(src);

            var weiBoResolver = getWeiBoResolver();

            // 2:微博用户ID-微博ID-序号(如:5578564422-4375413591293810-01.jpg)
            if (resourceNamingStrategy == 2) {

                name = weiBoResolver.getWeiBoUserId($ul) + nameingSeparator + weiBoResolver.getWeiBoId($ul) + nameingSeparator + index + postfix;

            } else { // 1:微博用户名-微博ID-序号(如:小米商城-4375413591293810-01.jpg)[缺省]

                name = weiBoResolver.getWeiBoUserName($ul) + nameingSeparator + weiBoResolver.getWeiBoId($ul) + nameingSeparator + index + postfix;

            }
        }

        return name;
    }

    /**
     * 得到后缀
     * @param  {字符串} path 路径
     * @return {字符串}     后缀(含.)
     */
    function getPathPostfix(path) {

        var postfix = path.substring(path.lastIndexOf("."));

        if (isDebug) {
            console.log("截得后缀为:" + postfix);
        }

        return postfix;
    }

    /**
     * 得到资源原始名称
     * @param  {字符串} path 路径
     * @return {字符串}     名称(含后缀)
     */
    function getPathName(path) {

        var name = path.substring(path.lastIndexOf("/") + 1);

        if (isDebug) {
            console.log("截得名称为:" + name);
        }

        return name;
    }





    /**
     * 生成一个进度条
     * @param  {$标签对象} $sub card的子节点
     * @param  {int}      max  最大值
     * @return {标签对象}     进度条
     */
    function bornProgress($sub) {

        var $div = getWeiBoResolver().getProgressContainer($sub);

        // 尝试获取进度条
        var $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 图片地址集
     */
    function startZip($ul, $links) {

        tip("正在提取,请稍候...", "iconExtract");

        var progress = bornProgress($ul);

        var zip = new JSZip();

        var names = [];

        $links.each(function(i, it) {

            var name = it.name;

            GM_xmlhttpRequest({
                method: 'GET',
                url: it.src,
                timeout: maxRequestTime,
                responseType: "blob",
                onload: function(response) {

                    zip.file(name, response.response);

                    downloadZipIfComplete($ul, progress, name, zip, names, $links.length);
                },
                onerror: function(e) {

                    console.error(e);

                    tipError("第" + (i + 1) + "个对象,获取失败!");

                    downloadZipIfComplete($ul, progress, name, zip, names, $links.length);
                },
                ontimeout: function() {

                    tipError("第" + (i + 1) + "个对象,请求超时!");

                    downloadZipIfComplete($ul, progress, name, zip, names, $links.length);
                }
            });
        });
    }

    /**
     * 下载打包,如果完成
     */
    function downloadZipIfComplete($ul, progress, name, zip, names, length) {

        names.push(name);

        var value = names.length / length;

        progress.value = value;

        if (names.length === length) {

            tip("正在打包,请稍候...", "iconZip");

            zip.generateAsync({
                type: "blob"
            }, function(metadata) {

                progress.value = metadata.percent / 100;

            }).then(function(content) {

                tipSuccess("打包完成,即将开始下载!");

                var zipName = getZipName($ul);

                saveAs(content, zipName + ".zip");
            });
        }
    }

    function tip(text, iconName) {
        GM_notification({
            text: text,
            image: GM_getResourceURL(iconName)
        });
    }

    function tipInfo(text) {
        tip(text, "iconInfo");
    }

    function tipError(text) {
        tip(text, "iconError");
    }

    function tipSuccess(text) {
        tip(text, "iconSuccess");
    }

    setInterval(handleWeiBoCard, space);
})();