├── .browserslistrc ├── .eslintrc.js ├── .github └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── README.md ├── auto-imports.d.ts ├── babel.config.js ├── components.d.ts ├── consts.js ├── ipcMain.js ├── logo.ico ├── main.js ├── package-lock.json ├── package.json ├── preload.js ├── public ├── favicon.ico └── index.html ├── qqCore.js ├── src ├── App.vue ├── assets │ └── logo.png ├── components │ ├── DownloadPage.vue │ ├── InputGroup.vue │ └── SelectAlbum.vue ├── main.ts ├── router │ └── index.ts ├── shims-vue.d.ts └── utils.ts ├── tsconfig.json ├── vue.config.js └── yarn.lock /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | not ie 11 5 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | extends: [ 7 | "plugin:vue/vue3-essential", 8 | "eslint:recommended", 9 | "@vue/typescript/recommended", 10 | "plugin:prettier/recommended", 11 | ], 12 | parserOptions: { 13 | ecmaVersion: 2020, 14 | }, 15 | rules: { 16 | "no-undef":"off", 17 | "prettier/prettier": "off", 18 | "no-console": process.env.NODE_ENV === "production" ? "warn" : "off", 19 | "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off", 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - "v*.*.*" 8 | 9 | jobs: 10 | release: 11 | name: build and release electron app 12 | runs-on: ${{ matrix.os }} 13 | 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: [windows-latest, macos-latest] 18 | 19 | steps: 20 | - name: Check out git repository 21 | uses: actions/checkout@v3.0.0 22 | 23 | - name: Install Node.js 24 | uses: actions/setup-node@v3.0.0 25 | with: 26 | node-version: "16" 27 | 28 | - name: Install Dependencies 29 | run: npm install 30 | 31 | - name: Build Electron App 32 | run: npm run build && npm run pack 33 | 34 | - name: Cleanup Artifacts 35 | shell: pwsh 36 | run: | 37 | if ("${{ matrix.os }}" -eq "windows-latest") { 38 | npx rimraf "dist/!(*.exe)" 39 | } elseif ("${{ matrix.os }}" -eq "macos-latest") { 40 | npx rimraf "dist/!(*.dmg)" 41 | } 42 | 43 | - name: Release 44 | uses: softprops/action-gh-release@v2 45 | with: 46 | files: "dist/**" 47 | tag_name: ${{ steps.tag.outputs.tag }} 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | /test.js 5 | /web 6 | 7 | # local env files 8 | .env.local 9 | .env.*.local 10 | 11 | # Log files 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | pnpm-debug.log* 16 | 17 | # Editor directories and files 18 | .idea 19 | .vscode 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2025 LiHengDao 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # QQ群相册批量下载 2 | 3 | 施工中... 4 | 5 | # 版权声明 6 | 7 | 程序ICONS由 Pixel perfect 提供 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /auto-imports.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* prettier-ignore */ 3 | // @ts-nocheck 4 | // noinspection JSUnusedGlobalSymbols 5 | // Generated by unplugin-auto-import 6 | // biome-ignore lint: disable 7 | export {} 8 | declare global { 9 | const ElMessage: typeof import('element-plus/es')['ElMessage'] 10 | const ElMessageBox: typeof import('element-plus/es')['ElMessageBox'] 11 | } 12 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ["@vue/cli-plugin-babel/preset"], 3 | }; 4 | -------------------------------------------------------------------------------- /components.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // @ts-nocheck 3 | // Generated by unplugin-vue-components 4 | // Read more: https://github.com/vuejs/core/pull/3399 5 | export {} 6 | 7 | /* prettier-ignore */ 8 | declare module 'vue' { 9 | export interface GlobalComponents { 10 | DownloadPage: typeof import('./src/components/DownloadPage.vue')['default'] 11 | ElButton: typeof import('element-plus/es')['ElButton'] 12 | ElCol: typeof import('element-plus/es')['ElCol'] 13 | ElInput: typeof import('element-plus/es')['ElInput'] 14 | ElLink: typeof import('element-plus/es')['ElLink'] 15 | ElRow: typeof import('element-plus/es')['ElRow'] 16 | ElScrollbar: typeof import('element-plus/es')['ElScrollbar'] 17 | ElSpace: typeof import('element-plus/es')['ElSpace'] 18 | ElStep: typeof import('element-plus/es')['ElStep'] 19 | ElSteps: typeof import('element-plus/es')['ElSteps'] 20 | ElTable: typeof import('element-plus/es')['ElTable'] 21 | ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] 22 | InputGroup: typeof import('./src/components/InputGroup.vue')['default'] 23 | RouterLink: typeof import('vue-router')['RouterLink'] 24 | RouterView: typeof import('vue-router')['RouterView'] 25 | SelectAlbum: typeof import('./src/components/SelectAlbum.vue')['default'] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /consts.js: -------------------------------------------------------------------------------- 1 | const TaskStatus = { 2 | WATING: "wating", 3 | RUN: "run", 4 | FINISH: "finish", 5 | ERROR: "error", 6 | PAUSE: "pause", 7 | }; 8 | 9 | const TaskStatusText={ 10 | [TaskStatus.WATING]:"等待", 11 | [TaskStatus.RUN]:"运行", 12 | [TaskStatus.FINISH]:"完成", 13 | [TaskStatus.ERROR]:"错误", 14 | [TaskStatus.PAUSE]:"暂停", 15 | 16 | } 17 | 18 | exports.TaskStatus = TaskStatus; 19 | exports.TaskStatusText = TaskStatusText; 20 | -------------------------------------------------------------------------------- /ipcMain.js: -------------------------------------------------------------------------------- 1 | const { default: axios } = require("axios"); 2 | const { ipcMain, dialog, shell } = require("electron"); 3 | const fsExtra = require("fs-extra"); 4 | const path = require("path"); 5 | const { TaskStatus } = require("./consts"); 6 | const { getCookies, getQQ, getTk } = require("./qqCore"); 7 | const CryptoJS = require("crypto-js"); 8 | 9 | function getMD5FirstSixChars(input) { 10 | // 计算 MD5 哈希值 11 | const hash = CryptoJS.MD5(input).toString(CryptoJS.enc.Hex); 12 | 13 | // 取前六位字符 14 | const firstSixChars = hash.substring(0, 6); 15 | 16 | return firstSixChars; 17 | } 18 | async function getAlbumList(event, qunId) { 19 | const url = `https://h5.qzone.qq.com/proxy/domain/u.photo.qzone.qq.com/cgi-bin/upp/qun_list_album_v2?g_tk=${getTk()}&callback=shine2_Callback&qunId=${qunId}&uin=${getQQ()}&start=0&num=1000&getMemberRole=1&inCharset=utf-8&outCharset=utf-8&source=qzone&attach_info=&callbackFun=shine2`; 20 | try { 21 | const { data } = await axios.get(url, { 22 | headers: { 23 | Cookie: getCookies(), 24 | }, 25 | }); 26 | if (data.indexOf("对不起,您") !== -1) { 27 | return { 28 | status: "error", 29 | msg: "无访问权限", 30 | }; 31 | } 32 | let list = 33 | new Function("", "const shine2_Callback=a=>a;return " + data)().data 34 | .album ?? []; 35 | 36 | list = list 37 | .map((item) => { 38 | return { 39 | id: item.id, 40 | title: item.title, 41 | num: item.photocnt, 42 | }; 43 | }) 44 | .filter((item) => item.num != 0); 45 | return { 46 | status: "success", 47 | data: list, 48 | }; 49 | } catch (error) { 50 | console.log(error); 51 | 52 | return { 53 | status: "error", 54 | msg: "未知错误", 55 | }; 56 | } 57 | } 58 | async function getPatchAlbum(qunId, albumId, start) { 59 | const url = `https://h5.qzone.qq.com/groupphoto/inqq?g_tk=` + getTk(); 60 | const postData = `"qunId=${qunId}&albumId=${albumId}&uin=${getQQ()}&start=${start}&num=36&getCommentCnt=0&getMemberRole=0&hostUin=${getQQ()}&getalbum=0&platform=qzone&inCharset=utf-8&outCharset=utf-8&source=qzone&cmd=qunGetPhotoList&qunid=${qunId}&albumid=${albumId}&attach_info=start_count%3D${start}"`; 61 | try { 62 | const { data } = await axios.post(url, postData, { 63 | headers: { 64 | Cookie: getCookies(), 65 | "Referrer-Policy": "strict-origin-when-cross-origin", 66 | accept: "application/json, text/javascript, */*; q=0.01", 67 | "content-type": "application/x-www-form-urlencoded; charset=UTF-8", 68 | "sec-ch-ua": 69 | '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"', 70 | "x-requested-with": "XMLHttpRequest", 71 | }, 72 | }); 73 | 74 | let list = data.data.photolist; 75 | list = list 76 | .map((item) => { 77 | const picList = []; 78 | for (const key in item.photourl) { 79 | picList.push(item.photourl[key]); 80 | } 81 | picList.sort((a, b) => { 82 | if (a.width !== b.width) { 83 | return b.width - a.width; 84 | } 85 | if (a.height !== b.height) { 86 | return b.height - a.height; 87 | } 88 | return b.enlarge_rate - a.enlarge_rate; 89 | }); 90 | return { 91 | photoURL: picList[0].url, //目前未遇到不存在 92 | videoURL: 93 | item.videodata.actionurl == "" 94 | ? undefined 95 | : item.videodata.actionurl, //默认值空字符串 96 | name: item.sloc, 97 | }; 98 | }) 99 | .filter((item) => item.num != 0); 100 | return { 101 | status: "success", 102 | data: list, 103 | }; 104 | } catch (error) { 105 | console.log(error); 106 | 107 | return { 108 | status: "error", 109 | msg: "未知错误", 110 | }; 111 | } 112 | } 113 | let globalQueue; 114 | 115 | let download = async () => undefined; 116 | function downloadFactory(userDir) { 117 | // eslint-disable-next-line @typescript-eslint/no-empty-function 118 | return async function (url, albumDirName, name) { 119 | // eslint-disable-next-line no-async-promise-executor 120 | return new Promise(async (resolve, reject) => { 121 | name = filterFileName(name); 122 | let albumName = sanitizeFileName(albumDirName); 123 | if (albumName.length == 0) { 124 | albumName = generateAlbumName(albumDirName); 125 | } 126 | const baseDir = path.join(userDir, "./" + albumName + "/"); 127 | const fileName = path.join(userDir, "./" + albumName + "/" + name); 128 | await fsExtra.mkdirp(baseDir); 129 | // eslint-disable-next-line no-async-promise-executor 130 | const fileStatus = await new Promise(async (resolve) => { 131 | try { 132 | const result = await fsExtra.pathExists(fileName); 133 | resolve(result); 134 | } catch (error) { 135 | resolve(false); 136 | } 137 | }); 138 | if (fileStatus) { 139 | resolve(); 140 | return; 141 | } 142 | const stream = ( 143 | await axios.get(url, { 144 | responseType: "stream", 145 | }) 146 | ).data; 147 | const fileSteam = fsExtra.createWriteStream(fileName, { 148 | highWaterMark: 1000, 149 | }); 150 | stream.pipe(fileSteam); 151 | let isEnd = false; 152 | const timer = setInterval(() => { 153 | if (isEnd) { 154 | isEnd = false; 155 | } else { 156 | clearInterval(timer); 157 | fileSteam.end(); 158 | reject(); 159 | } 160 | }, 30000); 161 | stream.on("end", () => { 162 | isEnd = true; 163 | clearInterval(timer); 164 | resolve(); 165 | }); 166 | stream.on("progress", () => { 167 | isEnd = true; 168 | }); 169 | }); 170 | }; 171 | } 172 | 173 | async function createDownloadAlbum(event, qunId, arr) { 174 | await globalQueue?.pause(); 175 | globalQueue = new queue(); 176 | for (let index = 0; index < arr.length; index++) { 177 | const item = arr[index]; 178 | globalQueue.addTask(new AlbumTask(qunId, item.id, item.num, item.title)); 179 | } 180 | return true; 181 | } 182 | async function startDownloadAlbum() { 183 | const showDialog = await dialog.showOpenDialog({ 184 | properties: ["openDirectory"], 185 | }); 186 | if (showDialog.filePaths.length == 0) { 187 | return false; 188 | } 189 | download = downloadFactory(showDialog.filePaths[0]); 190 | globalQueue?.run(); 191 | } 192 | 193 | async function stopDownloadAlbum(event, id) { 194 | await globalQueue?.pause(id); 195 | } 196 | async function resumeDownloadAlbum(event, id) { 197 | await globalQueue?.resume(id); 198 | } 199 | function openPage(event, url) { 200 | shell.openExternal(url); 201 | } 202 | async function deleteDownloadAlbum(event, id) { 203 | if (id !== undefined) { 204 | await globalQueue?.delete(id); 205 | } else { 206 | await globalQueue?.deleteAll(); 207 | globalQueue = undefined; 208 | } 209 | } 210 | async function getDownloadAlbumStatus() { 211 | return globalQueue?.getAllStatus() ?? []; 212 | } 213 | ipcMain?.handle("getAlbumList", getAlbumList); 214 | ipcMain?.handle("createDownloadAlbum", createDownloadAlbum); 215 | ipcMain?.handle("startDownloadAlbum", startDownloadAlbum); 216 | ipcMain?.handle("stopDownloadAlbum", stopDownloadAlbum); 217 | ipcMain?.handle("resumeDownloadAlbum", resumeDownloadAlbum); 218 | ipcMain?.handle("openPage", openPage); 219 | ipcMain?.handle("deleteDownloadAlbum", deleteDownloadAlbum); 220 | ipcMain?.handle("getDownloadAlbumStatus", getDownloadAlbumStatus); 221 | exports.getAlbumList = getAlbumList; 222 | exports.getPatchAlbum = getPatchAlbum; 223 | const sanitizeFileName = (fileName) => { 224 | return fileName.replace(/[^a-zA-Z0-9\u4e00-\u9fa5]/g, ""); 225 | }; 226 | const filterFileName = (name) => { 227 | return name.match(/[0-9a-zA-Z/.]*/g).join(""); 228 | }; 229 | const generateAlbumName = (albumDirName) => { 230 | const randomString = getMD5FirstSixChars(albumDirName); 231 | // 返回【相册下载】+ 6 个随机字符 232 | return `相册下载${randomString}`; 233 | }; 234 | 235 | // 这部分涉及到下载功能 236 | // 第一次首先考虑的是能限制数量并且顺序下载 237 | // 更新后改成并发 238 | // 但是涉及到任务的暂停,删除 239 | // 之前的循环执行不适合 240 | // 那么第一反应是分为一个完成数组,运行数组,等待数组 241 | // 但是这样设计,暂停的任务再继续,需要更换不同数组 242 | // 颠倒了用户顺序,而用户顺序是不能变的,就推翻了之前我们的设想 243 | // 这个时候就考虑到一个显示的用户数组 244 | // 一个进行下载的队列数组 245 | // 所以设计了一个任务队列,一个运行队列,而用户的信息从taskList获取 246 | // 监听子任务run的结束然后回调运行下一个子任务 247 | // 如果用then反复暂停继续会多次监听,所以多封装一层callback 248 | // 确保每个task的run函数只回调一次 249 | 250 | class queue { 251 | taskList = []; //用户插入任务的顺序,具体的执行顺序由waiQueue和runList控制 252 | flag = ""; // pause 暂停 run 运行中 253 | maxRun = 2; 254 | runList = []; 255 | waitQueue = []; 256 | addTask(item) { 257 | this.taskList.push(item); 258 | this.waitQueue.push(item); 259 | } 260 | async delete(id) { 261 | //删除列表内容 262 | const itemIndex = this.taskList.findIndex( 263 | (item) => item.getSingleID() == id 264 | ); 265 | if (itemIndex != -1) { 266 | this.taskList.splice(itemIndex, 1); 267 | } 268 | //如果在运行,需要暂停再删除 如果在等待,可以直接删除 269 | const runIndex = this.runList.findIndex((item) => item.getSingleID() == id); 270 | if (runIndex != -1) { 271 | await this.runList[runIndex].pause(); 272 | this.runList.splice(runIndex, 1); 273 | } else { 274 | const waitIndex = this.waitQueue.findIndex( 275 | (item) => item.getSingleID() == id 276 | ); 277 | if (waitIndex != -1) { 278 | this.waitQueue.splice(waitIndex, 1); 279 | } 280 | } 281 | } 282 | async deleteAll() { 283 | await this.pause(); 284 | this.taskList.length = 0; 285 | this.runList.length = 0; 286 | this.waitQueue.length = 0; 287 | } 288 | async pause(id) { 289 | if (id == undefined) { 290 | //全局暂停 291 | this.flag = "pause"; 292 | } 293 | const list = []; 294 | const addList = (item) => { 295 | list.push(item.pause()); 296 | }; 297 | for (let index = 0; index < this.runList.length; index++) { 298 | if (id == undefined) { 299 | addList(this.runList[index]); 300 | } else if (id === this.runList[index].getSingleID()) { 301 | addList(this.runList[index]); 302 | } 303 | } 304 | return Promise.all(list); 305 | } 306 | async resume(id) { 307 | if (id == undefined) { 308 | for (const item of this.runList) { 309 | item.resume(); 310 | } 311 | this.run(); 312 | } else { 313 | const index = this.taskList.findIndex((item) => item.getSingleID() == id); 314 | if (index !== -1) { 315 | this.taskList[index].resume(); 316 | this.waitQueue.unshift(this.taskList[index]); 317 | this.run(); 318 | } 319 | } 320 | } 321 | async runTask(index) { 322 | if (this.runList[index].isRun()) { 323 | return; 324 | } 325 | const id = this.runList[index].getSingleID(); 326 | this.runList[index].registerRun(() => { 327 | if (this.flag == "run") { 328 | //队列运行中,且执行过任务才可以执行下一个任务 329 | const itemIndex = this.runList.findIndex( 330 | (item) => item.getSingleID() == id 331 | ); 332 | //需要继续运行,删除队列进行处理 333 | if (itemIndex !== -1) { 334 | this.runList.splice(itemIndex, 1); 335 | } 336 | process.nextTick(() => { 337 | this.run(); 338 | }); 339 | } 340 | }); 341 | } 342 | async run() { 343 | this.flag = "run"; 344 | while (this.runList.length < this.maxRun && this.waitQueue.length !== 0) { 345 | const item = this.waitQueue.shift(); 346 | this.runList.push(item); 347 | } 348 | for (let index = 0; index < this.runList.length; index++) { 349 | this.runTask(index); 350 | } 351 | } 352 | getAllStatus() { 353 | const list = []; 354 | for (const item of this.taskList) { 355 | const data = item.getStatus(); 356 | list.push(data); 357 | } 358 | return list; 359 | } 360 | } 361 | class AlbumTask { 362 | list = []; 363 | qunId; 364 | albumId; 365 | start = 0; 366 | runStatus = TaskStatus.WATING; 367 | // wating 等待中 run 运行中 pause暂停中 finish完成 error 错误 368 | // 等待中->运行中 369 | // 运行中->暂停中/完成/错误 370 | // 暂停中->等待中 371 | waitResolve = undefined; 372 | success = 0; 373 | fail = 0; 374 | total = 0; 375 | title = ""; 376 | runCallback = undefined; 377 | constructor(qunId, albumId, total, title) { 378 | this.qunId = qunId; 379 | this.albumId = albumId; 380 | this.total = total; 381 | this.title = title; 382 | } 383 | getSingleID() { 384 | return this.albumId; 385 | } 386 | 387 | async nextAlbum() { 388 | for (let index = 0; index < 3; index++) { 389 | const data = await getPatchAlbum(this.qunId, this.albumId, this.start); 390 | if (data.status == "success") { 391 | this.start += 40; 392 | this.list = data.data; 393 | return; 394 | } 395 | } 396 | this.list = []; 397 | this.runStatus = TaskStatus.ERROR; 398 | } 399 | isRun() { 400 | return this.runStatus == TaskStatus.RUN; 401 | } 402 | async pause() { 403 | return new Promise((resolve) => { 404 | if (this.runStatus != TaskStatus.RUN) { 405 | resolve(); 406 | } else { 407 | this.runStatus = TaskStatus.PAUSE; 408 | this.waitResolve = resolve; 409 | } 410 | }); 411 | } 412 | async resume() { 413 | if (this.runStatus == TaskStatus.PAUSE) { 414 | this.runStatus = TaskStatus.WATING; 415 | } 416 | } 417 | async registerRun(callback) { 418 | this.runCallback = callback; 419 | await this.run(); 420 | if (this.runCallback) { 421 | this.runCallback(); 422 | this.runCallback = undefined; 423 | } 424 | } 425 | async run() { 426 | if (this.runStatus != TaskStatus.WATING) { 427 | return false; 428 | } 429 | this.runStatus = TaskStatus.RUN; 430 | if (this.runStatus == TaskStatus.ERROR || this.list.length == 0) { 431 | // 理论第一个判断是失效的 432 | // 因为执行nextAlbum,相册必然为0 433 | // 这里当暂停,继续的时候相册刚好为0则获取下一部分 434 | // 首次执行为0也获取下一部分 435 | // 如果溢出腾讯会自动返回空数组,无需担心 436 | await this.nextAlbum(); 437 | } 438 | while (this.list.length !== 0 && this.runStatus == TaskStatus.RUN) { 439 | const item = this.list.pop(); 440 | try { 441 | await download(item.photoURL, this.title, item.name + ".jpg"); 442 | if (item.videoURL) { 443 | await download(item.videoURL, this.title, item.name + ".mp4"); 444 | this.success++; 445 | } else { 446 | this.success++; 447 | } 448 | } catch (error) { 449 | this.fail++; 450 | } 451 | //离开状态 452 | if (this.runStatus != TaskStatus.RUN) { 453 | break; 454 | } 455 | if (this.list.length == 0) { 456 | await this.nextAlbum(); 457 | } 458 | } 459 | if (this.runStatus === TaskStatus.PAUSE) { 460 | // 回调暂停 461 | this.waitResolve(); 462 | } else if (this.runStatus == TaskStatus.RUN) { 463 | // 回调完成 464 | this.runStatus = TaskStatus.FINISH; 465 | } 466 | return true; 467 | } 468 | getStatus() { 469 | let showText = ""; 470 | if (this.success == 0 && this.fail == 0) { 471 | showText = `无执行内容`; 472 | } else { 473 | showText = `成功:${this.success} 失败:${this.fail}`; 474 | } 475 | return { 476 | id: this.albumId, 477 | num: this.total, 478 | fail: this.fail, 479 | success: this.success, 480 | status: this.runStatus, 481 | title: this.title, 482 | showText: showText, 483 | }; 484 | } 485 | } 486 | -------------------------------------------------------------------------------- /logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lihengdao666/QQGroupAlbumDownload/08cd73f86755b623a8f5a1cf2778f8869ac2c1f4/logo.ico -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | const { app, BrowserWindow, dialog } = require("electron"); 2 | const { setCookies, setTk, setQQ } = require("./qqCore"); 3 | require("./ipcMain.js"); 4 | const path = require("node:path"); 5 | 6 | let loginWindow; 7 | let mainWindow; 8 | 9 | const mainURL = 10 | process.env.NODE_ENV === "development" 11 | ? "http://localhost:8080" 12 | : `./web/index.html`; 13 | 14 | const QQURL = 15 | "https://xui.ptlogin2.qq.com/cgi-bin/xlogin?proxy_url=https%3A//qzs.qq.com/qzone/v6/portal/proxy.html&daid=5&&hide_title_bar=1&low_login=0&qlogin_auto_login=1&no_verifyimg=1&link_target=blank&appid=549000912&style=22&target=self&s_url=https%3A%2F%2Fqzs.qq.com%2Fqzone%2Fv5%2Floginsucc.html%3Fpara%3Dizone&pt_qr_app=%E6%89%8B%E6%9C%BAQQ%E7%A9%BA%E9%97%B4&pt_qr_link=https%3A//z.qzone.com/download.html&self_regurl=https%3A//qzs.qq.com/qzone/v6/reg/index.html&pt_qr_help_link=https%3A//z.qzone.com/download.html&pt_no_auth=0"; 16 | 17 | function generateTK(str) { 18 | let hash = 5381; 19 | for (let i = 0, len = str.length; i < len; i++) { 20 | hash += (hash << 5) + str.charCodeAt(i); 21 | } 22 | return hash & 0x7fffffff; 23 | } 24 | function createMainWindow() { 25 | mainWindow = new BrowserWindow({ 26 | height: 600, 27 | useContentSize: true, 28 | width: 800, 29 | title: "控制中心", 30 | autoHideMenuBar: true, 31 | webPreferences: { 32 | devTools: true, 33 | preload: path.join(__dirname, "preload.js"), 34 | }, 35 | }); 36 | if( process.env.NODE_ENV === "development"){ 37 | mainWindow.loadURL(mainURL); 38 | }else{ 39 | mainWindow.loadFile(mainURL); 40 | } 41 | 42 | 43 | mainWindow.on("closed", function () { 44 | loginWindow = null; 45 | }); 46 | } 47 | 48 | function createWindow() { 49 | loginWindow = new BrowserWindow({ 50 | height: 500, 51 | useContentSize: true, 52 | width: 400, 53 | title: "登录QQ账号", 54 | autoHideMenuBar: true, 55 | webPreferences: { 56 | devTools: true, 57 | }, 58 | }); 59 | 60 | loginWindow.loadURL(QQURL); 61 | loginWindow.webContents.on("dom-ready", () => { 62 | const currentURL = loginWindow.webContents.getURL(); 63 | if (currentURL.indexOf(`https://user.qzone.qq.com/`) !== -1) { 64 | loginWindow.webContents.session.cookies 65 | .get({ url: currentURL }) 66 | .then((cookies) => { 67 | setCookies( 68 | cookies 69 | .map((cookie) => { 70 | if (cookie.name == "p_skey") { 71 | setTk(generateTK(cookie.value)); 72 | } 73 | if (cookie.name == "p_uin") { 74 | setQQ(cookie.value.match(/[1-9][0-9]*/g)); 75 | } 76 | return `${cookie.name}=${cookie.value}`; 77 | }) 78 | .join("; ") 79 | ); 80 | 81 | dialog 82 | .showMessageBox(loginWindow, { 83 | type: "info", 84 | title: "信息", 85 | message: "登陆成功!", 86 | buttons: ["OK"], 87 | }) 88 | .then(() => { 89 | createMainWindow(); 90 | loginWindow.destroy(); 91 | }); 92 | }); 93 | } 94 | }); 95 | 96 | loginWindow.on("closed", function () { 97 | loginWindow = null; 98 | }); 99 | } 100 | 101 | app.whenReady().then(createWindow); 102 | 103 | app.on("window-all-closed", function () { 104 | if (process.platform !== "darwin") app.quit(); 105 | }); 106 | 107 | app.on("activate", function () { 108 | if (loginWindow === null && mainWindow == null) createWindow(); 109 | }); 110 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qq-album-download", 3 | "version": "0.1.4", 4 | "description": "qq-album-download", 5 | "author": "lihengdao", 6 | "main": "main.js", 7 | "build": { 8 | "appId": "qq.album.download", 9 | "copyright": "LiHengDao", 10 | "icon": "logo.ico", 11 | "productName": "QQGroupAlbumDownload", 12 | "win": { 13 | "target": [ 14 | "portable" 15 | ], 16 | "artifactName": "${productName} ${version}.${ext}" 17 | } 18 | }, 19 | "private": true, 20 | "scripts": { 21 | "serve": "vue-cli-service serve", 22 | "build": "vue-cli-service build", 23 | "electron-build-dev": "cross-env NODE_ENV=development npx electronmon ./main.js --log-level=3", 24 | "electron-build-pro": "npx electronmon ./main.js --log-level=3", 25 | "pack": "electron-builder", 26 | "lint": "vue-cli-service lint" 27 | }, 28 | "dependencies": { 29 | "@types/lodash": "^4.17.5", 30 | "axios": "^1.7.9", 31 | "core-js": "^3.8.3", 32 | "crypto-js": "^4.2.0", 33 | "electron-reloader": "^1.2.3", 34 | "element-plus": "^2.9.1", 35 | "fs-extra": "^11.2.0", 36 | "vue": "^3.2.13", 37 | "vue-router": "^4.0.3" 38 | }, 39 | "devDependencies": { 40 | "@typescript-eslint/eslint-plugin": "^5.4.0", 41 | "@typescript-eslint/parser": "^5.4.0", 42 | "@vue/cli-plugin-babel": "~5.0.0", 43 | "@vue/cli-plugin-eslint": "~5.0.0", 44 | "@vue/cli-plugin-router": "~5.0.0", 45 | "@vue/cli-plugin-typescript": "~5.0.0", 46 | "@vue/cli-service": "~5.0.0", 47 | "@vue/eslint-config-typescript": "^9.1.0", 48 | "cross-env": "^7.0.3", 49 | "electron": "^33.2.1", 50 | "electron-builder": "^25.1.8", 51 | "eslint": "^7.32.0", 52 | "eslint-config-prettier": "^8.3.0", 53 | "eslint-plugin-prettier": "^4.0.0", 54 | "eslint-plugin-vue": "^8.0.3", 55 | "prettier": "^2.4.1", 56 | "sass": "^1.32.7", 57 | "sass-loader": "^12.0.0", 58 | "typescript": "~4.5.5", 59 | "unplugin-auto-import": "^0.19.0", 60 | "unplugin-vue-components": "^0.28.0" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /preload.js: -------------------------------------------------------------------------------- 1 | const { contextBridge, ipcRenderer } = require("electron"); 2 | 3 | const preloadInjectObj = { 4 | openPage: (url) => ipcRenderer.invoke("openPage", url), 5 | startDownloadAlbum: () => ipcRenderer.invoke("startDownloadAlbum"), 6 | getAlbumList: (qqGroup) => ipcRenderer.invoke("getAlbumList", qqGroup), 7 | createDownloadAlbum: (qunId, arr) => 8 | ipcRenderer.invoke("createDownloadAlbum", qunId, arr), 9 | stopDownloadAlbum: (id) => ipcRenderer.invoke("stopDownloadAlbum", id), 10 | resumeDownloadAlbum: (id) => ipcRenderer.invoke("resumeDownloadAlbum", id), 11 | deleteDownloadAlbum: (id) => ipcRenderer.invoke("deleteDownloadAlbum", id), 12 | getDownloadAlbumStatus: () => ipcRenderer.invoke("getDownloadAlbumStatus"), 13 | }; 14 | 15 | contextBridge.exposeInMainWorld("QQ", preloadInjectObj); 16 | exports.preloadInjectObj=preloadInjectObj 17 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lihengdao666/QQGroupAlbumDownload/08cd73f86755b623a8f5a1cf2778f8869ac2c1f4/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | QQ群相册下载工具V0.1.4 9 | 10 | 11 | 18 |
19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /qqCore.js: -------------------------------------------------------------------------------- 1 | let cookieStr = ""; 2 | let tk = ""; 3 | let qq = ""; 4 | exports.setTk = (value) => { 5 | tk = value; 6 | }; 7 | exports.setQQ = (value) => { 8 | qq = value; 9 | }; 10 | exports.setCookies = (value) => { 11 | cookieStr = value; 12 | }; 13 | exports.getTk = () => { 14 | return tk 15 | }; 16 | exports.getQQ = () => { 17 | return qq 18 | }; 19 | exports.getCookies = () => { 20 | return cookieStr 21 | }; 22 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 50 | 85 | 86 | 125 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lihengdao666/QQGroupAlbumDownload/08cd73f86755b623a8f5a1cf2778f8869ac2c1f4/src/assets/logo.png -------------------------------------------------------------------------------- /src/components/DownloadPage.vue: -------------------------------------------------------------------------------- 1 | 97 | 177 | -------------------------------------------------------------------------------- /src/components/InputGroup.vue: -------------------------------------------------------------------------------- 1 | 37 | 88 | -------------------------------------------------------------------------------- /src/components/SelectAlbum.vue: -------------------------------------------------------------------------------- 1 | 34 | 73 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import App from "./App.vue"; 3 | import router from "./router"; 4 | 5 | createApp(App).use(router).mount("#app"); 6 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHashHistory, RouteRecordRaw } from "vue-router"; 2 | 3 | const routes: Array = [ 4 | 5 | ]; 6 | 7 | const router = createRouter({ 8 | history: createWebHashHistory(), 9 | routes, 10 | }); 11 | 12 | export default router; 13 | -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | declare module "*.vue" { 3 | import type { DefineComponent } from "vue"; 4 | const component: DefineComponent<{}, {}, any>; 5 | export default component; 6 | } 7 | declare interface Window { 8 | QQ: any 9 | } 10 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { toRaw, isRef, isReactive, isProxy } from "vue"; 2 | 3 | export function deepToRaw>(sourceObj: T): T { 4 | const objectIterator = (input: any): any => { 5 | if (Array.isArray(input)) { 6 | return input.map((item) => objectIterator(item)); 7 | } 8 | if (isRef(input) || isReactive(input) || isProxy(input)) { 9 | return objectIterator(toRaw(input)); 10 | } 11 | if (input && typeof input === "object") { 12 | return Object.keys(input).reduce((acc, key) => { 13 | acc[key as keyof typeof acc] = objectIterator(input[key]); 14 | return acc; 15 | }, {} as T); 16 | } 17 | return input; 18 | }; 19 | 20 | return objectIterator(sourceObj); 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "noImplicitAny": false, 8 | "moduleResolution": "node", 9 | "skipLibCheck": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "useDefineForClassFields": true, 14 | "sourceMap": true, 15 | "baseUrl": ".", 16 | "types": [ 17 | "webpack-env" 18 | ], 19 | "paths": { 20 | "@/*": [ 21 | "src/*" 22 | ] 23 | }, 24 | "lib": [ 25 | "esnext", 26 | "dom", 27 | "dom.iterable", 28 | "scripthost" 29 | ] 30 | }, 31 | "include": [ 32 | "src/**/*.d.ts", 33 | "src/**/*.ts", 34 | "src/**/*.tsx", 35 | "src/**/*.vue", 36 | "tests/**/*.ts", 37 | "tests/**/*.tsx", 38 | "auto-imports.d.ts" 39 | ], 40 | "exclude": [ 41 | "node_modules" 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require("@vue/cli-service"); 2 | const AutoImport = require("unplugin-auto-import/webpack").default; 3 | const Components = require("unplugin-vue-components/webpack").default; 4 | const { ElementPlusResolver } = require("unplugin-vue-components/resolvers"); 5 | module.exports = defineConfig({ 6 | transpileDependencies: true, 7 | publicPath: "./", 8 | outputDir: "web", 9 | configureWebpack: { 10 | plugins: [ 11 | AutoImport({ 12 | resolvers: [ElementPlusResolver()], 13 | }), 14 | Components({ 15 | resolvers: [ElementPlusResolver()], 16 | }), 17 | ], 18 | }, 19 | }); 20 | --------------------------------------------------------------------------------