import { logger } from 'node-karin' import axios from 'node-karin/axios' export interface XiaohongshuIdData { type: 'note' | 'unknown' [x: string]: any } /** * 小红书分享链接的合法主域。 * 展开短链前先校验输入域名,避免把消息里伪装成小红书链接的任意 URL 交给请求器(SSRF)。 */ const XIAOHONGSHU_HOST_SUFFIXES = ['xiaohongshu.com', 'xhslink.com', 'xhslink.cn'] const isXiaohongshuUrl = (url: string): boolean => { try { const { hostname } = new URL(url) return XIAOHONGSHU_HOST_SUFFIXES.some((suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`)) } catch { return false } } /** * 解析小红书分享链接,提取作品ID * - 典型长链接: https://www.xiaohongshu.com/explore/ * - 短链: https://xhslink.com/、https://xhslink.cn/o/(会重定向到长链接) */ export const getXiaohongshuID = async (url: string, log = true): Promise => { // 非小红书域名的链接不发起请求,按原样进入下方识别,走既有「无法提取笔记ID」兜底 let longLink = url if (isXiaohongshuUrl(url)) { const resp = await axios.get(url, { headers: { 'User-Agent': 'Apifox/1.0.0 (https://apifox.com)' } }) longLink = resp?.request?.res?.responseUrl ?? url } else { logger.warn(`链接不是小红书域名,跳过短链展开: ${url}`) } // 安全解码:如果最终地址里包含百分号编码的真实链接,解码后才能命中正则 const normalizedLink = (() => { try { return decodeURIComponent(longLink) } catch { return longLink } })() const effectiveLink = (() => { try { const u = new URL(normalizedLink) if (u.pathname.startsWith('/404')) { const rp = u.searchParams.get('redirectPath') if (rp) { try { return decodeURIComponent(rp) } catch { return rp } } } const mm = /[?&]redirectPath=([^&#]+)/.exec(normalizedLink) if (mm?.[1]) { try { return decodeURIComponent(mm[1]) } catch { return mm[1] } } return normalizedLink } catch { const mm = /[?&]redirectPath=([^&#]+)/.exec(normalizedLink) if (mm?.[1]) { try { return decodeURIComponent(mm[1]) } catch { return mm[1] } } return normalizedLink } })() // 同时从 effectiveLink 与 normalizedLink 中获取 token,优先使用有效链接 const pickToken = (s: string): string | undefined => { try { const u = new URL(s) const t = u.searchParams.get('xsec_token') || u.searchParams.get('XSEC_TOKEN') || undefined if (t) return t if (u.hash) { const mm = /(?:^|[?&#])(?:xsec_token|XSEC_TOKEN)=([^&#]+)/.exec(u.hash) if (mm?.[1]) return mm[1] } return undefined } catch { const mm = /(?:^|[?&#])(?:xsec_token|XSEC_TOKEN)=([^&#]+)/.exec(s) return mm?.[1] } } const finalToken = pickToken(effectiveLink) ?? pickToken(normalizedLink) let result: XiaohongshuIdData = { type: 'unknown' } switch (true) { case /xiaohongshu\.com\/discovery\/item\/([0-9a-zA-Z]+)/.test(effectiveLink): { const m = /xiaohongshu\.com\/discovery\/item\/([0-9a-zA-Z]+)/.exec(effectiveLink) result = { type: 'note', note_id: m ? m[1] : undefined, xsec_token: finalToken } break } case /xiaohongshu\.com\/explore\/([0-9a-zA-Z]+)/.test(effectiveLink): { const m = /xiaohongshu\.com\/explore\/([0-9a-zA-Z]+)/.exec(effectiveLink) result = { type: 'note', note_id: m ? m[1] : undefined, xsec_token: finalToken } break } case /[?&]target_note_id=([0-9a-zA-Z]+)/.test(effectiveLink) || /[?&]target_note_id=([0-9a-zA-Z]+)/.test(normalizedLink): { // 笔记暂不可查看等场景会跳到 /explore?...&target_note_id=,路径里没有 ID const m = /[?&]target_note_id=([0-9a-zA-Z]+)/.exec(effectiveLink) ?? /[?&]target_note_id=([0-9a-zA-Z]+)/.exec(normalizedLink) result = { type: 'note', note_id: m ? m[1] : undefined, xsec_token: finalToken } break } default: result = { type: 'unknown' } break } if (result.type === 'unknown') { throw new Error('无法从链接中提取小红书笔记ID') } if (log) { console.log(result) } return result }