98tangTo115

自动检测复制的 magnet/ed2k 链接,一键推送到 115 网盘离线下载

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==UserScript==
// @name         98tangTo115
// @namespace    http://tampermonkey.net/
// @version      1.1.6
// @description  自动检测复制的 magnet/ed2k 链接,一键推送到 115 网盘离线下载
// @author       gangz1o
// @match        *://*sehuatang.net/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_notification
// @grant        GM_addStyle
// @connect      115.com
// @connect      my.115.com
// @connect      webapi.115.com
// @license      MIT
// @run-at       document-start
// ==/UserScript==

;(function () {
	'use strict'

	// ========== 配置相关 ==========
	const CONFIG_KEYS = {
		SAVE_PATH: '115_save_path',
		SAVE_PATH_CID: '115_save_path_cid',
		AUTO_ORGANIZE: '115_auto_organize',
		AUTO_DELETE_SMALL: '115_auto_delete_small',
		DELETE_SIZE_THRESHOLD: '115_delete_size_threshold',
		PANEL_MINIMIZED: '115_panel_minimized',
	}

	// 默认配置
	const DEFAULT_CONFIG = {
		[CONFIG_KEYS.SAVE_PATH]: '离线下载',
		[CONFIG_KEYS.SAVE_PATH_CID]: '0',
		[CONFIG_KEYS.AUTO_ORGANIZE]: false,
		[CONFIG_KEYS.AUTO_DELETE_SMALL]: false,
		[CONFIG_KEYS.DELETE_SIZE_THRESHOLD]: 100,
		[CONFIG_KEYS.PANEL_MINIMIZED]: true,
	}

	// 视频文件扩展名
	const VIDEO_EXTENSIONS = [
		'.mp4',
		'.mkv',
		'.avi',
		'.wmv',
		'.mov',
		'.flv',
		'.rmvb',
		'.rm',
		'.ts',
		'.m2ts',
		'.webm',
		'.m4v',
		'.3gp',
		'.mpeg',
		'.mpg',
	]

	// 获取配置
	function getConfig(key) {
		return GM_getValue(key, DEFAULT_CONFIG[key])
	}

	// 设置配置
	function setConfig(key, value) {
		GM_setValue(key, value)
	}

	// 展开面板
	function expandPanel() {
		const panel = document.getElementById('push115-panel')
		if (panel && panel.classList.contains('minimized')) {
			panel.classList.remove('minimized')
			// 不保存状态,因为这是临时展开
		}
	}

	// 折叠面板
	function collapsePanel() {
		const panel = document.getElementById('push115-panel')
		if (panel && !panel.classList.contains('minimized')) {
			panel.classList.add('minimized')
			setConfig(CONFIG_KEYS.PANEL_MINIMIZED, true)
		}
	}

	// ========== 115 网盘 API ==========
	class API115 {
		constructor() {
			this.headers = {
				'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
				Accept: 'application/json, text/javascript, */*; q=0.01',
				Origin: 'https://115.com',
				'User-Agent':
					'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 115Browser/27.0.0',
				Referer: 'https://115.com/?cid=0&offset=0&mode=wangpan',
				'X-Requested-With': 'XMLHttpRequest',
			}
			this._uid = null
			this._sign = null
			this._time = null
		}

		// 通用请求方法
		request(url, method = 'GET', data = null) {
			return new Promise((resolve, reject) => {
				const options = {
					method: method,
					url: url,
					headers: this.headers,
					withCredentials: true,
					onload: response => {
						try {
							const result = JSON.parse(response.responseText)
							resolve(result)
						} catch (e) {
							reject(new Error('解析响应失败: ' + e.message))
						}
					},
					onerror: error => {
						reject(new Error('请求失败: ' + error.statusText))
					},
				}

				if (data) {
					if (typeof data === 'object') {
						options.data = new URLSearchParams(data).toString()
					} else {
						options.data = data
					}
				}

				GM_xmlhttpRequest(options)
			})
		}

		// 检查登录状态
		async checkLogin() {
			try {
				const result = await this.request('https://my.115.com/?ct=guide&ac=status')
				return result.state === true
			} catch (e) {
				console.error('检查登录状态失败:', e)
				return false
			}
		}

		// 获取 UID
		async getUid() {
			if (this._uid) return this._uid
			try {
				const result = await this.request('https://my.115.com/?ct=ajax&ac=get_user_aq')
				if (result.state === true && result.data && result.data.uid) {
					this._uid = result.data.uid
					return this._uid
				}
				throw new Error('获取 UID 失败')
			} catch (e) {
				throw new Error('获取 UID 失败: ' + e.message)
			}
		}

		// 获取 Sign 和 Time
		async getSignAndTime() {
			if (this._sign && this._time) {
				return { sign: this._sign, time: this._time }
			}
			try {
				const result = await this.request('https://115.com/?ct=offline&ac=space')
				if (result.state === true && result.sign && result.time) {
					this._sign = result.sign
					this._time = result.time
					return { sign: this._sign, time: this._time }
				}
				throw new Error('获取 Sign 和 Time 失败')
			} catch (e) {
				throw new Error('获取 Sign 和 Time 失败: ' + e.message)
			}
		}

		// 添加离线下载任务
		async addOfflineTask(url) {
			const isLoggedIn = await this.checkLogin()
			if (!isLoggedIn) {
				throw new Error('未登录 115 网盘,请先在 115.com 登录')
			}

			const uid = await this.getUid()
			const { sign, time } = await this.getSignAndTime()
			const cid = getConfig(CONFIG_KEYS.SAVE_PATH_CID)

			const formData = {
				url: url,
				savepath: '',
				wp_path_id: cid,
				uid: uid,
				sign: sign,
				time: time,
			}

			const result = await this.request('https://115.com/web/lixian/?ct=lixian&ac=add_task_url', 'POST', formData)

			if (result.state !== true) {
				throw new Error(result.error_msg || '添加任务失败')
			}

			return result
		}

		// 获取离线任务列表
		async getOfflineTasks() {
			const result = await this.request('https://115.com/web/lixian/?ct=lixian&ac=task_lists')
			if (result.state !== true) {
				throw new Error('获取任务列表失败')
			}
			return result.tasks || []
		}

		// 获取文件列表
		async getFileList(cid = '0') {
			const result = await this.request(
				`https://webapi.115.com/files?aid=1&cid=${cid}&o=user_ptime&asc=0&offset=0&show_dir=1&limit=500&snap=0&natsort=1`,
			)
			return result
		}

		// 创建文件夹
		async createFolder(parentCid, folderName) {
			const formData = {
				pid: parentCid,
				cname: folderName,
			}
			const result = await this.request('https://webapi.115.com/files/add', 'POST', formData)
			return result
		}

		// 移动文件
		async moveFile(fileId, targetCid) {
			const formData = {
				fid: fileId,
				pid: targetCid,
				move_proid: '',
			}
			const result = await this.request('https://webapi.115.com/files/move', 'POST', formData)
			return result
		}

		// 重命名文件或文件夹
		async renameFileOrFolder(fileId, newName) {
			if (!fileId || !newName) return { state: false, error: '参数错误' }
			const formData = {
				fid: fileId,
				name: newName,
			}
			const result = await this.request('https://webapi.115.com/files/edit', 'POST', formData)
			return result
		}

		// 删除文件
		async deleteFiles(fileIds) {
			if (!Array.isArray(fileIds)) {
				fileIds = [fileIds]
			}

			// 构建 fid[0]=xxx&fid[1]=yyy 格式的参数
			const params = new URLSearchParams()
			fileIds.forEach((fid, index) => {
				params.append(`fid[${index}]`, fid)
			})
			params.append('ignore_warn', '1')

			const result = await this.request('https://webapi.115.com/rb/delete', 'POST', params.toString())
			return result
		}

		// 清理小文件(递归扫描子文件夹)
		async cleanSmallFiles(cid, thresholdMB) {
			const thresholdBytes = thresholdMB * 1024 * 1024
			let allSmallFiles = []

			const scanFolder = async (folderId, depth = 0) => {
				if (depth > 3) return

				const fileList = await this.getFileList(folderId)
				if (!fileList.data || !Array.isArray(fileList.data)) return

				console.log(`[清理] 扫描目录,找到 ${fileList.data.length} 个项目`)

				for (const item of fileList.data) {
					const fileName = item.n || item.name || ''

					// 判断是否为文件夹(没有 sha 的是文件夹)
					if (!item.sha) {
						const folderCid = item.cid || item.fid
						if (folderCid && folderCid !== folderId) {
							console.log(`[清理] 进入子文件夹: ${fileName}`)
							await scanFolder(folderCid, depth + 1)
						}
						continue
					}

					// 是文件,检查大小
					const fileSize = item.size || item.s || 0

					if (fileSize > 0 && fileSize < thresholdBytes) {
						console.log(`[清理] 发现小文件: ${fileName} (${(fileSize / 1024).toFixed(2)} KB)`)
						allSmallFiles.push({ fid: item.fid, name: fileName, size: fileSize })
					}
				}
			}

			await scanFolder(cid)

			console.log(`[清理] 总共找到 ${allSmallFiles.length} 个小文件`)

			if (allSmallFiles.length === 0) {
				return { deleted: 0, files: [] }
			}

			const fileIds = allSmallFiles.map(f => f.fid)
			const fileNames = allSmallFiles.map(f => f.name)

			console.log('[清理] 准备删除文件 IDs:', fileIds)
			const deleteResult = await this.deleteFiles(fileIds)
			console.log('[清理] 删除 API 返回:', deleteResult)

			if (deleteResult.state !== true) {
				console.error('[清理] 删除失败:', deleteResult)
				throw new Error(deleteResult.error || '删除失败')
			}

			return { deleted: allSmallFiles.length, files: fileNames }
		}

		// 整理视频文件到独立文件夹
		async organizeVideosToFolders(sourceCid, currentFolderName = '', targetParentCid = null) {
			const fileList = await this.getFileList(sourceCid)

			if (!fileList.data || !Array.isArray(fileList.data)) {
				console.log('[整理] 未找到文件列表数据')
				return { organized: 0, files: [] }
			}

			// 筛选直接在目录下的视频文件(不在子文件夹中的)
			const videoFiles = fileList.data.filter(f => {
				// 文件必须有 sha(文件夹没有)
				if (!f.sha) return false
				// 检查是否为视频文件
				const fileName = (f.n || f.name || '').toLowerCase()
				return VIDEO_EXTENSIONS.some(ext => fileName.endsWith(ext))
			})

			console.log(`[整理] 找到 ${videoFiles.length} 个视频文件需要整理`)

			if (videoFiles.length === 0) {
				return { organized: 0, files: [] }
			}

			let organizedCount = 0
			const organizedFiles = []

			for (const video of videoFiles) {
				try {
					const fileName = video.n || video.name
					const extMatch = fileName.match(/\.[^.]+$/)
					const ext = extMatch ? extMatch[0] : ''
					const code = extractVideoCode(fileName)
					const folderName = code || fileName.replace(/\.[^.]+$/, '').toUpperCase()
					const currentNameNormalized = normalizeCode(currentFolderName)
					const folderNameNormalized = normalizeCode(folderName)
					const shouldSkipFolder =
						targetParentCid === null &&
						currentNameNormalized &&
						folderNameNormalized &&
						(currentNameNormalized === folderNameNormalized ||
							currentNameNormalized.includes(folderNameNormalized) ||
							folderNameNormalized.includes(currentNameNormalized))

					let canRename = false
					if (shouldSkipFolder) {
						console.log(`[整理] 当前目录已包含目标名称,跳过创建子文件夹: ${fileName}`)
						canRename = true
					} else {
						console.log(`[整理] 处理视频: ${fileName} -> 文件夹: ${folderName}`)

						const parentCid = targetParentCid || sourceCid
						const createResult = await this.createFolder(parentCid, folderName)

						let targetCid
						if (createResult.cid) {
							targetCid = createResult.cid
						} else if (createResult.file_id) {
							targetCid = createResult.file_id
						} else {
							const updatedList = await this.getFileList(parentCid)
							const folder = updatedList.data?.find(f => !f.sha && (f.n || f.name)?.toUpperCase() === folderName)
							if (folder) {
								targetCid = folder.cid || folder.fid
							} else {
								console.log(`[整理] 无法获取文件夹 CID`)
								targetCid = null
							}
						}

						if (targetCid) {
							const moveResult = await this.moveFile(video.fid, targetCid)
							if (moveResult.state === true) {
								organizedCount++
								organizedFiles.push(fileName)
								console.log(`[整理] 成功: ${fileName} -> ${folderName}/`)
								canRename = true
							} else {
								console.log(`[整理] 移动文件失败:`, moveResult)
							}
						}
					}

					if (code && canRename) {
						const newName = `${code}${ext}`
						if (newName && newName !== fileName) {
							const renameResult = await this.renameFileOrFolder(video.fid, newName)
							if (renameResult.state === true) {
								console.log(`[整理] 重命名成功: ${fileName} -> ${newName}`)
							} else {
								console.log(`[整理] 重命名失败:`, renameResult)
							}
						}
					}

					// 短暂延迟
					await new Promise(resolve => setTimeout(resolve, 500))
				} catch (e) {
					console.error(`[整理] 处理视频失败:`, e)
				}
			}

			return { organized: organizedCount, files: organizedFiles }
		}
	}

	const api = new API115()

	// ========== UI 相关 ==========
	// Apple Liquid Glass 风格样式
	GM_addStyle(`
      .push115-panel {
          position: fixed;
          bottom: 20px;
          right: 20px;
          width: 300px;
          background: rgba(255, 255, 255, 0.72);
          backdrop-filter: blur(40px) saturate(180%);
          -webkit-backdrop-filter: blur(40px) saturate(180%);
          border-radius: 20px;
          border: 1px solid rgba(255, 255, 255, 0.5);
          box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04);
          z-index: 999999;
          font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif;
          overflow: hidden;
          transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
      }

      .push115-panel.minimized {
          width: 48px;
          height: 48px;
          border-radius: 24px;
          cursor: pointer;
      }

      .push115-panel.minimized .push115-content,
      .push115-panel.minimized .push115-header {
          display: none;
      }

      .push115-panel.minimized .push115-min-icon {
          display: flex;
      }

      .push115-min-icon {
          display: none;
          width: 48px;
          height: 48px;
          align-items: center;
          justify-content: center;
          font-size: 20px;
          background: rgba(0, 122, 255, 0.1);
          color: #007AFF;
      }

      .push115-header {
          background: rgba(255, 255, 255, 0.5);
          border-bottom: 1px solid rgba(0, 0, 0, 0.06);
          padding: 14px 16px;
          display: flex;
          justify-content: space-between;
          align-items: center;
      }

      .push115-header-title {
          font-size: 15px;
          font-weight: 600;
          color: #1d1d1f;
          letter-spacing: -0.2px;
      }

      .push115-header-btns {
          display: flex;
          gap: 8px;
      }

      .push115-header-btn {
          background: rgba(0, 0, 0, 0.04);
          border: none;
          color: #8e8e93;
          width: 26px;
          height: 26px;
          border-radius: 13px;
          cursor: pointer;
          display: flex;
          align-items: center;
          justify-content: center;
          font-size: 14px;
          font-weight: 500;
          transition: all 0.2s;
      }

      .push115-header-btn:hover {
          background: rgba(0, 0, 0, 0.08);
          color: #1d1d1f;
      }

      .push115-content {
          padding: 16px;
      }

      .push115-section {
          margin-bottom: 16px;
      }

      .push115-section:last-child {
          margin-bottom: 0;
      }

      .push115-label {
          display: block;
          font-size: 13px;
          font-weight: 500;
          color: #86868b;
          margin-bottom: 8px;
      }

      .push115-input {
          width: 100%;
          padding: 10px 14px;
          border: 1px solid rgba(0, 0, 0, 0.08);
          border-radius: 10px;
          font-size: 14px;
          background: rgba(255, 255, 255, 0.8);
          box-sizing: border-box;
          transition: all 0.2s;
          color: #1d1d1f;
      }

      .push115-input:focus {
          outline: none;
          border-color: #007AFF;
          box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.15);
      }

      .push115-hint {
          font-size: 12px;
          color: #86868b;
          margin-top: 6px;
      }

      .push115-checkbox-group {
          display: flex;
          align-items: center;
          gap: 10px;
      }

      .push115-checkbox {
          width: 20px;
          height: 20px;
          cursor: pointer;
          accent-color: #007AFF;
      }

      .push115-checkbox-label {
          font-size: 14px;
          color: #1d1d1f;
          cursor: pointer;
      }

      .push115-row {
          display: flex;
          align-items: center;
          gap: 8px;
          font-size: 13px;
          color: #86868b;
      }

      .push115-row .push115-input {
          text-align: center;
      }

      .push115-status {
          padding: 12px 14px;
          border-radius: 12px;
          font-size: 13px;
          font-weight: 500;
          margin-bottom: 12px;
          animation: statusSlide 0.3s ease;
      }

      @keyframes statusSlide {
          from { opacity: 0; transform: translateY(-8px); }
          to { opacity: 1; transform: translateY(0); }
      }

      .push115-status.success {
          background: rgba(52, 199, 89, 0.12);
          color: #248a3d;
      }

      .push115-status.error {
          background: rgba(255, 59, 48, 0.12);
          color: #d70015;
      }

      .push115-status.info {
          background: rgba(0, 122, 255, 0.12);
          color: #0066cc;
      }

      .push115-status.warning {
          background: rgba(255, 149, 0, 0.12);
          color: #c93400;
      }

      .push115-divider {
          height: 1px;
          background: rgba(0, 0, 0, 0.06);
          margin: 16px 0;
      }

      .push115-progress {
          margin-bottom: 12px;
      }

      .push115-progress.hidden {
          display: none;
      }

      .push115-progress-text {
          font-size: 12px;
          color: #86868b;
          margin-bottom: 6px;
      }

      .push115-progress-track {
          width: 100%;
          height: 8px;
          border-radius: 999px;
          background: rgba(0, 0, 0, 0.06);
          overflow: hidden;
          position: relative;
      }

      .push115-progress-bar {
          height: 100%;
          width: 30%;
          border-radius: 999px;
          background: linear-gradient(90deg, rgba(0, 122, 255, 0.2), rgba(0, 122, 255, 0.9));
          animation: progressIndeterminate 1.2s ease-in-out infinite;
      }

      .push115-progress-bar.determinate {
          animation: none;
      }

      @keyframes progressIndeterminate {
          0% { transform: translateX(-60%); }
          50% { transform: translateX(120%); }
          100% { transform: translateX(240%); }
      }

      /* 确认弹窗 */
      .push115-modal-overlay {
          position: fixed;
          top: 0;
          left: 0;
          right: 0;
          bottom: 0;
          background: rgba(0, 0, 0, 0.4);
          backdrop-filter: blur(8px);
          -webkit-backdrop-filter: blur(8px);
          z-index: 9999999;
          display: flex;
          align-items: center;
          justify-content: center;
          animation: modalFade 0.2s ease;
      }

      @keyframes modalFade {
          from { opacity: 0; }
          to { opacity: 1; }
      }

      .push115-modal {
          background: rgba(255, 255, 255, 0.85);
          backdrop-filter: blur(40px) saturate(180%);
          -webkit-backdrop-filter: blur(40px) saturate(180%);
          border-radius: 20px;
          border: 1px solid rgba(255, 255, 255, 0.5);
          width: 380px;
          max-width: 90vw;
          box-shadow: 0 24px 80px rgba(0, 0, 0, 0.15);
          animation: modalSlide 0.3s cubic-bezier(0.4, 0, 0.2, 1);
          overflow: hidden;
      }

      @keyframes modalSlide {
          from {
              opacity: 0;
              transform: scale(0.95) translateY(10px);
          }
          to {
              opacity: 1;
              transform: scale(1) translateY(0);
          }
      }

      .push115-modal-header {
          background: rgba(255, 255, 255, 0.5);
          border-bottom: 1px solid rgba(0, 0, 0, 0.06);
          padding: 18px 20px;
      }

      .push115-modal-title {
          font-size: 17px;
          font-weight: 600;
          color: #1d1d1f;
          margin: 0;
          letter-spacing: -0.2px;
      }

      .push115-modal-body {
          padding: 20px;
      }

      .push115-modal-link {
          background: rgba(0, 0, 0, 0.04);
          padding: 12px 14px;
          border-radius: 10px;
          word-break: break-all;
          font-size: 12px;
          color: #1d1d1f;
          max-height: 70px;
          overflow-y: auto;
          margin-bottom: 16px;
          font-family: 'SF Mono', Monaco, monospace;
      }

      .push115-modal-info {
          font-size: 14px;
          color: #86868b;
          margin-bottom: 8px;
          display: flex;
          align-items: center;
          gap: 6px;
      }

      .push115-modal-footer {
          padding: 16px 20px;
          display: flex;
          gap: 10px;
          justify-content: flex-end;
          background: rgba(0, 0, 0, 0.02);
      }

      .push115-btn {
          padding: 10px 20px;
          border: none;
          border-radius: 10px;
          font-size: 14px;
          font-weight: 500;
          cursor: pointer;
          transition: all 0.2s;
      }

      .push115-btn-primary {
          background: #007AFF;
          color: white;
      }

      .push115-btn-primary:hover {
          background: #0066d6;
      }

      .push115-btn-primary:active {
          transform: scale(0.98);
      }

      .push115-btn-secondary {
          background: rgba(0, 0, 0, 0.05);
          color: #007AFF;
      }

      .push115-btn-secondary:hover {
          background: rgba(0, 0, 0, 0.08);
      }

      .push115-btn:disabled {
          opacity: 0.5;
          cursor: not-allowed;
      }

      .push115-loading {
          display: inline-block;
          width: 14px;
          height: 14px;
          border: 2px solid rgba(255, 255, 255, 0.3);
          border-top-color: white;
          border-radius: 50%;
          animation: spin 0.8s linear infinite;
          margin-right: 8px;
      }

      @keyframes spin {
          to { transform: rotate(360deg); }
      }
  `)

	// 创建配置面板
	function createConfigPanel() {
		const panel = document.createElement('div')
		panel.className = 'push115-panel'
		panel.id = 'push115-panel'

		panel.innerHTML = `
          <div class="push115-min-icon" title="点击展开">📥</div>
          <div class="push115-header">
              <span class="push115-header-title">115 离线下载</span>
              <div class="push115-header-btns">
                  <button class="push115-header-btn" id="push115-minimize" title="最小化">−</button>
              </div>
          </div>
          <div class="push115-content">
              <div id="push115-status-area"></div>
              <div id="push115-progress" class="push115-progress hidden">
                  <div class="push115-progress-text" id="push115-progress-text">处理中...</div>
                  <div class="push115-progress-track">
                      <div class="push115-progress-bar" id="push115-progress-bar"></div>
                  </div>
              </div>

              <div class="push115-section">
                  <label class="push115-label">离线保存路径(格式:名称:CID)</label>
                  <input type="text" class="push115-input" id="push115-path-combo" placeholder="例如:98预处理:3280039214730565554">
                  <div class="push115-hint">CID 可在 115 网盘 URL 中找到,0 表示根目录</div>
              </div>

              <div class="push115-divider"></div>

              <div class="push115-section">
                  <div class="push115-checkbox-group">
                      <input type="checkbox" class="push115-checkbox" id="push115-auto-delete">
                      <label class="push115-checkbox-label" for="push115-auto-delete">自动删除小文件</label>
                  </div>
              </div>

              <div class="push115-section" id="push115-delete-section" style="display: none;">
                  <div class="push115-row">
                      <span>删除小于</span>
                      <input type="number" class="push115-input" id="push115-delete-size" style="width: 80px;" min="1" value="100">
                      <span>MB 的文件</span>
                  </div>
              </div>

              <div class="push115-section">
                  <div class="push115-checkbox-group">
                      <input type="checkbox" class="push115-checkbox" id="push115-auto-organize">
                      <label class="push115-checkbox-label" for="push115-auto-organize">自动整理视频到文件夹</label>
                  </div>
                  <div class="push115-hint">将视频移动到以文件名命名的文件夹中</div>
              </div>

              <div class="push115-divider"></div>

              <div class="push115-section">
                  <button class="push115-btn push115-btn-primary" id="push115-check-login" style="width: 100%;">
                      检查 115 登录状态
                  </button>
              </div>
          </div>
      `

		document.body.appendChild(panel)

		// 根据保存的状态设置面板折叠状态
		if (getConfig(CONFIG_KEYS.PANEL_MINIMIZED)) {
			panel.classList.add('minimized')
		}

		// 初始化配置值
		const savedPath = getConfig(CONFIG_KEYS.SAVE_PATH)
		const savedCid = getConfig(CONFIG_KEYS.SAVE_PATH_CID)
		if (savedPath && savedCid && savedCid !== '0') {
			document.getElementById('push115-path-combo').value = `${savedPath}:${savedCid}`
		} else if (savedCid && savedCid !== '0') {
			document.getElementById('push115-path-combo').value = savedCid
		}

		document.getElementById('push115-auto-organize').checked = getConfig(CONFIG_KEYS.AUTO_ORGANIZE)
		document.getElementById('push115-auto-delete').checked = getConfig(CONFIG_KEYS.AUTO_DELETE_SMALL)
		document.getElementById('push115-delete-size').value = getConfig(CONFIG_KEYS.DELETE_SIZE_THRESHOLD)

		// 显示/隐藏删除设置
		if (getConfig(CONFIG_KEYS.AUTO_DELETE_SMALL)) {
			document.getElementById('push115-delete-section').style.display = 'block'
		}

		// 绑定事件
		bindPanelEvents()

		return panel
	}

	// 绑定面板事件
	function bindPanelEvents() {
		// 最小化按钮
		document.getElementById('push115-minimize').addEventListener('click', () => {
			document.getElementById('push115-panel').classList.add('minimized')
			setConfig(CONFIG_KEYS.PANEL_MINIMIZED, true)
		})

		// 点击最小化图标恢复
		document.querySelector('.push115-min-icon').addEventListener('click', () => {
			document.getElementById('push115-panel').classList.remove('minimized')
			setConfig(CONFIG_KEYS.PANEL_MINIMIZED, false)
		})

		// 保存路径(名称:CID 格式)
		document.getElementById('push115-path-combo').addEventListener('change', e => {
			const value = e.target.value.trim()
			if (value.includes(':')) {
				const parts = value.split(':')
				const name = parts.slice(0, -1).join(':')
				const cid = parts[parts.length - 1]
				setConfig(CONFIG_KEYS.SAVE_PATH, name)
				setConfig(CONFIG_KEYS.SAVE_PATH_CID, cid || '0')
			} else if (/^\d+$/.test(value)) {
				setConfig(CONFIG_KEYS.SAVE_PATH, '')
				setConfig(CONFIG_KEYS.SAVE_PATH_CID, value)
			} else {
				setConfig(CONFIG_KEYS.SAVE_PATH, value)
				setConfig(CONFIG_KEYS.SAVE_PATH_CID, '0')
			}
		})

		// 自动删除开关
		document.getElementById('push115-auto-delete').addEventListener('change', e => {
			setConfig(CONFIG_KEYS.AUTO_DELETE_SMALL, e.target.checked)
			document.getElementById('push115-delete-section').style.display = e.target.checked ? 'block' : 'none'
		})

		// 删除阈值
		document.getElementById('push115-delete-size').addEventListener('change', e => {
			setConfig(CONFIG_KEYS.DELETE_SIZE_THRESHOLD, parseInt(e.target.value) || 100)
		})

		// 自动整理开关
		document.getElementById('push115-auto-organize').addEventListener('change', e => {
			setConfig(CONFIG_KEYS.AUTO_ORGANIZE, e.target.checked)
		})

		// 检查登录状态
		document.getElementById('push115-check-login').addEventListener('click', async () => {
			const btn = document.getElementById('push115-check-login')
			btn.disabled = true
			btn.innerHTML = '<span class="push115-loading"></span>检查中...'

			try {
				const isLoggedIn = await api.checkLogin()
				if (isLoggedIn) {
					showStatus('success', '✅ 已登录 115 网盘')
				} else {
					showStatus('error', '❌ 未登录,请先访问 115.com 登录')
				}
			} catch (e) {
				showStatus('error', '检查失败: ' + e.message)
			} finally {
				btn.disabled = false
				btn.textContent = '检查 115 登录状态'
			}
		})
	}

	// 显示状态信息
	function showStatus(type, message, durationMs = 5000) {
		const statusArea = document.getElementById('push115-status-area')
		statusArea.innerHTML = `<div class="push115-status ${type}">${message}</div>`
		if (durationMs > 0) {
			setTimeout(() => {
				statusArea.innerHTML = ''
			}, durationMs)
		}
	}

	function setProcessingState(active, message = '') {
		const progressEl = document.getElementById('push115-progress')
		const textEl = document.getElementById('push115-progress-text')
		const barEl = document.getElementById('push115-progress-bar')
		if (!progressEl || !textEl || !barEl) return

		if (!active) {
			progressEl.classList.add('hidden')
			return
		}

		progressEl.classList.remove('hidden')
		textEl.textContent = message || '处理中...'
		barEl.classList.remove('determinate')
		barEl.style.width = '30%'
	}

	function extractVideoCode(rawName) {
		const withoutExt = (rawName || '').toString().replace(/\.[^.]+$/, '')
		const cleaned = withoutExt
			.toUpperCase()
			.replace(/\[[^\]]*\]/g, ' ')
			.replace(/【[^】]*】/g, ' ')
			.replace(/\([^\)]*\)/g, ' ')
			.replace(/[^A-Z0-9]+/g, ' ')
		const matches = cleaned.match(/[A-Z]{2,6}-\d{2,5}(?:-[A-Z])?/g)
		if (matches && matches.length > 0) return matches[0]

		const compact = cleaned.replace(/\s+/g, '')
		const fallback = compact.match(/([A-Z]{2,6})(\d{2,5})([A-Z]?)/)
		if (!fallback) return ''
		const suffix = fallback[3] ? `-${fallback[3]}` : ''
		return `${fallback[1]}-${fallback[2]}${suffix}`
	}

	function normalizeCode(value) {
		return (value || '')
			.toString()
			.toUpperCase()
			.replace(/\[[^\]]*\]/g, '')
			.replace(/【[^】]*】/g, '')
			.replace(/\([^\)]*\)/g, '')
			.replace(/[^A-Z0-9]+/g, '')
	}

	// 创建确认弹窗
	function createConfirmModal(linkUrl, linkType) {
		const existingModal = document.getElementById('push115-modal-overlay')
		if (existingModal) {
			existingModal.remove()
		}

		const overlay = document.createElement('div')
		overlay.className = 'push115-modal-overlay'
		overlay.id = 'push115-modal-overlay'

		const savePath = getConfig(CONFIG_KEYS.SAVE_PATH)
		const savePathCid = getConfig(CONFIG_KEYS.SAVE_PATH_CID)
		const autoOrganize = getConfig(CONFIG_KEYS.AUTO_ORGANIZE)
		const autoDelete = getConfig(CONFIG_KEYS.AUTO_DELETE_SMALL)
		const deleteSize = getConfig(CONFIG_KEYS.DELETE_SIZE_THRESHOLD)
		const displayPath = savePath || (savePathCid === '0' ? '根目录' : `CID: ${savePathCid}`)

		const hasAutoTasks = autoOrganize || autoDelete

		overlay.innerHTML = `
          <div class="push115-modal">
              <div class="push115-modal-header">
                  <h3 class="push115-modal-title">📥 推送到 115 网盘</h3>
              </div>
              <div class="push115-modal-body">
                  <div class="push115-modal-info">检测到 <strong>${linkType}</strong> 链接:</div>
                  <div class="push115-modal-link">${linkUrl}</div>
                  <div class="push115-modal-info">保存路径:<strong>${displayPath}</strong></div>
                  ${autoDelete ? `<div class="push115-modal-info">🗑️ 自动删除小于 ${deleteSize}MB 的文件</div>` : ''}
                  ${autoOrganize ? `<div class="push115-modal-info">📁 自动整理视频到文件夹</div>` : ''}
                  ${hasAutoTasks ? `<div class="push115-modal-info" style="color: #e65100;">⚠️ 请保持此页面打开直到处理完成</div>` : ''}
              </div>
              <div class="push115-modal-footer">
                  <button class="push115-btn push115-btn-secondary" id="push115-modal-cancel">取消</button>
                  <button class="push115-btn push115-btn-primary" id="push115-modal-confirm">确定推送</button>
              </div>
          </div>
      `

		document.body.appendChild(overlay)

		// 绑定事件
		document.getElementById('push115-modal-cancel').addEventListener('click', () => {
			overlay.remove()
		})

		document.getElementById('push115-modal-confirm').addEventListener('click', async () => {
			// 展开面板以便查看任务进度
			expandPanel()
			
			const confirmBtn = document.getElementById('push115-modal-confirm')
			confirmBtn.disabled = true
			confirmBtn.innerHTML = '<span class="push115-loading"></span>推送中...'

			try {
				const result = await api.addOfflineTask(linkUrl)
				overlay.remove()
				showStatus('success', `✅ 推送成功: ${result.name || '离线任务已添加'}`)
				GM_notification({
					title: '115 离线下载',
					text: `推送成功: ${result.name || '任务已添加'}`,
					timeout: 3000,
				})

				// 如果开启了自动处理,启动监控
				const autoOrganize = getConfig(CONFIG_KEYS.AUTO_ORGANIZE)
				const autoDelete = getConfig(CONFIG_KEYS.AUTO_DELETE_SMALL)
				if (autoOrganize || autoDelete) {
					const taskMeta = {
						id: result.info_hash || result.name || linkUrl,
						name: result.name || '',
					}
					monitorAndProcess(taskMeta)
				} else {
					// 如果没有自动处理任务,推送成功后延迟 3 秒折叠面板
					setTimeout(() => {
						collapsePanel()
					}, 3000)
				}
			} catch (e) {
				confirmBtn.disabled = false
				confirmBtn.textContent = '确定推送'
				showStatus('error', '❌ 推送失败: ' + e.message)
			}
		})

		// 点击遮罩关闭
		overlay.addEventListener('click', e => {
			if (e.target === overlay) {
				overlay.remove()
			}
		})

		// ESC 关闭
		const escHandler = e => {
			if (e.key === 'Escape') {
				overlay.remove()
				document.removeEventListener('keydown', escHandler)
			}
		}
		document.addEventListener('keydown', escHandler)
	}

	// ========== 监控任务并处理 ==========
	async function monitorAndProcess(taskMeta) {
		const taskId = taskMeta?.id || taskMeta
		let taskName = taskMeta?.name || ''
		let taskFileCid = ''

		console.log(`[监控] 开始监控任务: ${taskId}`)
		showStatus('warning', '⏳ 正在监控离线任务,完成后自动处理\n请保持此页面打开!', 0)
		setProcessingState(true, '正在监控任务...')

		const maxAttempts = 120
		const interval = 10000
		let attempts = 0
		let completed = false

		const checkTask = async () => {
			attempts++
			if (attempts > maxAttempts) {
				showStatus('error', '监控超时,请手动检查', 0)
				setProcessingState(false)
				setTimeout(() => {
					collapsePanel()
				}, 3000)
				return
			}

			try {
				const tasks = await api.getOfflineTasks()
				const task = tasks.find(t => t.info_hash === taskId || t.name === taskId)

				console.log(`[监控] 第 ${attempts} 次检查`)
				if (task) {
					showStatus('info', '⏳ 任务进行中...', 0)
					setProcessingState(true, '任务进行中...')
				} else {
					showStatus('info', `⏳ 正在监控任务...(第 ${attempts} 次检查)`, 0)
					setProcessingState(true, `正在监控任务...(第 ${attempts} 次检查)`)
				}

				if (task) {
					if (!taskName && task.name) {
						taskName = task.name
					}
					if (!taskFileCid) {
						taskFileCid = task.file_id || task.fileId || task.dir_id || task.dirId || task.wppath_id || ''
					}
					if (task.status === 2 || task.percentDone === 100) {
						if (!completed) {
							completed = true
							showStatus('info', '✅ 任务完成,开始处理文件...', 0)
							setProcessingState(true, '任务完成,开始处理文件...')
							setTimeout(() => processFiles({ taskName, taskFileCid }), 5000)
						}
						return
					}
					if (task.status === -1) {
						showStatus('error', '离线任务失败', 0)
						setProcessingState(false)
						setTimeout(() => {
							collapsePanel()
						}, 3000)
						return
					}
				} else {
					if (!completed) {
						completed = true
						setProcessingState(true, '任务完成,开始处理文件...')
						setTimeout(() => processFiles({ taskName, taskFileCid }), 5000)
					}
					return
				}

				setTimeout(checkTask, interval)
			} catch (e) {
				console.error('[监控] 检查失败:', e)
				setTimeout(checkTask, interval)
			}
		}

		const resolveTaskFolderCid = async (saveCid, name, fileCid) => {
			if (fileCid) {
				return { cid: fileCid, found: true, label: '任务文件夹' }
			}
			if (!name) {
				return { cid: saveCid, found: false, label: '保存路径' }
			}

			const list = await api.getFileList(saveCid)
			if (!list.data || !Array.isArray(list.data)) {
				return { cid: saveCid, found: false, label: '保存路径' }
			}

			const normalize = v => (v || '').toString().trim().toLowerCase()
			const taskNameNorm = normalize(name)
			const folders = list.data.filter(item => !item.sha)

			const exact = folders.find(item => normalize(item.n || item.name) === taskNameNorm)
			if (exact) {
				return { cid: exact.cid || exact.fid, found: true, label: '任务文件夹' }
			}

			const fuzzy = folders.find(item => normalize(item.n || item.name).includes(taskNameNorm))
			if (fuzzy) {
				return { cid: fuzzy.cid || fuzzy.fid, found: true, label: '任务文件夹' }
			}

			return { cid: saveCid, found: false, label: '保存路径' }
		}

		const resolveFolderNameByCid = async (parentCid, folderCid) => {
			if (!parentCid || !folderCid) return ''
			const list = await api.getFileList(parentCid)
			if (!list.data || !Array.isArray(list.data)) return ''
			const folder = list.data.find(item => !item.sha && (item.cid === folderCid || item.fid === folderCid))
			return folder ? folder.n || folder.name || '' : ''
		}

		const processFiles = async (context = {}) => {
			const saveCid = getConfig(CONFIG_KEYS.SAVE_PATH_CID)
			const autoDelete = getConfig(CONFIG_KEYS.AUTO_DELETE_SMALL)
			const autoOrganize = getConfig(CONFIG_KEYS.AUTO_ORGANIZE)
			let messages = []

			showStatus('info', '⚙️ 正在处理文件...', 0)
			setProcessingState(true, '正在处理文件...')

			try {
				const targetInfo = await resolveTaskFolderCid(saveCid, context.taskName, context.taskFileCid)
				if (!targetInfo.found) {
					showStatus('warning', '未找到本次任务的文件夹,已跳过自动处理(避免扫描整个目录)', 0)
					setProcessingState(false)
					setTimeout(() => {
						collapsePanel()
					}, 3000)
					return
				}

				const cid = targetInfo.cid
				console.log(`[处理] 仅处理${targetInfo.label}: CID=${cid}`)
				const rawTaskName =
					context.taskName || (await resolveFolderNameByCid(saveCid, context.taskFileCid || cid)) || ''
				const taskCode = extractVideoCode(rawTaskName)
				const taskNameNorm = normalizeCode(rawTaskName)
				const taskCodeNorm = normalizeCode(taskCode || '')
				const useParentCid = taskCode && taskNameNorm && taskCodeNorm && taskNameNorm !== taskCodeNorm
				const organizeParentCid = useParentCid ? saveCid : null
				const organizeFolderName = useParentCid ? '' : rawTaskName || ''
				if (taskCode && taskNameNorm && taskCodeNorm && taskNameNorm !== taskCodeNorm) {
					const folderId = context.taskFileCid || cid
					const renameResult = await api.renameFileOrFolder(folderId, taskCode)
					if (renameResult.state === true) {
						console.log(`[整理] 已重命名任务文件夹: ${rawTaskName || ''} -> ${taskCode}`)
					} else {
						console.log(`[整理] 重命名任务文件夹失败:`, renameResult)
					}
				}

				// 1. 先删除小文件
				if (autoDelete) {
					const threshold = getConfig(CONFIG_KEYS.DELETE_SIZE_THRESHOLD)
					console.log(`[清理] 开始删除小于 ${threshold}MB 的文件`)
					const result = await api.cleanSmallFiles(cid, threshold)
					if (result.deleted > 0) {
						messages.push(`删除 ${result.deleted} 个小文件`)
						console.log(`[清理] 已删除:`, result.files)
					}
				}

				// 2. 再整理视频
				if (autoOrganize) {
					console.log(`[整理] 开始整理视频`)
					const result = await api.organizeVideosToFolders(cid, organizeFolderName, organizeParentCid)
					if (result.organized > 0) {
						messages.push(`整理 ${result.organized} 个视频`)
						console.log(`[整理] 已整理:`, result.files)
					}
				}

				if (messages.length > 0) {
					showStatus('success', `✅ ${messages.join(',')}`)
					setProcessingState(false)
					GM_notification({
						title: '115 处理完成',
						text: messages.join(','),
						timeout: 5000,
					})
				} else {
					showStatus('info', '处理完成,没有需要处理的内容')
					setProcessingState(false)
				}
				// 任务处理完成后,延迟 3 秒折叠面板
				setTimeout(() => {
					collapsePanel()
				}, 3000)
			} catch (e) {
				console.error('[处理] 失败:', e)
				showStatus('error', '处理失败: ' + e.message)
				setProcessingState(false)
				// 任务处理失败后,延迟 3 秒折叠面板
				setTimeout(() => {
					collapsePanel()
				}, 3000)
			}
		}

		setTimeout(checkTask, 20000)
	}

	// ========== 链接检测 ==========
	function isMagnetLink(text) {
		return /^magnet:\?xt=urn:[a-z0-9]+:[a-z0-9]{32,}/i.test(text)
	}

	function isEd2kLink(text) {
		return /^ed2k:\/\/\|file\|/i.test(text)
	}

	function getLinkType(text) {
		if (isMagnetLink(text)) return 'Magnet'
		if (isEd2kLink(text)) return 'ED2K'
		return null
	}

	// 监听复制事件
	function setupCopyListener() {
		document.addEventListener('copy', () => {
			setTimeout(async () => {
				try {
					const text = await navigator.clipboard.readText()
					const linkType = getLinkType(text.trim())
					if (linkType) {
						createConfirmModal(text.trim(), linkType)
					}
				} catch (e) {
					const selection = window.getSelection()
					if (selection) {
						const text = selection.toString().trim()
						const linkType = getLinkType(text)
						if (linkType) {
							createConfirmModal(text, linkType)
						}
					}
				}
			}, 100)
		})
	}

	// ========== 初始化 ==========
	function init() {
		// 防止重复初始化
		if (document.getElementById('push115-panel')) return
		
		createConfigPanel()
		setupCopyListener()
		console.log('[sehuatang to 115] 插件已加载')
	}

	// 尽早初始化:DOM 准备好就执行
	function earlyInit() {
		if (document.body) {
			init()
		} else {
			// 如果 body 还不存在,使用 MutationObserver 监听
			const observer = new MutationObserver((mutations, obs) => {
				if (document.body) {
					obs.disconnect()
					init()
				}
			})
			observer.observe(document.documentElement, { childList: true })
		}
	}

	earlyInit()
})()