Skip to main content
Glama
client.ts18.1 kB
import { createHash } from "crypto"; import type { CommitUploadPayload, CommitUploadResponse, CreateFolderResponse, CreateNoteResponse, DeleteFolderResponse, DeleteNoteResponse, FolderDetailResponse, FullPageResponse, MiNoteConfig, NoteDetailResponse, RequestUploadResponse, SyncFullResponse, WriteFolderEntry, WriteNoteEntry, } from "./types"; const DEFAULT_BASE_URL = "https://i.mi.com"; const DEFAULT_HEADERS: Record<string, string> = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", Accept: "*/*", Referer: "https://i.mi.com/note/h5", }; export interface XiaomiNoteClientOptions { onConfigUpdated?: (config: MiNoteConfig) => Promise<void> | void; } export class XiaomiNoteClient { private readonly baseUrl: string; private cookieHeader: string; private currentConfig: MiNoteConfig; private loginPromise: Promise<void> | null = null; constructor(config: MiNoteConfig, private readonly options: XiaomiNoteClientOptions = {}) { this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL; this.currentConfig = { ...config }; this.cookieHeader = this.buildCookieHeader(); } async fetchFullPage(limit = 200): Promise<FullPageResponse> { const ts = Date.now(); const path = `/note/full/page?ts=${ts}&limit=${limit}`; return this.request<FullPageResponse>(path, { method: "GET" }); } async getNote(noteId: string): Promise<NoteDetailResponse> { const ts = Date.now(); const path = `/note/note/${encodeURIComponent(noteId)}/?ts=${ts}`; return this.request<NoteDetailResponse>(path, { method: "GET" }); } async createNote(entry: WriteNoteEntry): Promise<CreateNoteResponse> { const formBody = this.buildFormBody({ entry: JSON.stringify(entry), serviceToken: this.currentConfig.serviceToken, }); return this.request<CreateNoteResponse>("/note/note", { method: "POST", body: formBody, headers: { "Content-Type": "application/x-www-form-urlencoded", }, }); } async updateNote(noteId: string, entry: WriteNoteEntry): Promise<NoteDetailResponse> { const formBody = this.buildFormBody({ entry: JSON.stringify(entry), serviceToken: this.currentConfig.serviceToken, }); return this.request<NoteDetailResponse>(`/note/note/${encodeURIComponent(noteId)}`, { method: "POST", body: formBody, headers: { "Content-Type": "application/x-www-form-urlencoded", }, }); } async deleteNote(noteId: string, tag: string, purge = false): Promise<DeleteNoteResponse> { const formBody = this.buildFormBody({ tag, purge: String(purge), serviceToken: this.currentConfig.serviceToken, }); return this.request(`/note/full/${encodeURIComponent(noteId)}/delete`, { method: "POST", body: formBody, headers: { "Content-Type": "application/x-www-form-urlencoded", }, }); } async syncFull(syncTag: string): Promise<SyncFullResponse> { const ts = Date.now(); const data = encodeURIComponent(JSON.stringify({ note_view: { syncTag, }, })); const path = `/note/sync/full/?ts=${ts}&data=${data}&inactiveTime=10`; return this.request<SyncFullResponse>(path, { method: "GET" }); } async createFolder(subject: string, parentId = "0"): Promise<CreateFolderResponse> { const now = Date.now(); const entry: WriteFolderEntry = { subject, folderId: parentId, createDate: now, modifyDate: now, colorId: 0, alertDate: 0, alertTag: 0, type: "folder", setting: { themeId: 0, stickyTime: 0, version: 0, }, }; const formBody = this.buildFormBody({ entry: JSON.stringify(entry), serviceToken: this.currentConfig.serviceToken, }); return this.request<CreateFolderResponse>("/note/folder", { method: "POST", body: formBody, headers: { "Content-Type": "application/x-www-form-urlencoded", }, }); } async updateFolder(folderId: string, folder: WriteFolderEntry): Promise<FolderDetailResponse> { const formBody = this.buildFormBody({ entry: JSON.stringify(folder), serviceToken: this.currentConfig.serviceToken, }); return this.request<FolderDetailResponse>(`/note/folder/${encodeURIComponent(folderId)}`, { method: "POST", body: formBody, headers: { "Content-Type": "application/x-www-form-urlencoded", }, }); } async deleteFolder(folderId: string, tag: string, purge = false): Promise<DeleteFolderResponse> { const formBody = this.buildFormBody({ tag, purge: String(purge), serviceToken: this.currentConfig.serviceToken, }); return this.request(`/note/full/${encodeURIComponent(folderId)}/delete`, { method: "POST", body: formBody, headers: { "Content-Type": "application/x-www-form-urlencoded", }, }); } async requestUploadImage(metadata: { filename: string; size: number; sha1: string; md5: string; mimeType: string; }): Promise<RequestUploadResponse> { const payload = { type: "note_img", storage: { filename: metadata.filename, size: metadata.size, sha1: metadata.sha1, mimeType: metadata.mimeType, kss: { block_infos: [ { blob: {}, size: metadata.size, md5: metadata.md5, sha1: metadata.sha1, }, ], }, }, }; const formBody = this.buildFormBody({ data: JSON.stringify(payload), serviceToken: this.currentConfig.serviceToken, }); return this.request<RequestUploadResponse>("/file/v2/user/request_upload_file", { method: "POST", body: formBody, headers: { "Content-Type": "application/x-www-form-urlencoded", }, }); } async commitUpload(payload: CommitUploadPayload): Promise<CommitUploadResponse> { const formBody = this.buildFormBody({ commit: JSON.stringify(payload), serviceToken: this.currentConfig.serviceToken, }); return this.request<CommitUploadResponse>("/file/v2/user/commit", { method: "POST", body: formBody, headers: { "Content-Type": "application/x-www-form-urlencoded", }, }); } async uploadImageFromBuffer(buffer: ArrayBuffer, params: { filename: string; mimeType: string }): Promise<CommitUploadResponse> { const uint8 = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); const size = uint8.byteLength; const sha1 = this.computeHash("sha1", uint8); const md5 = this.computeHash("md5", uint8); const tokenResponse = await this.requestUploadImage({ filename: params.filename, size, sha1, md5, mimeType: params.mimeType, }); const { uploadId, exists, kss } = tokenResponse.data.storage; let commitMetas = [] as { commit_meta: string }[]; if (!exists) { const nodeUrl = kss.node_urls[0]; const blockMeta = kss.block_metas[0]?.block_meta; if (!nodeUrl || !blockMeta) { throw new Error("上传凭证缺少节点信息"); } const uploadUrl = `${nodeUrl}/upload_block_chunk?chunk_pos=0&file_meta=${encodeURIComponent(kss.file_meta)}&block_meta=${encodeURIComponent(blockMeta)}`; const uploadResponse = await fetch(uploadUrl, { method: "POST", headers: { "Content-Type": "application/octet-stream", }, body: uint8, }); if (!uploadResponse.ok) { const text = await uploadResponse.text(); throw new Error(`上传图片数据失败 ${uploadResponse.status}: ${text}`); } const commitMetaBody = await this.safeJson(uploadResponse); if (!commitMetaBody || typeof commitMetaBody.commit_meta !== "string") { throw new Error("上传节点返回异常,缺少 commit_meta"); } commitMetas = [{ commit_meta: commitMetaBody.commit_meta }]; } const commitPayload: CommitUploadPayload = { storage: { uploadId, size, sha1, kss: { file_meta: kss.file_meta, commit_metas: commitMetas, }, }, }; return this.commitUpload(commitPayload); } private async request<T>(path: string, init: RequestInit = {}): Promise<T> { const url = path.startsWith("http") ? path : this.baseUrl + path; const execute = async (): Promise<Response> => { const headers = new Headers(DEFAULT_HEADERS); headers.set("Cookie", this.cookieHeader); if (init.headers) { const extra = new Headers(init.headers as HeadersInit); extra.forEach((value, key) => headers.set(key, value)); } return fetch(url, { ...init, headers, }); }; let response = await execute(); await this.updateCookiesFromResponse(response); if ( response.status === 401 && this.canAutoLogin() && this.canRetryRequest(init) ) { await this.ensureLogin(); response = await execute(); await this.updateCookiesFromResponse(response); } if (!response.ok) { const body = await response.text(); throw new Error(`请求失败 ${response.status} ${response.statusText}: ${body}`); } try { return (await response.json()) as T; } catch (error) { throw new Error(`解析响应失败:${(error as Error).message}`); } } private canRetryRequest(init: RequestInit): boolean { if (!("body" in init) || init.body === undefined || init.body === null) { return true; } const body = init.body as unknown; return ( typeof body === "string" || body instanceof Uint8Array || body instanceof ArrayBuffer || body instanceof URLSearchParams || (typeof Blob !== "undefined" && body instanceof Blob) ); } private canAutoLogin(): boolean { return Boolean( this.currentConfig.passToken && this.currentConfig.deviceId && this.currentConfig.userId, ); } private async ensureLogin(): Promise<void> { if (!this.canAutoLogin()) { throw new Error("缺少 passToken 或 deviceId,无法自动登录小米笔记账户"); } if (!this.loginPromise) { const task = this.performLogin(); this.loginPromise = task.finally(() => { this.loginPromise = null; }); } await this.loginPromise; } private async performLogin(): Promise<void> { const locale = this.currentConfig.uLocale ?? "zh_CN"; const followUp = `${this.baseUrl}/note/h5#/`; const loginApiUrl = `${this.baseUrl}/api/user/login?ts=${Date.now()}&followUp=${encodeURIComponent(followUp)}&_locale=${encodeURIComponent(locale)}`; const loginHeaders = new Headers(DEFAULT_HEADERS); loginHeaders.set("Cookie", this.cookieHeader); const loginResponse = await fetch(loginApiUrl, { method: "GET", headers: loginHeaders, }); if (!loginResponse.ok) { const text = await loginResponse.text(); throw new Error(`获取登录链接失败 ${loginResponse.status}: ${text}`); } await this.updateCookiesFromResponse(loginResponse); let loginPayload: unknown; try { loginPayload = await loginResponse.json(); } catch (error) { throw new Error(`解析登录链接响应失败:${(error as Error).message}`); } const loginData = (loginPayload as Record<string, unknown>)?.data as Record<string, unknown> | undefined; const redirectUrl = typeof loginData?.loginUrl === "string" ? loginData.loginUrl : null; if (!redirectUrl) { throw new Error("登录接口未返回有效的跳转链接"); } const accountHeaders = new Headers(); accountHeaders.set("User-Agent", DEFAULT_HEADERS["User-Agent"]!); accountHeaders.set("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"); accountHeaders.set("Referer", "https://i.mi.com/"); accountHeaders.set("Cookie", this.buildAccountCookieHeader()); const accountResponse = await fetch(redirectUrl, { method: "GET", headers: accountHeaders, redirect: "manual", }); await this.updateCookiesFromResponse(accountResponse); if (![301, 302, 303, 307, 308].includes(accountResponse.status)) { const text = await accountResponse.text(); throw new Error(`小米账号登录跳转失败 ${accountResponse.status}: ${text}`); } const stsUrl = accountResponse.headers.get("location"); if (!stsUrl) { throw new Error("登录跳转缺少目标地址,可能需要重新手动登录以刷新 passToken"); } const stsHeaders = new Headers(DEFAULT_HEADERS); stsHeaders.set("Cookie", this.cookieHeader); const stsResponse = await fetch(stsUrl, { method: "GET", headers: stsHeaders, redirect: "manual", }); await this.updateCookiesFromResponse(stsResponse); if (stsResponse.status >= 400) { const text = await stsResponse.text(); throw new Error(`获取 serviceToken 失败 ${stsResponse.status}: ${text}`); } this.cookieHeader = this.buildCookieHeader(); } private buildAccountCookieHeader(): string { if (!this.currentConfig.deviceId || !this.currentConfig.passToken) { throw new Error("缺少 deviceId 或 passToken,无法构造账号登录 Cookie"); } const parts = [ `deviceId=${this.currentConfig.deviceId}`, "pass_ua=web", `passToken=${this.currentConfig.passToken}`, `userId=${this.currentConfig.userId}`, ]; const locale = this.currentConfig.uLocale ?? "zh_CN"; parts.push(`uLocale=${locale}`); if (this.currentConfig.cUserId) { parts.push(`cUserId=${this.currentConfig.cUserId}`); } return parts.join("; "); } private buildCookieHeader(): string { const parts = [`serviceToken=${this.currentConfig.serviceToken}`, `userId=${this.currentConfig.userId}`]; const locale = this.currentConfig.uLocale ?? "zh_CN"; parts.push(`uLocale=${locale}`); if (this.currentConfig.slh) { parts.push(`i.mi.com_slh=${this.currentConfig.slh}`); } if (this.currentConfig.ph) { parts.push(`i.mi.com_ph=${this.currentConfig.ph}`); } return parts.join("; "); } private buildFormBody(params: Record<string, string>): string { return Object.entries(params) .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) .join("&"); } private computeHash(algorithm: "sha1" | "md5", buffer: Uint8Array): string { return createHash(algorithm).update(buffer).digest("hex"); } private async safeJson(response: Response): Promise<any> { const text = await response.text(); if (!text) { return undefined; } try { return JSON.parse(text); } catch (error) { throw new Error(`解析上传响应失败:${(error as Error).message}`); } } private async updateCookiesFromResponse(response: Response): Promise<void> { const cookies = response.headers.getSetCookie?.() ?? extractSetCookieHeaders(response.headers); if (!cookies || cookies.length === 0) { return; } let updated = false; for (const cookie of cookies) { const [pair] = cookie.split(";"); if (!pair) continue; const separator = pair.indexOf("="); if (separator <= 0) { continue; } const key = pair.slice(0, separator).trim(); const value = pair.slice(separator + 1).trim(); switch (key) { case "serviceToken": if (value && value !== this.currentConfig.serviceToken) { this.currentConfig.serviceToken = value; updated = true; } break; case "i.mi.com_slh": if (value && value !== this.currentConfig.slh) { this.currentConfig.slh = value; updated = true; } break; case "i.mi.com_ph": if (value && value !== this.currentConfig.ph) { this.currentConfig.ph = value; updated = true; } break; case "passToken": if (value && value !== this.currentConfig.passToken) { this.currentConfig.passToken = value; updated = true; } break; case "deviceId": if (value && value !== this.currentConfig.deviceId) { this.currentConfig.deviceId = value; updated = true; } break; case "cUserId": if (value && value !== this.currentConfig.cUserId) { this.currentConfig.cUserId = value; updated = true; } break; case "userId": if (value && value !== this.currentConfig.userId) { this.currentConfig.userId = value; updated = true; } break; case "uLocale": if (value && value !== this.currentConfig.uLocale) { this.currentConfig.uLocale = value; updated = true; } break; default: break; } } if (updated) { this.cookieHeader = this.buildCookieHeader(); await this.persistConfig(); } } private async persistConfig(): Promise<void> { if (!this.options.onConfigUpdated) { return; } const snapshot: MiNoteConfig = { ...this.currentConfig }; try { await this.options.onConfigUpdated(snapshot); } catch (error) { console.error(`保存配置失败:${(error as Error).message}`); } } } function extractSetCookieHeaders(headers: Headers): string[] { const values: string[] = []; headers.forEach((value, key) => { if (key.toLowerCase() === "set-cookie") { values.push(value); } }); return values; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/LaelLuo/mi_note_mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server