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;
}