XHS-Downloader

提取小红书作品/用户链接,下载小红书无水印图文/视频作品文件

  1. // ==UserScript==
  2. // @name XHS-Downloader
  3. // @namespace https://github.com/JoeanAmier/XHS-Downloader
  4. // @version 2.0.1
  5. // @description 提取小红书作品/用户链接,下载小红书无水印图文/视频作品文件
  6. // @author JoeanAmier
  7. // @match http*://xhslink.com/*
  8. // @match http*://www.xiaohongshu.com/explore*
  9. // @match http*://www.xiaohongshu.com/user/profile/*
  10. // @match http*://www.xiaohongshu.com/search_result*
  11. // @match http*://www.xiaohongshu.com/board/*
  12. // @icon data:image/png;base64,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
  13. // @grant GM_getValue
  14. // @grant GM_setValue
  15. // @grant unsafeWindow
  16. // @grant GM_setClipboard
  17. // @grant GM_registerMenuCommand
  18. // @grant GM_unregisterMenuCommand
  19. // @license GNU General Public License v3.0
  20. // @run-at document-end
  21. // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.9.1/jszip.min.js
  22. // ==/UserScript==
  23.  
  24. (function () {
  25. 'use strict';
  26.  
  27. const iconBase64 = "data:image/png;base64,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";
  28.  
  29. let config = {
  30. disclaimer: GM_getValue("disclaimer", false),
  31. packageDownloadFiles: GM_getValue("packageDownloadFiles", true),
  32. autoScrollSwitch: GM_getValue("autoScrollSwitch", false),
  33. maxScrollCount: GM_getValue("maxScrollCount", 50),
  34. fileNameFormat: undefined,
  35. imageFileFormat: undefined,
  36. icon: {
  37. type: 'image', // 可选: image/svg/font
  38. image: {
  39. url: iconBase64, // 图片URL或Base64
  40. size: 64, // 图标尺寸
  41. borderRadius: '50%' // 形状(50%为圆形)
  42. },
  43. }, // 位置配置
  44. position: {
  45. bottom: '8rem', left: '2rem'
  46. }, // 动画配置
  47. animation: {
  48. duration: 0.35, // 动画时长(s)
  49. easing: 'cubic-bezier(0.4, 0, 0.2, 1)'
  50. }
  51. };
  52.  
  53. const readme = () => {
  54. const instructions = `
  55. XHS-Downloader 用户脚本 功能清单:
  56. 1. 下载小红书无水印作品文件
  57. 2. 提取推荐页面作品链接
  58. 3. 提取账号发布作品链接
  59. 4. 提取账号收藏作品链接
  60. 5. 提取账号专辑作品链接
  61. 6. 提取账号点赞作品链接
  62. 7. 提取搜索结果作品链接
  63. 8. 提取搜索结果用户链接
  64.  
  65. XHS-Downloader 用户脚本 详细说明:
  66. 1. 下载小红书无水印作品文件时,脚本需要花费时间处理文件,请等待片刻,请勿多次点击下载按钮
  67. 2. 无水印作品文件较大,可能需要较长的时间处理,页面跳转可能会导致下载失败
  68. 3. 提取账号发布、收藏、点赞、专辑作品链接时,脚本可以自动滚动页面直至加载全部作品
  69. 4. 提取推荐作品链接、搜索作品、用户链接时,脚本可以自动滚动指定次数加载更多内容,默认滚动次数:50
  70. 5. 自动滚动页面功能默认关闭;用户可以自由开启,并修改滚动页面次数,修改后立即生效
  71. 6. 如果未开启自动滚动页面功能,用户需要手动滚动页面以便加载更多内容后再进行其他操作
  72. 7. 支持作品文件打包下载;该功能默认开启,多个文件的作品将会以压缩包格式下载
  73.  
  74. 项目开源地址:https://github.com/JoeanAmier/XHS-Downloader
  75. `
  76. const disclaimer_content = `
  77. 关于 XHS-Downloader 免责声明:
  78.  
  79. 1. 使用者对本项目的使用由使用者自行决定,并自行承担风险。作者对使用者使用本项目所产生的任何损失、责任、或风险概不负责。
  80. 2. 本项目的作者提供的代码和功能是基于现有知识和技术的开发成果。作者尽力确保代码的正确性和安全性,但不保证代码完全没有错误或缺陷。
  81. 3. 使用者在使用本项目时必须严格遵守 GNU General Public License v3.0 的要求,并在适当的地方注明使用了 GNU General Public License v3.0 的代码。
  82. 4. 使用者在任何情况下均不得将本项目的作者、贡献者或其他相关方与使用者的使用行为联系起来,或要求其对使用者使用本项目所产生的任何损失或损害负责。
  83. 5. 使用者在使用本项目的代码和功能时,必须自行研究相关法律法规,并确保其使用行为合法合规。任何因违反法律法规而导致的法律责任和风险,均由使用者自行承担。
  84. 6. 本项目的作者不会提供 XHS-Downloader 项目的付费版本,也不会提供与 XHS-Downloader 项目相关的任何商业服务。
  85. 7. 基于本项目进行的任何二次开发、修改或编译的程序与原创作者无关,原创作者不承担与二次开发行为或其结果相关的任何责任,使用者应自行对因二次开发可能带来的各种情况负全部责任。
  86.  
  87. 在使用本项目的代码和功能之前,请您认真考虑并接受以上免责声明。如果您对上述声明有任何疑问或不同意,请不要使用本项目的代码和功能。如果您使用了本项目的代码和功能,则视为您已完全理解并接受上述免责声明,并自愿承担使用本项目的一切风险和后果。
  88.  
  89. 是否已阅读 XHS-Downloader 功能说明与免责声明(YES/NO)
  90. `
  91. alert(instructions);
  92. if (!config.disclaimer) {
  93. const answer = prompt(disclaimer_content, "");
  94. if (!answer) {
  95. GM_setValue("disclaimer", false);
  96. config.disclaimer = false;
  97. } else {
  98. GM_setValue("disclaimer", answer.toUpperCase() === "YES" || answer.toUpperCase() === "Y");
  99. config.disclaimer = GM_getValue("disclaimer");
  100. }
  101. }
  102. };
  103.  
  104. if (!config.disclaimer) {
  105. readme();
  106. }
  107.  
  108. console.info("用户接受 XHS-Downloader 免责声明", config.disclaimer)
  109.  
  110. GM_registerMenuCommand("阅读脚本说明和免责声明", function () {
  111. readme();
  112. });
  113.  
  114. const updatePackageDownloadFiles = (value) => {
  115. config.packageDownloadFiles = value;
  116. GM_setValue("packageDownloadFiles", config.packageDownloadFiles);
  117. };
  118.  
  119. const updateAutoScrollSwitch = (value) => {
  120. config.autoScrollSwitch = value;
  121. GM_setValue("autoScrollSwitch", config.autoScrollSwitch);
  122. };
  123.  
  124. const updateMaxScrollCount = (value) => {
  125. config.maxScrollCount = parseInt(value) || 50;
  126. GM_setValue("maxScrollCount", config.maxScrollCount);
  127. };
  128.  
  129. const updateFileNameFormat = (value) => {
  130. config.fileNameFormat = value;
  131. GM_setValue("fileNameFormat", config.fileNameFormat);
  132. };
  133.  
  134. const about = () => {
  135. window.open('https://github.com/JoeanAmier/XHS-Downloader', '_blank');
  136. }
  137.  
  138. const abnormal = (text) => {
  139. alert(`${text}请向作者反馈!\n项目开源地址:https://github.com/JoeanAmier/XHS-Downloader`);
  140. };
  141.  
  142. const generateVideoUrl = note => {
  143. try {
  144. return [`https://sns-video-bd.xhscdn.com/${note.video.consumer.originVideoKey}`];
  145. } catch (error) {
  146. console.error("Error generating video URL:", error);
  147. return [];
  148. }
  149. };
  150.  
  151. const generateImageUrl = note => {
  152. let images = note.imageList;
  153. const regex = /http:\/\/sns-webpic-qc\.xhscdn.com\/\d+\/[0-9a-z]+\/(\S+)!/;
  154. let urls = [];
  155. try {
  156. images.forEach((item) => {
  157. let match = item.urlDefault.match(regex);
  158. if (match && match[1]) {
  159. urls.push(`https://ci.xiaohongshu.com/${match[1]}?imageView2/format/png`);
  160. }
  161. })
  162. return urls
  163. } catch (error) {
  164. console.error("Error generating image URLs:", error);
  165. return [];
  166. }
  167. };
  168.  
  169. const extractImageWebpUrls = (note, urls,) => {
  170. try {
  171. let items = []
  172. let {imageList} = note;
  173. if (urls.length !== imageList.length) {
  174. console.error("图片数量不一致!")
  175. return []
  176. }
  177. for (const [index, item] of imageList.entries()) {
  178. if (item.urlDefault) {
  179. items.push({
  180. webp: item.urlDefault, index: index + 1, url: urls[index],
  181. })
  182. } else {
  183. console.error("提取图片预览链接失败", item)
  184. break
  185. }
  186. }
  187. return items;
  188. } catch (error) {
  189. console.error("Error occurred in generating image object:", error);
  190. return []
  191. }
  192. };
  193.  
  194. const download = async (urls, note) => {
  195. const name = extractName();
  196. console.info(`文件名称 ${name}`);
  197. if (note.type === "video") {
  198. await downloadVideo(urls[0], name);
  199. } else {
  200. let items = extractImageWebpUrls(note, urls);
  201. if (items.length === 0) {
  202. console.error("解析图文作品数据失败", note)
  203. abnormal("解析图文作品数据发生异常!")
  204. } else if (urls.length > 1) {
  205. showImageSelectionModal(items, name,)
  206. } else {
  207. await downloadImage(items, name);
  208. }
  209. }
  210. };
  211.  
  212. const exploreDeal = async note => {
  213. try {
  214. let links;
  215. if (note.type === "normal") {
  216. links = generateImageUrl(note);
  217. } else {
  218. links = generateVideoUrl(note);
  219. }
  220. if (links.length > 0) {
  221. console.info("下载链接", links);
  222. await download(links, note);
  223. } else {
  224. abnormal("处理下载链接发生异常!")
  225. }
  226. } catch (error) {
  227. console.error("Error in exploreDeal function:", error);
  228. abnormal("下载作品文件发生异常!");
  229. }
  230. };
  231.  
  232. const extractNoteInfo = () => {
  233. const regex = /\/explore\/([^?]+)/;
  234. const match = currentUrl.match(regex);
  235. if (match) {
  236. return unsafeWindow.__INITIAL_STATE__.note.noteDetailMap[match[1]]
  237. } else {
  238. console.error("从链接提取作品 ID 失败", currentUrl,);
  239. }
  240. };
  241.  
  242. const extractDownloadLinks = async () => {
  243. if (currentUrl.includes("https://www.xiaohongshu.com/explore/")) {
  244. let note = extractNoteInfo();
  245. if (note.note) {
  246. await exploreDeal(note.note);
  247. } else {
  248. abnormal("读取作品数据发生异常!");
  249. }
  250. }
  251. };
  252.  
  253. const triggerDownload = (name, blob) => {
  254. // 创建 Blob 对象的 URL
  255. const blobUrl = URL.createObjectURL(blob);
  256.  
  257. // 创建一个临时链接元素
  258. const tempLink = document.createElement("a");
  259. tempLink.href = blobUrl;
  260. tempLink.download = name;
  261.  
  262. // 将链接添加到 DOM 并模拟点击
  263. document.body.appendChild(tempLink); // 避免某些浏览器安全限制
  264. tempLink.click();
  265.  
  266. // 清理临时链接元素
  267. document.body.removeChild(tempLink); // 从 DOM 中移除临时链接
  268. URL.revokeObjectURL(blobUrl); // 释放 URL
  269.  
  270. console.info(`文件已成功下载: ${name}`);
  271. }
  272.  
  273. const downloadFile = async (link, name, trigger = true, retries = 5) => {
  274. for (let attempt = 1; attempt <= retries; attempt++) {
  275. try {
  276. // 使用 fetch 获取文件数据
  277. const response = await fetch(link, {
  278. "headers": {
  279. "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
  280. "accept-language": "zh-SG,zh;q=0.9",
  281. },
  282. "method": "GET",
  283. });
  284.  
  285. // 检查响应状态码
  286. if (!response.ok) {
  287. console.error(`下载失败,状态码: ${response.status},URL: ${link},尝试次数: ${attempt}`);
  288. continue; // 继续下一次尝试
  289. }
  290.  
  291. const blob = await response.blob();
  292.  
  293. if (trigger) {
  294. triggerDownload(name, blob);
  295. return true;
  296. } else {
  297. return blob;
  298. }
  299. } catch (error) {
  300. console.error(`下载失败 (${name}),错误信息:`, error, `尝试次数: ${attempt}`);
  301. if (attempt === retries) {
  302. return false; // 如果达到最大重试次数,返回失败
  303. }
  304. }
  305. }
  306. return false; // 如果所有尝试都失败,返回失败
  307. };
  308.  
  309. const downloadFiles = async (items, name,) => {
  310. const downloadResults = []; // 用于存储下载结果
  311.  
  312. const downloadPromises = items.map(async (item) => {
  313. let fileName;
  314. if (item.index) {
  315. fileName = `${name}_${item.index}.png`; // 根据索引生成文件名
  316. } else {
  317. fileName = `${name}.png`;
  318. }
  319. const result = await downloadFile(item.url, fileName, false); // 调用单个文件下载方法
  320. if (result) {
  321. downloadResults.push({name: fileName, file: result});
  322. return true; // 成功
  323. } else {
  324. return false; // 失败
  325. }
  326. });
  327.  
  328. // 等待所有下载操作完成
  329. const results = await Promise.all(downloadPromises);
  330.  
  331. if (results.every(result => result === true)) {
  332. try {
  333. const zip = new JSZip();
  334. downloadResults.forEach((item) => {
  335. zip.file(item.name, item.file);
  336. });
  337.  
  338. const content = await zip.generateAsync({type: "blob", compression: "STORE"});
  339. triggerDownload(`${name}.zip`, content,)
  340. return true;
  341. } catch (error) {
  342. console.error('生成 ZIP 文件或保存失败,错误信息:', error);
  343. return false;
  344. }
  345. } else {
  346. return false;
  347. }
  348. };
  349.  
  350. const truncateString = (str, maxLength) => {
  351. if (str.length > maxLength) {
  352. const halfLength = Math.floor(maxLength / 2) - 1; // 减去 1 留出省略号的空间
  353. return str.slice(0, halfLength) + '...' + str.slice(-halfLength);
  354. }
  355. return str;
  356. };
  357.  
  358. const extractName = () => {
  359. let name = document.title.replace(/ - 小红书$/, "").replace(/[^\u4e00-\u9fa5a-zA-Z0-9 ~!@#$%&()_\-+=\[\];"',.!()【】:“”,。《》?]/g, "");
  360. name = truncateString(name, 64,);
  361. let match = currentUrl.match(/\/([^\/]+)$/);
  362. let id = match ? match[1] : null;
  363. return name === "" ? id : name
  364. };
  365.  
  366. const downloadVideo = async (url, name) => {
  367. if (!await downloadFile(url, `${name}.mp4`)) {
  368. abnormal("下载视频作品文件发生异常!");
  369. }
  370. };
  371.  
  372. const downloadImage = async (items, name) => {
  373. let success;
  374. if (!config.packageDownloadFiles && items.length > 1) {
  375. let result = [];
  376. for (let item of items) {
  377. result.push(await downloadFile(item.url, `${name}_${item.index}.png`));
  378. }
  379. success = result.every(item => item === true);
  380. } else if (items.length === 1) {
  381. success = await downloadFile(items[0].url, `${name}.png`);
  382. } else {
  383. success = await downloadFiles(items, name,);
  384. }
  385. if (!success) {
  386. abnormal("下载图文作品文件发生异常!");
  387. }
  388. };
  389.  
  390. const window_scrollBy = (x, y,) => {
  391. window.scrollBy(x, y,);
  392. }
  393.  
  394. // 随机整数生成函数
  395. const getRandomInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
  396.  
  397. // 判断是否需要暂停,模拟用户的停顿行为
  398. const shouldPause = () => Math.random() < 0.2; // 20%几率停顿
  399.  
  400. // 执行一次增量滚动
  401. const scrollOnce = () => {
  402. const scrollDistanceMin = 100; // 最小滚动距离
  403. const scrollDistanceMax = 300; // 最大滚动距离
  404. const scrollDistance = getRandomInt(scrollDistanceMin, scrollDistanceMax);
  405. window_scrollBy(0, scrollDistance); // 增量滚动
  406. };
  407.  
  408. // 检查是否已经滚动到底部
  409. const isAtBottom = () => {
  410. const docHeight = document.documentElement.scrollHeight;
  411. const winHeight = window.innerHeight;
  412. const scrollPos = window.scrollY;
  413.  
  414. return (docHeight - winHeight - scrollPos <= 10); // 如果距离底部小于10px,认为滚动到底部
  415. };
  416.  
  417. // 自动滚动主函数
  418. const scrollScreen = (callback, endless = false, scrollCount = 0,) => {
  419. const timeoutMin = 250; // 最小滚动间隔
  420. const timeoutMax = 500; // 最大滚动间隔
  421.  
  422. const scrollInterval = setInterval(() => {
  423. if (shouldPause()) {
  424. // 停顿,模拟用户的休息
  425. clearInterval(scrollInterval);
  426. setTimeout(() => {
  427. scrollScreen(callback, endless, scrollCount,); // 重新启动滚动
  428. }, getRandomInt(timeoutMin, timeoutMax,)); // 随机停顿时间
  429. } else if (endless) {
  430. // 无限滚动至底部模式
  431. if (!isAtBottom()) {
  432. scrollOnce(); // 执行一次滚动
  433. } else {
  434. // 到达底部,停止滚动
  435. clearInterval(scrollInterval);
  436. callback(); // 调用回调函数
  437. }
  438. } else if (scrollCount < config.maxScrollCount && !isAtBottom()) {
  439. scrollOnce(); // 执行一次滚动
  440. scrollCount++;
  441. } else {
  442. // 如果到达底部或滚动次数已满,停止滚动
  443. clearInterval(scrollInterval);
  444. callback(); // 调用回调函数
  445. }
  446. }, getRandomInt(timeoutMin, timeoutMax)); // 随机滚动间隔
  447. };
  448.  
  449. const scrollScreenEvent = (callback, endless = false) => {
  450. if (config.autoScrollSwitch) {
  451. scrollScreen(callback, endless,);
  452. } else {
  453. callback();
  454. }
  455. };
  456.  
  457. const extractNotesInfo = order => {
  458. const notesRawValue = unsafeWindow.__INITIAL_STATE__.user.notes._rawValue[order];
  459. return notesRawValue.map(item => [item.id, item.xsecToken,]);
  460. };
  461.  
  462. const extractBoardInfo = () => {
  463. // 定义正则表达式来匹配 URL 中的 ID
  464. const regex = /\/board\/([a-z0-9]+)\?/;
  465.  
  466. // 使用 exec 方法执行正则表达式
  467. const match = regex.exec(currentUrl);
  468.  
  469. // 检查是否有匹配
  470. if (match) {
  471. // 提取 ID
  472. const id = match[1]; // match[0] 是整个匹配的字符串,match[1] 是第一个括号内的匹配
  473.  
  474. const notesRawValue = unsafeWindow.__INITIAL_STATE__.board.boardFeedsMap._rawValue[id].notes;
  475. return notesRawValue.map(item => [item.noteId, item.xsecToken,]);
  476. } else {
  477. console.error("从链接提取专辑 ID 失败", currentUrl,);
  478. return [];
  479. }
  480. };
  481.  
  482. const extractFeedInfo = () => {
  483. const notesRawValue = unsafeWindow.__INITIAL_STATE__.feed.feeds._rawValue;
  484. return notesRawValue.map(item => [item.id, item.xsecToken,]);
  485. };
  486.  
  487. const extractSearchNotes = () => {
  488. const notesRawValue = unsafeWindow.__INITIAL_STATE__.search.feeds._rawValue;
  489. return notesRawValue.map(item => [item.id, item.xsecToken,]);
  490. }
  491.  
  492. const extractSearchUsers = () => {
  493. const notesRawValue = unsafeWindow.__INITIAL_STATE__.search.userLists._rawValue;
  494. return notesRawValue.map(item => item.id);
  495. }
  496.  
  497. const generateNoteUrls = data => data.map(([id, token,]) => `https://www.xiaohongshu.com/discovery/item/${id}?source=webshare&xhsshare=pc_web&xsec_token=${token}&xsec_source=pc_share`).join(" ");
  498.  
  499. const generateUserUrls = data => data.map(id => `https://www.xiaohongshu.com/user/profile/${id}`).join(" ");
  500.  
  501. const extractAllLinks = (callback, order) => {
  502. scrollScreenEvent(() => {
  503. let data;
  504. if (order >= 0 && order <= 2) {
  505. data = extractNotesInfo(order);
  506. } else if (order === 3) {
  507. data = extractSearchNotes();
  508. } else if (order === 4) {
  509. data = extractSearchUsers();
  510. } else if (order === -1) {
  511. data = extractFeedInfo()
  512. } else if (order === 5) {
  513. data = extractBoardInfo()
  514. } else {
  515. data = [];
  516. }
  517. let urlsString = order !== 4 ? generateNoteUrls(data) : generateUserUrls(data);
  518. callback(urlsString);
  519. }, [0, 1, 2, 5].includes(order))
  520. };
  521.  
  522. const extractAllLinksEvent = (order = 0) => {
  523. extractAllLinks(urlsString => {
  524. if (urlsString) {
  525. GM_setClipboard(urlsString, "text", () => {
  526. alert('作品/用户链接已复制到剪贴板!');
  527. });
  528. } else {
  529. alert("未提取到任何作品/用户链接!")
  530. }
  531. }, order);
  532. };
  533.  
  534. if (typeof JSZip === 'undefined') {
  535. alert("XHS-Downloader 用户脚本依赖库 JSZip 加载失败,作品文件打包下载功能无法使用,请尝试刷新网页或者向作者反馈!");
  536. }
  537.  
  538. /* ==================== 样式定义 ==================== */
  539. let style = document.createElement('style');
  540. style.textContent = `
  541. /* 弹窗基础样式 */
  542. #SettingsOverlay {
  543. position: fixed;
  544. top: 0;
  545. left: 0;
  546. width: 100%;
  547. height: 100%;
  548. background: rgba(0,0,0,0.32);
  549. backdrop-filter: blur(4px);
  550. display: flex;
  551. justify-content: center;
  552. align-items: center;
  553. z-index: 10000;
  554. animation: fadeIn 0.3s;
  555. }
  556.  
  557. .optimized-scroll-modal {
  558. background: white;
  559. border-radius: 16px;
  560. width: 380px; /* 缩小窗口宽度 */
  561. max-width: 95vw;
  562. box-shadow: 0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23);
  563. overflow: hidden;
  564. animation: scaleUp 0.3s;
  565. }
  566.  
  567. /* 头部样式 */
  568. .modal-header {
  569. padding: 1rem;
  570. border-bottom: 1px solid #eee;
  571. text-align: center;
  572. }
  573.  
  574. .modal-header span {
  575. font-size: 1.25rem;
  576. font-weight: 500;
  577. color: #212121;
  578. }
  579.  
  580. /* 内容区域 */
  581. .modal-body {
  582. padding: 1rem; /* 减小内边距 */
  583. }
  584.  
  585. /* 设置项样式 */
  586. .setting-item {
  587. margin: 0.5rem 0; /* 减少设置项间距 */
  588. padding: 10px;
  589. border-radius: 8px;
  590. transition: background 0.2s;
  591. }
  592.  
  593. .setting-item:hover {
  594. background: #f0f0f0;
  595. }
  596.  
  597. .setting-item label {
  598. display: flex;
  599. justify-content: space-between;
  600. align-items: center;
  601. width: 100%;
  602. }
  603.  
  604. /* 设置项标题 */
  605. .setting-item label span {
  606. font-size: 1rem; /* 增大标题文字 */
  607. font-weight: 500;
  608. color: #333;
  609. }
  610.  
  611. /* 开关样式 */
  612. .toggle-switch {
  613. position: relative;
  614. width: 40px;
  615. height: 20px;
  616. }
  617.  
  618. .toggle-switch input {
  619. opacity: 0;
  620. width: 0;
  621. height: 0;
  622. }
  623.  
  624. .slider {
  625. position: absolute;
  626. cursor: pointer;
  627. top: 0;
  628. left: 0;
  629. right: 0;
  630. bottom: 0;
  631. background: #ccc;
  632. transition: 0.4s;
  633. border-radius: 34px;
  634. }
  635.  
  636. .slider:before {
  637. content: "";
  638. position: absolute;
  639. height: 16px;
  640. width: 16px;
  641. left: 2px;
  642. bottom: 2px;
  643. background: white;
  644. border-radius: 50%;
  645. transition: 0.4s;
  646. }
  647.  
  648. input:checked + .slider {
  649. background: #2196F3;
  650. }
  651.  
  652. input:checked + .slider:before {
  653. transform: translateX(20px);
  654. }
  655.  
  656. /* 数值输入 */
  657. .number-input {
  658. display: flex;
  659. align-items: center;
  660. border: 1px solid #ddd;
  661. border-radius: 8px;
  662. overflow: hidden;
  663. margin: 6px 0;
  664. }
  665.  
  666. .number-input input {
  667. width: 60px;
  668. text-align: center;
  669. border: none;
  670. }
  671.  
  672. .number-button {
  673. padding: 4px 8px;
  674. background: #f0f0f0;
  675. border: none;
  676. cursor: pointer;
  677. transition: all 0.2s;
  678. }
  679.  
  680. /* 文本输入框 */
  681. .text-input {
  682. width: 100%;
  683. padding: 8px;
  684. border: 1px solid #ddd;
  685. border-radius: 4px;
  686. font-size: 0.9rem;
  687. margin-top: 8px; /* 增加与标题的距离 */
  688. transition: border-color 0.2s;
  689. }
  690.  
  691. .text-input:focus {
  692. outline: none;
  693. border-color: #2196F3;
  694. box-shadow: 0 0 4px rgba(33, 150, 243, 0.3);
  695. }
  696.  
  697. /* 设置项说明 */
  698. .setting-description {
  699. font-size: 0.875rem;
  700. color: #757575;
  701. margin-top: 4px;
  702. line-height: 1.4;
  703. text-align: left; /* 左对齐 */
  704. }
  705.  
  706. /* 底部按钮 */
  707. .modal-footer {
  708. padding: 1rem;
  709. border-top: 1px solid #eee;
  710. display: flex;
  711. justify-content: flex-end;
  712. gap: 12px;
  713. }
  714.  
  715. .primary-btn {
  716. background: #2196F3;
  717. color: white;
  718. padding: 8px 24px;
  719. border-radius: 24px;
  720. cursor: pointer;
  721. transition: all 0.2s;
  722. }
  723.  
  724. .secondary-btn {
  725. background: #f0f0f0;
  726. color: #666;
  727. padding: 8px 24px;
  728. border-radius: 24px;
  729. cursor: pointer;
  730. transition: all 0.2s;
  731. }
  732.  
  733. /* 动画 */
  734. @keyframes fadeIn {
  735. from { opacity: 0; }
  736. to { opacity: 1; }
  737. }
  738.  
  739. @keyframes scaleUp {
  740. from { transform: scale(0.98); }
  741. to { transform: scale(1); }
  742. }
  743. `;
  744. document.head.appendChild(style);
  745.  
  746. // 创建开关项
  747. const createSettingItem = ({label, description, checked}) => {
  748. const item = document.createElement('div');
  749. item.className = 'setting-item';
  750.  
  751. item.innerHTML = `
  752. <label>
  753. <span>${label}</span>
  754. <div class="toggle-switch">
  755. <input type="checkbox" ${checked ? 'checked' : ''}>
  756. <span class="slider"></span>
  757. </div>
  758. </label>
  759. <div class="setting-description">${description}</div>
  760. `;
  761.  
  762. return item;
  763. };
  764.  
  765. // 创建数值输入项
  766. const createNumberInput = ({label, description, value, min, max, disabled}) => {
  767. const item = document.createElement('div');
  768. item.className = 'setting-item';
  769.  
  770. const numberInput = document.createElement('div');
  771. numberInput.className = 'number-input';
  772. numberInput.style.opacity = disabled ? 0.6 : 1;
  773. numberInput.innerHTML = `
  774. <button class="number-button" data-action="decrement">−</button>
  775. <input type="number" value="${value}" min="${min}" max="${max}" ${disabled ? 'disabled' : ''}>
  776. <button class="number-button" data-action="increment">+</button>
  777. `;
  778.  
  779. item.innerHTML = `
  780. <label>
  781. <span>${label}</span>
  782. ${numberInput.outerHTML}
  783. </label>
  784. <div class="setting-description">${description}</div>
  785. `;
  786.  
  787. // 绑定数值按钮事件
  788. const container = item.querySelector('.number-input');
  789. container.querySelectorAll('.number-button').forEach(btn => {
  790. btn.addEventListener('click', () => {
  791. const input = container.querySelector('input');
  792. if (input.disabled) {
  793. return;
  794. }
  795.  
  796. let val = parseInt(input.value);
  797. if (btn.dataset.action === 'increment') {
  798. val = Math.min(val + 1, max);
  799. } else {
  800. val = Math.max(val - 1, min);
  801. }
  802. input.value = val;
  803. });
  804. });
  805.  
  806. return item;
  807. };
  808.  
  809. // 创建文本输入项
  810. const createTextInput = ({label, description, placeholder, value}) => {
  811. const item = document.createElement('div');
  812. item.className = 'setting-item';
  813.  
  814. item.innerHTML = `
  815. <div>
  816. <span style="font-size: 1rem; font-weight: 500; color: #333;">${label}</span>
  817. </div>
  818. <div class="setting-description">${description}</div>
  819. <input type="text" class="text-input" placeholder="${placeholder}" value="${value}">
  820. `;
  821.  
  822. return item;
  823. };
  824.  
  825. // 关闭弹窗函数
  826. const closeSettingsModal = () => {
  827. const overlay = document.getElementById('SettingsOverlay');
  828. if (overlay) {
  829. overlay.style.animation = 'fadeOut 0.2s';
  830. setTimeout(() => overlay.remove(), 200);
  831. }
  832. };
  833.  
  834. /* ==================== 弹窗逻辑 ==================== */
  835. const showSettings = () => {
  836. if (document.getElementById('SettingsOverlay')) {
  837. return;
  838. }
  839.  
  840. // 创建覆盖层
  841. const overlay = document.createElement('div');
  842. overlay.id = 'SettingsOverlay';
  843.  
  844. // 创建弹窗
  845. const modal = document.createElement('div');
  846. modal.className = 'optimized-scroll-modal';
  847.  
  848. // 创建头部
  849. const header = document.createElement('div');
  850. header.className = 'modal-header';
  851. header.innerHTML = `
  852. <span>用户脚本设置</span>
  853. `;
  854.  
  855. // 创建内容区域
  856. const body = document.createElement('div');
  857. body.className = 'modal-body';
  858.  
  859. // 自动滚动开关
  860. const autoScroll = createSettingItem({
  861. label: '自动滚动页面',
  862. description: '启用后,页面将根据规则自动滚动以便加载更多内容',
  863. checked: GM_getValue("autoScrollSwitch", false),
  864. });
  865.  
  866. // 文件打包开关
  867. const filePack = createSettingItem({
  868. label: '文件打包下载',
  869. description: '启用后,多个文件的作品将会以压缩包格式下载',
  870. checked: GM_getValue("packageDownloadFiles", true)
  871. });
  872.  
  873. // 滚动次数设置
  874. const scrollCount = createNumberInput({
  875. label: '自动滚动次数',
  876. description: '自动滚动页面的次数(仅在启用自动滚动页面时可用)',
  877. value: GM_getValue("maxScrollCount", 50),
  878. min: 10,
  879. max: 5000,
  880. disabled: !GM_getValue("autoScrollSwitch", false),
  881. });
  882.  
  883. // 名称格式设置
  884. // const nameFormat = createTextInput({
  885. // label: '文件名称格式',
  886. // description: '设置文件的名称格式(例如:{date}-{title})。',
  887. // placeholder: '{date}-{title}',
  888. // value: GM_getValue("fileNameFormat",)
  889. // });
  890.  
  891. // 绑定自动滚动开关控制次数输入
  892. autoScroll.querySelector('input').addEventListener('change', (e) => {
  893. scrollCount.querySelector('input').disabled = !e.target.checked;
  894. scrollCount.querySelector('.number-input').style.opacity = e.target.checked ? 1 : 0.6;
  895. });
  896.  
  897. // 组合内容
  898. body.appendChild(filePack);
  899. body.appendChild(autoScroll);
  900. body.appendChild(scrollCount);
  901. // body.appendChild(nameFormat);
  902.  
  903. // 创建底部按钮
  904. const footer = document.createElement('div');
  905. footer.className = 'modal-footer';
  906. const saveBtn = document.createElement('button');
  907. saveBtn.className = 'primary-btn';
  908. saveBtn.textContent = '保存设置';
  909. const cancelBtn = document.createElement('button');
  910. cancelBtn.className = 'secondary-btn';
  911. cancelBtn.textContent = '放弃修改';
  912. footer.appendChild(saveBtn);
  913. footer.appendChild(cancelBtn);
  914.  
  915. // 组装弹窗
  916. modal.appendChild(header);
  917. modal.appendChild(body);
  918. modal.appendChild(footer);
  919. overlay.appendChild(modal);
  920. document.body.appendChild(overlay);
  921.  
  922. // 保存事件
  923. saveBtn.addEventListener('click', () => {
  924. updateAutoScrollSwitch(autoScroll.querySelector('input').checked);
  925. updatePackageDownloadFiles(filePack.querySelector('input').checked);
  926. updateMaxScrollCount(parseInt(scrollCount.querySelector('input').value) || 50)
  927. // updateFileNameFormat(nameFormat.querySelector('.text-input').value.trim() || null);
  928. closeSettingsModal();
  929. });
  930.  
  931. // 关闭事件
  932. cancelBtn.addEventListener('click', closeSettingsModal);
  933. overlay.addEventListener('click', (e) => e.target === overlay && closeSettingsModal());
  934. };
  935.  
  936. /* ==================== 样式定义 ==================== */
  937. style = document.createElement('style');
  938. style.textContent = `
  939. /* 弹窗基础样式 */
  940. #imageSelectionOverlay {
  941. position: fixed;
  942. top: 0;
  943. left: 0;
  944. width: 100%;
  945. height: 100%;
  946. background: rgba(0,0,0,0.32);
  947. backdrop-filter: blur(4px);
  948. display: flex;
  949. justify-content: center;
  950. align-items: center;
  951. z-index: 10000;
  952. animation: fadeIn 0.3s;
  953. }
  954.  
  955. .image-selection-modal {
  956. background: white;
  957. border-radius: 16px;
  958. width: 80%;
  959. max-width: 900px;
  960. max-height: 90vh;
  961. box-shadow: 0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23);
  962. overflow: hidden;
  963. animation: scaleUp 0.3s;
  964. display: flex;
  965. flex-direction: column;
  966. }
  967.  
  968. /* 头部样式 */
  969. .modal-header {
  970. padding: 1rem;
  971. border-bottom: 1px solid #eee;
  972. text-align: center;
  973. }
  974.  
  975. .modal-header span {
  976. font-size: 1.25rem;
  977. font-weight: 500;
  978. color: #212121;
  979. }
  980.  
  981. /* 内容区域 */
  982. .modal-body {
  983. flex: 1;
  984. padding: 1rem;
  985. overflow-y: auto;
  986. }
  987.  
  988. /* 图片网格 */
  989. .image-grid {
  990. display: grid;
  991. grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
  992. gap: 12px;
  993. }
  994.  
  995. .image-item {
  996. position: relative;
  997. border-radius: 8px;
  998. overflow: hidden;
  999. cursor: pointer;
  1000. transition: all 0.2s;
  1001. border: 2px solid transparent;
  1002. }
  1003.  
  1004. .image-item img {
  1005. width: 100%;
  1006. height: 100px;
  1007. object-fit: cover;
  1008. border-radius: 6px;
  1009. }
  1010.  
  1011. .image-item.selected {
  1012. border-color: #2196F3;
  1013. }
  1014.  
  1015. .image-checkbox {
  1016. position: absolute;
  1017. top: 8px;
  1018. right: 8px;
  1019. width: 20px;
  1020. height: 20px;
  1021. opacity: 0;
  1022. }
  1023.  
  1024. .image-checkbox + label {
  1025. position: absolute;
  1026. top: 8px;
  1027. right: 8px;
  1028. width: 20px;
  1029. height: 20px;
  1030. background: white;
  1031. border: 1px solid #ccc;
  1032. border-radius: 50%;
  1033. cursor: pointer;
  1034. display: flex;
  1035. justify-content: center;
  1036. align-items: center;
  1037. transition: all 0.2s;
  1038. }
  1039.  
  1040. .image-checkbox:checked + label {
  1041. background: #2196F3;
  1042. border-color: #2196F3;
  1043. }
  1044.  
  1045. .image-checkbox:checked + label::after {
  1046. content: "✓";
  1047. color: white;
  1048. font-size: 12px;
  1049. }
  1050.  
  1051. /* 底部按钮 */
  1052. .modal-footer {
  1053. padding: 1rem;
  1054. border-top: 1px solid #eee;
  1055. display: flex;
  1056. justify-content: flex-end;
  1057. gap: 12px;
  1058. }
  1059.  
  1060. .primary-btn {
  1061. background: #2196F3;
  1062. color: white;
  1063. padding: 8px 24px;
  1064. border-radius: 24px;
  1065. cursor: pointer;
  1066. transition: all 0.2s;
  1067. }
  1068.  
  1069. .secondary-btn {
  1070. background: #f0f0f0;
  1071. color: #666;
  1072. padding: 8px 24px;
  1073. border-radius: 24px;
  1074. cursor: pointer;
  1075. transition: all 0.2s;
  1076. }
  1077.  
  1078. /* 动画 */
  1079. @keyframes fadeIn {
  1080. from { opacity: 0; }
  1081. to { opacity: 1; }
  1082. }
  1083.  
  1084. @keyframes scaleUp {
  1085. from { transform: scale(0.98); }
  1086. to { transform: scale(1); }
  1087. }
  1088. `;
  1089. document.head.appendChild(style);
  1090.  
  1091. // 关闭弹窗函数
  1092. const closeImagesModal = () => {
  1093. const overlay = document.getElementById('imageSelectionOverlay');
  1094. if (overlay) {
  1095. overlay.style.animation = 'fadeOut 0.2s';
  1096. setTimeout(() => overlay.remove(), 200);
  1097. }
  1098. };
  1099.  
  1100. /* ==================== 弹窗逻辑 ==================== */
  1101. const showImageSelectionModal = (imageUrls, name) => {
  1102. if (document.getElementById('imageSelectionOverlay')) {
  1103. return;
  1104. }
  1105.  
  1106. // 创建覆盖层
  1107. const overlay = document.createElement('div');
  1108. overlay.id = 'imageSelectionOverlay';
  1109.  
  1110. // 创建弹窗
  1111. const modal = document.createElement('div');
  1112. modal.className = 'image-selection-modal';
  1113.  
  1114. // 创建头部
  1115. const header = document.createElement('div');
  1116. header.className = 'modal-header';
  1117. header.innerHTML = `
  1118. <span>请选中需要下载的图片</span>
  1119. `;
  1120.  
  1121. // 创建内容区域
  1122. const body = document.createElement('div');
  1123. body.className = 'modal-body';
  1124.  
  1125. // 创建图片网格
  1126. const imageGrid = document.createElement('div');
  1127. imageGrid.className = 'image-grid';
  1128.  
  1129. // 动态生成图片项
  1130. imageUrls.forEach((image) => {
  1131. const item = document.createElement('div');
  1132. item.className = 'image-item';
  1133.  
  1134. const checkbox = document.createElement('input');
  1135. checkbox.type = 'checkbox';
  1136. checkbox.className = 'image-checkbox';
  1137. checkbox.id = `image-checkbox-${image.index}`;
  1138. checkbox.checked = true;
  1139.  
  1140. const label = document.createElement('label');
  1141. label.htmlFor = `image-checkbox-${image.index}`;
  1142.  
  1143. const img = document.createElement('img');
  1144. img.src = image.webp;
  1145. img.index = image.index;
  1146. img.url = image.url;
  1147. img.alt = `图片_${image.index}`;
  1148.  
  1149. item.appendChild(checkbox);
  1150. item.appendChild(label);
  1151. item.appendChild(img);
  1152.  
  1153. // 绑定点击事件
  1154. item.addEventListener('click', (e) => {
  1155. if (e.target.tagName !== 'INPUT') {
  1156. checkbox.checked = !checkbox.checked;
  1157. item.classList.toggle('selected', checkbox.checked);
  1158. }
  1159. });
  1160.  
  1161. imageGrid.appendChild(item);
  1162. });
  1163.  
  1164. body.appendChild(imageGrid);
  1165.  
  1166. // 创建底部按钮
  1167. const footer = document.createElement('div');
  1168. footer.className = 'modal-footer';
  1169. const confirmBtn = document.createElement('button');
  1170. confirmBtn.className = 'primary-btn';
  1171. confirmBtn.textContent = '开始下载';
  1172. const closeBtn = document.createElement('button');
  1173. closeBtn.className = 'secondary-btn';
  1174. closeBtn.textContent = '关闭窗口';
  1175. footer.appendChild(confirmBtn);
  1176. footer.appendChild(closeBtn);
  1177.  
  1178. // 组装弹窗
  1179. modal.appendChild(header);
  1180. modal.appendChild(body);
  1181. modal.appendChild(footer);
  1182. overlay.appendChild(modal);
  1183. document.body.appendChild(overlay);
  1184.  
  1185. // 确认事件
  1186. confirmBtn.addEventListener('click', async () => {
  1187. const selectedImages = Array.from(document.querySelectorAll('.image-checkbox:checked')).map((checkbox) => {
  1188. let item = checkbox.parentElement.querySelector('img');
  1189. return {
  1190. index: item.index, url: item.url,
  1191. }
  1192. });
  1193. if (selectedImages.length === 0) {
  1194. alert('请至少选择一张图片!');
  1195. return;
  1196. }
  1197. closeImagesModal();
  1198. await downloadImage(selectedImages, name)
  1199. });
  1200.  
  1201. // 关闭事件
  1202. closeBtn.addEventListener('click', closeImagesModal);
  1203. overlay.addEventListener('click', (e) => e.target === overlay && closeImagesModal());
  1204. };
  1205.  
  1206. // 创建主图标
  1207. const createIcon = () => {
  1208. const icon = document.createElement('div');
  1209. icon.style = `
  1210. position: fixed;
  1211. bottom: ${config.position.bottom};
  1212. left: ${config.position.left};
  1213. width: ${config.icon[config.icon.type].size}px;
  1214. height: ${config.icon[config.icon.type].size}px;
  1215. background: white;
  1216. border-radius: ${config.icon.image.borderRadius || '8px'};
  1217. cursor: pointer;
  1218. z-index: 9999;
  1219. box-shadow: 0 3px 5px rgba(0,0,0,0.12), 0 3px 5px rgba(0,0,0,0.24);
  1220. display: flex;
  1221. align-items: center;
  1222. justify-content: center;
  1223. transition: all ${config.animation.duration}s ${config.animation.easing};
  1224. `;
  1225.  
  1226. icon.style.backgroundImage = `url(${config.icon.image.url})`;
  1227. icon.style.backgroundSize = 'cover';
  1228.  
  1229. return icon;
  1230. };
  1231.  
  1232. // 创建菜单容器
  1233. const menu = document.createElement('div');
  1234. menu.style = `
  1235. position: fixed;
  1236. bottom: calc(${config.position.bottom} + ${config.icon[config.icon.type].size}px + 1rem);
  1237. left: ${config.position.left};
  1238. width: 255px;
  1239. max-width: calc(100vw - 4rem);
  1240. background: white;
  1241. border-radius: 16px;
  1242. box-shadow: 0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23);
  1243. overflow: hidden;
  1244. display: none;
  1245. z-index: 9998;
  1246. transform-origin: bottom left;
  1247. transition: all ${config.animation.duration}s ${config.animation.easing};
  1248. opacity: 0;
  1249. transform: translateY(10px) scaleY(0.95);
  1250. will-change: transform, opacity;
  1251. `;
  1252.  
  1253. // 创建菜单内容容器
  1254. const menuContent = document.createElement('div');
  1255. menuContent.style = `
  1256. max-height: 400px;
  1257. overflow-y: auto;
  1258. overscroll-behavior: contain;
  1259. `;
  1260. menu.appendChild(menuContent);
  1261.  
  1262. // 初始化样式
  1263. style = document.createElement('style');
  1264. style.textContent = `
  1265. :root {
  1266. --primary: #2196F3;
  1267. --surface: #ffffff;
  1268. --on-surface: #212121;
  1269. --ripple-color: rgba(33, 150, 243, 0.15);
  1270. --border-radius: 12px;
  1271. }
  1272.  
  1273. .menu-item {
  1274. display: flex;
  1275. padding: 1rem 1.5rem;
  1276. cursor: pointer;
  1277. position: relative;
  1278. transition: all 0.2s ease;
  1279. align-items: center;
  1280. }
  1281.  
  1282. .menu-item:hover {
  1283. background: var(--ripple-color);
  1284. }
  1285.  
  1286. .menu-item:not(:last-child) {
  1287. border-bottom: 1px solid #eee;
  1288. }
  1289.  
  1290. .icon-container {
  1291. margin-right: 1rem;
  1292. display: flex;
  1293. align-items: center;
  1294. }
  1295.  
  1296. .material-icons {
  1297. font-size: 24px;
  1298. color: var(--primary);
  1299. }
  1300.  
  1301. .content {
  1302. flex: 1;
  1303. }
  1304.  
  1305. .title {
  1306. font-size: 0.95rem;
  1307. color: var(--on-surface);
  1308. font-weight: 500;
  1309. margin-bottom: 2px;
  1310. }
  1311.  
  1312. .subtitle {
  1313. font-size: 0.75rem;
  1314. color: #757575;
  1315. line-height: 1.4;
  1316. }
  1317.  
  1318. .menu-enter {
  1319. animation: slideIn ${config.animation.duration}s ${config.animation.easing};
  1320. }
  1321.  
  1322. .menu-exit {
  1323. animation: slideOut ${config.animation.duration}s ${config.animation.easing};
  1324. }
  1325.  
  1326. @keyframes slideIn {
  1327. from {
  1328. opacity: 0;
  1329. transform: translateY(10px) scaleY(0.95);
  1330. }
  1331. to {
  1332. opacity: 1;
  1333. transform: translateY(0) scaleY(1);
  1334. }
  1335. }
  1336.  
  1337. @keyframes slideOut {
  1338. from {
  1339. opacity: 1;
  1340. transform: translateY(0) scaleY(1);
  1341. }
  1342. to {
  1343. opacity: 0;
  1344. transform: translateY(10px) scaleY(0.95);
  1345. }
  1346. }
  1347.  
  1348. .ripple {
  1349. position: absolute;
  1350. border-radius: 50%;
  1351. transform: scale(0);
  1352. animation: ripple 0.6s linear;
  1353. background: var(--ripple-color);
  1354. pointer-events: none;
  1355. }
  1356.  
  1357. @keyframes ripple {
  1358. to {
  1359. transform: scale(2);
  1360. opacity: 0;
  1361. }
  1362. }
  1363.  
  1364. /* 滚动条样式 */
  1365. ::-webkit-scrollbar {
  1366. width: 8px;
  1367. }
  1368.  
  1369. ::-webkit-scrollbar-track {
  1370. background: #f1f1f1;
  1371. border-radius: 10px;
  1372. }
  1373.  
  1374. ::-webkit-scrollbar-thumb {
  1375. background: #c1c1c1;
  1376. border-radius: 10px;
  1377. }
  1378.  
  1379. ::-webkit-scrollbar-thumb:hover {
  1380. background: #a8a8a8;
  1381. }
  1382. `;
  1383. document.head.appendChild(style);
  1384.  
  1385. // 涟漪效果
  1386. const createRipple = e => {
  1387. const target = e.currentTarget;
  1388. const rect = target.getBoundingClientRect();
  1389. const size = Math.max(rect.width, rect.height);
  1390. const x = e.clientX - rect.left - size / 2;
  1391. const y = e.clientY - rect.top - size / 2;
  1392.  
  1393. const ripple = document.createElement('span');
  1394. ripple.className = 'ripple';
  1395. ripple.style.width = ripple.style.height = `${size}px`;
  1396. ripple.style.left = `${x}px`;
  1397. ripple.style.top = `${y}px`;
  1398.  
  1399. target.appendChild(ripple);
  1400. setTimeout(() => ripple.remove(), 600);
  1401. };
  1402.  
  1403. // 隐藏菜单
  1404. let hideTimeout;
  1405.  
  1406. const hideMenu = () => {
  1407. hideTimeout = setTimeout(() => {
  1408. menu.classList.remove('menu-enter');
  1409. menu.classList.add('menu-exit');
  1410. menu.style.opacity = 0;
  1411. menu.style.transform = 'translateY(10px) scaleY(0.95)';
  1412.  
  1413. setTimeout(() => {
  1414. menu.style.display = 'none';
  1415. menu.classList.remove('menu-exit');
  1416. isMenuVisible = false;
  1417. }, config.animation.duration * 1000);
  1418. }, 100);
  1419. };
  1420.  
  1421. let currentUrl;
  1422.  
  1423. // 动态生成菜单内容
  1424. const updateMenuContent = () => {
  1425. menuContent.innerHTML = '';
  1426.  
  1427. // 根据URL生成不同菜单项
  1428. currentUrl = window.location.href;
  1429. const menuItems = [];
  1430.  
  1431. if (!config.disclaimer) {
  1432. menuItems.push({
  1433. text: 'README', icon: ' 📄 ', action: readme, description: '阅读脚本说明和免责声明'
  1434. },);
  1435. } else if (currentUrl === "https://www.xiaohongshu.com/explore" || currentUrl.includes("https://www.xiaohongshu.com/explore?")) {
  1436. menuItems.push({
  1437. text: '提取推荐作品链接',
  1438. icon: ' ⛓ ',
  1439. action: () => extractAllLinksEvent(-1),
  1440. description: '提取当前页面的作品链接至剪贴板'
  1441. },);
  1442. } else if (currentUrl.includes("https://www.xiaohongshu.com/explore/")) {
  1443. menuItems.push({
  1444. text: '下载作品文件', icon: ' 📦 ', action: extractDownloadLinks, description: '下载当前作品的无水印文件'
  1445. },);
  1446. } else if (currentUrl.includes("https://www.xiaohongshu.com/user/profile/")) {
  1447. menuItems.push({
  1448. text: '提取发布作品链接',
  1449. icon: ' ⛓ ',
  1450. action: () => extractAllLinksEvent(0),
  1451. description: '提取账号发布作品链接至剪贴板'
  1452. }, {
  1453. text: '提取点赞作品链接',
  1454. icon: ' ⛓ ',
  1455. action: () => extractAllLinksEvent(2),
  1456. description: '提取账号点赞作品链接至剪贴板'
  1457. }, {
  1458. text: '提取收藏作品链接',
  1459. icon: ' ⛓ ',
  1460. action: () => extractAllLinksEvent(1),
  1461. description: '提取账号收藏作品链接至剪贴板'
  1462. },);
  1463. } else if (currentUrl.includes("https://www.xiaohongshu.com/search_result")) {
  1464. menuItems.push({
  1465. text: '提取作品链接', icon: ' ⛓ ', action: () => extractAllLinksEvent(3), description: '提取搜索结果的作品链接至剪贴板'
  1466. }, {
  1467. text: '提取用户链接', icon: ' ⛓ ', action: () => extractAllLinksEvent(4), description: '提取搜索结果的用户链接至剪贴板'
  1468. },);
  1469. } else if (currentUrl.includes("https://www.xiaohongshu.com/board/")) {
  1470. menuItems.push({
  1471. text: "提取专辑作品链接",
  1472. icon: ' ⛓ ',
  1473. action: () => extractAllLinksEvent(5),
  1474. description: '提取当前专辑的作品链接至剪贴板'
  1475. },);
  1476. }
  1477.  
  1478. // 常用功能
  1479. menuItems.push({
  1480. separator: true
  1481. }, {
  1482. text: '修改用户脚本设置', icon: ' ⚙️ ', action: showSettings, description: '修改用户脚本设置'
  1483. }, {
  1484. text: '访问项目开源仓库', icon: ' 📒 ', action: about, description: '访问项目 GitHub 开源仓库'
  1485. });
  1486.  
  1487. // 创建菜单项
  1488. menuItems.forEach(item => {
  1489. if (item.separator) {
  1490. const divider = document.createElement('div');
  1491. divider.style = `
  1492. height: 8px;
  1493. background: #f5f5f5;
  1494. `;
  1495. menuContent.appendChild(divider);
  1496. return;
  1497. }
  1498.  
  1499. const btn = document.createElement('div');
  1500. btn.className = 'menu-item';
  1501. btn.innerHTML = `
  1502. <div class="icon-container">
  1503. <span class="material-icons">${item.icon}</span>
  1504. </div>
  1505. <div class="content">
  1506. <div class="title">${item.text}</div>
  1507. <div class="subtitle">${item.description}</div>
  1508. </div>
  1509. `;
  1510.  
  1511. btn.addEventListener('click', (e) => {
  1512. e.stopPropagation();
  1513. item.action();
  1514. hideMenu();
  1515. });
  1516.  
  1517. btn.addEventListener('mousedown', createRipple);
  1518.  
  1519. menuContent.appendChild(btn);
  1520. });
  1521. };
  1522.  
  1523. // URL监测相关
  1524. let lastUrl = window.location.href;
  1525. let isMenuVisible = false;
  1526.  
  1527. // 显示菜单
  1528. const showMenu = () => {
  1529. clearTimeout(hideTimeout);
  1530. menu.style.display = 'block';
  1531. void menu.offsetHeight; // 触发重绘
  1532. menu.classList.add('menu-enter');
  1533. menu.style.opacity = 1;
  1534. menu.style.transform = 'translateY(0) scaleY(1)';
  1535. updateMenuContent();
  1536. isMenuVisible = true;
  1537. };
  1538.  
  1539. // 事件监听
  1540. const icon = createIcon();
  1541. icon.addEventListener('mouseenter', showMenu);
  1542. icon.addEventListener('mouseleave', hideMenu);
  1543. menu.addEventListener('mouseenter', () => clearTimeout(hideTimeout));
  1544. menu.addEventListener('mouseleave', hideMenu);
  1545.  
  1546. // URL变化监听
  1547. const setupUrlListener = () => {
  1548. const observeUrl = () => {
  1549. if (window.location.href !== lastUrl) {
  1550. lastUrl = window.location.href;
  1551. if (isMenuVisible) {
  1552. updateMenuContent();
  1553. }
  1554. }
  1555. requestAnimationFrame(observeUrl);
  1556. };
  1557. observeUrl();
  1558. };
  1559.  
  1560. // 添加到页面
  1561. document.body.appendChild(icon);
  1562. document.body.appendChild(menu);
  1563. document.head.appendChild(style);
  1564. setupUrlListener();
  1565. })();