2025-05-01 03:59:37 +08:00

426 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import axios from 'axios';
import qs from 'querystring';
import { get_xs } from './jsvmp/xhs';
import fs from 'fs';
import { getXCommon, getSearchId, SearchSortType, SearchNoteType } from './helper.js';
import { ErrorEnum, DataFetchError, IPBlockError, SignError, NeedVerifyError } from './exception';
const camelToUnderscore = (key) => {
return key.replace(/([A-Z])/g, '_$1').toLowerCase();
};
const transformJsonKeys = (jsonData) => {
const dataDict = typeof jsonData === 'string' ? JSON.parse(jsonData) : jsonData;
const dictNew = {};
for (const [key, value] of Object.entries(dataDict)) {
const newKey = camelToUnderscore(key);
if (!value) {
dictNew[newKey] = value;
} else if (typeof value === 'object' && !Array.isArray(value)) {
dictNew[newKey] = transformJsonKeys(value);
} else if (Array.isArray(value)) {
dictNew[newKey] = value.map((item) => (item && typeof item === 'object' ? transformJsonKeys(item) : item));
} else {
dictNew[newKey] = value;
}
}
return dictNew;
};
class XhsClient {
/**
* Constructor for XhsClient
* @param {Object} options - Configuration options
* @param {string} options.cookie - Cookie string for authentication
* @param {string} options.userAgent - User agent string for requests
* @param {number} options.timeout - Request timeout in milliseconds
* @param {string} options.proxies - Proxy settings
*/
constructor({ cookie = null, userAgent = null, timeout = 10000, proxies = null } = {}) {
this.proxies = proxies;
this.timeout = timeout;
this._host = 'https://edith.xiaohongshu.com';
this._creatorHost = 'https://creator.xiaohongshu.com';
this._customerHost = 'https://customer.xiaohongshu.com';
this.home = 'https://www.xiaohongshu.com';
this.userAgent = userAgent || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36';
this.axiosInstance = axios.create({
timeout: this.timeout,
headers: {
'user-agent': this.userAgent,
'Content-Type': 'application/json',
},
});
if (cookie) {
this.cookie = cookie;
}
}
// Getter for cookie
get cookie() {
return this.axiosInstance.defaults.headers.Cookie;
}
// Setter for cookie
set cookie(cookie) {
this.axiosInstance.defaults.headers.Cookie = cookie;
}
// Getter for cookieDict
get cookieDict() {
const cookieStr = this.axiosInstance.defaults.headers.Cookie;
return cookieStr ? qs.parse(cookieStr.replace(/; /g, '&')) : {};
}
_preHeaders(url, data = null) {
let a1 = this.cookieDict.a1;
let b1 = '';
let x_s_result = get_xs(url, data, this.cookie);
const X_S = x_s_result['X-s'];
const X_t = x_s_result['X-t'].toString();
const X_S_COMMON = getXCommon(a1, b1, X_S, X_t);
// this.axiosInstance.defaults.headers['X-s'] = X_S;
// this.axiosInstance.defaults.headers['X-t'] = X_t;
// this.axiosInstance.defaults.headers['X-s-common'] = X_S_COMMON;
return {
headers: {
'X-s': X_S,
'X-t': X_t,
'X-s-common': X_S_COMMON,
},
};
}
getCookieMap() {
const cookie = this.cookie;
let cookieDict = {};
if (cookie) {
const cookieArray = cookie.split(';');
cookieArray.forEach((item) => {
const [key, value] = item.split('=');
const trimKey = key.trim();
if (trimKey) {
const _value = value ? value.trim() : '';
cookieDict[trimKey] = _value;
}
});
return cookieDict;
}
return {};
}
/**
* Get X-S and X-T
* @param {*} url
* @param {*} data
* @param {*} cookie
* @returns
*/
get_xs(url, data, cookie) {
return get_xs(url, data, cookie);
}
async request(method, url, config = {}) {
try {
const response = await this.axiosInstance({ method, url, ...config });
if (!response.data) return response;
// console.log('response', response)
if (response.status === 471 || response.status === 461) {
const verifyType = response.headers['verifytype'];
const verifyUuid = response.headers['verifyuuid'];
throw new NeedVerifyError(`出现验证码请求失败Verifytype: ${verifyType}Verifyuuid: ${verifyUuid}`, response, verifyType, verifyUuid);
}
const data = response.data;
if (data.success) {
return data.data || data.success;
} else if (data.code === ErrorEnum.IP_BLOCK.code) {
throw new IPBlockError(ErrorEnum.IP_BLOCK.msg, response);
} else if (data.code === ErrorEnum.SIGN_FAULT.code) {
throw new SignError(ErrorEnum.SIGN_FAULT.msg, response);
} else {
throw new DataFetchError(data, response);
}
} catch (error) {
if (error.response && (error.response.status === 471 || error.response.status) === 461) {
// Handle verification error
const verifyType = error.response.headers['verifytype'];
const verifyUuid = error.response.headers['verifyuuid'];
throw new NeedVerifyError(`出现验证码请求失败Verifytype: ${verifyType}Verifyuuid: ${verifyUuid}`, error.response, verifyType, verifyUuid);
}
throw error;
}
}
/**
*
* @param {*} uri
* @param {*} params
* @param {Object} config
* @param {*} [config.sign] - Whether to sign the request
* @param {boolean} [config.isCreator] - Whether the request is for a creator
* @param {boolean} [config.isCustomer] - Whether the request is for a customer
* @param {*} [config.headers] - XSEC token for authentication
* @returns
*/
async get(uri, params = null, config = {}) {
if (params) {
uri = `${uri}?${qs.stringify(params)}`;
}
if (config.sign) {
await config.sign(uri, data, config);
} else {
const { headers } = this._preHeaders(uri, null);
config = { ...config, headers: { ...config.headers, ...headers } };
}
let isCreator = config?.isCreator ?? false;
let isCustomer = config?.isCustomer ?? false;
let endpoint = this._host;
if (isCustomer) {
endpoint = this._customerHost;
} else if (isCreator) {
endpoint = this._creatorHost;
}
return this.request('GET', `${endpoint}${uri}`, config);
}
/**
*
* @param {*} uri
* @param {*} data
* @param {Object} config
* @param {*} [config.sign] - Whether to sign the request
* @param {boolean} [config.isCreator] - Whether the request is for a creator
* @param {boolean} [config.isCustomer] - Whether the request is for a customer
* @param {*} [config.headers] - XSEC token for authentication
* @returns
*/
async post(uri, data = null, config = {}) {
let jsonStr = data ? JSON.stringify(data) : null;
let endpoint = this._host;
if (config.sign) {
await config.sign(uri, data, config);
} else {
const { headers } = this._preHeaders(uri, data);
config = { ...config, headers: { ...config.headers, ...headers } };
}
let isCreator = config?.isCreator ?? false;
let isCustomer = config?.isCustomer ?? false;
if (isCustomer) {
endpoint = this._customerHost;
} else if (isCreator) {
endpoint = this._creatorHost;
}
if (data) {
return this.request('POST', `${endpoint}${uri}`, {
...config,
data: jsonStr,
headers: {
...config.headers,
'Content-Type': 'application/json',
},
});
}
return this.request('POST', `${endpoint}${uri}`, { ...config, data });
}
/**
* 获取笔记详情
* 注意: 需要xsec_token
* @param {string} noteId
* @returns
*/
async getNoteById(noteId, xsecToken, xsecSource = 'pc_feed') {
if (!xsecToken) {
throw new Error('xsecToken is required');
}
const data = {
source_note_id: noteId,
image_scenes: ['CRD_WM_WEBP'],
xsec_token: xsecToken,
xsec_source: xsecSource,
};
const uri = '/api/sns/web/v1/feed';
try {
const res = await this.post(uri, data);
return res.items[0].note_card;
} catch (error) {
console.error('Error fetching note:', error);
throw error;
}
}
async getNoteByIdFromHtml(noteId, xsecToken, xsecSource = 'pc_feed') {
const url = `https://www.xiaohongshu.com/explore/${noteId}?xsec_token=${xsecToken}&xsec_source=${xsecSource}`;
let html = '';
try {
const response = await this.axiosInstance.get(url, {
headers: {
'user-agent': this.userAgent,
referer: 'https://www.xiaohongshu.com/',
},
});
html = response.data;
const stateMatch = html.match(/window.__INITIAL_STATE__=({.*})<\/script>/);
if (stateMatch) {
const state = stateMatch[1].replace(/undefined/g, '""');
if (state !== '{}') {
const noteDict = transformJsonKeys(JSON.parse(state));
return noteDict.note.note_detail_map[noteId].note;
}
}
if (html.includes(ErrorEnum.IP_BLOCK.value)) {
throw new IPBlockError(ErrorEnum.IP_BLOCK.value);
}
throw new DataFetchError(html);
} catch (error) {
console.error('Error fetching note:', error);
fs.writeFileSync('a.html', html);
throw error;
}
}
async getSelfInfo() {
const uri = '/api/sns/web/v1/user/selfinfo';
return this.get(uri);
}
async getSelfInfoV2() {
const uri = '/api/sns/web/v2/user/me';
return this.get(uri);
}
async getUserInfo(userId) {
const uri = '/api/sns/web/v1/user/otherinfo';
const params = {
target_user_id: userId,
};
return this.get(uri, params);
}
/**
*
* @param {string} keyword 关键词
* @param {number} page 页码
* @param {number} pageSize 分页查询的数量
* @param {string} sort 搜索的类型,分为: general, popularity_descending, time_descending
* @param {number} noteType 笔记类型
* @returns
*/
async getNoteByKeyword(keyword, page = 1, pageSize = 20, sort = SearchSortType.GENERAL, noteType = SearchNoteType.ALL) {
const uri = '/api/sns/web/v1/search/notes';
const data = {
keyword: keyword,
page: page,
page_size: pageSize,
search_id: getSearchId(),
sort: sort.value,
note_type: noteType.value,
image_formats: ['jpg', 'webp', 'avif'],
ext_flags: [],
};
return this.post(uri, data);
}
/**
* 获取笔记评论
* @param {string} noteId 笔记id
* @param {string} cursor 分页查询的下标,默认为""
* @returns
*/
async getNoteComments(noteId, cursor = '') {
const uri = '/api/sns/web/v2/comment/page';
const params = {
note_id: noteId,
cursor: cursor,
};
return this.get(uri, params);
}
/**
* 获取用户笔记
* @param {*} userId
* @param {*} cursor
* @returns
*/
async getUserNotes(userId, cursor = '') {
const uri = '/api/sns/web/v1/user_posted';
const params = {
cursor: cursor,
num: 30,
user_id: userId,
image_scenes: 'FD_WM_WEBP',
};
return this.get(uri, params);
}
/**
* 获取账号@我通知
* @param {*} num
* @param {*} cursor
* @returns
*/
async getMentionNotifications(num = 20, cursor = '') {
const uri = '/api/sns/web/v1/you/mentions';
const params = { num: num, cursor: cursor };
return this.get(uri, params);
}
/**
* 获取点赞通知
* @param {*} num
* @param {*} cursor
* @returns
*/
async getLikeNotifications(num = 20, cursor = '') {
const uri = '/api/sns/web/v1/you/likes';
const params = { num: num, cursor: cursor };
return this.get(uri, params);
}
/**
* 获取关注通知
* @param {*} num
* @param {*} cursor
* @returns
*/
async getFollowNotifications(num = 20, cursor = '') {
const uri = '/api/sns/web/v1/you/connections';
const params = { num: num, cursor: cursor };
return this.get(uri, params);
}
async getUserInfoFromHtml(userId) {
const url = `https://www.xiaohongshu.com/user/profile/${userId}`;
try {
const response = await this.axiosInstance.get(url, {
headers: {
'user-agent': this.userAgent,
referer: 'https://www.xiaohongshu.com/',
},
});
const html = response.data;
const stateMatch = html.match(/window.__INITIAL_STATE__=({.*})<\/script>/);
if (stateMatch) {
const state = stateMatch[1].replace(/"undefined"/g, '"_"').replace(/\bundefined\b/g, '""');
if (state !== '{}') {
const parsedState = JSON.parse(state);
const userBasicInfo = transformJsonKeys(parsedState).user.user_page_data.basic_info;
return userBasicInfo;
}
}
return response.data;
} catch (error) {
console.error('Error fetching user info:', error);
throw error;
}
}
}
export { XhsClient };