From 5b54e78582aa9ad33f07d2d5c6ec908e7da65c34 Mon Sep 17 00:00:00 2001 From: Quan Date: Sun, 10 Aug 2025 16:13:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(script):=20=E6=96=B0=E5=A2=9E=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E4=BB=85=E6=8F=90=E5=8F=96=E5=8B=BE=E9=80=89=E7=9A=84?= =?UTF-8?q?=E4=BD=9C=E5=93=81=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/Release_Notes.md | 5 +- static/XHS-Downloader.js | 292 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 287 insertions(+), 10 deletions(-) diff --git a/static/Release_Notes.md b/static/Release_Notes.md index c6f6bf0..30a5e9f 100644 --- a/static/Release_Notes.md +++ b/static/Release_Notes.md @@ -18,11 +18,12 @@ **用户脚本更新内容:** -**版本号:2.1.2** +**版本号:2.1.4** 1. 修复单张图片的作品下载后文件损坏问题 2. 添加未登录状态下无法提取作品链接的提示 3. 脚本设置新增 `菜单始终显示` 配置项 4. 修复用户脚本 CSS 样式冲突问题 5. 优化部分弹窗提示的交互界面样式 -6. 优化提取作品 ID 的正则表达式 +6. 新增支持仅提取勾选的作品链接 +7. 优化提取作品 ID 的正则表达式 diff --git a/static/XHS-Downloader.js b/static/XHS-Downloader.js index 610cd1b..7a39cac 100644 --- a/static/XHS-Downloader.js +++ b/static/XHS-Downloader.js @@ -1,7 +1,7 @@ // ==UserScript== // @name XHS-Downloader // @namespace https://github.com/JoeanAmier/XHS-Downloader -// @version 2.1.3 +// @version 2.1.4 // @description 提取小红书作品/用户链接,下载小红书无水印图文/视频作品文件 // @author JoeanAmier // @match http*://xhslink.com/* @@ -485,7 +485,7 @@ const extractNotesInfo = order => { const notesRawValue = unsafeWindow.__INITIAL_STATE__.user.notes._rawValue[order]; - return notesRawValue.map(item => [item.id, item.xsecToken,]); + return notesRawValue.map(item => [item.id, item.xsecToken, item.noteCard.cover.urlDefault, item.noteCard.user.nickName, item.noteCard.displayTitle,]); }; const extractBoardInfo = () => { @@ -501,7 +501,7 @@ const id = match[1]; // match[0] 是整个匹配的字符串,match[1] 是第一个括号内的匹配 const notesRawValue = unsafeWindow.__INITIAL_STATE__.board.boardFeedsMap._rawValue[id].notes; - return notesRawValue.map(item => [item.noteId, item.xsecToken,]); + return notesRawValue.map(item => [item.noteId, item.xsecToken, item.noteCard.cover.urlDefault, item.noteCard.user.nickName, item.noteCard.displayTitle,]); } else { console.error("从链接提取专辑 ID 失败", currentUrl,); return []; @@ -510,12 +510,12 @@ const extractFeedInfo = () => { const notesRawValue = unsafeWindow.__INITIAL_STATE__.feed.feeds._rawValue; - return notesRawValue.map(item => [item.id, item.xsecToken,]); + return notesRawValue.map(item => [item.id, item.xsecToken, item.noteCard.cover.urlDefault, item.noteCard.user.nickName, item.noteCard.displayTitle,]); }; const extractSearchNotes = () => { const notesRawValue = unsafeWindow.__INITIAL_STATE__.search.feeds._rawValue; - return notesRawValue.map(item => [item.id, item.xsecToken,]); + return notesRawValue.map(item => [item.id, item.xsecToken, item.noteCard.cover.urlDefault, item.noteCard.user.nickName, item.noteCard.displayTitle,]); } const extractSearchUsers = () => { @@ -547,10 +547,26 @@ } else if (order === 5) { data = extractBoardInfo() } else { - data = []; + data = [] + } + if (data.length === 0) { + callback(""); + return; + } + let urlsString; + if (order === 4) { + urlsString = generateUserUrls(data); + callback(urlsString); + } else { + showListSelectionModal(data.map(([id, token, cover, author, title,]) => ({ + id: id, token: token, image: cover, author: author, title: title, + })),).then((selected) => { + if (selected.length > 0) { + urlsString = generateNoteUrls(selected.map(item => [item.id, item.token])); + callback(urlsString); + } + }); } - let urlsString = order !== 4 ? generateNoteUrls(data) : generateUserUrls(data); - callback(urlsString); }, [0, 1, 2, 5].includes(order)) }; @@ -1230,6 +1246,266 @@ overlay.addEventListener('click', (e) => e.target === overlay && closeImagesModal()); }; + (() => { + if (!document.getElementById('listSelectionStyle')) { + const style = document.createElement('style'); + style.id = 'listSelectionStyle'; + style.textContent = ` + /* 列表弹窗容器,仅定义差异尺寸,其他沿用通用 modal 样式 */ + .list-selection-modal { + background: #fff; + border-radius: 16px; + width: 80%; + max-width: 800px; + max-height: 80vh; + box-shadow: 0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23); + overflow: hidden; + animation: scaleUp 0.3s; + display: flex; + flex-direction: column; + } + + /* 列表容器 */ + .list-container { + display: flex; + flex-direction: column; + gap: 10px; + } + + /* 列表项 */ + .list-item { + display: grid; + grid-template-columns: 24px 64px 1fr; /* 复选框、缩略图、文本区 */ + align-items: center; + gap: 12px; + padding: 10px; + border: 1px solid #eee; + border-radius: 10px; + transition: background 0.15s, border-color 0.15s, box-shadow 0.15s; + cursor: pointer; + } + .list-item:hover { + background: #fafafa; + border-color: #e6e6e6; + } + .list-item.selected { + border-color: #2196F3; + box-shadow: 0 0 0 4px rgba(33,150,243,0.12) inset; + } + + /* 复选框样式(使用原生复选框以保证可访问性与简单性) */ + .list-checkbox { + width: 18px; + height: 18px; + margin: 0 0 0 2px; + cursor: pointer; + } + + /* 缩略图 */ + .list-thumb { + width: 64px; + height: 64px; + object-fit: cover; + border-radius: 8px; + user-select: none; + pointer-events: none; /* 点击行切换选择,避免图片拦截点击 */ + background: #f2f2f2; + } + + /* 文本区 */ + .list-texts { + display: flex; + flex-direction: column; + min-width: 0; /* 允许文本正确换行截断 */ + } + .list-author { + font-size: 0.95rem; + color: #212121; + font-weight: 500; + line-height: 1.4; + word-break: break-word; + } + .list-title { + margin-top: 4px; + font-size: 0.85rem; + color: #757575; + line-height: 1.4; + word-break: break-word; + } + `; + document.head.appendChild(style); + } + })(); + + /** + * 显示列表选择弹窗 + * @param {Array<{id:any, image:string, author:string, title:string}>} list + * @param {Object} [options] + * @param {string} [options.title='请选择'] 弹窗标题 + * @param {string} [options.confirmText='确认'] 确认按钮文本 + * @param {string} [options.cancelText='取消'] 取消按钮文本 + * @param {boolean} [options.prechecked=true] 是否默认勾选全部 + * @returns {Promise} 点击确认返回选中项数组;取消/关闭返回 null + */ + function showListSelectionModal(list, options = {}) { + const { + title = '请选择需要提取的项目', confirmText = '确认', cancelText = '取消', prechecked = true, + } = options; + + if (document.getElementById('listSelectionOverlay')) return Promise.resolve(null); + + return new Promise((resolve) => { + // 覆盖层 + const overlay = document.createElement('div'); + overlay.id = 'listSelectionOverlay'; + overlay.style.cssText = ` + position: fixed; inset: 0; background: rgba(0,0,0,0.32); + backdrop-filter: blur(4px); display: flex; justify-content: center; align-items: center; + z-index: 10000; animation: fadeIn 0.3s; + `; + + // 弹窗 + const modal = document.createElement('div'); + modal.className = 'list-selection-modal'; + + // 头部 + const header = document.createElement('div'); + header.className = 'modal-header'; + header.innerHTML = `${title}`; + + // 内容 + const body = document.createElement('div'); + body.className = 'modal-body'; + + const container = document.createElement('div'); + container.className = 'list-container'; + + // id -> item 映射 + const map = new Map(); + list.forEach(item => { + const row = document.createElement('div'); + row.className = 'list-item'; + row.dataset.key = item.id; + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = 'list-checkbox'; + checkbox.checked = prechecked; + + const img = document.createElement('img'); + img.className = 'list-thumb'; + img.src = item.image || ''; + img.alt = 'thumb'; + + const texts = document.createElement('div'); + texts.className = 'list-texts'; + const author = document.createElement('div'); + author.className = 'list-author'; + author.textContent = item.author ?? ''; + const title = document.createElement('div'); + title.className = 'list-title'; + title.textContent = item.title ?? ''; + texts.appendChild(author); + texts.appendChild(title); + + row.appendChild(checkbox); + row.appendChild(img); + row.appendChild(texts); + + if (checkbox.checked) row.classList.add('selected'); + + row.addEventListener('click', (e) => { + if ((e.target instanceof HTMLElement) && e.target.classList.contains('list-checkbox')) return; + checkbox.checked = !checkbox.checked; + row.classList.toggle('selected', checkbox.checked); + }); + + checkbox.addEventListener('change', () => { + row.classList.toggle('selected', checkbox.checked); + }); + + map.set(row.dataset.key, item); + container.appendChild(row); + }); + + body.appendChild(container); + + // 底部按钮 + const footer = document.createElement('div'); + footer.className = 'modal-footer'; + + // 新增:全选 / 全不选 + const selectAllBtn = document.createElement('button'); + selectAllBtn.className = 'secondary-btn'; + selectAllBtn.textContent = '全选'; + + const selectNoneBtn = document.createElement('button'); + selectNoneBtn.className = 'secondary-btn'; + selectNoneBtn.textContent = '全不选'; + + // 右侧操作:确认 / 取消 + const confirmBtn = document.createElement('button'); + confirmBtn.className = 'primary-btn'; + confirmBtn.textContent = confirmText; + + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'secondary-btn'; + cancelBtn.textContent = cancelText; + + // 将按钮加入 footer(顺序:全选、全不选、确认、取消) + footer.appendChild(selectAllBtn); + footer.appendChild(selectNoneBtn); + footer.appendChild(confirmBtn); + footer.appendChild(cancelBtn); + + // 组装 + modal.appendChild(header); + modal.appendChild(body); + modal.appendChild(footer); + overlay.appendChild(modal); + document.body.appendChild(overlay); + + // 辅助:批量设置选择状态 + const setAllChecked = (checked) => { + container.querySelectorAll('.list-item').forEach((row) => { + const box = row.querySelector('.list-checkbox'); + if (box) { + box.checked = checked; + row.classList.toggle('selected', checked); + } + }); + }; + + // 关闭 + const close = (result) => { + overlay.style.animation = 'fadeOut 0.2s'; + setTimeout(() => { + overlay.remove(); + resolve(result); + }, 200); + }; + + // 事件绑定 + selectAllBtn.addEventListener('click', () => setAllChecked(true)); + selectNoneBtn.addEventListener('click', () => setAllChecked(false)); + cancelBtn.addEventListener('click', () => close(null)); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) close(null); + }); + confirmBtn.addEventListener('click', () => { + const selected = []; + container.querySelectorAll('.list-item').forEach((row) => { + const checkbox = row.querySelector('.list-checkbox'); + if (checkbox && checkbox.checked) { + const key = row.dataset.key; + if (map.has(key)) selected.push(map.get(key)); + } + }); + close(selected); + }); + }); + } + // 创建主图标 const createIcon = () => { const icon = document.createElement('div');