镜像自地址
https://github.com/lhly/cloud-drive-renamer.git
已同步 2026-10-22 14:25:41 +08:00
refactor(cmcc): 改进API调用响应解析与平台检测
- 添加 `normalizeResponse` 方法处理嵌套 JSON 字符串响应 - 统一成功状态码验证逻辑 - 使用 XHR 传输保留请求头大小写 - 修复 SPA 根路径 `/w/` 的平台检测 - 添加 DOM 同步观察器断开逻辑
这个提交包含在:
父节点
cb32e8aa55
当前提交
cefc989c20
@ -68,16 +68,18 @@ export class CMCCAdapter extends SharedCloudDriveAdapter {
|
|||||||
startNumber: cursor.type === 'initial' ? 1 : cursor.value,
|
startNumber: cursor.type === 'initial' ? 1 : cursor.value,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await this.callAPI(
|
const response = this.normalizeResponse<CMCCListResponse>(await this.callAPI(
|
||||||
'POST',
|
'POST',
|
||||||
'https://personal-kd-njs.yun.139.com/hcy/file/list',
|
'https://personal-kd-njs.yun.139.com/hcy/file/list',
|
||||||
this.createSignedJsonBody(payload, signPayload)
|
this.createSignedJsonBody(payload, signPayload)
|
||||||
) as CMCCListResponse;
|
));
|
||||||
this.assertSuccessful(String(response.code ?? '0') === '0' || String(response.code ?? '0') === '200', response.code, response.message || '移动云盘列表获取失败', response);
|
this.assertSuccessful(this.isSuccessfulResponseCode(response.code), response.code, response.message || '移动云盘列表获取失败', response);
|
||||||
|
|
||||||
const list = response.data?.items;
|
const list = response.data?.items;
|
||||||
if (!Array.isArray(list)) {
|
if (!Array.isArray(list)) {
|
||||||
throw new Error('移动云盘列表获取失败: missing file list');
|
const responseKeys = Object.keys(response || {}).join(',') || 'none';
|
||||||
|
const dataKeys = response.data ? Object.keys(response.data).join(',') || 'none' : 'none';
|
||||||
|
throw new Error(`移动云盘列表获取失败: missing file list (response keys: ${responseKeys}; data keys: ${dataKeys})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
files.push(...list.filter((item) => item.type !== 'folder').map((item) => this.toFileItem(item, targetParentId)));
|
files.push(...list.filter((item) => item.type !== 'folder').map((item) => this.toFileItem(item, targetParentId)));
|
||||||
@ -98,13 +100,12 @@ export class CMCCAdapter extends SharedCloudDriveAdapter {
|
|||||||
contentID: fileId,
|
contentID: fileId,
|
||||||
contentName: newName,
|
contentName: newName,
|
||||||
};
|
};
|
||||||
const response = await this.callAPI(
|
const response = this.normalizeResponse<CMCCUpdateResponse>(await this.callAPI(
|
||||||
'POST',
|
'POST',
|
||||||
'https://personal-kd-njs.yun.139.com/hcy/file/update',
|
'https://personal-kd-njs.yun.139.com/hcy/file/update',
|
||||||
this.createSignedJsonBody(payload, signPayload)
|
this.createSignedJsonBody(payload, signPayload)
|
||||||
) as CMCCUpdateResponse;
|
));
|
||||||
const code = String(response.code ?? '0');
|
this.assertSuccessful(this.isSuccessfulResponseCode(response.code), response.code, response.message || '移动云盘重命名失败', response);
|
||||||
this.assertSuccessful(code === '0' || code === '200' || code.toLowerCase() === 'success', response.code, response.message || '移动云盘重命名失败', response);
|
|
||||||
}, fileId, newName);
|
}, fileId, newName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,6 +117,40 @@ export class CMCCAdapter extends SharedCloudDriveAdapter {
|
|||||||
return getCMCCPageScriptInjector();
|
return getCMCCPageScriptInjector();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isSuccessfulResponseCode(code: string | number | undefined): boolean {
|
||||||
|
const normalized = String(code ?? '0').toLowerCase();
|
||||||
|
return normalized === '0' || normalized === '0000' || normalized === '200' || normalized === 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeResponse<T>(response: unknown): T {
|
||||||
|
let current = response;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
if (typeof current === 'string') {
|
||||||
|
const text = current.trim();
|
||||||
|
if (!text) return {} as T;
|
||||||
|
try {
|
||||||
|
current = JSON.parse(text) as unknown;
|
||||||
|
continue;
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current && typeof current === 'object' && !Array.isArray(current)) {
|
||||||
|
const text = (current as { text?: unknown }).text;
|
||||||
|
if (typeof text === 'string') {
|
||||||
|
current = text;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return current as T;
|
||||||
|
}
|
||||||
|
|
||||||
private toFileItem(item: CMCCFileItem, parentId: string): FileItem {
|
private toFileItem(item: CMCCFileItem, parentId: string): FileItem {
|
||||||
const name = item.name || '';
|
const name = item.name || '';
|
||||||
const parsed = parseFileName(name);
|
const parsed = parseFileName(name);
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import { installCloudDrivePageScript } from '../shared/page-script';
|
import { installCloudDrivePageScript, type PageScriptInstallOptions } from '../shared/page-script';
|
||||||
|
|
||||||
export const CMCC_PAGE_SCRIPT_OPTIONS = {
|
export const CMCC_PAGE_SCRIPT_OPTIONS: PageScriptInstallOptions = {
|
||||||
requestType: 'CMCC_API_REQUEST',
|
requestType: 'CMCC_API_REQUEST',
|
||||||
responseType: 'CMCC_API_RESPONSE',
|
responseType: 'CMCC_API_RESPONSE',
|
||||||
readyFlagName: '__CMCC_PAGE_SCRIPT_READY__',
|
readyFlagName: '__CMCC_PAGE_SCRIPT_READY__',
|
||||||
datasetReadyKey: 'cmccPageScriptReady',
|
datasetReadyKey: 'cmccPageScriptReady',
|
||||||
datasetTimestampKey: 'cmccPageScriptTimestamp',
|
datasetTimestampKey: 'cmccPageScriptTimestamp',
|
||||||
logPrefix: 'CMCCPageScript',
|
logPrefix: 'CMCCPageScript',
|
||||||
|
transport: 'xhr',
|
||||||
captureHeaders: [
|
captureHeaders: [
|
||||||
'caller',
|
'caller',
|
||||||
'Cms-Device',
|
'Cms-Device',
|
||||||
@ -20,7 +21,6 @@ export const CMCC_PAGE_SCRIPT_OPTIONS = {
|
|||||||
'x-inner-ntwk',
|
'x-inner-ntwk',
|
||||||
'x-m4c-caller',
|
'x-m4c-caller',
|
||||||
'INNER-HCY-ROUTER-HTTPS',
|
'INNER-HCY-ROUTER-HTTPS',
|
||||||
'hcy-cool-flag',
|
|
||||||
'X-Svctype',
|
'X-Svctype',
|
||||||
'x-yun-Api-Version',
|
'x-yun-Api-Version',
|
||||||
'x-yun-channel-source',
|
'x-yun-channel-source',
|
||||||
|
|||||||
@ -6,6 +6,7 @@ export interface PageScriptInstallOptions {
|
|||||||
datasetTimestampKey: string;
|
datasetTimestampKey: string;
|
||||||
logPrefix: string;
|
logPrefix: string;
|
||||||
captureHeaders?: string[];
|
captureHeaders?: string[];
|
||||||
|
transport?: 'fetch' | 'xhr';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface APIRequestMessage {
|
interface APIRequestMessage {
|
||||||
@ -98,6 +99,65 @@ export function markPageScriptReady(options: PageScriptInstallOptions, timestamp
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseResponseText(text: string): unknown {
|
||||||
|
if (!text) return {};
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return { text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestWithFetch(
|
||||||
|
message: Partial<APIRequestMessage>,
|
||||||
|
encoded: { body?: BodyInit; headers?: Record<string, string> },
|
||||||
|
capturedHeaders: Map<string, string>,
|
||||||
|
signal: AbortSignal
|
||||||
|
): Promise<{ success: boolean; status: number; body: unknown }> {
|
||||||
|
const headers = new Headers();
|
||||||
|
for (const [key, value] of capturedHeaders.entries()) headers.set(key, value);
|
||||||
|
appendHeaders(headers, encoded.headers);
|
||||||
|
|
||||||
|
const response = await fetch(message.url || '', {
|
||||||
|
method: message.method,
|
||||||
|
credentials: 'include',
|
||||||
|
headers,
|
||||||
|
body: message.method?.toUpperCase() === 'GET' ? undefined : encoded.body,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
return { success: response.ok, status: response.status, body: parseResponseText(text) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestWithXhr(
|
||||||
|
message: Partial<APIRequestMessage>,
|
||||||
|
encoded: { body?: BodyInit; headers?: Record<string, string> },
|
||||||
|
capturedHeaders: Map<string, string>
|
||||||
|
): Promise<{ success: boolean; status: number; body: unknown }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open(message.method || 'GET', message.url || '', true);
|
||||||
|
xhr.withCredentials = true;
|
||||||
|
if (message.timeout) xhr.timeout = message.timeout;
|
||||||
|
|
||||||
|
for (const [key, value] of capturedHeaders.entries()) xhr.setRequestHeader(key, value);
|
||||||
|
for (const [key, value] of Object.entries(encoded.headers || {})) xhr.setRequestHeader(key, value);
|
||||||
|
|
||||||
|
xhr.onreadystatechange = () => {
|
||||||
|
if (xhr.readyState !== XMLHttpRequest.DONE) return;
|
||||||
|
resolve({
|
||||||
|
success: xhr.status >= 200 && xhr.status < 300,
|
||||||
|
status: xhr.status,
|
||||||
|
body: parseResponseText(xhr.responseText || ''),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
xhr.onerror = () => reject(new Error(`XHR request failed: ${message.url}`));
|
||||||
|
xhr.ontimeout = () => reject(new Error(`XHR request timeout: ${message.url}`));
|
||||||
|
|
||||||
|
xhr.send(message.method?.toUpperCase() === 'GET' ? undefined : (encoded.body as XMLHttpRequestBodyInit | undefined));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function installCloudDrivePageScript(options: PageScriptInstallOptions): void {
|
export function installCloudDrivePageScript(options: PageScriptInstallOptions): void {
|
||||||
const capturedHeaders = new Map<string, string>();
|
const capturedHeaders = new Map<string, string>();
|
||||||
const allowHeader = new Set((options.captureHeaders || []).map(normalizeHeaderName));
|
const allowHeader = new Set((options.captureHeaders || []).map(normalizeHeaderName));
|
||||||
@ -133,35 +193,17 @@ export function installCloudDrivePageScript(options: PageScriptInstallOptions):
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const encoded = createBodyAndHeaders(message.body);
|
const encoded = createBodyAndHeaders(message.body);
|
||||||
const headers = new Headers();
|
const data = options.transport === 'xhr'
|
||||||
for (const [key, value] of capturedHeaders.entries()) headers.set(key, value);
|
? await requestWithXhr(message, encoded, capturedHeaders)
|
||||||
appendHeaders(headers, encoded.headers);
|
: await requestWithFetch(message, encoded, capturedHeaders, controller.signal);
|
||||||
|
|
||||||
const response = await fetch(message.url, {
|
|
||||||
method: message.method,
|
|
||||||
credentials: 'include',
|
|
||||||
headers,
|
|
||||||
body: message.method.toUpperCase() === 'GET' ? undefined : encoded.body,
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
window.clearTimeout(timeoutId);
|
window.clearTimeout(timeoutId);
|
||||||
|
|
||||||
const text = await response.text();
|
|
||||||
let data: unknown = {};
|
|
||||||
if (text) {
|
|
||||||
try {
|
|
||||||
data = JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
data = { text };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.postMessage({
|
window.postMessage({
|
||||||
type: options.responseType,
|
type: options.responseType,
|
||||||
requestId: message.requestId,
|
requestId: message.requestId,
|
||||||
success: response.ok,
|
success: data.success,
|
||||||
data,
|
data: data.body,
|
||||||
status: response.status,
|
status: data.status,
|
||||||
}, '*');
|
}, '*');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.clearTimeout(timeoutId);
|
window.clearTimeout(timeoutId);
|
||||||
|
|||||||
@ -21,6 +21,7 @@ export class CloudDriveAPIError extends Error {
|
|||||||
export abstract class SharedCloudDriveAdapter extends BasePlatformAdapter {
|
export abstract class SharedCloudDriveAdapter extends BasePlatformAdapter {
|
||||||
private lastRequestTime = 0;
|
private lastRequestTime = 0;
|
||||||
private pendingConflictListRequests = new Map<string, Promise<FileItem[]>>;
|
private pendingConflictListRequests = new Map<string, Promise<FileItem[]>>;
|
||||||
|
private static activeDomSyncCleanup: (() => void) | null = null;
|
||||||
|
|
||||||
protected constructor(config: Partial<PlatformConfig>) {
|
protected constructor(config: Partial<PlatformConfig>) {
|
||||||
super({
|
super({
|
||||||
@ -156,6 +157,8 @@ export abstract class SharedCloudDriveAdapter extends BasePlatformAdapter {
|
|||||||
return { success: true, method: 'none' };
|
return { success: true, method: 'none' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.disconnectActiveDomSyncObserver();
|
||||||
|
|
||||||
const documents = this.getDomPatchDocuments();
|
const documents = this.getDomPatchDocuments();
|
||||||
let patchedById = 0;
|
let patchedById = 0;
|
||||||
let patchedByOldName = 0;
|
let patchedByOldName = 0;
|
||||||
@ -251,7 +254,7 @@ export abstract class SharedCloudDriveAdapter extends BasePlatformAdapter {
|
|||||||
const info = renameInfoById.get(fileId);
|
const info = renameInfoById.get(fileId);
|
||||||
if (!info) continue;
|
if (!info) continue;
|
||||||
|
|
||||||
const row = node.closest('tr,[role="row"],.ant-table-row,.file-item,.list-item,li[rel="item"],li') || node;
|
const row = node.closest('tr,[role="row"],.ant-table-row,.document_table_list,.file-item,.list-item,li[rel="item"],li') || node;
|
||||||
patched += this.patchRowElement(row, info.oldName, info.newName);
|
patched += this.patchRowElement(row, info.oldName, info.newName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -262,7 +265,7 @@ export abstract class SharedCloudDriveAdapter extends BasePlatformAdapter {
|
|||||||
if (renameByOldName.size === 0) return 0;
|
if (renameByOldName.size === 0) return 0;
|
||||||
|
|
||||||
let patched = 0;
|
let patched = 0;
|
||||||
const nodes = Array.from(root.querySelectorAll('.file-name,.filename,.name,.table-file-name,.table-file-name-tooltip-host,.table-list-file-name,[title],[aria-label]'));
|
const nodes = Array.from(root.querySelectorAll('.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 node of nodes) {
|
||||||
for (const [oldName, newName] of renameByOldName.entries()) {
|
for (const [oldName, newName] of renameByOldName.entries()) {
|
||||||
patched += this.patchNameElement(node, oldName, newName);
|
patched += this.patchNameElement(node, oldName, newName);
|
||||||
@ -272,7 +275,7 @@ export abstract class SharedCloudDriveAdapter extends BasePlatformAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private patchRowElement(row: Element, oldName: string | undefined, newName: string): number {
|
private patchRowElement(row: Element, oldName: string | undefined, newName: string): number {
|
||||||
const preferredNameNode = row.querySelector('.table-file-name,.table-file-name-tooltip-host,.table-list-file-name,.file-name,.filename,.name');
|
const preferredNameNode = row.querySelector('.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) {
|
if (preferredNameNode && oldName) {
|
||||||
return this.patchStructuredNameWithinElement(preferredNameNode, oldName, newName);
|
return this.patchStructuredNameWithinElement(preferredNameNode, oldName, newName);
|
||||||
}
|
}
|
||||||
@ -393,7 +396,7 @@ export abstract class SharedCloudDriveAdapter extends BasePlatformAdapter {
|
|||||||
const roots = new Set<ParentNode>();
|
const roots = new Set<ParentNode>();
|
||||||
const ownerWindow = doc.defaultView ?? window;
|
const ownerWindow = doc.defaultView ?? window;
|
||||||
const NodeCtor = ownerWindow.Node;
|
const NodeCtor = ownerWindow.Node;
|
||||||
const rowSelector = 'tr,[role="row"],.ant-table-row,.file-item,.list-item,li[rel="item"],li';
|
const rowSelector = 'tr,[role="row"],.ant-table-row,.document_table_list,.file-item,.list-item,li[rel="item"],li';
|
||||||
|
|
||||||
const addNode = (node: Node | null) => {
|
const addNode = (node: Node | null) => {
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
@ -412,6 +415,12 @@ export abstract class SharedCloudDriveAdapter extends BasePlatformAdapter {
|
|||||||
return Array.from(roots);
|
return Array.from(roots);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private disconnectActiveDomSyncObserver(): void {
|
||||||
|
const cleanup = SharedCloudDriveAdapter.activeDomSyncCleanup;
|
||||||
|
SharedCloudDriveAdapter.activeDomSyncCleanup = null;
|
||||||
|
cleanup?.();
|
||||||
|
}
|
||||||
|
|
||||||
private observeAndPatchRenamedRows(
|
private observeAndPatchRenamedRows(
|
||||||
renameInfoById: Map<string, { oldName?: string; newName: string }>,
|
renameInfoById: Map<string, { oldName?: string; newName: string }>,
|
||||||
renameByOldName: Map<string, string>,
|
renameByOldName: Map<string, string>,
|
||||||
@ -458,14 +467,29 @@ export abstract class SharedCloudDriveAdapter extends BasePlatformAdapter {
|
|||||||
cleanupCallbacks.push(() => {
|
cleanupCallbacks.push(() => {
|
||||||
if (flushTimer !== null) {
|
if (flushTimer !== null) {
|
||||||
observerWindow.clearTimeout(flushTimer);
|
observerWindow.clearTimeout(flushTimer);
|
||||||
|
flushTimer = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
window.setTimeout(() => {
|
let timeoutId: number | null = null;
|
||||||
for (const cleanup of cleanupCallbacks) cleanup();
|
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();
|
for (const observer of observers) observer.disconnect();
|
||||||
}, durationMs);
|
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 } {
|
private tryTriggerNativeFileListRerender(documents: Document[] = this.getDomPatchDocuments()): { triggered: boolean; message?: string } {
|
||||||
@ -496,7 +520,7 @@ export abstract class SharedCloudDriveAdapter extends BasePlatformAdapter {
|
|||||||
|
|
||||||
private findFilenameInRow(row: Element): string {
|
private findFilenameInRow(row: Element): string {
|
||||||
return (
|
return (
|
||||||
row.querySelector('.table-file-name,.table-file-name-tooltip-host,.table-list-file-name,.file-name,.filename,.name,[title]')?.textContent?.trim() ||
|
row.querySelector('.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('title') ||
|
||||||
row.getAttribute('aria-label') ||
|
row.getAttribute('aria-label') ||
|
||||||
''
|
''
|
||||||
|
|||||||
@ -108,7 +108,8 @@ export function detectPlatformFromUrl(url: string, pathname?: string): PlatformN
|
|||||||
|
|
||||||
// 移动云盘
|
// 移动云盘
|
||||||
if (url.includes('yun.139.com')) {
|
if (url.includes('yun.139.com')) {
|
||||||
return url.includes('/w/#/main') || url.includes('/w/#/index') ? 'cmcc' : null;
|
const pathToCheck = pathname ?? extractPathnameFromUrl(url);
|
||||||
|
return pathToCheck === '/w/' || url.includes('/w/#/main') || url.includes('/w/#/index') ? 'cmcc' : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 天翼云盘
|
// 天翼云盘
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { EsurfingAdapter } from '../../../src/adapters/esurfing/adapter';
|
|||||||
import { getDrive115PageScriptInjector } from '../../../src/adapters/115/page-script-injector';
|
import { getDrive115PageScriptInjector } from '../../../src/adapters/115/page-script-injector';
|
||||||
import { getDrive123PageScriptInjector } from '../../../src/adapters/123/page-script-injector';
|
import { getDrive123PageScriptInjector } from '../../../src/adapters/123/page-script-injector';
|
||||||
import { getCMCCPageScriptInjector } from '../../../src/adapters/cmcc/page-script-injector';
|
import { getCMCCPageScriptInjector } from '../../../src/adapters/cmcc/page-script-injector';
|
||||||
|
import { CMCC_PAGE_SCRIPT_OPTIONS } from '../../../src/adapters/cmcc/page-script';
|
||||||
import { getEsurfingPageScriptInjector } from '../../../src/adapters/esurfing/page-script-injector';
|
import { getEsurfingPageScriptInjector } from '../../../src/adapters/esurfing/page-script-injector';
|
||||||
|
|
||||||
vi.mock('../../../src/adapters/115/page-script-injector', () => ({
|
vi.mock('../../../src/adapters/115/page-script-injector', () => ({
|
||||||
@ -133,6 +134,38 @@ describe('missing platform adapters', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not duplicate CMCC explicit auth headers or capture encryption-triggering headers', () => {
|
||||||
|
const headers = CMCC_PAGE_SCRIPT_OPTIONS.captureHeaders?.map((header) => header.toLowerCase());
|
||||||
|
expect(headers).not.toContain('authorization');
|
||||||
|
expect(headers).not.toContain('hcy-cool-flag');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses CMCC list responses that arrive as JSON strings', async () => {
|
||||||
|
const adapter = new CMCCAdapter({ requestInterval: 0 });
|
||||||
|
localStorage.setItem('currentCatalogID', 'folder139');
|
||||||
|
mockCMCCCallAPI.mockResolvedValue(JSON.stringify({
|
||||||
|
code: '0000',
|
||||||
|
message: '请求成功',
|
||||||
|
data: {
|
||||||
|
items: [{ fileId: 'file-139', name: 'Episode 01.mkv', parentFileId: 'folder139', fileExtension: 'mkv', size: 56, updatedAt: 789, type: 'file' }],
|
||||||
|
nextPageCursor: null,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const files = await adapter.getAllFiles();
|
||||||
|
|
||||||
|
expect(files[0]).toEqual({ id: 'file-139', name: 'Episode 01.mkv', ext: '.mkv', parentId: 'folder139', size: 56, mtime: 789 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts CMCC rename responses with the real success code', async () => {
|
||||||
|
const adapter = new CMCCAdapter({ requestInterval: 0 });
|
||||||
|
mockCMCCCallAPI.mockResolvedValue({ code: '0000', message: '请求成功' });
|
||||||
|
|
||||||
|
const result = await adapter.renameFile('file-139', 'Episode 01-fixed.mkv');
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true, newName: 'Episode 01-fixed.mkv' });
|
||||||
|
});
|
||||||
|
|
||||||
it('uses CMCC APIs and deduplicates concurrent conflict checks', async () => {
|
it('uses CMCC APIs and deduplicates concurrent conflict checks', async () => {
|
||||||
const adapter = new CMCCAdapter({ requestInterval: 0 });
|
const adapter = new CMCCAdapter({ requestInterval: 0 });
|
||||||
Object.defineProperty(window, 'location', {
|
Object.defineProperty(window, 'location', {
|
||||||
@ -223,6 +256,14 @@ describe('missing platform adapters', () => {
|
|||||||
await expect(adapter.getAllFiles()).rejects.toThrow('list failed');
|
await expect(adapter.getAllFiles()).rejects.toThrow('list failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reports CMCC response shape when the list payload is missing', async () => {
|
||||||
|
const adapter = new CMCCAdapter({ requestInterval: 0 });
|
||||||
|
localStorage.setItem('currentCatalogID', 'folder139');
|
||||||
|
mockCMCCCallAPI.mockResolvedValue({ code: 0, data: { catalogList: [] } });
|
||||||
|
|
||||||
|
await expect(adapter.getAllFiles()).rejects.toThrow('response keys: code,data; data keys: catalogList');
|
||||||
|
});
|
||||||
|
|
||||||
it('throws instead of returning an empty list when Esurfing list API fails', async () => {
|
it('throws instead of returning an empty list when Esurfing list API fails', async () => {
|
||||||
const adapter = new EsurfingAdapter({ requestInterval: 0 });
|
const adapter = new EsurfingAdapter({ requestInterval: 0 });
|
||||||
Object.defineProperty(window, 'location', {
|
Object.defineProperty(window, 'location', {
|
||||||
@ -342,6 +383,65 @@ describe('missing platform adapters', () => {
|
|||||||
expect(nameNode?.textContent).toBe('WADBS_1.3.apk');
|
expect(nameNode?.textContent).toBe('WADBS_1.3.apk');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('patches CMCC visible rows with document table filename classes after rename', async () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div class="main_file_list">
|
||||||
|
<div class="document_table_list">
|
||||||
|
<div class="document_table_list_name name-col-3-item">
|
||||||
|
<div class="document_table_list_name_text">
|
||||||
|
<div class="touch-div"><span>未命名项目-图层 1.png</span></div>
|
||||||
|
<span class="mirror-name">未命名项目-图层 1.png</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="document_table_list_time">今天 11:40</div>
|
||||||
|
<div class="document_table_list_size">246.59KB</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const adapter = new CMCCAdapter({ requestInterval: 0 });
|
||||||
|
const result = await adapter.syncAfterRename([
|
||||||
|
{ fileId: 'file-139', oldName: '未命名项目-图层 1.png', newName: 'test-未命名项目-图层 1.png' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const nameNode = document.querySelector<HTMLElement>('.touch-div');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.method).toBe('dom-patch');
|
||||||
|
expect(nameNode?.textContent).toBe('test-未命名项目-图层 1.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disconnects older DOM sync observers before undo sync starts', async () => {
|
||||||
|
document.body.innerHTML = '<main id="file-list"></main>';
|
||||||
|
|
||||||
|
const adapter = new CMCCAdapter({ requestInterval: 0 });
|
||||||
|
await adapter.syncAfterRename([
|
||||||
|
{ fileId: 'file-139', oldName: '未命名项目-图层 1.png', newName: 'test-未命名项目-图层 1.png' },
|
||||||
|
]);
|
||||||
|
await adapter.syncAfterRename([
|
||||||
|
{ fileId: 'file-139', oldName: 'test-未命名项目-图层 1.png', newName: '未命名项目-图层 1.png' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const observedNames: string[] = [];
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const text = document.querySelector<HTMLElement>('.touch-div')?.textContent?.trim();
|
||||||
|
if (text) observedNames.push(text);
|
||||||
|
});
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
|
||||||
|
|
||||||
|
document.querySelector('#file-list')!.insertAdjacentHTML('beforeend', `
|
||||||
|
<div class="document_table_list">
|
||||||
|
<div class="document_table_list_name_text">
|
||||||
|
<div class="touch-div"><span>未命名项目-图层 1.png</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 20));
|
||||||
|
observer.disconnect();
|
||||||
|
|
||||||
|
expect(observedNames).not.toContain('test-未命名项目-图层 1.png');
|
||||||
|
expect(document.querySelector<HTMLElement>('.touch-div')?.textContent).toBe('未命名项目-图层 1.png');
|
||||||
|
});
|
||||||
|
|
||||||
it('patches later 123Pan row mutations without rescanning the whole document', async () => {
|
it('patches later 123Pan row mutations without rescanning the whole document', async () => {
|
||||||
document.body.innerHTML = '<main id="file-list"></main>';
|
document.body.innerHTML = '<main id="file-list"></main>';
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
import { markPageScriptReady } from '../../../src/adapters/shared/page-script';
|
import { installCloudDrivePageScript, markPageScriptReady } from '../../../src/adapters/shared/page-script';
|
||||||
|
import { CMCC_PAGE_SCRIPT_OPTIONS } from '../../../src/adapters/cmcc/page-script';
|
||||||
|
|
||||||
describe('shared cloud drive page script', () => {
|
describe('shared cloud drive page script', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -22,4 +23,82 @@ describe('shared cloud drive page script', () => {
|
|||||||
|
|
||||||
Object.defineProperty(document, 'body', { value: body, configurable: true });
|
Object.defineProperty(document, 'body', { value: body, configurable: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses XHR transport for CMCC so request header casing is preserved', () => {
|
||||||
|
expect(CMCC_PAGE_SCRIPT_OPTIONS.transport).toBe('xhr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends API requests with original header casing in XHR transport', async () => {
|
||||||
|
const sentHeaders: Array<[string, string]> = [];
|
||||||
|
const openedRequests: Array<{ method: string; url: string }> = [];
|
||||||
|
|
||||||
|
class FakeXHR {
|
||||||
|
static DONE = 4;
|
||||||
|
readyState = 0;
|
||||||
|
status = 200;
|
||||||
|
responseText = '{"success":true}';
|
||||||
|
withCredentials = false;
|
||||||
|
onreadystatechange: (() => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
ontimeout: (() => void) | null = null;
|
||||||
|
timeout = 0;
|
||||||
|
|
||||||
|
open(method: string, url: string): void {
|
||||||
|
openedRequests.push({ method, url });
|
||||||
|
}
|
||||||
|
|
||||||
|
setRequestHeader(name: string, value: string): void {
|
||||||
|
sentHeaders.push([name, value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
send(): void {
|
||||||
|
this.readyState = FakeXHR.DONE;
|
||||||
|
this.onreadystatechange?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalXHR = window.XMLHttpRequest;
|
||||||
|
Object.defineProperty(window, 'XMLHttpRequest', { value: FakeXHR, configurable: true });
|
||||||
|
|
||||||
|
installCloudDrivePageScript({
|
||||||
|
requestType: 'TEST_XHR_REQUEST',
|
||||||
|
responseType: 'TEST_XHR_RESPONSE',
|
||||||
|
readyFlagName: '__TEST_XHR_READY__',
|
||||||
|
datasetReadyKey: 'testXhrReady',
|
||||||
|
datasetTimestampKey: 'testXhrTimestamp',
|
||||||
|
logPrefix: 'TestXHR',
|
||||||
|
transport: 'xhr',
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestEvent = new MessageEvent('message', {
|
||||||
|
data: {
|
||||||
|
type: 'TEST_XHR_REQUEST',
|
||||||
|
requestId: 'request-1',
|
||||||
|
method: 'POST',
|
||||||
|
url: 'https://example.com/api',
|
||||||
|
body: {
|
||||||
|
bodyMode: 'json',
|
||||||
|
data: { ok: true },
|
||||||
|
headers: {
|
||||||
|
'CMS-DEVICE': 'default',
|
||||||
|
'INNER-HCY-ROUTER-HTTPS': '1',
|
||||||
|
'X-SvcType': '1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(requestEvent, 'source', { value: window });
|
||||||
|
window.dispatchEvent(requestEvent);
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(openedRequests).toEqual([{ method: 'POST', url: 'https://example.com/api' }]);
|
||||||
|
expect(sentHeaders).toEqual(expect.arrayContaining([
|
||||||
|
['Content-Type', 'application/json;charset=UTF-8'],
|
||||||
|
['CMS-DEVICE', 'default'],
|
||||||
|
['INNER-HCY-ROUTER-HTTPS', '1'],
|
||||||
|
['X-SvcType', '1'],
|
||||||
|
]));
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'XMLHttpRequest', { value: originalXHR, configurable: true });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -33,4 +33,15 @@ describe('manifest platform matches', () => {
|
|||||||
expect(webAccessibleMatches).toContain('https://yun.123pan.cn/*');
|
expect(webAccessibleMatches).toContain('https://yun.123pan.cn/*');
|
||||||
expect(webAccessibleMatches).not.toContain('https://www.123pan.com/*');
|
expect(webAccessibleMatches).not.toContain('https://www.123pan.com/*');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('injects CMCC UI on yun.139.com and allows its API host', () => {
|
||||||
|
const manifest = loadManifest();
|
||||||
|
const contentScriptMatches = manifest.content_scripts?.flatMap((script) => script.matches ?? []) ?? [];
|
||||||
|
const webAccessibleMatches = manifest.web_accessible_resources?.flatMap((resource) => resource.matches ?? []) ?? [];
|
||||||
|
|
||||||
|
expect(manifest.host_permissions).toContain('https://yun.139.com/*');
|
||||||
|
expect(manifest.host_permissions).toContain('https://personal-kd-njs.yun.139.com/*');
|
||||||
|
expect(contentScriptMatches).toContain('https://yun.139.com/*');
|
||||||
|
expect(webAccessibleMatches).toContain('https://yun.139.com/*');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -226,6 +226,22 @@ describe('Platform Detection - Unified Utils', () => {
|
|||||||
expect(result).toBe('cmcc');
|
expect(result).toBe('cmcc');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should detect CMCC Drive on the SPA index route', () => {
|
||||||
|
const result = detectPlatformFromUrl(
|
||||||
|
'https://yun.139.com/w/#/index',
|
||||||
|
'/w/'
|
||||||
|
);
|
||||||
|
expect(result).toBe('cmcc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect CMCC Drive on the app entry before hash routing is ready', () => {
|
||||||
|
const result = detectPlatformFromUrl(
|
||||||
|
'https://yun.139.com/w/',
|
||||||
|
'/w/'
|
||||||
|
);
|
||||||
|
expect(result).toBe('cmcc');
|
||||||
|
});
|
||||||
|
|
||||||
it('should detect Esurfing Drive', () => {
|
it('should detect Esurfing Drive', () => {
|
||||||
const result = detectPlatformFromUrl(
|
const result = detectPlatformFromUrl(
|
||||||
'https://cloud.189.cn/web/main/file/folder/folder189',
|
'https://cloud.189.cn/web/main/file/folder/folder189',
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户