Initial commit for open-source version

This commit is contained in:
jiangrui
2024-12-17 11:30:59 +08:00
commit 42c07ed34c
57 changed files with 10559 additions and 0 deletions

24
.env.example Normal file
View File

@@ -0,0 +1,24 @@
# # 数据库配置
# DB_HOST=localhost
# DB_USER=your_username
# DB_PASSWORD=your_password
# # API密钥
# API_KEY=your_api_key
# # 其他敏感信息
# CLOUD115_TOKEN=your_token
# 代理信息
HTTP_PROXY_HOST=127.0.0.1
HTTP_PROXY_PORT=7890
# 115网盘配置
CLOUD115_USER_ID=your_user_id # 用户ID 可以不填
CLOUD115_COOKIE=your_cookie
# 夸克网盘配置
QUARK_COOKIE='your_cookie'
# RSS配置
RSS_BASE_URL=https://rsshub.rssforever.com/telegram/channel

18
.gitignore vendored Normal file
View File

@@ -0,0 +1,18 @@
node_modules/
dist/
.env
.env.local
.env.*.local
# 保留模板
!.env.example
!frontend/.env
# 其他敏感文件
config.private.ts
*.pem
*.key
.DS_Store
*.log

27
.prettierignore Normal file
View File

@@ -0,0 +1,27 @@
# 构建产物
dist
build
coverage
# 依赖目录
node_modules
# 日志文件
*.log
# 环境配置
.env*
!.env.example
# 编辑器配置
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# 系统文件
.DS_Store
Thumbs.db

11
.prettierrc.json Normal file
View File

@@ -0,0 +1,11 @@
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"printWidth": 100,
"trailingComma": "es5",
"bracketSpacing": true,
"endOfLine": "lf",
"arrowParens": "always",
"vueIndentScriptAndStyle": true
}

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 CloudSaver
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:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
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.

94
README.md Normal file
View File

@@ -0,0 +1,94 @@
# CloudSaver
一个基于 Vue 3 + Express 的网盘资源搜索与转存工具。
## 特别声明
1. 此项目仅供学习交流使用,请勿用于非法用途。
2. 仅支持个人使用,不支持任何形式的 commercial 使用。
3. 禁止在项目页面上进行任何形式的广告宣传。
4. 所有搜索到的资源均来自第三方,本项目不对其真实性、合法性做出任何保证。
## 注意事项
1. 此项目的资源搜索需要用到代理环境,请自行搭建。
2. 默认的代理地址为:`http://127.0.0.1`,端口为:`7890`,如需修改,请自行 `.env`中配置。
## 功能特性
- 支持多个资源订阅(电报群)源搜索
- 支持 115 网盘与夸克网盘资源转存
- 支持关键词搜索与资源链接解析
- 支持转存文件夹展示与选择
## 预览
### 最新资源搜索
<img src="./docs/images/screenshot-20241216-172442.png" width="400">
### 转存
<img src="./docs/images/screenshot-20241216-172609.png" width="400">
### 关键词搜索
<img src="./docs/images/screenshot-20241216-172710.png" width="400">
### 直接链接解析
<img src="./docs/images/screenshot-20241216-173136.png" width="400">
## 技术栈
### 前端
- Vue 3
- TypeScript
- Element Plus
- Pinia
- Vue Router
- Vite
### 后端
- Node.js
- Express
- TypeScript
- RSS Parser
## 环境配置
### Node.js 版本
本项目需要 `Node.js` 版本 `18.x` 或更高版本。请确保在运行项目之前安装了正确的版本。
### 后端配置项
复制环境变量模板文件:
```
cp .env.example ./backend/.env
```
- 根据 `.env.example`文件在 `backend`目录下创建 `.env`文件。
- `CLOUD115_COOKIE`115 用户 cookie
- `QUARK_COOKIE`quark cookie
## 使用
### 开发环境本地运行
- 安装依赖:`npm run install`
- 启动开发环境:`npm run dev`
### 打包部署
- 前端打包:`npm run build:frontend` 或者进入前端目录`frontend`,执行 `npm run build`
- 将前端构建产物`dist`目录下的文件复制到服务器上,例如`nginx``html`目录下。
- 后端服务:先进入后端目录`backend``npm run build`构建,然后执行 `npm run start`启动,默认端口为`8009`
- 通过`nginx`配置代理服务,将前端api的请求映射到后端服务。
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

7
TODO.md Normal file
View File

@@ -0,0 +1,7 @@
# TODO
- ~~资源列表增加网盘标识~~
- ~~增加对夸克网盘转存的支持~~
- ~~增加搜索框直接解析链接~~
- ~~替换rsshub改为直接从telegram获取资源信息~~
- ~~增加资源列表web源加载更多功能~~

1694
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
backend/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "cloud-saver-server",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "nodemon --exec ts-node src/app.ts",
"build": "tsc",
"start": "node dist/app.js"
},
"dependencies": {
"axios": "^1.6.7",
"cheerio": "^1.0.0",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.18.3",
"rss-parser": "^3.13.0",
"socket.io": "^4.8.1",
"tunnel": "^0.0.6"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.7",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.11.25",
"@types/tunnel": "^0.0.7",
"nodemon": "^3.1.0",
"ts-node": "^10.9.2",
"typescript": "^5.4.2"
}
}

30
backend/src/app.ts Normal file
View File

@@ -0,0 +1,30 @@
import express from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
import routes from "./routes/api";
import { errorHandler } from "./middleware/errorHandler";
const app = express();
app.use(
cors({
origin: "*",
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "Cookie"],
})
);
app.use(cookieParser());
app.use(express.json());
app.use("/", routes);
// 错误处理
app.use(errorHandler);
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
export default app;

View File

