// ==UserScript==
// @name 百度贴吧批量下载原图
// @namespace https://greasyfork.org/users/your-namespace
// @version 1.0
// @description 右下角出现按钮可供下载,文件名格式:标题-楼层-楼内序号,建议在一楼下载
// @match *://tieba.baidu.com/p/*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_xmlhttpRequest
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// ========== 配置与默认值 ==========
const STORAGE_KEY = 'tb_batch_download_config_v2';
const DEFAULT_CONFIG = {
enabled: true,
filenameFormat: '{title}-{floor}楼-{floorIndex}.{ext}',
includeSign: false,
autoPageDelay: 500,
};
const tiebaTid = location.pathname.match(/\d+/)[0];
let config = GM_getValue(STORAGE_KEY, DEFAULT_CONFIG);
// ========== 添加样式(按钮组布局) ==========
GM_addStyle(`
.tb-btn-container {
position: fixed;
bottom: 30px;
right: 30px;
z-index: 99999;
display: flex;
flex-direction: column;
gap: 10px;
}
.tb-download-btn {
background: #4ab3f4;
color: white;
border: none;
border-radius: 50px;
padding: 12px 24px;
font-size: 16px;
font-weight: bold;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.tb-download-btn:hover {
background: #1da1f2;
transform: scale(1.05);
box-shadow: 0 6px 16px rgba(0,0,0,0.4);
}
.tb-download-btn:active {
transform: scale(0.95);
}
.tb-download-progress {
position: fixed;
bottom: 160px;
right: 30px;
z-index: 99999;
background: rgba(0,0,0,0.8);
color: white;
border-radius: 8px;
padding: 10px 20px;
font-size: 14px;
max-width: 300px;
word-break: break-word;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
display: none;
}
.tb-download-progress.show { display: block; }
.tb-progress-bar {
width: 100%;
height: 4px;
background: #333;
border-radius: 2px;
margin-top: 8px;
}
.tb-progress-fill {
height: 100%;
background: #4ab3f4;
border-radius: 2px;
width: 0%;
transition: width 0.2s;
}
/* 选择列表模态框(与之前相同,略) */
.tb-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100000;
}
.tb-modal-content {
background: white;
border-radius: 12px;
width: 80%;
max-width: 900px;
max-height: 80%;
display: flex;
flex-direction: column;
box-shadow: 0 20px 40px rgba(0,0,0,0.4);
animation: tb-modal-fadein 0.2s;
}
@keyframes tb-modal-fadein {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
.tb-modal-header {
padding: 16px 24px;
border-bottom: 1px solid #e6ecf0;
display: flex;
justify-content: space-between;
align-items: center;
}
.tb-modal-header h3 { margin: 0; font-size: 20px; font-weight: 500; }
.tb-modal-close {
cursor: pointer;
font-size: 24px;
color: #999;
background: none;
border: none;
padding: 0 8px;
}
.tb-modal-close:hover { color: #333; }
.tb-modal-body {
padding: 16px 24px;
overflow-y: auto;
flex: 1;
}
.tb-select-all-bar {
margin-bottom: 16px;
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.tb-select-all-bar button {
padding: 6px 16px;
border: 1px solid #4ab3f4;
background: white;
color: #4ab3f4;
border-radius: 30px;
cursor: pointer;
font-size: 14px;
transition: 0.2s;
}
.tb-select-all-bar button:hover {
background: #4ab3f4;
color: white;
}
.tb-image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 16px;
}
.tb-image-item {
border: 1px solid #e6ecf0;
border-radius: 8px;
padding: 8px;
display: flex;
flex-direction: column;
align-items: center;
background: #fafafa;
transition: 0.2s;
cursor: default;
user-select: none;
}
.tb-image-item:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
background: #f0f7ff;
}
.tb-image-item input[type="checkbox"] {
margin-bottom: 8px;
transform: scale(1.2);
cursor: pointer;
pointer-events: auto;
}
.tb-image-item img {
width: 120px;
height: 120px;
object-fit: cover;
border-radius: 4px;
margin-bottom: 8px;
background: #eee;
pointer-events: none;
}
.tb-image-item .tb-index {
font-size: 13px;
color: #666;
margin-bottom: 4px;
}
.tb-image-item .tb-picid {
font-size: 11px;
color: #999;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tb-floor-badge {
background: #e6ecf0;
border-radius: 12px;
padding: 2px 8px;
font-size: 11px;
color: #333;
margin-bottom: 4px;
}
.tb-modal-footer {
padding: 16px 24px;
border-top: 1px solid #e6ecf0;
display: flex;
justify-content: flex-end;
gap: 12px;
}
.tb-modal-footer button {
padding: 10px 24px;
border: none;
border-radius: 30px;
font-size: 16px;
cursor: pointer;
transition: 0.2s;
}
.tb-btn-primary {
background: #4ab3f4;
color: white;
}
.tb-btn-primary:hover {
background: #1da1f2;
}
.tb-btn-secondary {
background: #e6ecf0;
color: #333;
}
.tb-btn-secondary:hover {
background: #d0d8dd;
}
`);
// ========== 创建UI元素(两个按钮) ==========
const container = document.createElement('div');
container.className = 'tb-btn-container';
document.body.appendChild(container);
const btnPage = document.createElement('button');
btnPage.className = 'tb-download-btn';
btnPage.innerHTML = '📄 下载本页图片';
container.appendChild(btnPage);
const btnAll = document.createElement('button');
btnAll.className = 'tb-download-btn';
btnAll.innerHTML = '📚 下载全帖图片';
container.appendChild(btnAll);
const progressDiv = document.createElement('div');
progressDiv.className = 'tb-download-progress';
progressDiv.innerHTML = `
准备就绪
`;
document.body.appendChild(progressDiv);
function updateProgress(text, percent = 0) {
progressDiv.querySelector('.tb-progress-text').textContent = text;
progressDiv.querySelector('.tb-progress-fill').style.width = percent + '%';
if (!progressDiv.classList.contains('show')) progressDiv.classList.add('show');
}
// ========== 工具函数 ==========
function getPostTitle() {
const titleElem = document.querySelector('h3.core_title_txt.pull-left.text-overflow');
if (titleElem) return titleElem.textContent.trim();
return document.title.replace('_百度贴吧', '').trim() || '贴吧帖子';
}
function sanitizeFilename(name) {
return name.replace(/[\\/:*?"<>|]/g, '_');
}
function extractPicId(imgSrc) {
const match = imgSrc.match(/\/([^/]+)\.(jpg|jpeg|png|gif|bmp|webp)(?:\?.*)?$/i);
if (match) return match[1];
const parts = imgSrc.split('/');
const last = parts.pop().split('?')[0];
return last.replace(/\.[^.]+$/, '');
}
// 改进的楼层号提取函数
function getFloorNumber(floorElement) {
// 1. 从 data-floor 属性获取
if (floorElement.dataset.floor) return floorElement.dataset.floor;
// 2. 查找包含楼层信息的 tail-info
const tailSpans = floorElement.querySelectorAll('.tail-info');
for (const span of tailSpans) {
const text = span.textContent.trim();
const match = text.match(/(\d+)楼/);
if (match) return match[1];
}
// 3. 如果是楼主楼层(没有数字楼号,但可能是楼主标识)
if (floorElement.querySelector('.d_name a') && floorElement.textContent.includes('楼主')) {
return '1';
}
return '?';
}
// 从给定的文档对象中提取图片信息(通用)
function extractImagesFromDoc(doc, sourceDesc = '') {
// 楼层容器选择器:增加 .d_post_content_main 以适应新版贴吧
const floorSelectors = ['.l_post', '.d_post_content_main', '.post_content_wrap'];
let floorElements = [];
for (const sel of floorSelectors) {
const found = doc.querySelectorAll(sel);
if (found.length) {
floorElements = Array.from(found);
break;
}
}
if (!floorElements.length) {
console.warn('未找到楼层容器');
return [];
}
const images = [];
floorElements.forEach(floorElem => {
const floor = getFloorNumber(floorElem);
// 根据配置获取图片
let imgSelector = '.BDE_Image, .d_content_img';
if (config.includeSign) imgSelector += ', .j_user_sign';
const imgs = floorElem.querySelectorAll(imgSelector);
let floorImgIndex = 0;
imgs.forEach(img => {
const src = img.src;
if (!src) return;
const picId = extractPicId(src);
if (!picId) return;
floorImgIndex++;
images.push({
src,
picId,
floor,
floorIndex: floorImgIndex,
});
});
});
return images;
}
// 获取当前页图片(直接从document)
function collectPageImages() {
return extractImagesFromDoc(document, '当前页');
}
// 获取总页数(优先从分页控件解析)
function getTotalPages() {
// 定位分页容器(根据您提供的结构)
const pager = document.querySelector('.l_pager.pager_theme_4.pb_list_pager');
if (pager) {
let maxPage = 1;
// 获取所有数字链接(href包含pn=)
const pageLinks = pager.querySelectorAll('a[href*="pn="]');
pageLinks.forEach(link => {
const match = link.href.match(/pn=(\d+)/);
if (match) {
const p = parseInt(match[1], 10);
if (p > maxPage) maxPage = p;
}
});
// 检查当前页span(类名 tP)
const currentSpan = pager.querySelector('span.tP');
if (currentSpan) {
const current = parseInt(currentSpan.textContent.trim(), 10);
if (current > maxPage) maxPage = current; // 当前页可能是最后一页
}
return maxPage;
}
// 回退:从所有带pn=的链接中取最大值
const allLinks = document.querySelectorAll('a[href*="pn="]');
let maxPn = 1;
allLinks.forEach(link => {
const m = link.href.match(/pn=(\d+)/);
if (m) maxPn = Math.max(maxPn, parseInt(m[1], 10));
});
return maxPn;
}
// 请求页面HTML
function fetchPageHtml(pageNum) {
const url = `https://tieba.baidu.com/p/${tiebaTid}?pn=${pageNum}`;
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: url,
responseType: 'text',
onload: (resp) => {
if (resp.status === 200) resolve(resp.responseText);
else reject(`HTTP ${resp.status}`);
},
onerror: (err) => reject(err)
});
});
}
// 收集所有页面的图片
async function collectAllImages() {
const totalPages = getTotalPages();
updateProgress(`正在获取帖子信息,共 ${totalPages} 页...`, 0);
const allImages = [];
for (let page = 1; page <= totalPages; page++) {
try {
updateProgress(`正在加载第 ${page}/${totalPages} 页图片...`, (page-1)/totalPages*100);
const html = await fetchPageHtml(page);
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const pageImages = extractImagesFromDoc(doc);
allImages.push(...pageImages);
if (page < totalPages) await new Promise(r => setTimeout(r, config.autoPageDelay));
} catch (err) {
console.error(`第${page}页加载失败:`, err);
updateProgress(`第${page}页加载失败,继续下一页`, (page-1)/totalPages*100);
}
}
updateProgress(`共加载 ${allImages.length} 张图片`, 100);
return allImages;
}
// ========== 显示选择模态框(默认多选:点击切换,Shift范围选择) ==========
function showSelectionModal(imgList, sourceDesc = '') {
return new Promise((resolve) => {
if (imgList.length === 0) {
alert('没有找到可下载的图片');
resolve([]);
return;
}
const postTitle = getPostTitle();
const modal = document.createElement('div');
modal.className = 'tb-modal';
let itemsHtml = '';
imgList.forEach((item, i) => {
itemsHtml += `
`;
});
modal.innerHTML = `
标题: ${postTitle}
${itemsHtml}
`;
document.body.appendChild(modal);
const items = modal.querySelectorAll('.tb-image-item');
const checkboxes = modal.querySelectorAll('.tb-img-checkbox');
const downloadBtn = modal.querySelector('.tb-download-selected');
const closeButtons = modal.querySelectorAll('.tb-modal-close');
const selectAllBtn = modal.querySelector('.tb-select-all');
const selectNoneBtn = modal.querySelector('.tb-select-none');
let lastClickedIndex = null;
// 更新下载按钮的选中数量
function updateSelectedCount() {
const checked = modal.querySelectorAll('.tb-img-checkbox:checked').length;
downloadBtn.textContent = `下载选中 (${checked})`;
}
// 为每个图片项添加点击事件(支持Shift范围选择,无Ctrl,默认切换)
items.forEach((item, idx) => {
item.addEventListener('click', (e) => {
e.preventDefault(); // 阻止默认行为(如图片拖拽)
e.stopPropagation(); // 防止冒泡
const currentCheckbox = item.querySelector('.tb-img-checkbox');
if (e.shiftKey && lastClickedIndex !== null) {
// Shift多选:选中从lastClickedIndex到当前索引之间的所有项,其他不变
const start = Math.min(lastClickedIndex, idx);
const end = Math.max(lastClickedIndex, idx);
items.forEach((it, i) => {
const cb = it.querySelector('.tb-img-checkbox');
if (i >= start && i <= end) {
cb.checked = true; // 范围内设为选中
}
// 注意:按Shift时不取消范围外的项,以保留已有选择
});
} else {
// 默认(无Shift):切换当前项的选中状态,不影响其他项
currentCheckbox.checked = !currentCheckbox.checked;
}
// 更新最后点击索引
lastClickedIndex = idx;
// 更新下载按钮文本
updateSelectedCount();
});
});
// 全选
selectAllBtn.addEventListener('click', () => {
checkboxes.forEach(cb => { cb.checked = true; });
lastClickedIndex = null;
updateSelectedCount();
});
// 反选
selectNoneBtn.addEventListener('click', () => {
checkboxes.forEach(cb => { cb.checked = !cb.checked; });
lastClickedIndex = null;
updateSelectedCount();
});
// 关闭模态框
function closeModal() {
modal.remove();
resolve([]);
}
closeButtons.forEach(btn => btn.addEventListener('click', closeModal));
// 下载选中
downloadBtn.addEventListener('click', () => {
const selectedIndices = [];
checkboxes.forEach((cb, idx) => {
if (cb.checked) selectedIndices.push(idx);
});
const selectedImages = selectedIndices.map(i => imgList[i]);
modal.remove();
resolve(selectedImages);
});
// 点击背景关闭
modal.addEventListener('click', (e) => {
if (e.target === modal) closeModal();
});
// 初始计数
updateSelectedCount();
});
}
// ========== 下载选中图片 ==========
async function downloadSelectedImages(selectedImages) {
if (!config.enabled) {
alert('批量下载功能已禁用,请通过菜单启用');
return;
}
if (selectedImages.length === 0) {
alert('没有选中任何图片');
return;
}
const postTitle = sanitizeFilename(getPostTitle());
updateProgress(`共选中 ${selectedImages.length} 张图片,正在获取原图地址...`, 0);
let successCount = 0;
let failCount = 0;
for (let i = 0; i < selectedImages.length; i++) {
const item = selectedImages[i];
try {
updateProgress(`正在获取第 ${i+1}/${selectedImages.length} 张原图地址...`, (i / selectedImages.length) * 100);
const originalUrl = await fetchOriginalUrl(item.picId);
if (!originalUrl) throw new Error('原图URL为空');
const extMatch = originalUrl.match(/\.(jpg|jpeg|png|gif|bmp|webp)(?:\?.*)?$/i);
const ext = extMatch ? extMatch[1] : 'jpg';
const filename = config.filenameFormat
.replace('{title}', postTitle)
.replace('{floor}', item.floor)
.replace('{floorIndex}', String(item.floorIndex))
.replace('{ext}', ext);
updateProgress(`正在下载: ${filename} (${i+1}/${selectedImages.length})`, (i / selectedImages.length) * 100);
await downloadImage(originalUrl, sanitizeFilename(filename));
successCount++;
} catch (err) {
console.error(`下载第${item.floor}楼-${item.floorIndex}张失败:`, err);
failCount++;
}
}
updateProgress(`下载完成!成功: ${successCount}, 失败: ${failCount}`, 100);
setTimeout(() => {
progressDiv.classList.remove('show');
}, 3000);
}
function fetchOriginalUrl(picId) {
return new Promise((resolve, reject) => {
const apiUrl = `https://tieba.baidu.com/photo/p?alt=jview&pic_id=${picId}&tid=${tiebaTid}`;
GM_xmlhttpRequest({
method: 'GET',
url: apiUrl,
responseType: 'json',
onload: (resp) => {
try {
if (resp.status === 200 && resp.response) {
const original = resp.response?.data?.img?.original;
if (original) {
const url = original.waterurl || original.url;
if (url) resolve(url);
else reject('未找到原图URL');
} else {
reject('API返回数据格式异常');
}
} else {
reject(`API请求失败,状态码: ${resp.status}`);
}
} catch (e) {
reject(`解析响应失败: ${e.message}`);
}
},
onerror: (err) => reject(`网络请求错误: ${err}`)
});
});
}
function downloadImage(url, filename) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: url,
responseType: 'blob',
onload: (resp) => {
if (resp.status === 200) {
const blob = resp.response;
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
resolve();
} else {
reject(`下载失败,状态码: ${resp.status}`);
}
},
onerror: (err) => reject(`下载错误: ${err}`)
});
});
}
// ========== 按钮点击事件 ==========
btnPage.addEventListener('click', async () => {
if (!config.enabled) {
alert('批量下载功能已禁用,请通过菜单启用');
return;
}
const images = collectPageImages();
const selected = await showSelectionModal(images, '【当前页】');
if (selected.length > 0) {
downloadSelectedImages(selected);
} else {
progressDiv.classList.remove('show');
}
});
btnAll.addEventListener('click', async () => {
if (!config.enabled) {
alert('批量下载功能已禁用,请通过菜单启用');
return;
}
try {
updateProgress('开始获取全帖图片...', 0);
const images = await collectAllImages();
if (images.length === 0) {
alert('未找到任何图片');
updateProgress('未找到图片', 0);
setTimeout(() => progressDiv.classList.remove('show'), 2000);
return;
}
const selected = await showSelectionModal(images, '【全帖】');
if (selected.length > 0) {
downloadSelectedImages(selected);
} else {
progressDiv.classList.remove('show');
}
} catch (err) {
console.error('收集图片失败:', err);
alert('收集图片失败,请查看控制台');
updateProgress('收集失败', 0);
}
});
// ========== 菜单设置(同前) ==========
function editConfig() {
const newFormat = prompt('请输入文件名格式,可用变量:\n{title} 帖子标题\n{floor} 楼层号\n{floorIndex} 楼内第几张\n{ext} 扩展名\n例如: {title}-{floor}楼-{floorIndex}.{ext}', config.filenameFormat);
if (newFormat !== null && newFormat.trim()) {
config.filenameFormat = newFormat.trim();
GM_setValue(STORAGE_KEY, config);
alert('文件名格式已更新');
}
}
function toggleIncludeSign() {
config.includeSign = !config.includeSign;
GM_setValue(STORAGE_KEY, config);
alert(`签名档图片包含: ${config.includeSign ? '是' : '否'}`);
}
function setPageDelay() {
const newDelay = prompt('设置翻页延迟(毫秒),建议300-1000', config.autoPageDelay);
if (newDelay !== null && !isNaN(parseInt(newDelay))) {
config.autoPageDelay = parseInt(newDelay);
GM_setValue(STORAGE_KEY, config);
alert('翻页延迟已更新');
}
}
function toggleEnabled() {
config.enabled = !config.enabled;
GM_setValue(STORAGE_KEY, config);
alert(`批量下载功能: ${config.enabled ? '启用' : '禁用'}`);
}
GM_registerMenuCommand('设置文件名格式', editConfig);
GM_registerMenuCommand('切换包含签名档图片', toggleIncludeSign);
GM_registerMenuCommand('设置翻页延迟', setPageDelay);
GM_registerMenuCommand('启用/禁用下载功能', toggleEnabled);
})();