refactor(cmcc): 改进API调用响应解析与平台检测

- 添加 `normalizeResponse` 方法处理嵌套 JSON 字符串响应
- 统一成功状态码验证逻辑
- 使用 XHR 传输保留请求头大小写
- 修复 SPA 根路径 `/w/` 的平台检测
- 添加 DOM 同步观察器断开逻辑
这个提交包含在:
Zain 2026-08-15 08:34:57 +08:00
父节点 cb32e8aa55
当前提交 cefc989c20
共有 9 个文件被更改,包括 353 次插入 和 45 次删除

查看文件

@ -68,16 +68,18 @@ export class CMCCAdapter extends SharedCloudDriveAdapter {
startNumber: cursor.type === 'initial' ? 1 : cursor.value,
};
const response = await this.callAPI(
const response = this.normalizeResponse<CMCCListResponse>(await this.callAPI(
'POST',
'https://personal-kd-njs.yun.139.com/hcy/file/list',
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;
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)));
@ -98,13 +100,12 @@ export class CMCCAdapter extends SharedCloudDriveAdapter {
contentID: fileId,
contentName: newName,
};
const response = await this.callAPI(
const response = this.normalizeResponse<CMCCUpdateResponse>(await this.callAPI(
'POST',
'https://personal-kd-njs.yun.139.com/hcy/file/update',
this.createSignedJsonBody(payload, signPayload)
) as CMCCUpdateResponse;
const code = String(response.code ?? '0');
this.assertSuccessful(code === '0' || code === '200' || code.toLowerCase() === 'success', response.code, response.message || '移动云盘重命名失败', response);
));
this.assertSuccessful(this.isSuccessfulResponseCode(response.code), response.code, response.message || '移动云盘重命名失败', response);
}, fileId, newName);
}
@ -116,6 +117,40 @@ export class CMCCAdapter extends SharedCloudDriveAdapter {
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 {
const name = item.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',
responseType: 'CMCC_API_RESPONSE',
readyFlagName: '__CMCC_PAGE_SCRIPT_READY__',
datasetReadyKey: 'cmccPageScriptReady',
datasetTimestampKey: 'cmccPageScriptTimestamp',
logPrefix: 'CMCCPageScript',
transport: 'xhr',
captureHeaders: [
'caller',
'Cms-Device',
@ -20,7 +21,6 @@ export const CMCC_PAGE_SCRIPT_OPTIONS = {
'x-inner-ntwk',
'x-m4c-caller',
'INNER-HCY-ROUTER-HTTPS',
'hcy-cool-flag',
'X-Svctype',
'x-yun-Api-Version',
'x-yun-channel-source',

查看文件

@ -6,6 +6,7 @@ export interface PageScriptInstallOptions {
datasetTimestampKey: string;
logPrefix: string;
captureHeaders?: string[];
transport?: 'fetch' | 'xhr';
}
interface APIRequestMessage {
@ -98,6 +99,65 @@ export function markPageScriptReady(options: PageScriptInstallOptions, timestamp
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 {
const capturedHeaders = new Map<string, string>();
const allowHeader = new Set((options.captureHeaders || []).map(normalizeHeaderName));
@ -133,35 +193,17 @@ export function installCloudDrivePageScript(options: PageScriptInstallOptions):
try {
const encoded = createBodyAndHeaders(message.body);
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: controller.signal,
});
const data = options.transport === 'xhr'
? await requestWithXhr(message, encoded, capturedHeaders)
: await requestWithFetch(message, encoded, capturedHeaders, controller.signal);
window.clearTimeout(timeoutId);
const text = await response.text();
let data: unknown = {};
if (text) {
try {
data = JSON.parse(text);
} catch {
data = { text };
}
}
window.postMessage({
type: options.responseType,
requestId: message.requestId,
success: response.ok,
data,
status: response.status,
success: data.success,
data: data.body,
status: data.status,
}, '*');
} catch (error) {
window.clearTimeout(timeoutId);

查看文件

@ -21,6 +21,7 @@ export class CloudDriveAPIError extends Error {
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({
@ -156,6 +157,8 @@ export abstract class SharedCloudDriveAdapter extends BasePlatformAdapter {
return { success: true, method: 'none' };
}
this.disconnectActiveDomSyncObserver();
const documents = this.getDomPatchDocuments();
let patchedById = 0;
let patchedByOldName = 0;
@ -251,7 +254,7 @@ export abstract class SharedCloudDriveAdapter extends BasePlatformAdapter {
const info = renameInfoById.get(fileId);
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);
}
@ -262,7 +265,7 @@ export abstract class SharedCloudDriveAdapter extends BasePlatformAdapter {
if (renameByOldName.size === 0) return 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 [oldName, newName] of renameByOldName.entries()) {
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 {
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) {
return this.patchStructuredNameWithinElement(preferredNameNode, oldName, newName);
}
@ -393,7 +396,7 @@ export abstract class SharedCloudDriveAdapter extends BasePlatformAdapter {
const roots = new Set<ParentNode>();
const ownerWindow = doc.defaultView ?? window;
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) => {
if (!node) return;
@ -412,6 +415,12 @@ export abstract class SharedCloudDriveAdapter extends BasePlatformAdapter {
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>,
@ -458,14 +467,29 @@ export abstract class SharedCloudDriveAdapter extends BasePlatformAdapter {
cleanupCallbacks.push(() => {
if (flushTimer !== null) {
observerWindow.clearTimeout(flushTimer);
flushTimer = null;
}
});
}
window.setTimeout(() => {
for (const cleanup of cleanupCallbacks) cleanup();
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();
}, 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 } {
@ -496,7 +520,7 @@ export abstract class SharedCloudDriveAdapter extends BasePlatformAdapter {
private findFilenameInRow(row: Element): string {
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('aria-label') ||
''

查看文件

@ -108,7 +108,8 @@ export function detectPlatformFromUrl(url: string, pathname?: string): PlatformN
// 移动云盘
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 { getDrive123PageScriptInjector } from '../../../src/adapters/123/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';
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 () => {
const adapter = new CMCCAdapter({ requestInterval: 0 });
Object.defineProperty(window, 'location', {
@ -223,6 +256,14 @@ describe('missing platform adapters', () => {
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 () => {
const adapter = new EsurfingAdapter({ requestInterval: 0 });
Object.defineProperty(window, 'location', {
@ -342,6 +383,65 @@ describe('missing platform adapters', () => {
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 () => {
document.body.innerHTML = '<main id="file-list"></main>';

查看文件

@ -1,5 +1,6 @@
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', () => {
afterEach(() => {
@ -22,4 +23,82 @@ describe('shared cloud drive page script', () => {
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).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');
});
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', () => {
const result = detectPlatformFromUrl(
'https://cloud.189.cn/web/main/file/folder/folder189',