Greasy Fork is available in English.
webdav简单客户端,支持创建文件,列目录,读取文件,写入文件,删除文件,移动/复制文件,判断文件是否存在等
此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.greasyfork.icu/scripts/554239/1687285/WebDAVClient.js
class WebDAVClient {
constructor(config) {
this.baseUrl = config.url.replace(/\/$/, '');
this.username = config.username;
this.password = config.password;
this.authHeader = 'Basic ' + btoa(`${this.username}:${this.password}`);
}
_getFullUrl(path) {
const cleanPath = path.startsWith('/') ? path : '/' + path;
return this.baseUrl + cleanPath;
}
/**
* 发送 WebDAV 请求 (使用 GM_xmlhttpRequest)
* @param {string} method - HTTP 方法
* @param {string} path - 路径
* @param {Object} options - 其他选项
* @returns {Promise<Object>} 包含 ok, status, statusText 和响应内容方法的对象
*/
async _request(method, path, options = {}) {
const url = this._getFullUrl(path);
const headers = {
'Authorization': this.authHeader,
...options.headers
};
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: method,
url: url,
headers: headers,
data: options.body,
responseType: options.responseType || 'text',
onload: function(response) {
// 模拟 fetch 的 Response 对象
const mockResponse = {
ok: response.status >= 200 && response.status < 300,
status: response.status,
statusText: response.statusText,
headers: response.responseHeaders,
// 添加响应内容获取方法
text: async () => response.responseText,
json: async () => JSON.parse(response.responseText),
arrayBuffer: async () => {
// 如果需要二进制数据,需要设置 responseType: 'arraybuffer'
if (response.response instanceof ArrayBuffer) {
return response.response;
}
// 否则尝试从文本转换
const encoder = new TextEncoder();
return encoder.encode(response.responseText).buffer;
}
};
resolve(mockResponse);
},
onerror: function(error) {
console.error(error);
reject(new Error(`请求失败: ${error.statusText || error.error}`));
},
ontimeout: function() {
reject(new Error('请求超时'));
}
});
});
}
async exists(path) {
try {
const response = await this._request('HEAD', path);
return response.ok;
} catch (error) {
console.error('exists 检查失败:', error);
return false;
}
}
async createDirectory(path, options = { recursive: true }) {
try {
if (options.recursive) {
const parts = path.split('/').filter(p => p);
let currentPath = '';
for (const part of parts) {
currentPath += '/' + part;
const exists = await this.exists(currentPath);
if (!exists) {
const response = await this._request('MKCOL', currentPath);
if (!response.ok && response.status !== 405) {
throw new Error(`创建目录失败: ${response.status} ${response.statusText}`);
}
}
}
} else {
const response = await this._request('MKCOL', path);
if (!response.ok && response.status !== 405) {
throw new Error(`创建目录失败: ${response.status} ${response.statusText}`);
}
}
} catch (error) {
console.error('createDirectory 失败:', error);
throw error;
}
}
async getFileContents(path, options = { format: 'text' }) {
try {
const requestOptions = {};
if (options.format === 'binary') {
requestOptions.responseType = 'arraybuffer';
}
const response = await this._request('GET', path, requestOptions);
if (!response.ok) {
throw new Error(`获取文件失败: ${response.status} ${response.statusText}`);
}
switch (options.format) {
case 'text':
return await response.text();
case 'binary':
return await response.arrayBuffer();
case 'json':
return await response.json();
default:
return await response.text();
}
} catch (error) {
console.error('getFileContents 失败:', error);
throw error;
}
}
async putFileContents(path, content, options = { overwrite: true }) {
try {
if (!options.overwrite) {
const exists = await this.exists(path);
if (exists) {
throw new Error('文件已存在,且未设置覆盖选项');
}
}
const headers = {};
if (options.contentType) {
headers['Content-Type'] = options.contentType;
} else if (typeof content === 'string') {
headers['Content-Type'] = 'text/plain; charset=utf-8';
}
const response = await this._request('PUT', path, {
headers,
body: content
});
if (!response.ok) {
throw new Error(`上传文件失败: ${response.status} ${response.statusText}`);
}
} catch (error) {
console.error('putFileContents 失败:', error);
throw error;
}
}
async getDirectoryContents(path, options = { recursive: false }) {
try {
// 支持传入内部 _visitedPaths 来避免循环
options = options || {};
if (!options._visitedPaths) options._visitedPaths = new Set();
// 规范化并防止重复访问
let normalizedRequestPath = path.startsWith('/') ? path : '/' + path;
// 去掉尾部斜杠
normalizedRequestPath = normalizedRequestPath.replace(/\/+$/, '');
// 如果已访问过则返回空
if (options._visitedPaths.has(normalizedRequestPath)) {
return [];
}
options._visitedPaths.add(normalizedRequestPath);
const response = await this._request('PROPFIND', normalizedRequestPath, {
headers: {
'Depth': '1'
}
});
if (!response.ok) {
throw new Error(`列出目录失败: ${response.status} ${response.statusText}`);
}
const text = await response.text();
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(text, 'text/xml');
const items = [];
// === 修改开始(使用命名空间无关的查找并正确处理 D: 前缀) ===
// 使用 getElementsByTagNameNS('*', ...) 来匹配任意命名空间的 response 节点
let responses = xmlDoc.getElementsByTagNameNS('*', 'response');
// 规范化目标 pathname(便于比较),以 baseUrl 为基础
let targetPathname;
try {
targetPathname = new URL(this._getFullUrl(normalizedRequestPath)).pathname.replace(/\/+$/, '');
} catch (e) {
targetPathname = normalizedRequestPath.replace(/\/+$/, '');
}
for (let i = 0; i < responses.length; i++) {
const resp = responses[i];
// href 节点(任意命名空间)
let hrefEl = resp.getElementsByTagNameNS('*', 'href')[0];
if (!hrefEl) {
// 兜底:在子节点中查 localName 为 'href'
for (let k = 0; k < resp.childNodes.length; k++) {
const cn = resp.childNodes[k];
if (cn && cn.localName === 'href') {
hrefEl = cn;
break;
}
}
}
if (!hrefEl || !hrefEl.textContent) continue;
const hrefRaw = hrefEl.textContent.trim();
// 用 baseUrl 做 base 来规范化 href(处理相对或绝对 href)
let hrefPathname;
try {
const hrefUrl = new URL(hrefRaw, this.baseUrl);
hrefPathname = hrefUrl.pathname.replace(/\/+$/, '');
} catch (e) {
hrefPathname = hrefRaw.replace(/\/+$/, '');
}
// 跳过目标目录自身条目
if (hrefPathname === targetPathname) {
continue;
}
// 判断是否为 collection(目录)
let isCollection = false;
const resTypeEl = resp.getElementsByTagNameNS('*', 'resourcetype')[0];
if (resTypeEl) {
for (let m = 0; m < resTypeEl.childNodes.length; m++) {
const child = resTypeEl.childNodes[m];
if (child && child.localName === 'collection') {
isCollection = true;
break;
}
}
}
// 解析 filename(取 hrefPathname 最后一段)
const parts = hrefPathname.split('/').filter(p => p);
const filename = parts.length ? decodeURIComponent(parts.pop()) : '';
items.push({
filename,
path: hrefRaw,
type: isCollection ? 'directory' : 'file',
_hrefPathname: hrefPathname //内部使用,便于递归时计算相对路径
});
}
// === 修改结束 ===
// === 新增修改:过滤 macOS AppleDouble 文件(以 ._ 开头)和 .DS_Store,然后返回 ===
let filtered = items.filter(i => {
if (!i.filename) return false;
// 过滤以 ._ 开头的 AppleDouble 文件和 .DS_Store
return !(i.filename.startsWith('._') || i.filename === '.DS_Store');
});
// === 新增:递归合并子目录(当 options.recursive 为 true 时) ===
if (options.recursive) {
// 计算 base path 的 pathname(无尾斜杠),用于把 hrefPathname 转回相对于 baseUrl 的 path 参数
let basePathname = '';
try {
basePathname = new URL(this.baseUrl).pathname.replace(/\/+$/, '');
} catch (e) {
basePathname = '';
}
// 收集要递归的目录
const dirs = filtered.filter(i => i.type === 'directory');
for (const dir of dirs) {
// dir._hrefPathname 是形如 '/dav/cursor-chat-history/sub'
let childHrefPath = dir._hrefPathname || dir.path;
// 把 basePathname 前缀剥离,得到相对于 this.baseUrl 的路径(以 / 开头)
let childRelative;
if (basePathname && childHrefPath.startsWith(basePathname)) {
childRelative = childHrefPath.slice(basePathname.length);
if (!childRelative.startsWith('/')) childRelative = '/' + childRelative;
} else {
childRelative = childHrefPath;
}
// 调用自身递归(传入同一个 options._visitedPaths 集合以防环)
const childItems = await this.getDirectoryContents(childRelative, options);
// childItems 已经过滤了 AppleDouble 等
// 把子项并入 filtered(保持扁平结构)
filtered = filtered.concat(childItems);
}
}
// 最终返回:移除内部字段 _hrefPathname
const result = filtered.map(({ _hrefPathname, ...rest }) => rest);
return result;
// === 修改结束(过滤与递归) ===
} catch (error) {
console.error('getDirectoryContents 失败:', error);
throw error;
}
}
async deleteFile(path) {
try {
const response = await this._request('DELETE', path);
if (!response.ok) {
throw new Error(`删除失败: ${response.status} ${response.statusText}`);
}
} catch (error) {
console.error('deleteFile 失败:', error);
throw error;
}
}
async moveFile(fromPath, toPath, options = { overwrite: false }) {
try {
const response = await this._request('MOVE', fromPath, {
headers: {
'Destination': this._getFullUrl(toPath),
'Overwrite': options.overwrite ? 'T' : 'F'
}
});
if (!response.ok) {
throw new Error(`移动文件失败: ${response.status} ${response.statusText}`);
}
} catch (error) {
console.error('moveFile 失败:', error);
throw error;
}
}
async copyFile(fromPath, toPath, options = { overwrite: false }) {
try {
const response = await this._request('COPY', fromPath, {
headers: {
'Destination': this._getFullUrl(toPath),
'Overwrite': options.overwrite ? 'T' : 'F'
}
});
if (!response.ok) {
throw new Error(`复制文件失败: ${response.status} ${response.statusText}`);
}
} catch (error) {
console.error('copyFile 失败:', error);
throw error;
}
}
}