cloud-drive-renamer/src/adapters/shared/shared-cloud-drive-adapter.ts
Zain d32e5ef66c feat(woozooo): 新增蓝奏云网盘支持
新增蓝奏云网盘适配,支持文件列表获取、重命名及域名注入;
更新相关文档、测试用例及多语言描述,将蓝奏云纳入支持平台。
2026-08-15 10:37:34 +08:00

545 行
20 KiB
TypeScript

import type { FileItem, PageSyncResult, PlatformConfig, PlatformName, RenameResult } from '../../types/platform';
import { BasePlatformAdapter } from '../base/adapter.interface';
import { logger } from '../../utils/logger';
import { parseFileName } from '../../utils/helpers';
export interface PageScriptAPI {
callAPI(method: string, url: string, body?: unknown, timeout?: number): Promise<unknown>;
}
export class CloudDriveAPIError extends Error {
constructor(
public readonly code: string | number | undefined,
message: string,
public readonly response?: unknown
) {
super(message);
this.name = 'CloudDriveAPIError';
}
}
export abstract class SharedCloudDriveAdapter extends BasePlatformAdapter {
private lastRequestTime = 0;
private pendingConflictListRequests = new Map<string, Promise<FileItem[]>>;
private static activeDomSyncCleanup: (() => void) | null = null;
protected constructor(config: Partial<PlatformConfig>) {
super({
requestInterval: 800,
maxRetries: 3,
timeout: 30000,
...config,
});
}
abstract readonly platform: PlatformName;
protected abstract getCurrentFolderId(): string;
protected abstract getPageScriptAPI(): PageScriptAPI;
getCurrentDirectoryKey(): string {
return this.getCurrentFolderId();
}
async getSelectedFiles(): Promise<FileItem[]> {
try {
const checkedItems = document.querySelectorAll('.ant-checkbox-checked,.is-checked input[type="checkbox"],input[type="checkbox"]:checked');
if (checkedItems.length === 0) return [];
const files: FileItem[] = [];
for (const checkbox of Array.from(checkedItems)) {
const row = checkbox.closest('tr,[role="row"],.ant-table-row,.file-item,.list-item,[data-file-id],[data-fid],[data-id]');
if (!row || row.closest('thead')) continue;
const isDir =
row.classList.contains('is-directory') ||
row.getAttribute('data-is-dir') === 'true' ||
row.getAttribute('data-type') === 'folder' ||
row.getAttribute('data-dir') === 'true';
if (isDir) continue;
const fileId =
row.getAttribute('data-file-id') ||
row.getAttribute('data-fid') ||
row.getAttribute('data-fileid') ||
row.getAttribute('data-row-key') ||
row.getAttribute('data-key') ||
row.getAttribute('data-id') ||
'';
const fileName = this.findFilenameInRow(row);
if (!fileId || !fileName) continue;
const sizeAttr = row.getAttribute('data-size');
const mtimeAttr = row.getAttribute('data-mtime') || row.getAttribute('data-updated-at');
const { ext } = parseFileName(fileName);
files.push({
id: fileId,
name: fileName,
ext,
parentId: this.getCurrentFolderId(),
size: sizeAttr ? Number.parseInt(sizeAttr, 10) : 0,
mtime: mtimeAttr ? Number.parseInt(mtimeAttr, 10) : Date.now(),
});
}
return files;
} catch (error) {
const errorObj = error instanceof Error ? error : new Error(String(error));
logger.error(`Failed to get ${this.platform} selected files:`, errorObj);
throw new Error(`获取选中文件失败: ${errorObj.message}`);
}
}
async checkNameConflict(fileName: string, parentId: string): Promise<boolean> {
try {
const files = await this.getConflictCheckFiles(parentId);
return files.some((file) => file.name === fileName);
} catch (error) {
logger.error(`Failed to check ${this.platform} name conflict:`, error instanceof Error ? error : new Error(String(error)));
return true;
}
}
async getFileInfo(fileId: string): Promise<FileItem> {
try {
const selector = `[data-file-id="${this.escapeAttribute(fileId)}"],[data-fid="${this.escapeAttribute(fileId)}"],[data-fileid="${this.escapeAttribute(fileId)}"],[data-row-key="${this.escapeAttribute(fileId)}"],[data-key="${this.escapeAttribute(fileId)}"],[data-id="${this.escapeAttribute(fileId)}"],[file_id="${this.escapeAttribute(fileId)}"]`;
const row = document.querySelector(selector);
if (!row) throw new Error(`找不到文件 ID: ${fileId}`);
const fileName = this.findFilenameInRow(row);
const sizeAttr = row.getAttribute('data-size');
const mtimeAttr = row.getAttribute('data-mtime') || row.getAttribute('data-updated-at');
const { ext } = parseFileName(fileName);
return {
id: fileId,
name: fileName,
ext,
parentId: this.getCurrentFolderId(),
size: sizeAttr ? Number.parseInt(sizeAttr, 10) : 0,
mtime: mtimeAttr ? Number.parseInt(mtimeAttr, 10) : Date.now(),
};
} catch (error) {
const errorObj = error instanceof Error ? error : new Error(String(error));
logger.error(`Failed to get ${this.platform} file info for ${fileId}:`, errorObj);
throw new Error(`获取文件信息失败: ${errorObj.message}`);
}
}
private getDomPatchDocuments(): Document[] {
const documents: Document[] = [document];
for (const frame of Array.from(document.querySelectorAll('iframe'))) {
try {
const frameDocument = frame.contentDocument;
if (frameDocument?.body && !documents.includes(frameDocument)) {
documents.push(frameDocument);
}
} catch {
// Cross-origin frames are intentionally ignored; same-origin frames can be patched.
}
}
return documents;
}
async syncAfterRename(
renames: Array<{ fileId: string; oldName?: string; newName: string }>
): Promise<PageSyncResult> {
const renameInfoById = new Map<string, { oldName?: string; newName: string }>();
const renameByOldName = new Map<string, string>();
for (const item of renames) {
if (!item?.fileId || !item?.newName) continue;
renameInfoById.set(item.fileId, { oldName: item.oldName, newName: item.newName });
if (item.oldName) renameByOldName.set(item.oldName, item.newName);
}
if (renameInfoById.size === 0) {
return { success: true, method: 'none' };
}
this.disconnectActiveDomSyncObserver();
const documents = this.getDomPatchDocuments();
let patchedById = 0;
let patchedByOldName = 0;
for (const doc of documents) {
patchedById += this.applyRenameMappingToDomById(renameInfoById, doc);
patchedByOldName += this.applyRenameMappingToDomByOldName(renameByOldName, doc);
}
const patchedCount = patchedById + patchedByOldName;
this.observeAndPatchRenamedRows(renameInfoById, renameByOldName, 15000, documents);
const rerender = this.tryTriggerNativeFileListRerender(documents);
if (rerender.triggered) {
return { success: true, method: 'ui-refresh', message: rerender.message };
}
if (patchedCount > 0) {
return { success: true, method: 'dom-patch', message: `patched ${patchedCount} nodes` };
}
return { success: true, method: 'none', message: `no visible ${this.platform} rows to patch` };
}
protected async callAPI(method: string, url: string, body?: unknown): Promise<unknown> {
await this.rateLimit();
return this.getPageScriptAPI().callAPI(method, url, body, this.config.timeout);
}
protected async retryableRename(requestFn: () => Promise<void>, fileId: string, newName: string): Promise<RenameResult> {
let lastError: unknown;
for (let attempt = 1; attempt <= this.config.maxRetries; attempt++) {
try {
await requestFn();
return { success: true, newName };
} catch (error) {
lastError = error;
if (!this.isRetryableError(error) || attempt === this.config.maxRetries) break;
const backoffDelay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
logger.warn(`重命名文件 ${fileId} 失败(第 ${attempt}/${this.config.maxRetries} 次尝试),${backoffDelay}ms 后重试:`, error instanceof Error ? error : new Error(String(error)));
await this.sleep(backoffDelay);
}
}
return {
success: false,
error: lastError instanceof Error ? lastError : new Error(String(lastError)),
};
}
protected assertSuccessful(condition: boolean, code: string | number | undefined, message: string, response?: unknown): void {
if (!condition) {
throw new CloudDriveAPIError(code, message || 'request failed', response);
}
}
protected normalizeTime(value: unknown): number {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string') {
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric > 0) return numeric;
const parsed = Date.parse(value);
if (Number.isFinite(parsed)) return parsed;
}
return Date.now();
}
private async getConflictCheckFiles(parentId: string): Promise<FileItem[]> {
const pendingRequest = this.pendingConflictListRequests.get(parentId);
if (pendingRequest) return pendingRequest;
const request = this.getAllFiles(parentId).finally(() => {
this.pendingConflictListRequests.delete(parentId);
});
this.pendingConflictListRequests.set(parentId, request);
return request;
}
private applyRenameMappingToDomById(renameInfoById: Map<string, { oldName?: string; newName: string }>, root: ParentNode = document): number {
let patched = 0;
const nodes = Array.from(root.querySelectorAll('[data-file-id],[data-fid],[data-fileid],[data-row-key],[data-key],[data-id],[file_id],.f_tb[id^="f"]'));
for (const node of nodes) {
const fileId =
node.getAttribute('data-file-id') ||
node.getAttribute('data-fid') ||
node.getAttribute('data-fileid') ||
node.getAttribute('data-row-key') ||
node.getAttribute('data-key') ||
node.getAttribute('data-id') ||
node.getAttribute('file_id') ||
node.id?.match(/^f(\d+)$/)?.[1];
if (!fileId) continue;
const info = renameInfoById.get(fileId);
if (!info) continue;
const row = node.closest('tr,[role="row"],.ant-table-row,.document_table_list,.f_tb,.file-item,.list-item,li[rel="item"],li') || node;
patched += this.patchRowElement(row, info.oldName, info.newName);
}
return patched;
}
private applyRenameMappingToDomByOldName(renameByOldName: Map<string, string>, root: ParentNode = document): number {
if (renameByOldName.size === 0) return 0;
let patched = 0;
const nodes = Array.from(root.querySelectorAll('[class*="SourceListItem__name"],.f_name_title,.touch-div,.document_table_list_name_text,.file-name,.filename,.name,.table-file-name,.table-file-name-tooltip-host,.table-list-file-name,[title],[aria-label]'));
for (const node of nodes) {
for (const [oldName, newName] of renameByOldName.entries()) {
patched += this.patchNameElement(node, oldName, newName);
}
}
return patched;
}
private patchRowElement(row: Element, oldName: string | undefined, newName: string): number {
const preferredNameNode = row.querySelector('[class*="SourceListItem__name"],.f_name_title,.touch-div,.document_table_list_name_text,.table-file-name,.table-file-name-tooltip-host,.table-list-file-name,.file-name,.filename,.name');
if (preferredNameNode && oldName) {
return this.patchStructuredNameWithinElement(preferredNameNode, oldName, newName);
}
let patched = 0;
const candidates = Array.from(row.querySelectorAll('[title],[aria-label]'));
for (const candidate of candidates) {
patched += this.patchNameElement(candidate, oldName, newName);
}
return patched;
}
private patchNameElement(node: Element, oldName: string | undefined, newName: string): number {
if (!oldName) return 0;
return this.patchStructuredNameWithinElement(node, oldName, newName);
}
private patchStructuredNameWithinElement(root: Element, oldName: string, newName: string): number {
const fullMap = new Map([[oldName, newName]]);
let patched = this.patchExactTextInElement(root, fullMap);
if (patched === 0) {
const oldParts = parseFileName(oldName);
const newParts = parseFileName(newName);
const partsMap = new Map<string, string>();
if (oldParts.name && oldParts.name !== newParts.name) partsMap.set(oldParts.name, newParts.name);
if (oldParts.ext && oldParts.ext !== newParts.ext) partsMap.set(oldParts.ext, newParts.ext);
patched += this.patchExactTextInElement(root, partsMap);
}
if (patched === 0) {
patched += this.patchDistributedTextInElement(root, oldName, newName);
}
patched += this.patchAttributesInElement(root, fullMap, 'title');
patched += this.patchAttributesInElement(root, fullMap, 'aria-label');
return patched;
}
private patchExactTextInElement(root: Node, renameByOldName: Map<string, string>): number {
if (renameByOldName.size === 0) return 0;
let patched = 0;
const ownerDocument = root.ownerDocument ?? document;
const ownerWindow = ownerDocument.defaultView ?? window;
const walker = ownerDocument.createTreeWalker(root, ownerWindow.NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
const text = node.nodeValue?.trim();
if (!text) return ownerWindow.NodeFilter.FILTER_SKIP;
return renameByOldName.has(text) ? ownerWindow.NodeFilter.FILTER_ACCEPT : ownerWindow.NodeFilter.FILTER_SKIP;
},
});
let node: Node | null;
while ((node = walker.nextNode())) {
const oldText = node.nodeValue?.trim();
if (!oldText) continue;
const next = renameByOldName.get(oldText);
if (!next) continue;
if (node.nodeValue !== next) {
node.nodeValue = next;
patched++;
}
}
return patched;
}
private patchDistributedTextInElement(root: Element, oldName: string, newName: string): number {
const ownerDocument = root.ownerDocument ?? document;
const ownerWindow = ownerDocument.defaultView ?? window;
const textNodes: Node[] = [];
const walker = ownerDocument.createTreeWalker(root, ownerWindow.NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
const text = node.nodeValue?.trim();
if (!text) return ownerWindow.NodeFilter.FILTER_SKIP;
return ownerWindow.NodeFilter.FILTER_ACCEPT;
},
});
let node: Node | null;
while ((node = walker.nextNode())) {
textNodes.push(node);
}
const combinedText = textNodes.map((textNode) => textNode.nodeValue?.trim() || '').join('');
if (combinedText !== oldName || textNodes.length === 0) return 0;
let patched = 0;
textNodes.forEach((textNode, index) => {
const nextValue = index === 0 ? newName : '';
if (textNode.nodeValue !== nextValue) {
textNode.nodeValue = nextValue;
patched++;
}
});
return patched;
}
private patchAttributesInElement(root: Element, renameByOldName: Map<string, string>, attributeName: 'title' | 'aria-label'): number {
if (renameByOldName.size === 0) return 0;
let patched = 0;
const nodes = [root, ...Array.from(root.querySelectorAll(`[${attributeName}]`))];
for (const node of nodes) {
const value = node.getAttribute(attributeName);
if (!value) continue;
const next = renameByOldName.get(value);
if (!next) continue;
if (value !== next) {
node.setAttribute(attributeName, next);
patched++;
}
}
return patched;
}
private getMutationPatchRoots(mutations: MutationRecord[], doc: Document): ParentNode[] {
const roots = new Set<ParentNode>();
const ownerWindow = doc.defaultView ?? window;
const NodeCtor = ownerWindow.Node;
const rowSelector = 'tr,[role="row"],.ant-table-row,.document_table_list,.f_tb,.file-item,.list-item,li[rel="item"],li';
const addNode = (node: Node | null) => {
if (!node) return;
const element = node.nodeType === NodeCtor.ELEMENT_NODE ? node as Element : node.parentElement;
if (!element || !doc.documentElement.contains(element)) return;
roots.add(element.closest(rowSelector) || element);
};
for (const mutation of mutations) {
addNode(mutation.target);
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(addNode);
}
}
return Array.from(roots);
}
private disconnectActiveDomSyncObserver(): void {
const cleanup = SharedCloudDriveAdapter.activeDomSyncCleanup;
SharedCloudDriveAdapter.activeDomSyncCleanup = null;
cleanup?.();
}
private observeAndPatchRenamedRows(
renameInfoById: Map<string, { oldName?: string; newName: string }>,
renameByOldName: Map<string, string>,
durationMs: number,
documents: Document[] = this.getDomPatchDocuments()
): void {
if (typeof MutationObserver === 'undefined') return;
const observers: MutationObserver[] = [];
const cleanupCallbacks: Array<() => void> = [];
for (const doc of documents) {
if (!doc.body) continue;
const observerWindow = doc.defaultView ?? window;
const Observer = observerWindow.MutationObserver ?? MutationObserver;
const pendingRoots = new Set<ParentNode>();
let flushTimer: number | null = null;
const flush = () => {
flushTimer = null;
const roots = Array.from(pendingRoots);
pendingRoots.clear();
for (const root of roots) {
this.applyRenameMappingToDomById(renameInfoById, root);
this.applyRenameMappingToDomByOldName(renameByOldName, root);
}
};
const observer = new Observer((mutations) => {
for (const root of this.getMutationPatchRoots(mutations, doc)) {
pendingRoots.add(root);
}
if (pendingRoots.size === 0 || flushTimer !== null) return;
flushTimer = observerWindow.setTimeout(flush, 0);
});
observer.observe(doc.body, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
attributeFilter: ['title', 'aria-label'],
});
observers.push(observer);
cleanupCallbacks.push(() => {
if (flushTimer !== null) {
observerWindow.clearTimeout(flushTimer);
flushTimer = null;
}
});
}
let timeoutId: number | null = null;
let cleaned = false;
const cleanup = () => {
if (cleaned) return;
cleaned = true;
if (timeoutId !== null) {
window.clearTimeout(timeoutId);
timeoutId = null;
}
for (const clearPendingFlush of cleanupCallbacks) clearPendingFlush();
for (const observer of observers) observer.disconnect();
if (SharedCloudDriveAdapter.activeDomSyncCleanup === cleanup) {
SharedCloudDriveAdapter.activeDomSyncCleanup = null;
}
};
timeoutId = window.setTimeout(cleanup, durationMs);
SharedCloudDriveAdapter.activeDomSyncCleanup = cleanup;
}
private tryTriggerNativeFileListRerender(documents: Document[] = this.getDomPatchDocuments()): { triggered: boolean; message?: string } {
for (const doc of documents) {
const controls = Array.from(doc.querySelectorAll('button,[role="button"],[title],[aria-label]'));
const refreshControl = controls.find((control) => {
const text = control.textContent?.trim() || '';
const title = control.getAttribute('title') || '';
const ariaLabel = control.getAttribute('aria-label') || '';
return /刷新|refresh|重新加载|reload/i.test(`${text} ${title} ${ariaLabel}`);
});
if (refreshControl && typeof (refreshControl as HTMLElement).click === 'function') {
(refreshControl as HTMLElement).click();
return { triggered: true, message: `triggered ${this.platform} native refresh control` };
}
}
return { triggered: false };
}
private async rateLimit(): Promise<void> {
const now = Date.now();
const elapsed = now - this.lastRequestTime;
const waitTime = this.config.requestInterval - elapsed;
if (waitTime > 0) await this.sleep(waitTime);
this.lastRequestTime = Date.now();
}
private findFilenameInRow(row: Element): string {
return (
row.querySelector('[class*="SourceListItem__name"],.f_name_title,.touch-div,.document_table_list_name_text,.table-file-name,.table-file-name-tooltip-host,.table-list-file-name,.file-name,.filename,.name,[title]')?.textContent?.trim() ||
row.getAttribute('title') ||
row.getAttribute('aria-label') ||
''
);
}
private escapeAttribute(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
private isRetryableError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
if (/timeout|network|fetch|abort/i.test(error.message)) return true;
if (error instanceof CloudDriveAPIError) {
const numericCode = Number(error.code);
return numericCode === 429 || numericCode >= 500;
}
return false;
}
}