// ==UserScript==
// @name 微博 pic_info 图片自动下载器(适配嵌套结构)
// @namespace http://tampermonkey.net/
// @version 1.4
// @description 提取 page_info.pic_info 中的图片URL并自动下载,按页面分类存储
// @author Your Name
// @match *://weibo.com/*
// @match *://www.weibo.com/*
// @grant GM_download
// @run-at document-start
// ==/UserScript==
(function() {
'use strict';
// -------------------------- 核心配置 --------------------------
const TARGET_UID = '6409560260'; // 固定目标用户UID(与提供数据中的用户ID匹配)
const processedImages = new Set(); // 用于记录已下载的图片URL,避免重复下载
const processedPages = new Set(); // 避免重复下载同一页面图片
const DOWNLOAD_DELAY = 1500; // 下载间隔(ms),避免请求过快被拦截
// 控制台日志样式(不同状态用不同颜色高亮,清晰区分)
const LOG_STYLES = {
init: 'color: #9C27B0; font-weight: bold; background: #F3E5F5; padding: 2px 4px; border-radius: 2px',
intercept: 'color: #1976D2; font-weight: bold; background: #E3F2FD; padding: 2px 4px; border-radius: 2px',
download: 'color: #2E7D32; font-weight: bold; background: #E8F5E9; padding: 2px 4px; border-radius: 2px',
success: 'color: #1B5E20; font-weight: bold; background: #C8E6C9; padding: 2px 4px; border-radius: 2px',
error: 'color: #C62828; font-weight: bold; background: #FFEBEE; padding: 2px 4px; border-radius: 2px'
};
// -------------------------- 工具函数 --------------------------
/**
* 控制台日志输出(带时间戳和样式)
* @param {string} msg - 日志内容
* @param {string} type - 日志类型(init/intercept/download/success/error)
*/
function log(msg, type = 'init') {
console.log(`%c[微博pic_info下载器] ${new Date().toLocaleTimeString()} | ${msg}`, LOG_STYLES[type]);
}
/**
* 从URL提取页码(忽略其他参数,仅关注page字段)
* @param {string} url - 请求URL
* @returns {string|number} 页码(数字或"未知")
*/
function getPageNum(url) {
try {
const fullUrl = url.startsWith('http') ? url : `https://weibo.com${url}`;
const pageStr = new URL(fullUrl).searchParams.get('page');
return pageStr ? parseInt(pageStr, 10) : '未知';
} catch (e) {
log(`URL解析出错: ${e.message}`, 'error');
return '未知';
}
}
/**
* 从嵌套的 page_info.pic_info 中提取图片URL(适配提供的数据结构,自动去重)
* @param {Object} weiboItem - 单条微博数据(包含 page_info.pic_info 字段)
* @returns {Array} 去重后的图片URL数组
*/
function extractImageUrls(weiboItem) {
const imageUrls = new Set();
const pageInfo = weiboItem.page_info;
if (!pageInfo || typeof pageInfo !== 'object') {
log(`微博${weiboItem.id || '未知ID'}:无 page_info 字段,跳过`, 'error');
return [];
}
const picInfo = pageInfo.pic_info;
if (!picInfo || typeof picInfo !== 'object') {
log(`微博${weiboItem.id || '未知ID'}:page_info 下无 pic_info 字段,跳过`, 'error');
return [];
}
// 【关键修改1:遍历所有可能的图片字段,兼容微博不同数据格式】
const picSources = [
picInfo.pic_big?.url, // 高清图1
picInfo.pic_middle?.url, // 中等图
picInfo.pic_small?.url, // 缩略图
picInfo.url, // 直接挂载的URL(部分场景)
weiboItem.pic_url // 备用:微博根节点的图片URL(防止嵌套结构缺失)
].filter(Boolean); // 过滤空值
// 【关键修改2:处理URL,强制去除防盗链参数+转换高清格式】
picSources.forEach(rawUrl => {
if (typeof rawUrl === 'string' && rawUrl.startsWith('http')) {
// 1. 去除URL所有参数(如 ?k=xxx&t=xxx 防盗链参数)
const cleanUrl = rawUrl.split('?')[0];
// 2. 强制转换为高清格式(替换所有缩略图标识)
const highDefUrl = cleanUrl
.replace('/thumbnail/', '/large/') // 缩略图目录→高清目录
.replace('mw690/', 'large/') // 尺寸参数→高清
.replace('mw1080/', 'large/') // 兼容其他尺寸
.replace('mw2000/', 'large/'); // 兼容超大图
imageUrls.add(highDefUrl);
}
});
const urlsArr = Array.from(imageUrls);
log(`微博${weiboItem.id || '未知ID'}:提取到 ${urlsArr.length} 张有效图片`, 'intercept');
return urlsArr;
}
/**
* 自动下载图片(使用 GM_download,按“微博UID-页码”分类文件夹)
* @param {Array} imageUrls - 图片URL数组
* @param {number|string} pageNum - 当前页码
* @param {string} weiboId - 微博ID(用于命名图片,避免重复)
*/
async function downloadImages(imageUrls, pageNum, weiboId) {
if (imageUrls.length === 0) return;
if (typeof GM_download === 'undefined') {
log(`下载初始化失败:GM_download API未加载`, 'error');
return;
}
for (let i = 0; i < imageUrls.length; i++) {
const imgUrl = imageUrls[i];
if (processedImages.has(imgUrl)) {
log(`跳过重复图片:${imgUrl.split('/').pop()}`, 'download');
continue;
}
const fileName = imgUrl.split('/').pop() || `未知文件名_${Date.now()}.jpg`;
const savePath = `微博_${TARGET_UID}_第${pageNum}页/微博${weiboId}_图片${i + 1}_${fileName}`;
try {
// 【关键修改3:添加完整请求头,对抗防盗链和跨域】
GM_download({
url: imgUrl,
name: savePath,
description: `微博${weiboId}第${i + 1}张图`,
// 核心:模拟浏览器请求头,通过微博服务器校验
headers: {
"Referer": `https://weibo.com/${TARGET_UID}`, // 必须:请求来源为目标用户主页(非空且匹配微博域名)
"User-Agent": navigator.userAgent, // 用当前浏览器的UA,避免被识别为脚本
"Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
"Origin": "https://weibo.com", // 跨域请求必需:声明请求来源域名
"Connection": "keep-alive" // 保持连接,避免请求中断
},
// 【关键修改4:指定下载方式为blob,避免XHR直接请求被拦截】
method: "GET",
responseType: "blob", // 强制以blob格式接收响应,绕过部分XHR限制
onload: () => {
processedImages.add(imgUrl);
log(`下载成功:${savePath}`, 'success');
},
onerror: (error) => {
// 打印更详细的错误信息,便于后续排查
log(`下载失败:${fileName} | 原因:${error.error} | 状态码:${error.status || '无'} | URL:${imgUrl.slice(0, 50)}...`, 'error');
},
ontimeout: () => {
log(`下载超时:${fileName}(建议检查网络)`, 'error');
}
});
log(`开始下载:微博${weiboId}第${i + 1}张图 | 路径:${savePath}`, 'download');
await new Promise(resolve => setTimeout(resolve, DOWNLOAD_DELAY));
} catch (e) {
log(`下载异常:${fileName} | 详情:${e.message}`, 'error');
}
}
}
// -------------------------- 核心处理逻辑 --------------------------
/**
* 处理单页微博数据:提取图片URL并触发下载
* @param {Object} data - 微博页面数据(包含 data.list 数组,与提供数据结构匹配)
* @param {string} requestUrl - 拦截到的请求URL(用于提取页码)
*/
function processWeiboPage(data, requestUrl) {
const pageNum = getPageNum(requestUrl);
let totalImages = 0; // 统计本页总图片数
try {
// 验证数据结构:必须包含 data.list 数组(与提供的 "data.list" 结构匹配)
if (!data?.data?.list || !Array.isArray(data.data.list)) {
log(`第${pageNum}页:无有效微博列表(数据结构异常,缺少 data.list)`, 'error');
return;
}
log(`第${pageNum}页:开始处理(共${data.data.list.length}条微博)`, 'intercept');
// 遍历每条微博,提取图片并下载(异步处理,确保间隔生效)
data.data.list.forEach(async (weibo) => {
// 用微博ID作为唯一标识(优先用 idstr,无则用 id,避免ID格式问题)
const weiboId = weibo.idstr || weibo.id || `未知ID_${Date.now()}`;
// 提取当前微博的图片URL(调用适配嵌套结构的提取函数)
const imageUrls = extractImageUrls(weibo);
if (imageUrls.length > 0) {
totalImages += imageUrls.length;
// 触发图片下载(异步等待间隔)
await downloadImages(imageUrls, pageNum, weiboId);
}
});
// 输出本页汇总信息(延迟执行,确保统计到所有微博的图片数)
setTimeout(() => {
log(`第${pageNum}页处理完成:共发现${totalImages}张图片(部分可能仍在下载中)`, 'success');
}, totalImages * DOWNLOAD_DELAY);
} catch (e) {
log(`第${pageNum}页处理出错:${e.message}`, 'error');
}
}
// -------------------------- 请求拦截逻辑 --------------------------
/**
* 拦截 fetch 请求(微博主要数据请求方式,适配新版微博)
*/
const originalFetch = window.fetch;
window.fetch = async function(url, options) {
// 匹配规则:仅拦截目标用户的微博列表请求(包含 statuses/mymblog 且 UID 匹配)
if (url.includes('statuses/mymblog') && url.includes(`uid=${TARGET_UID}`)) {
const pageNum = getPageNum(url);
// 避免重复处理同一页面(刷新、滚动重复加载时跳过)
if (!processedPages.has(pageNum)) {
processedPages.add(pageNum);
// 截断URL日志(避免过长影响可读性)
const shortUrl = url.length > 120 ? `${url.slice(0, 120)}...` : url;
log(`拦截到fetch请求:第${pageNum}页 | URL: ${shortUrl}`, 'intercept');
// 发起原始请求,克隆响应(避免原始响应被消耗后页面无法读取数据)
const response = await originalFetch.apply(this, arguments);
const responseClone = response.clone();
// 解析JSON数据并处理(适配微博返回的JSON格式)
try {
const data = await responseClone.json();
processWeiboPage(data, url);
} catch (e) {
log(`第${pageNum}页fetch数据解析失败:${e.message}(可能是数据格式非JSON)`, 'error');
}
// 返回原始响应,确保页面正常加载(不影响用户浏览)
return response;
}
}
// 非目标请求,直接放行(不干扰页面其他功能)
return originalFetch.apply(this, arguments);
};
/**
* 拦截 XMLHttpRequest 请求(兼容微博旧版场景或部分异步请求)
*/
const originalXhrOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url, async) {
// 同样匹配目标用户的微博列表请求
if (url.includes('statuses/mymblog') && url.includes(`uid=${TARGET_UID}`)) {
// 绑定请求成功事件(load),在响应完成后处理数据
this.addEventListener('load', () => {
// 仅处理成功响应(状态码200-299),避免处理错误响应
if (this.status >= 200 && this.status < 300) {
const pageNum = getPageNum(url);
if (!processedPages.has(pageNum)) {
processedPages.add(pageNum);
const shortUrl = url.length > 120 ? `${url.slice(0, 120)}...` : url;
log(`拦截到XHR请求:第${pageNum}页 | URL: ${shortUrl}`, 'intercept');
// 解析响应文本(微博返回的是JSON格式)
try {
const data = JSON.parse(this.responseText);
processWeiboPage(data, url);
} catch (e) {
log(`第${pageNum}页XHR数据解析失败:${e.message}(可能是响应文本损坏)`, 'error');
}
}
}
});
}
// 调用原始 open 方法,确保页面正常发起请求
originalXhrOpen.call(this, method, url, async);
};
// -------------------------- 初始化提示 --------------------------
})();
// ==UserScript==
// @name 微博 statuses/show 单条微博缩略图自动下载器
// @namespace http://tampermonkey.net/
// @version 1.7
// @description 仅提取 statuses/show 接口返回数据中 pic_infos 的 thumbnail 图片 URL 并自动下载,按微博 ID 分类存储
// @author Your Name
// @match *://weibo.com/*
// @match *://www.weibo.com/*
// @match *://m.weibo.cn/*
// @grant GM_download
// @grant GM_setValue
// @grant GM_getValue
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
//-------------------------- 核心配置 & 全局存储 --------------------------
const TARGET_UID = '6409560260'; // 目标用户 UID
const DOWNLOAD_DELAY = 2000; // 下载间隔(ms)
const STORAGE_KEY = 'weibo_downloaded_thumbnails'; // 持久化存储键名(用于跨会话去重)
// 1. 从 Tampermonkey 持久化存储中读取已下载记录(跨页面/重启浏览器不丢失)
const persistedDownloaded = GM_getValue(STORAGE_KEY, []);
// 2. 内存缓存(当前会话内快速去重)
const processedPicIds = new Set(); // 按 pic_id 去重
const processedImageUrls = new Set(persistedDownloaded); // 全局 URL 去重
// 控制台日志样式
const LOG_STYLES = {
init: 'color: #9C27B0; font-weight: bold; background: #F3E5F5; padding: 2px 4px; border-radius: 2px',
intercept: 'color: #1976D2; font-weight: bold; background: #E3F2FD; padding: 2px 4px; border-radius: 2px',
download: 'color: #2E7D32; font-weight: bold; background: #E8F5E9; padding: 2px 4px; border-radius: 2px',
success: 'color: #1B5E20; font-weight: bold; background: #C8E6C9; padding: 2px 4px; border-radius: 2px',
skip: 'color: #FF8F00; font-weight: bold; background: #FFF3E0; padding: 2px 4px; border-radius: 2px',
error: 'color: #C62828; font-weight: bold; background: #FFEBEE; padding: 2px 4px; border-radius: 2px'
};
//-------------------------- 工具函数 --------------------------
/**
* 带样式和时间戳的控制台日志
* @param {string} msg - 日志内容
* @param {string} type - 日志类型(init/intercept/download/success/skip/error)
*/
function log(msg, type = 'init') {
console.log(`%c[微博缩略图下载器] ${new Date().toLocaleTimeString()} | ${msg}`, LOG_STYLES[type]);
}
/**
* 持久化存储已下载 URL(同步到 Tampermonkey 存储,跨会话生效)
* @param {string} url - 已成功下载的图片 URL
*/
function saveDownloadedUrl(url) {
processedImageUrls.add(url);
// 将 Set 转为数组存储(Tampermonkey 不支持直接存储 Set)
GM_setValue(STORAGE_KEY, Array.from(processedImageUrls));
}
/**
* 从 pic_infos 提取缩略图 URL(只保留 thumbnail 尺寸)
* @param {Object} picInfos - 微博接口返回的 pic_infos 字段
* @param {string} weiboId - 微博 ID(用于日志)
* @returns {Array} 去重后的 thumbnail URL 数组
*/
function extractThumbnailUrls(picInfos, weiboId) {
const thumbnailUrls = [];
if (!picInfos || typeof picInfos !== 'object' || Object.keys(picInfos).length === 0) {
log(`微博${weiboId || '未知ID'}:无有效 pic_infos 字段`, 'error');
return thumbnailUrls;
}
// 遍历每个 pic_id(单张图片的唯一标识)
Object.entries(picInfos).forEach(([picId, picDetail]) => {
// 只处理未处理过的 pic_id
if (processedPicIds.has(picId)) {
log(`微博${weiboId}:pic_id=${picId} 已处理,跳过`, 'skip');
return;
}
// 只提取 thumbnail 尺寸的 URL
if (picDetail.thumbnail && picDetail.thumbnail.url) {
const thumbUrl = picDetail.thumbnail.url;
// 简单处理 URL,确保是 thumbnail 版本
const cleanUrl = thumbUrl.split('?')[0];
thumbnailUrls.push({ url: cleanUrl, picId });
processedPicIds.add(picId);
log(`微博${weiboId}:提取到 thumbnail 图片 (pic_id=${picId})`, 'intercept');
} else {
log(`微博${weiboId}:pic_id=${picId} 无 thumbnail 尺寸`, 'error');
}
});
log(`微博${weiboId}:共提取到 ${thumbnailUrls.length} 张 thumbnail 图片`, 'intercept');
return thumbnailUrls;
}
/**
* 下载 thumbnail 图片
* @param {Array} thumbnailUrls - thumbnail 图片 URL 数组
* @param {string} weiboId - 微博 ID(用于文件夹命名)
* @param {string} screenName - 用户昵称(用于文件夹命名)
*/
async function downloadThumbnails(thumbnailUrls, weiboId, screenName) {
// 校验参数和 GM_download 可用性
if (thumbnailUrls.length === 0) return;
if (typeof GM_download === 'undefined') {
log('下载初始化失败:GM_download API未加载(请检查Tampermonkey配置)', 'error');
return;
}
// 遍历图片 URL,按间隔下载
for (let i = 0; i < thumbnailUrls.length; i++) {
const { url: imgUrl, picId } = thumbnailUrls[i];
const imgIndex = i + 1; // 图片序号(从 1 开始)
// 跳过已下载的图片
if (processedImageUrls.has(imgUrl)) {
log(`跳过已下载:微博${weiboId}_第${imgIndex}张(pic_id=${picId})`, 'skip');
continue;
}
// 构建下载路径:用户昵称_微博 ID / thumbnail_序号_picId_文件名
const fileName = imgUrl.split('/').pop() || `thumbnail_${Date.now()}.jpg`;
const savePath = `${screenName}_微博${weiboId}/thumbnail_第${imgIndex}张_${picId}_${fileName}`;
try {
// 调用 GM_download 下载
GM_download({
url: imgUrl,
name: savePath,
description: `微博${weiboId}第${imgIndex}张缩略图`,
// 防盗链请求头
headers: {
"Referer": `https://weibo.com/${TARGET_UID}`,
"User-Agent": navigator.userAgent,
"Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
"Origin": "https://weibo.com",
"Connection": "keep-alive"
},
responseType: "blob",
// 下载成功回调
onload: () => {
saveDownloadedUrl(imgUrl);
log(`下载成功:${savePath}`, 'success');
},
// 下载失败回调
onerror: (error) => {
log(`下载失败:第${imgIndex}张 | pic_id=${picId} | 错误:${error.error} | 状态码:${error.status || '无'}`, 'error');
},
// 下载超时回调
ontimeout: () => {
log(`下载超时:第${imgIndex}张 | pic_id=${picId}(建议检查网络后重试)`, 'error');
}
});
log(`开始下载:微博${weiboId}第${imgIndex}张缩略图 | 保存路径:${savePath}`, 'download');
// 等待下载间隔
await new Promise(resolve => setTimeout(resolve, DOWNLOAD_DELAY));
} catch (e) {
log(`下载异常:第${imgIndex}张 | pic_id=${picId} | 详情:${e.message}`, 'error');
}
}
}
//-------------------------- 核心处理逻辑 --------------------------
/**
* 处理 statuses/show 接口返回的单条微博数据
* @param {Object} responseData - 接口返回的完整数据
* @param {string} requestUrl - 拦截到的请求 URL
*/
function processWeiboData(responseData, requestUrl) {
// 适配接口返回格式
const weiboData = responseData.data || responseData;
const { idstr: weiboId, user, ok, pic_infos } = weiboData;
// 1. 校验数据合法性
if (!ok || !weiboId || !user) {
log(`数据非法:接口返回失败(ok = ${ok})或缺少关键字段`, 'error');
return;
}
// 2. 校验是否为目标用户的微博
const userUid = user.idstr || user.id;
const screenName = user.screen_name || '未知用户';
if (userUid !== TARGET_UID) {
log(`跳过非目标用户微博:用户UID = ${userUid}(目标UID = ${TARGET_UID})`, 'intercept');
return;
}
// 3. 提取 thumbnail 图片并下载
log(`开始处理目标微博:${weiboId}(发布者:${screenName})`, 'intercept');
const thumbnailUrls = extractThumbnailUrls(pic_infos, weiboId);
if (thumbnailUrls.length > 0) {
downloadThumbnails(thumbnailUrls, weiboId, screenName).catch(err => {
log(`微博${weiboId}下载流程异常:${err.message}`, 'error');
});
} else {
log(`微博${weiboId}(${screenName}):未提取到有效 thumbnail 图片`, 'intercept');
}
}
//-------------------------- 请求拦截逻辑 --------------------------
/**
* 拦截 fetch 请求
*/
const originalFetch = window.fetch;
window.fetch = async function (url, options) {
// 匹配 statuses/show 接口
if (url.includes('statuses/show') && url.includes('id=')) {
const shortUrl = url.length > 150 ? `${url.slice(0, 150)}...` : url;
log(`拦截到fetch请求:statuses/show接口 | URL:${shortUrl}`, 'intercept');
// 发起原始请求并克隆响应
const response = await originalFetch.apply(this, arguments);
const responseClone = response.clone();
// 解析数据并处理
try {
const data = await responseClone.json();
processWeiboData(data, url);
} catch (e) {
log(`fetch数据解析失败:${e.message}`, 'error');
}
return response;
}
// 非目标接口,直接放行
return originalFetch.apply(this, arguments);
};
/**
* 拦截 XMLHttpRequest 请求
*/
const originalXhrOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url, async) {
// 匹配 statuses/show 接口
if (url.includes('statuses/show') && url.includes('id=')) {
// 绑定请求成功事件
this.addEventListener('load', () => {
if (this.status >= 200 && this.status < 300) {
const shortUrl = url.length > 150 ? `${url.slice(0, 150)}...` : url;
log(`拦截到XHR请求:statuses/show接口 | URL:${shortUrl}`, 'intercept');
// 解析响应数据
try {
const data = JSON.parse(this.responseText);
processWeiboData(data, url);
} catch (e) {
log(`XHR数据解析失败:${e.message}`, 'error');
}
}
});
}
// 调用原始 open 方法
originalXhrOpen.call(this, method, url, async);
};
//-------------------------- 初始化提示 --------------------------
log('脚本已启动(仅下载 thumbnail 缩略图)', 'init');
log(`目标用户:UID = ${TARGET_UID} | 下载间隔:${DOWNLOAD_DELAY}ms`, 'init');
log('操作指引:1. 进入目标用户微博主页 2. 点击任意单条微博进入详情页 → 自动下载缩略图', 'init');
log('下载路径:浏览器默认下载目录 → 【用户昵称_微博 ID】文件夹', 'init');
log('注意事项:已下载的缩略图会被记录,不会重复下载', 'init');
})();