@@ -0,0 +1,92 @@
import dotenv from "dotenv";
// 加载.env文件
dotenv.config();
interface Channel {
id: string;
name: string;
}
interface CloudPatterns {
baiduPan: RegExp;
tianyi: RegExp;
weiyun: RegExp;
aliyun: RegExp;
pan115: RegExp;
quark: RegExp;
}
interface Cloud115Config {
userId: string;
cookie: string;
}
interface QuarkConfig {
userId: string;
cookie: string;
}
interface HttpProxyConfig {
host: string;
port: string;
}
interface Config {
rss: {
baseUrl: string;
channels: Channel[];
};
telegram: {
baseUrl: string;
};
httpProxy: HttpProxyConfig;
cloudPatterns: CloudPatterns;
cloud115: Cloud115Config;
quark: QuarkConfig;
}
export const config: Config = {
rss: {
baseUrl: process.env.RSS_BASE_URL || "https://rsshub.rssforever.com/telegram/channel",
channels: [
{
id: "guaguale115",
name: "115网盘资源分享",
},
{
id: "hao115",
name: "115网盘资源分享频道",
},
{
id: "yunpanshare",
name: "网盘资源收藏(夸克)",
},
],
},
telegram: {
baseUrl: process.env.TELEGRAM_BASE_URL || "https://t.me/s",
},
httpProxy: {
host: process.env.HTTP_PROXY_HOST || "127.0.0.1",
port: process.env.HTTP_PROXY_PORT || "7890",
},
cloudPatterns: {
baiduPan: /https?:\/\/(?:pan|yun)\.baidu\.com\/[^\s<>"]+/g,
tianyi: /https?:\/\/cloud\.189\.cn\/[^\s<>"]+/g,
weiyun: /https?:\/\/share\.weiyun\.com\/[^\s<>"]+/g,
aliyun: /https?:\/\/\w+\.aliyundrive\.com\/[^\s<>"]+/g,
// pan115有两个域名 115.com 和 anxia.com
pan115: /https?:\/\/(?:115|anxia)\.com\/s\/[^\s<>"]+/g,
quark: /https?:\/\/pan\.quark\.cn\/[^\s<>"]+/g,
},
cloud115: {
userId: "",
cookie: process.env.CLOUD115_COOKIE || "",
},
quark: {
userId: process.env.QUARK_USER_ID || "",
cookie: process.env.QUARK_COOKIE || "",
},
};

View File

@@ -0,0 +1,46 @@
import { Request, Response, NextFunction } from "express";
import { Cloud115Service } from "../services/Cloud115Service";
import { config } from "../config";
import handleError from "../utils/handleError";
import { handleResponse } from "../utils/responseHandler";
const { cookie } = config.cloud115;
const cloud115 = new Cloud115Service(cookie);
export const cloud115Controller = {
async getShareInfo(req: Request, res: Response, next: NextFunction) {
try {
const { shareCode, receiveCode } = req.query;
const result = await cloud115.getShareInfo(shareCode as string, receiveCode as string);
handleResponse(res, result, true);
} catch (error) {
handleError(res, error, "获取分享信息失败", next);
}
},
async getFolderList(req: Request, res: Response, next: NextFunction) {
try {
const { parentCid } = req.query;
const result = await cloud115.getFolderList(parentCid as string);
handleResponse(res, result, true);
} catch (error) {
handleError(res, error, "获取目录列表失败", next);
}
},
async saveFile(req: Request, res: Response, next: NextFunction) {
try {
const { shareCode, receiveCode, fileId, folderId } = req.body;
const result = await cloud115.saveSharedFile({
shareCode,
receiveCode,
fileId,
cid: folderId,
});
handleResponse(res, result, true);
} catch (error) {
handleError(res, error, "保存文件失败", next);
}
},
};

View File

@@ -0,0 +1,40 @@
import { Request, Response, NextFunction } from "express";
import { QuarkService } from "../services/QuarkService";
import { config } from "../config";
import { handleResponse } from "../utils/responseHandler";
import handleError from "../utils/handleError";
const { cookie } = config.quark;
const quark = new QuarkService(cookie);
export const quarkController = {
async getShareInfo(req: Request, res: Response, next: NextFunction) {
try {
const { pwdId, passcode } = req.query;
const result = await quark.getShareInfo(pwdId as string, passcode as string);
handleResponse(res, result, true);
} catch (error) {
handleError(res, error, "获取分享信息失败", next);
}
},
async getFolderList(req: Request, res: Response, next: NextFunction) {
try {
const { parentCid } = req.query;
const result = await quark.getFolderList(parentCid as string);
handleResponse(res, result, true);
} catch (error) {
handleError(res, error, "获取目录列表失败", next);
}
},
async saveFile(req: Request, res: Response, next: NextFunction) {
try {
const result = await quark.saveSharedFile(req.body);
handleResponse(res, result, true);
} catch (error) {
handleError(res, error, "保存文件失败", next);
}
},
};

View File

@@ -0,0 +1,32 @@
import { Request, Response, NextFunction } from "express";
import { RSSSearcher } from "../services/RSSSearcher";
import { Searcher } from "../services/Searcher";
import { handleResponse } from "../utils/responseHandler";
import handleError from "../utils/handleError";
export const resourceController = {
async rssSearch(req: Request, res: Response, next: NextFunction) {
try {
const { keyword } = req.query;
const searcher = new RSSSearcher();
const result = await searcher.searchAll(keyword as string);
handleResponse(res, result, true);
} catch (error) {
handleError(res, error, "获取资源发生未知错误", next);
}
},
async search(req: Request, res: Response, next: NextFunction) {
try {
const { keyword, channelId = "", lastMessageId = "" } = req.query; // Remove `: string` from here
const searcher = new Searcher();
const result = await searcher.searchAll(
keyword as string,
channelId as string,
lastMessageId as string
);
handleResponse(res, result, true);
} catch (error) {
handleError(res, error, "获取资源发生未知错误", next);
}
},
};

View File

@@ -0,0 +1,9 @@
import { Request, Response, NextFunction } from "express";
export const errorHandler = (err: any, req: Request, res: Response, next: NextFunction) => {
console.error(err);
res.status(err.status || 500).json({
success: false,
error: err.message || "服务器内部错误",
});
};

View File

@@ -0,0 +1,14 @@
import { Request, Response, NextFunction } from "express";
export const validateRequest = (requiredParams: string[]) => {
return (req: Request, res: Response, next: NextFunction) => {
const missingParams = requiredParams.filter((param) => !req.query[param] && !req.body[param]);
if (missingParams.length > 0) {
return res.status(400).json({
success: false,
error: `缺少必要的参数: ${missingParams.join(", ")}`,
});
}
next();
};
};

22
backend/src/routes/api.ts Normal file
View File

@@ -0,0 +1,22 @@
import express from "express";
import { cloud115Controller } from "../controllers/cloud115";
import { quarkController } from "../controllers/quark";
import { resourceController } from "../controllers/resource";
const router = express.Router();
// 资源搜索
router.get("/search", resourceController.search);
router.get("/rssSearch", resourceController.rssSearch);
// 115网盘相关
router.get("/cloud115/share-info", cloud115Controller.getShareInfo);
router.get("/cloud115/folders", cloud115Controller.getFolderList);
router.post("/cloud115/save", cloud115Controller.saveFile);
// 夸克网盘相关
router.get("/quark/share-info", quarkController.getShareInfo);
router.get("/quark/folders", quarkController.getFolderList);
router.post("/quark/save", quarkController.saveFile);
export default router;

View File

@@ -0,0 +1,148 @@
import { AxiosHeaders, AxiosInstance } from "axios"; // 导入 AxiosHeaders
import { createAxiosInstance } from "../utils/axiosInstance";
import { Logger } from "../utils/logger";
import { config } from "../config/index";
import { ShareInfoResponse } from "../types/cloud115";
export class Cloud115Service {
private api: AxiosInstance;
constructor(cookie: string) {
if (!cookie) {
throw new Error("115网盘需要提供cookie进行身份验证");
}
this.api = createAxiosInstance(
"https://webapi.115.com",
AxiosHeaders.from({
Host: "webapi.115.com",
Connection: "keep-alive",
xweb_xhr: "1",
Origin: "",
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/6.8.0(0x16080000) NetType/WIFI MiniProgramEnv/Mac MacWechat/WMPF MacWechat/3.8.9(0x13080910) XWEB/1227",
Accept: "*/*",
"Sec-Fetch-Site": "cross-site",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Dest": "empty",
Referer: "https://servicewechat.com/wx2c744c010a61b0fa/94/page-frame.html",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "zh-CN,zh;q=0.9",
cookie: cookie,
})
);
}
async getShareInfo(shareCode: string, receiveCode = ""): Promise<ShareInfoResponse> {
try {
const response = await this.api.get("/share/snap", {
params: {
share_code: shareCode,
receive_code: receiveCode,
offset: 0,
limit: 20,
cid: "",
},
});
if (response.data?.state && response.data.data?.list?.length > 0) {
return {
success: true,
data: {
list: response.data.data.list.map((item: any) => ({
fileId: item.cid,
fileName: item.n,
fileSize: item.s,
})),
},
};
}
return {
success: false,
error: "未找到文件信息",
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "未知错误",
};
}
}
async getFolderList(parentCid = "0") {
try {
const response = await this.api.get("/files", {
params: {
aid: 1,
cid: parentCid,
o: "user_ptime",
asc: 0,
offset: 0,
show_dir: 1,
limit: 50,
type: 0,
format: "json",
star: 0,
suffix: "",
natsort: 1,
},
});
if (response.data?.state) {
return {
success: true,
data: response.data.data
.filter((item: any) => item.cid)
.map((folder: any) => ({
cid: folder.cid,
name: folder.n,
path: response.data.path,
})),
};
} else {
Logger.error("获取目录列表失败:", response.data.error);
return {
success: false,
error: "获取115pan目录列表失败:" + response.data.error,
};
}
} catch (error) {
Logger.error("获取目录列表失败:", error);
return {
success: false,
error: "获取115pan目录列表失败",
};
}
}
async saveSharedFile(params: {
cid: string;
shareCode: string;
receiveCode: string;
fileId: string;
}) {
try {
const param = new URLSearchParams({
cid: params.cid,
user_id: config.cloud115.userId,
share_code: params.shareCode,
receive_code: params.receiveCode,
file_id: params.fileId,
});
const response = await this.api.post("/share/receive", param.toString());
return {
success: response.data.state,
error: response.data.error,
data: response.data,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "未知错误",
};
}
}
}

View File

@@ -0,0 +1,178 @@
import { AxiosInstance, AxiosHeaders } from "axios";
import { Logger } from "../utils/logger";
import { createAxiosInstance } from "../utils/axiosInstance";
export class QuarkService {
private api: AxiosInstance;
constructor(cookie: string) {
if (!cookie) {
throw new Error("115网盘需要提供cookie进行身份验证");
}
this.api = createAxiosInstance(
"https://drive-h.quark.cn",
AxiosHeaders.from({
cookie: cookie,
accept: "application/json, text/plain, */*",
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
"content-type": "application/json",
priority: "u=1, i",
"sec-ch-ua": '"Microsoft Edge";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-site",
})
);
}
async getShareInfo(pwdId: string, passcode = "") {
try {
const response = await this.api.post(
`/1/clouddrive/share/sharepage/token?pr=ucpro&fr=pc&uc_param_str=&__dt=994&__t=${Date.now()}`,
{
pwd_id: pwdId,
passcode: "",
}
);
if (response.data?.status === 200 && response.data.data) {
const fileInfo = response.data.data;
if (fileInfo.stoken) {
let res = await this.getShareList(pwdId, fileInfo.stoken);
return {
success: true,
data: res,
};
}
}
return {
success: false,
error: "未找到文件信息",
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "未知错误",
};
}
}
async getShareList(pwdId: string, stoken: string) {
try {
const response = await this.api.get("/1/clouddrive/share/sharepage/detail", {
params: {
pr: "ucpro",
fr: "pc",
uc_param_str: "",
pwd_id: pwdId,
stoken: stoken,
pdir_fid: "0",
force: "0",
_page: "1",
_size: "50",
_fetch_banner: "1",
_fetch_share: "1",
_fetch_total: "1",
_sort: "file_type:asc,updated_at:desc",
__dt: "1589",
__t: Date.now(),
},
});
if (response.data?.data) {
const list = response.data.data.list
.filter((item: any) => item.fid)
.map((folder: any) => ({
fileId: folder.fid,
fileName: folder.file_name,
fileIdToken: folder.share_fid_token,
}));
return {
list,
pwdId,
stoken: stoken,
};
} else {
return {
list: [],
};
}
} catch (error) {
Logger.error("获取目录列表失败:", error);
return [];
}
}
async getFolderList(parentCid = "0") {
try {
const response = await this.api.get("/1/clouddrive/file/sort", {
params: {
pr: "ucpro",
fr: "pc",
uc_param_str: "",
pdir_fid: parentCid,
_page: "1",
_size: "100",
_fetch_total: "false",
_fetch_sub_dirs: "1",
_sort: "",
__dt: "2093126",
__t: Date.now(),
},
});
if (response.data?.data && response.data.data.list.length) {
return {
success: true,
data: response.data.data.list
.filter((item: any) => item.fid)
.map((folder: any) => ({
cid: folder.fid,
name: folder.file_name,
path: [],
})),
};
} else {
Logger.error("获取目录列表失败:", response.data.error);
return {
success: false,
error: "获取夸克目录列表失败:" + response.data.error,
};
}
} catch (error) {
Logger.error("获取目录列表失败:", error);
return {
success: false,
error: "获取夸克目录列表失败",
};
}
}
async saveSharedFile(params: {
fid_list: string[];
fid_token_list: string[];
to_pdir_fid: string;
pwd_id: string;
stoken: string;
pdir_fid: string;
scene: string;
}) {
try {
const response = await this.api.post(
`/1/clouddrive/share/sharepage/save?pr=ucpro&fr=pc&uc_param_str=&__dt=208097&__t=${Date.now()}`,
params
);
return {
success: response.data.code === 0,
error: response.data.message,
data: response.data.data,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "未知错误",
};
}
}
}

View File

@@ -0,0 +1,112 @@
import RSSParser from "rss-parser";
import { AxiosInstance, AxiosHeaders } from "axios";
import { config } from "../config";
import { Logger } from "../utils/logger";
import { createAxiosInstance } from "../utils/axiosInstance";
interface RSSItem {
title?: string;
link?: string;
pubDate?: string;
content?: string;
description?: string;
image?: string;
cloudLinks?: string[];
}
export class RSSSearcher {
private parser: RSSParser;
private axiosInstance: AxiosInstance;
constructor() {
this.parser = new RSSParser({
customFields: {
item: [
["content:encoded", "content"],
["description", "description"],
],
},
});
this.axiosInstance = createAxiosInstance(
config.rss.baseUrl,
AxiosHeaders.from({
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
Accept: "application/xml,application/xhtml+xml,text/html,application/rss+xml",
}),
true
);
}
private extractCloudLinks(text: string): { links: string[]; cloudType: string } {
const links: string[] = [];
let cloudType = "";
Object.values(config.cloudPatterns).forEach((pattern, index) => {
const matches = text.match(pattern);
if (matches) {
links.push(...matches);
cloudType = Object.keys(config.cloudPatterns)[index];
}
});
return {
links: [...new Set(links)],
cloudType,
};
}
async searchAll(keyword: string) {
const allResults = [];
for (let i = 0; i < config.rss.channels.length; i++) {
const channel = config.rss.channels[i];
try {
const rssUrl = `${config.rss.baseUrl}/${
channel.id
}${keyword ? `/searchQuery=${encodeURIComponent(keyword)}` : ""}`;
const results = await this.searchInRSSFeed(rssUrl);
if (results.items.length > 0) {
const channelResults = results.items
.filter((item: RSSItem) => item.cloudLinks && item.cloudLinks.length > 0)
.map((item: RSSItem) => ({
...item,
channel: channel.name + "(" + channel.id + ")",
}));
allResults.push(...channelResults);
}
} catch (error) {
Logger.error(`搜索频道 ${channel.name} 失败:`, error);
}
}
return allResults;
}
async searchInRSSFeed(rssUrl: string) {
try {
const response = await this.axiosInstance.get(rssUrl);
const feed = await this.parser.parseString(response.data);
return {
items: feed.items.map((item: RSSItem) => {
const linkInfo = this.extractCloudLinks(item.content || item.description || "");
return {
title: item.title || "",
link: item.link || "",
pubDate: item.pubDate || "",
image: item.image || "",
cloudLinks: linkInfo.links,
cloudType: linkInfo.cloudType,
};
}),
};
} catch (error) {
Logger.error(`RSS源解析错误: ${rssUrl}`, error);
return {
items: [],
};
}
}
}

View File

@@ -0,0 +1,159 @@
import { AxiosInstance, AxiosHeaders } from "axios";
import { createAxiosInstance } from "../utils/axiosInstance";
import * as cheerio from "cheerio";
import { config } from "../config";
import { Logger } from "../utils/logger";
interface sourceItem {
messageId?: string;
title?: string;
link?: string;
pubDate?: string;
content?: string;
description?: string;
image?: string;
cloudLinks?: string[];
cloudType?: string;
}
export class Searcher {
private axiosInstance: AxiosInstance;
constructor() {
this.axiosInstance = createAxiosInstance(
config.telegram.baseUrl,
AxiosHeaders.from({
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",
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
"cache-control": "max-age=0",
priority: "u=0, i",
"sec-ch-ua": '"Microsoft Edge";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "none",
"sec-fetch-user": "?1",
"upgrade-insecure-requests": "1",
}),
true
);
}
private extractCloudLinks(text: string): { links: string[]; cloudType: string } {
const links: string[] = [];
let cloudType = "";
Object.values(config.cloudPatterns).forEach((pattern, index) => {
const matches = text.match(pattern);
if (matches) {
links.push(...matches);
cloudType = Object.keys(config.cloudPatterns)[index];
}
});
return {
links: [...new Set(links)],
cloudType,
};
}
async searchAll(keyword: string, channelId?: string, messageId?: string) {
const allResults = [];
const totalChannels = config.rss.channels.length;
const channelList = channelId
? config.rss.channels.filter((channel) => channel.id === channelId)
: config.rss.channels;
for (let i = 0; i < channelList.length; i++) {
const channel = channelList[i];
try {
const messageIdparams = messageId ? `before=${messageId}` : "";
const url = `/${channel.id}${keyword ? `?q=${encodeURIComponent(keyword)}&${messageIdparams}` : `?${messageIdparams}`}`;
console.log(`Searching in channel ${channel.name} with URL: ${url}`);
const results = await this.searchInWeb(url, channel.id);
console.log(`Found ${results.items.length} items in channel ${channel.name}`);
if (results.items.length > 0) {
const channelResults = results.items
.filter((item: sourceItem) => item.cloudLinks && item.cloudLinks.length > 0)
.map((item: sourceItem) => ({
...item,
channel: channel.name,
channelId: channel.id,
}));
allResults.push(...channelResults);
}
} catch (error) {
Logger.error(`搜索频道 ${channel.name} 失败:`, error);
}
}
return allResults;
}
async searchInWeb(url: string, channelId: string) {
try {
const response = await this.axiosInstance.get(url);
const html = response.data;
const $ = cheerio.load(html);
const items: sourceItem[] = [];
// 遍历每个消息容器
$(".tgme_widget_message_wrap").each((_, element) => {
const messageEl = $(element);
// 通过 data-post 属性来获取消息的链接 去除channelId 获得消息id
const messageId = messageEl
.find(".tgme_widget_message")
.data("post")
?.toString()
.split("/")[1];
// 提取标题 (消息截取100长度)
const title = messageEl.find(".js-message_text").text().trim().substring(0, 50) + "...";
// 提取链接 (消息中的链接)
// const link = messageEl.find('.tgme_widget_message').data('post');
// 提取发布时间
const pubDate = messageEl.find("time").attr("datetime");
// 提取内容 (完整消息文本)
const content = messageEl.find(".js-message_text").text();
// 提取描述 (消息文本中"描述:"后的内容)
const description = content.split("描述:")[1]?.split("\n")[0]?.trim();
// 提取图片
const image = messageEl
.find(".tgme_widget_message_photo_wrap")
.attr("style")
?.match(/url\('(.+?)'\)/)?.[1];
// 提取云盘链接
const links = messageEl
.find(".tgme_widget_message_text a")
.map((_, el) => $(el).attr("href"))
.get();
const cloudInfo = this.extractCloudLinks(links.join(" "));
// 添加到数组第一位
items.unshift({
messageId,
title,
pubDate,
content,
description,
image,
cloudLinks: cloudInfo.links,
cloudType: cloudInfo.cloudType,
});
});
return { items };
} catch (error) {
Logger.error(`RSS源解析错误: ${url}`, error);
return {
items: [],
};
}
}
}

View File

@@ -0,0 +1,13 @@
export interface ShareInfo {
fileId: string;
fileName: string;
fileSize: number;
}
export interface ShareInfoResponse {
success: boolean;
data?: {
list: ShareInfo[];
};
error?: string;
}

View File

@@ -0,0 +1,28 @@
import axios, { AxiosInstance, AxiosRequestHeaders } from "axios";
import tunnel from "tunnel";
import { config } from "../config";
export function createAxiosInstance(
baseURL: string,
headers: AxiosRequestHeaders,
useProxy: boolean = false
): AxiosInstance {
let agent;
if (useProxy) {
agent = tunnel.httpsOverHttp({
proxy: {
host: config.httpProxy.host,
port: Number(config.httpProxy.port),
},
});
}
return axios.create({
baseURL,
timeout: 30000,
headers,
httpsAgent: useProxy ? agent : undefined,
withCredentials: true,
});
}

View File

@@ -0,0 +1,11 @@
import { Response, NextFunction } from "express";
import { Logger } from "../utils/logger";
export default function handleError(
res: Response,
error: any,
message: string,
next: NextFunction
) {
Logger.error(message, error);
next(error || { success: false, message });
}

View File

@@ -0,0 +1,38 @@
type LogLevel = "info" | "success" | "warn" | "error";
export const Logger = {
info(...args: any[]) {
this.log("info", ...args);
},
success(...args: any[]) {
this.log("success", ...args);
},
warn(...args: any[]) {
this.log("warn", ...args);
},
error(...args: any[]) {
this.log("error", ...args);
},
log(level: LogLevel, ...args: any[]) {
const timestamp = new Date().toISOString();
const prefix = `[${timestamp}] [${level.toUpperCase()}]`;
switch (level) {
case "success":
console.log(prefix, ...args);
break;
case "warn":
console.warn(prefix, ...args);
break;
case "error":
console.error(prefix, ...args);
break;
default:
console.log(prefix, ...args);
}
},
};

View File

@@ -0,0 +1,5 @@
import { Response } from "express";
export const handleResponse = (res: Response, data: any, success: boolean) => {
res.json({ success, data });
};

21
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

2
frontend/.env Normal file
View File

@@ -0,0 +1,2 @@
VITE_API_BASE_URL=""
VITE_API_BASE_URL_PROXY="http://127.0.0.1:8009"

9
frontend/auto-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
export {}
declare global {
}

33
frontend/components.d.ts vendored Normal file
View File

@@ -0,0 +1,33 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module 'vue' {
export interface GlobalComponents {
ElAlert: typeof import('element-plus/es')['ElAlert']
ElBacktop: typeof import('element-plus/es')['ElBacktop']
ElButton: typeof import('element-plus/es')['ElButton']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElImage: typeof import('element-plus/es')['ElImage']
ElInput: typeof import('element-plus/es')['ElInput']
ElLink: typeof import('element-plus/es')['ElLink']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTag: typeof import('element-plus/es')['ElTag']
ElTree: typeof import('element-plus/es')['ElTree']
FolderSelect: typeof import('./src/components/FolderSelect.vue')['default']
ResourceList: typeof import('./src/components/ResourceList.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SearchBar: typeof import('./src/components/SearchBar.vue')['default']
}
export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CloudSaver</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2479
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
frontend/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "cloud-saver-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.7",
"element-plus": "^2.6.1",
"pinia": "^2.1.7",
"socket.io-client": "^4.8.1",
"vue": "^3.4.21",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@types/node": "^20.11.25",
"@vitejs/plugin-vue": "^5.0.4",
"typescript": "^5.4.2",
"unplugin-auto-import": "^0.17.5",
"unplugin-vue-components": "^0.26.0",
"vite": "^5.1.5",
"vue-tsc": "^2.0.6"
}
}

11
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,11 @@
<template>
<el-config-provider>
<router-view />
</el-config-provider>
</template>
<style>
#app {
height: 100vh;
}
</style>

View File

@@ -0,0 +1,23 @@
import request from "@/utils/request";
import type { ShareInfoResponse, Folder, Save115FileParams } from "@/types";
export const cloud115Api = {
async getShareInfo(shareCode: string, receiveCode = ""): Promise<ShareInfoResponse> {
const { data } = await request.get("/api/cloud115/share-info", {
params: { shareCode, receiveCode },
});
return data;
},
async getFolderList(parentCid = "0"): Promise<{ data: Folder[] }> {
const { data } = await request.get("/api/cloud115/folders", {
params: { parentCid },
});
return data;
},
async saveFile(params: Save115FileParams) {
const { data } = await request.post("/api/cloud115/save", params);
return data;
},
};

23
frontend/src/api/quark.ts Normal file
View File

@@ -0,0 +1,23 @@
import request from "@/utils/request";
import type { ShareInfoResponse, Folder, SaveQuarkFileParams } from "@/types";
export const quarkApi = {
async getShareInfo(pwdId: string, passcode = ""): Promise<ShareInfoResponse> {
const { data } = await request.get("/api/quark/share-info", {
params: { pwdId, passcode },
});
return data;
},
async getFolderList(parentCid = "0"): Promise<{ data: Folder[] }> {
const { data } = await request.get("/api/quark/folders", {
params: { parentCid },
});
return data;
},
async saveFile(params: SaveQuarkFileParams) {
const { data } = await request.post("/api/quark/save", params);
return data;
},
};

View File

@@ -0,0 +1,10 @@
import request from "@/utils/request";
import type { Resource } from "@/types/index";
export const resourceApi = {
search(keyword: string, backupPlan: boolean, channelId?: string, lastMessageId?: string) {
return request.get<Resource[]>(`/api/${backupPlan ? "rssSearch" : "search"}`, {
params: { keyword, channelId, lastMessageId },
});
},
};

View File

@@ -0,0 +1,134 @@
<template>
<div class="folder-select">
<div class="folder-select-header">
当前位置<el-icon style="margin: 0 5px"><Folder /></el-icon
>{{ selectedFolder?.path?.map((x: Folder) => x.name).join("/") }}
</div>
<el-tree
ref="treeRef"
:data="folders"
:props="defaultProps"
node-key="cid"
:load="loadNode"
lazy
@node-click="handleNodeClick"
highlight-current
>
<template #default="{ node }">
<span class="folder-node">
<el-icon><Folder /></el-icon>
{{ node.label }}
</span>
</template>
</el-tree>
</div>
</template>
<script setup lang="ts">
import { ref, defineProps } from "vue";
import { cloud115Api } from "@/api/cloud115";
import { quarkApi } from "@/api/quark";
import type { TreeInstance } from "element-plus";
import type { Folder } from "@/types";
import { ElMessage } from "element-plus";
const props = defineProps({
cloudType: {
type: String,
required: true,
},
});
const treeRef = ref<TreeInstance>();
const folders = ref<Folder[]>([]);
const selectedFolder = ref<Folder | null>(null);
const emit = defineEmits<{
(e: "select", folderId: string): void;
(e: "close"): void;
}>();
const defaultProps = {
label: "name",
children: "children",
isLeaf: "leaf",
};
const cloudTypeApiMap = {
pan115: cloud115Api,
quark: quarkApi,
};
const loadNode = async (node: any, resolve: (data: Folder[]) => void) => {
const api = cloudTypeApiMap[props.cloudType as keyof typeof cloudTypeApiMap];
try {
let res: {
data: Folder[];
error?: string;
} = { data: [] };
if (node.level === 0) {
if (api.getFolderList) {
// 使用类型保护检查方法是否存在
res = await api.getFolderList();
}
} else {
if (api.getFolderList) {
// 使用类型保护检查方法是否存在
res = await api.getFolderList(node.data.cid);
}
}
if (res.data?.length > 0) {
resolve(res.data);
} else {
resolve([]);
throw new Error(res.error);
}
} catch (error) {
ElMessage.error(error instanceof Error ? `${error.message}` : "获取目录失败");
// 关闭模态框
emit("close");
resolve([]);
}
};
const handleNodeClick = (data: Folder) => {
selectedFolder.value = {
...data,
path: data.path ? [...data.path, data] : [data],
};
emit("select", data.cid);
};
</script>
<style scoped>
.folder-select {
min-height: 300px;
max-height: 500px;
overflow-y: auto;
}
.folder-node {
display: flex;
align-items: center;
gap: 8px;
}
.folder-path {
color: #999;
font-size: 12px;
margin-left: 8px;
}
:deep(.el-tree-node__content) {
height: 32px;
}
.folder-select-header {
display: flex;
align-items: center;
justify-content: flex-start;
margin-bottom: 10px;
font-size: 14px;
padding: 5px 10px;
border: 1px solid #e5e6e8;
border-radius: 8px;
}
</style>

View File

@@ -0,0 +1,221 @@
<template>
<div class="resource-list">
<el-table
v-loading="store.loading"
:data="groupedResources"
style="width: 100%"
row-key="id"
:default-expand-all="true"
>
<el-table-column type="expand">
<template #default="props">
<el-table :data="props.row.items" style="width: 100%">
<el-table-column label="图片" width="90">
<template #default="{ row }">
<el-image
v-if="row.image"
:src="row.image"
:preview-src-list="[row.image]"
:zoom-rate="1.2"
:max-scale="7"
:min-scale="0.2"
:initial-index="4"
preview-teleported
:z-index="999"
fit="cover"
width="60"
height="90"
/>
<el-icon v-else size="20"><Close /></el-icon>
</template>
</el-table-column>
<el-table-column prop="title" label="标题" width="180" />
<el-table-column label="地址">
<template #default="{ row }">
<el-link :href="row.cloudLinks[0]" target="_blank">
{{ row.cloudLinks[0] }}
</el-link>
</template>
</el-table-column>
<el-table-column label="云盘类型" width="120">
<template #default="{ row }">
<el-tag
:type="tagColor[row.cloudType as keyof typeof tagColor]"
effect="dark"
round
>
{{ row.cloudType }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template #default="{ row }">
<el-button @click="handleSave(row)">转存</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="props.row.hasMore" class="load-more">
<el-button :loading="props.row.loading" @click="handleLoadMore(props.row.channel)">
加载更多
</el-button>
</div>
</template>
</el-table-column>
<el-table-column label="来源" prop="channel">
<template #default="{ row }">
<div class="group-header">
<span>{{ row.channel }}</span>
<span class="item-count">({{ row.items.length }})</span>
</div>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="folderDialogVisible" title="选择保存目录" v-if="currentResource">
<template #header="{ titleId }">
<div class="my-header">
<div :id="titleId">
<el-tag
:type="tagColor[currentResource.cloudType as keyof typeof tagColor]"
effect="dark"
round
>
{{ currentResource.cloudType }}
</el-tag>
选择保存目录
</div>
</div>
</template>
<folder-select
v-if="folderDialogVisible"
@select="handleFolderSelect"
@close="folderDialogVisible = false"
:cloudType="currentResource.cloudType"
/>
<div class="dialog-footer">
<el-button @click="folderDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSaveBtnClick">保存</el-button>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { useResourceStore } from "@/stores/resource";
import FolderSelect from "./FolderSelect.vue";
import type { Resource } from "@/types";
const tagColor = {
baiduPan: "primary",
weiyun: "info",
aliyun: "warning",
pan115: "danger",
quark: "success",
};
const store = useResourceStore();
const folderDialogVisible = ref(false);
const currentResource = ref<Resource | null>(null);
const currentFolderId = ref<string | null>(null);
// 按来源分组的数据
const groupedResources = computed(() => {
const groups = store.resources.reduce(
(acc, curr) => {
const channel = curr.channel;
const channelId = curr.channelId;
if (!acc[channel]) {
acc[channel] = {
channel,
items: [],
hasMore: true,
loading: false, // 添加 loading 状态
id: channelId || "", // 用于row-key
};
}
acc[channel].items.push(curr);
return acc;
},
{} as Record<
string,
{ channel: string; items: Resource[]; id: string; hasMore: boolean; loading: boolean }
>
);
return Object.values(groups);
});
const handleSave = (resource: Resource) => {
currentResource.value = resource;
folderDialogVisible.value = true;
};
const handleFolderSelect = async (folderId: string) => {
if (!currentResource.value) return;
currentFolderId.value = folderId;
};
const handleSaveBtnClick = async () => {
if (!currentResource.value || !currentFolderId.value) return;
folderDialogVisible.value = false;
await store.saveResource(currentResource.value, currentFolderId.value);
};
// 添加加载更多处理函数
const handleLoadMore = async (channel: string) => {
const group = groupedResources.value.find((g) => g.channel === channel);
if (!group || group.loading) return;
group.loading = true;
try {
const lastMessageId = group.items[group.items.length - 1].messageId;
store.searchResources("", false, true, group.id, lastMessageId);
} finally {
group.loading = false;
}
};
</script>
<style scoped>
.resource-list {
margin-top: 20px;
}
.dialog-footer {
display: flex;
align-items: center;
justify-content: flex-end;
}
.group-header {
display: flex;
align-items: center;
gap: 8px;
}
.item-count {
color: #909399;
font-size: 0.9em;
}
:deep(.el-table__expand-column) {
.cell {
padding: 0 !important;
}
}
:deep(.el-table__expanded-cell) {
padding: 20px !important;
}
:deep(.el-table__expand-icon) {
height: 23px;
line-height: 23px;
}
.load-more {
display: flex;
justify-content: center;
padding: 16px 0;
}
</style>

View File

@@ -0,0 +1,82 @@
<template>
<div class="search-bar">
<el-input
v-model="keyword"
placeholder="请输入搜索关键词与输入链接直接解析"
class="input-with-select"
@keyup.enter="handleSearch"
style="margin-bottom: 8px"
>
<template #append>
<el-button type="success" @click="handleSearch">{{ searchBtnText }}</el-button>
</template>
</el-input>
<el-alert
title="可直接输入链接进行资源解析,也可进行资源搜索!"
type="info"
show-icon
:closable="false"
/>
<div class="search-new">
<el-button type="primary" @click="handleSearchNew">最新资源</el-button>
<div class="switch-source">
<el-switch v-model="backupPlan" /><span class="label">使用rsshub(较慢)</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { effect, ref } from "vue";
import { useResourceStore } from "@/stores/resource";
const keyword = ref("");
const backupPlan = ref(false);
const store = useResourceStore();
const searchBtnText = ref("搜索");
effect(() => {
// 监听搜索关键词的变化,如果存在,则自动触发搜索
if (keyword.value && keyword.value.startsWith("http")) {
searchBtnText.value = "解析";
} else {
searchBtnText.value = "搜索";
}
});
const handleSearch = async () => {
// 如果搜索内容是一个https的链接则尝试解析链接
if (keyword.value.startsWith("http")) {
store.parsingCloudLink(keyword.value);
return;
}
if (!keyword.value.trim()) {
return;
}
await store.searchResources(keyword.value, backupPlan.value);
};
const handleSearchNew = async () => {
keyword.value = "";
await store.searchResources("", backupPlan.value);
};
</script>
<style scoped>
.search-bar {
padding: 20px;
}
.search-new {
margin-top: 20px;
display: flex;
align-items: center;
justify-content: flex-start;
}
.switch-source {
margin-left: 20px;
}
.switch-source .label {
margin-left: 5px;
}
</style>

15
frontend/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

19
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,19 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import ElementPlus from "element-plus";
import "element-plus/dist/index.css";
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from "./App.vue";
import router from "./router/index";
const app = createApp(App);
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia());
app.use(router);
app.use(ElementPlus);
app.mount("#app");

View File

@@ -0,0 +1,18 @@
import { createRouter, createWebHistory } from "vue-router";
import type { RouteRecordRaw } from "vue-router";
import HomeView from "@/views/HomeView.vue";
const routes: RouteRecordRaw[] = [
{
path: "/",
name: "home",
component: HomeView,
},
];
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
});
export default router;

View File

@@ -0,0 +1,209 @@
import { defineStore } from "pinia";
import { cloud115Api } from "@/api/cloud115";
import { resourceApi } from "@/api/resource";
import { quarkApi } from "@/api/quark";
import type { Resource, ShareInfoResponse, Save115FileParams, SaveQuarkFileParams } from "@/types";
import { ElMessage } from "element-plus";
// 定义云盘驱动配置类型
interface CloudDriveConfig {
name: string;
type: string;
regex: RegExp;
api: {
getShareInfo: (parsedCode: any) => Promise<ShareInfoResponse>;
saveFile: (params: Record<string, any>) => Promise<{ success: boolean; error?: string }>;
};
parseShareCode: (match: RegExpMatchArray) => Record<string, string>;
getSaveParams: (shareInfo: ShareInfoResponse, folderId: string) => Record<string, any>;
}
// 云盘类型配置
export const CLOUD_DRIVES: CloudDriveConfig[] = [
{
name: "115网盘",
type: "pan115",
regex: /(?:115|anxia)\.com\/s\/([^?]+)(?:\?password=([^#]+))?/,
api: {
getShareInfo: (parsedCode: { shareCode: string; receiveCode: string }) =>
cloud115Api.getShareInfo(parsedCode.shareCode, parsedCode.receiveCode),
saveFile: (params) => cloud115Api.saveFile(params as Save115FileParams),
},
parseShareCode: (match) => ({
shareCode: match[1],
receiveCode: match[2] || "",
}),
getSaveParams: (shareInfo, folderId) => ({
shareCode: shareInfo.data.shareCode,
receiveCode: shareInfo.data.receiveCode,
fileId: shareInfo.data.list[0].fileId,
folderId,
}),
},
{
name: "夸克网盘",
type: "quark",
regex: /pan\.quark\.cn\/s\/([a-zA-Z0-9]+)/,
api: {
getShareInfo: (parsedCode: { pwdId: string }) => quarkApi.getShareInfo(parsedCode.pwdId),
saveFile: (params) => quarkApi.saveFile(params as SaveQuarkFileParams),
},
parseShareCode: (match) => ({ pwdId: match[1] }),
getSaveParams: (shareInfo, folderId) => ({
fid_list: shareInfo.data.list.map((item) => item.fileId || ""),
fid_token_list: shareInfo.data.list.map((item) => item.fileIdToken || ""),
to_pdir_fid: folderId,
pwd_id: shareInfo.data.pwdId || "",
stoken: shareInfo.data.stoken || "",
pdir_fid: "0",
scene: "link",
}),
},
];
export const useResourceStore = defineStore("resource", {
state: () => ({
resources: [] as Resource[],
selectedResources: [] as Resource[],
loading: false,
lastKeyWord: "",
backupPlan: false,
}),
actions: {
// 搜索资源
async searchResources(
keyword?: string,
backupPlan = false,
isLoadMore = false,
channelId?: string,
lastMessageId?: string
): Promise<void> {
this.loading = true;
if (!isLoadMore) this.resources = [];
try {
if (isLoadMore) {
if (!lastMessageId) {
ElMessage.error("当次搜索源不支持加载更多");
return;
}
keyword = this.lastKeyWord;
backupPlan = this.backupPlan;
}
const { data } = await resourceApi.search(
keyword || "",
backupPlan,
channelId,
lastMessageId
);
this.lastKeyWord = keyword || "";
if (isLoadMore) {
this.resources.push(
...data.filter(
(item) => !this.selectedResources.some((selectedItem) => selectedItem.id === item.id)
)
);
} else {
this.resources = data;
}
} catch (error) {
this.handleError("搜索失败,请重试", error);
} finally {
this.loading = false;
}
},
// 转存资源
async saveResource(resource: Resource, folderId: string): Promise<void> {
try {
const savePromises: Promise<void>[] = [];
CLOUD_DRIVES.forEach((drive) => {
if (resource.cloudLinks.some((link) => drive.regex.test(link))) {
savePromises.push(this.saveResourceToDrive(resource, folderId, drive));
}
});
await Promise.all(savePromises);
} catch (error) {
this.handleError("转存失败,请重试", error);
}
},
// 保存资源到网盘
async saveResourceToDrive(
resource: Resource,
folderId: string,
drive: CloudDriveConfig
): Promise<void> {
const link = resource.cloudLinks.find((link) => drive.regex.test(link));
if (!link) return;
const match = link.match(drive.regex);
if (!match) throw new Error("链接解析失败");
const parsedCode = drive.parseShareCode(match);
try {
let shareInfo = await drive.api.getShareInfo(parsedCode);
if (shareInfo?.data) {
shareInfo = {
...shareInfo,
data: {
...shareInfo.data,
...parsedCode,
},
};
}
const params = drive.getSaveParams(shareInfo, folderId);
const result = await drive.api.saveFile(params);
if (result.success) {
ElMessage.success(`${drive.name} 转存成功`);
} else {
throw new Error(result.error);
}
} catch (error) {
throw new Error(error instanceof Error ? error.message : `${drive.name} 转存失败`);
}
},
// 解析云盘链接
async parsingCloudLink(url: string): Promise<void> {
this.loading = true;
this.resources = [];
try {
const matchedDrive = CLOUD_DRIVES.find((drive) => drive.regex.test(url));
if (!matchedDrive) throw new Error("不支持的网盘链接");
const match = url.match(matchedDrive.regex);
if (!match) throw new Error("链接解析失败");
const parsedCode = matchedDrive.parseShareCode(match);
const shareInfo = await matchedDrive.api.getShareInfo(parsedCode);
if (shareInfo?.data?.list?.length) {
this.resources = [
{
id: "1",
title: shareInfo.data.list.map((item) => item.fileName).join(", "),
cloudLinks: [url],
cloudType: matchedDrive.type,
channel: matchedDrive.name,
pubDate: "",
},
];
} else {
throw new Error("解析失败,请检查链接是否正确");
}
} catch (error) {
this.handleError("解析失败,请重试", error);
} finally {
this.loading = false;
}
},
// 统一错误处理
handleError(message: string, error: unknown): void {
console.error(message, error);
ElMessage.error(error instanceof Error ? error.message : message);
},
},
});

View File

@@ -0,0 +1,63 @@
export interface Resource {
id: string;
title: string;
channel: string;
channelId?: string;
cloudLinks: string[];
pubDate: string;
cloudType: string;
messageId?: string;
}
export interface ShareInfo {
fileId: string;
fileName: string;
fileSize: number;
fileIdToken?: string;
}
export interface ShareInfoResponse {
data: {
list: ShareInfo[];
pwdId?: string;
stoken?: string;
shareCode?: string;
receiveCode?: string;
};
}
export interface Folder {
cid: string;
name: string;
path?: Folder[];
}
export interface SaveFileParams {
shareCode: string;
receiveCode: string;
fileId: string;
folderId: string;
}
export interface ApiResponse<T = any> {
success: boolean;
data?: T;
error?: string;
}
export interface Save115FileParams {
shareCode: string;
receiveCode: string;
fileId: string;
folderId: string;
}
export interface SaveQuarkFileParams {
fid_list: string[];
fid_token_list: string[];
to_pdir_fid: string;
pwd_id: string;
stoken: string;
pdir_fid: string;
scene: string;
}

View File

@@ -0,0 +1,28 @@
import axios, { AxiosResponse } from "axios";
import { ElMessage } from "element-plus";
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL as string,
timeout: 60000,
withCredentials: true,
headers: {
"Content-Type": "application/json",
},
});
request.interceptors.response.use(
(response: AxiosResponse) => {
const res = response.data;
if (!res.success) {
ElMessage.error(res.error || "请求失败");
return Promise.reject(new Error(res.error || "请求失败"));
}
return res;
},
(error) => {
ElMessage.error(error.message || "网络错误");
return Promise.reject(error);
}
);
export default request;

View File

@@ -0,0 +1,32 @@
<template>
<div class="home">
<search-bar />
<resource-list />
<el-backtop :bottom="100">
<div
style="
height: 100%;
width: 100%;
background-color: var(--el-bg-color-overlay);
box-shadow: var(--el-box-shadow-lighter);
text-align: center;
line-height: 40px;
color: #1989fa;
"
>
UP
</div>
</el-backtop>
</div>
</template>
<script setup lang="ts">
import SearchBar from "@/components/SearchBar.vue";
import ResourceList from "@/components/ResourceList.vue";
</script>
<style scoped>
.home {
padding: 20px;
}
</style>

25
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

55
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,55 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { fileURLToPath, URL } from "node:url";
import AutoImport from "unplugin-auto-import/vite";
import Components from "unplugin-vue-components/vite";
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
export default defineConfig({
base: "/",
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
server: {
host: "0.0.0.0",
port: 8008,
proxy: {
"/api": {
target: process.env.VITE_API_BASE_URL_PROXY || "http://127.0.0.1:8009",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
configure: (proxy, _options) => {
proxy.on("error", (err, _req, _res) => {
console.log("proxy error", err);
});
proxy.on("proxyReq", (proxyReq, req, _res) => {
console.log("Sending Request:", req.method, req.url);
});
proxy.on("proxyRes", (proxyRes, req, _res) => {
console.log("Received Response:", proxyRes.statusCode, req.url);
});
},
},
},
},
build: {
outDir: "dist",
assetsDir: "assets",
rollupOptions: {
input: {
main: fileURLToPath(new URL("./index.html", import.meta.url)),
},
},
},
});

32
package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "cloud-saver",
"version": "0.0.8",
"private": true,
"workspaces": [
"frontend",
"backend"
],
"scripts": {
"ins": "npm-run-all --parallel install:*",
"install:frontend": "cd frontend && npm install",
"install:backend": "cd backend && npm install",
"dev": "npm-run-all --parallel dev:*",
"dev:frontend": "cd frontend && npm run dev",
"dev:backend": "cd backend && npm run dev",
"build": "npm-run-all --parallel build:*",
"build:frontend": "cd frontend && npm run build",
"build:backend": "cd backend && npm run build",
"clean": "rimraf **/node_modules **/dist",
"format": "prettier --write \"**/*.{js,ts,vue,json,md}\"",
"format:check": "prettier --check \"**/*.{js,ts,vue,json,md}\""
},
"devDependencies": {
"npm-run-all": "^4.1.5",
"rimraf": "^5.0.5",
"prettier": "^3.2.5"
},
"dependencies": {},
"engines": {
"pnpm": ">=6.0.0"
}
}

4056
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
packages:
- 'frontend'
- 'backend'