// ==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 += `
${item.floor}楼 · 第${item.floorIndex}张
${item.picId}
`; }); modal.innerHTML = `

选择要下载的图片 (共 ${imgList.length} 张,来自 ${new Set(imgList.map(i=>i.floor)).size} 个楼层) ${sourceDesc}

标题: ${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); })();