diff --git a/lib/binance-square-user.test.ts b/lib/binance-square-user.test.ts new file mode 100644 index 000000000000..1a45e6f9ef9a --- /dev/null +++ b/lib/binance-square-user.test.ts @@ -0,0 +1,306 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const ofetchMock = vi.fn(); +const tryGetMock = vi.fn(); + +vi.mock('@/utils/ofetch', () => ({ + default: ofetchMock, +})); + +vi.mock('@/utils/cache', () => ({ + default: { + tryGet: tryGetMock, + }, +})); + +vi.mock('@/config', () => ({ + config: { + trueUA: 'test-user-agent', + }, +})); + +const createContext = (username: string, routeParams?: string, limit?: string) => { + const json: Record = {}; + + return { + req: { + param: (name: string) => { + if (name === 'username') { + return username; + } + if (name === 'routeParams') { + return routeParams; + } + }, + query: (name: string) => { + if (name === 'limit') { + return limit; + } + }, + }, + set: (_key: string, value: unknown) => { + Object.assign(json, value as Record); + }, + get jsonData() { + return json; + }, + }; +}; + +describe('/binance/square/user/:username', () => { + beforeEach(() => { + vi.resetModules(); + ofetchMock.mockReset(); + tryGetMock.mockReset(); + }); + + it('maps posts into feed items and uses profile username from API', async () => { + tryGetMock.mockImplementation((_key, fetcher) => fetcher()); + ofetchMock + .mockResolvedValueOnce({ + code: '000000', + data: { + squareUid: 'uid-1', + displayName: 'CZ', + username: 'CZ', + avatar: 'https://public.bnbstatic.com/avatar.jpg', + }, + }) + .mockResolvedValueOnce({ + code: '000000', + success: true, + data: { + contents: [ + { + id: 1, + bodyTextOnly: 'Hello Square', + createTime: 1_770_000_000_000, + webLink: 'https://www.binance.com/en/square/post/1', + displayName: 'CZ', + commentCount: 10, + likeCount: 20, + }, + { + id: 2, + displayName: 'CZ', + imageMetaList: [{ url: 'https://public.bnbstatic.com/image.png' }], + createTime: 1_770_000_000_001, + webLink: 'https://www.binance.com/en/square/post/2', + }, + ], + }, + }); + + const { route } = await import('@/routes/binance/square-user'); + const ctx = createContext('cz'); + const result = await route.handler(ctx as any); + + expect(result.title).toBe('CZ (@CZ) — Binance Square'); + expect(result.link).toBe('https://www.binance.com/en/square/profile/CZ'); + expect(result.item).toHaveLength(2); + expect(result.item?.[0]?.title).toBe('Hello Square'); + expect(result.item?.[1]?.title).toBe("CZ's post"); + expect(ofetchMock).toHaveBeenCalledTimes(2); + expect(ofetchMock.mock.calls[1]?.[0]).toContain('filterType=ALL'); + }); + + it('parses filter from routeParams', async () => { + tryGetMock.mockImplementation((_key, fetcher) => fetcher()); + ofetchMock + .mockResolvedValueOnce({ + code: '000000', + data: { + squareUid: 'uid-1', + displayName: 'CZ', + username: 'CZ', + }, + }) + .mockResolvedValueOnce({ + code: '000000', + success: true, + data: { contents: [] }, + }); + + const { route } = await import('@/routes/binance/square-user'); + await route.handler(createContext('cz', 'filter=quote') as any); + + expect(ofetchMock.mock.calls[1]?.[0]).toContain('filterType=QUOTE'); + }); + + it('throws when profile is not found', async () => { + tryGetMock.mockImplementation((_key, fetcher) => fetcher()); + ofetchMock.mockResolvedValueOnce({ + code: '000000', + data: { squareUid: null }, + }); + + const { route } = await import('@/routes/binance/square-user'); + + await expect(route.handler(createContext('missing-user') as any)).rejects.toThrow('not found on Binance Square'); + }); + + it('throws when posts API fails', async () => { + tryGetMock.mockImplementation((_key, fetcher) => fetcher()); + ofetchMock + .mockResolvedValueOnce({ + code: '000000', + data: { + squareUid: 'uid-1', + displayName: 'CZ', + username: 'CZ', + }, + }) + .mockResolvedValueOnce({ + code: '000002', + success: false, + message: 'illegal parameter', + data: null, + }); + + const { route } = await import('@/routes/binance/square-user'); + + await expect(route.handler(createContext('cz') as any)).rejects.toThrow('illegal parameter'); + }); + + it('respects limit query parameter', async () => { + tryGetMock.mockImplementation((_key, fetcher) => fetcher()); + ofetchMock + .mockResolvedValueOnce({ + code: '000000', + data: { + squareUid: 'uid-1', + displayName: 'CZ', + username: 'CZ', + }, + }) + .mockResolvedValueOnce({ + code: '000000', + success: true, + data: { + contents: Array.from({ length: 10 }, (_, index) => ({ + id: index, + bodyTextOnly: `Post ${index}`, + createTime: 1_770_000_000_000 + index, + webLink: `https://www.binance.com/en/square/post/${index}`, + })), + }, + }); + + const { route } = await import('@/routes/binance/square-user'); + const result = await route.handler(createContext('cz', undefined, '3') as any); + + expect(result.item).toHaveLength(3); + }); + + it('passes language headers when lang=zh-CN', async () => { + tryGetMock.mockImplementation((_key, fetcher) => fetcher()); + ofetchMock + .mockResolvedValueOnce({ + code: '000000', + data: { + squareUid: 'uid-1', + displayName: 'CZ', + username: 'CZ', + }, + }) + .mockResolvedValueOnce({ + code: '000000', + success: true, + data: { contents: [] }, + }); + + const { route } = await import('@/routes/binance/square-user'); + const result = await route.handler(createContext('cz', 'lang=zh-CN') as any); + + expect(result.link).toBe('https://www.binance.com/zh-CN/square/profile/CZ'); + const postsHeaders = ofetchMock.mock.calls[1]?.[1]?.headers; + expect(postsHeaders?.lang).toBe('zh-CN'); + expect(postsHeaders?.['Accept-Language']).toBe('zh-CN'); + expect(postsHeaders?.Referer).toBe('https://www.binance.com/zh-CN/square/profile/cz'); + }); + + it('uses translatedData content when language is not English', async () => { + tryGetMock.mockImplementation((_key, fetcher) => fetcher()); + ofetchMock + .mockResolvedValueOnce({ + code: '000000', + data: { + squareUid: 'uid-1', + displayName: 'CZ', + username: 'CZ', + }, + }) + .mockResolvedValueOnce({ + code: '000000', + success: true, + data: { + contents: [ + { + id: 3, + bodyTextOnly: 'English body', + translatedData: { + content: '中文正文', + }, + createTime: 1_770_000_000_000, + webLink: 'https://www.binance.com/zh-CN/square/post/3', + }, + ], + }, + }); + + const { route } = await import('@/routes/binance/square-user'); + const result = await route.handler(createContext('cz', 'lang=zh-CN') as any); + + expect(result.item?.[0]?.title).toBe('中文正文'); + expect(result.item?.[0]?.description).toContain('中文正文'); + expect(result.item?.[0]?.description).not.toContain('English body'); + }); + + it('supports combined filter and language routeParams', async () => { + tryGetMock.mockImplementation((_key, fetcher) => fetcher()); + ofetchMock + .mockResolvedValueOnce({ + code: '000000', + data: { + squareUid: 'uid-1', + displayName: 'CZ', + username: 'CZ', + }, + }) + .mockResolvedValueOnce({ + code: '000000', + success: true, + data: { contents: [] }, + }); + + const { route } = await import('@/routes/binance/square-user'); + await route.handler(createContext('cz', 'filter=quote&lang=zh-CN') as any); + + expect(ofetchMock.mock.calls[1]?.[0]).toContain('filterType=QUOTE'); + expect(ofetchMock.mock.calls[1]?.[1]?.headers?.lang).toBe('zh-CN'); + }); + + it('normalizes language aliases from routeParams', async () => { + tryGetMock.mockImplementation((_key, fetcher) => fetcher()); + ofetchMock + .mockResolvedValueOnce({ + code: '000000', + data: { + squareUid: 'uid-1', + displayName: 'CZ', + username: 'CZ', + }, + }) + .mockResolvedValueOnce({ + code: '000000', + success: true, + data: { contents: [] }, + }); + + const { route } = await import('@/routes/binance/square-user'); + const result = await route.handler(createContext('cz', 'lang=zh') as any); + + expect(result.link).toBe('https://www.binance.com/zh-CN/square/profile/CZ'); + expect(ofetchMock.mock.calls[1]?.[1]?.headers?.lang).toBe('zh-CN'); + }); +}); diff --git a/lib/routes/binance/square-user.ts b/lib/routes/binance/square-user.ts new file mode 100644 index 000000000000..cf4c226f4f44 --- /dev/null +++ b/lib/routes/binance/square-user.ts @@ -0,0 +1,251 @@ +import querystring from 'node:querystring'; + +import { config } from '@/config'; +import type { Route } from '@/types'; +import { ViewType } from '@/types'; +import cache from '@/utils/cache'; +import ofetch from '@/utils/ofetch'; +import { parseDate } from '@/utils/parse-date'; + +import type { SquareFilterType, SquarePost, SquarePostsResponse, SquareQuoteContent, SquareTranslatedData, SquareUserProfile, SquareUserProfileResponse } from './types'; + +const BASE_URL = 'https://www.binance.com'; + +const FILTER_MAP: Record = { + all: 'ALL', + quote: 'QUOTE', + live: 'LIVE', +}; + +const LANGUAGE_ALIASES: Record = { + 'en-US': 'en', + zh: 'zh-CN', +}; + +const normalizeLanguage = (lang: string) => LANGUAGE_ALIASES[lang] ?? lang; + +const buildHeaders = (username: string, language: string) => ({ + Referer: `${BASE_URL}/${language}/square/profile/${username}`, + 'Accept-Language': language, + 'User-Agent': config.trueUA, + clienttype: 'web', + lang: language, +}); + +const escapeHtml = (text: string) => text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); + +const textToHtml = (text: string) => escapeHtml(text).replaceAll('\n', '
'); + +const getTranslatedText = (language: string, original?: string, translated?: SquareTranslatedData | null) => { + if (language !== 'en') { + const localized = translated?.content || translated?.bodyTextOnly || translated?.body || translated?.title; + if (localized) { + return localized; + } + } + return original || ''; +}; + +const getQuoteText = (quote: SquareQuoteContent, language: string) => getTranslatedText(language, quote.bodyTextOnly || quote.title, quote.translatedData) || quote.title || ''; + +const getPostBody = (post: SquarePost, language: string) => { + let mainText = getTranslatedText(language, post.bodyTextOnly, post.translatedData); + if (!mainText && post.contentType === 4) { + mainText = post.title || ''; + } + return mainText; +}; + +const buildQuoteDescription = (quote: SquareQuoteContent, language: string) => { + const quoteText = getQuoteText(quote, language); + if (!quoteText) { + return ''; + } + + let html = `

${textToHtml(quoteText)}

`; + const imageUrl = quote.coverMeta?.url || quote.imageLink; + if (imageUrl) { + html += `

`; + } + if (quote.webLink) { + html += `

View quoted post

`; + } + html += '
'; + return html; +}; + +const buildPostDescription = (post: SquarePost, language: string) => { + const mainText = getPostBody(post, language); + + const parts: string[] = []; + if (mainText) { + parts.push(`

${textToHtml(mainText)}

`); + } + + if (post.coverMeta?.url) { + parts.push(`

`); + } + + const imageUrls = post.imageMetaList?.map((image) => image.url).filter(Boolean) ?? post.imageList?.filter(Boolean) ?? []; + for (const url of imageUrls) { + parts.push(`

`); + } + + if (post.quoteContent) { + parts.push(buildQuoteDescription(post.quoteContent, language)); + } + + return parts.join(''); +}; + +const getPostTitle = (post: SquarePost, language: string) => { + const quoteText = post.quoteContent ? getQuoteText(post.quoteContent, language) : undefined; + + return post.title || getPostBody(post, language) || quoteText || (post.displayName ? `${post.displayName}'s post` : `Post ${post.id}`); +}; + +const parseRouteOptions = (routeParams?: string) => { + const parsed = querystring.parse(routeParams); + const rawFilter = String(parsed.filter || 'all').toLowerCase(); + const filterType = FILTER_MAP[rawFilter]; + + if (!filterType) { + throw new Error(`Filter "${rawFilter}" is not supported. Use all, quote, or live.`); + } + + return { + filterType, + language: normalizeLanguage(String(parsed.lang || 'en')), + }; +}; + +const fetchUserProfile = (username: string, language: string) => + cache.tryGet(`binance:square:profile:${username.toLowerCase()}`, async () => { + const response = await ofetch(`${BASE_URL}/bapi/composite/v3/friendly/pgc/user/client`, { + method: 'POST', + headers: { + ...buildHeaders(username, language), + 'Content-Type': 'application/json', + }, + body: { username }, + }); + + if (!response.data?.squareUid) { + throw new Error(`User "${username}" not found on Binance Square`); + } + + return response.data; + }); + +const fetchUserPosts = async (squareUid: string, username: string, filterType: SquareFilterType, language: string) => { + const postsUrl = new URL(`${BASE_URL}/bapi/composite/v2/friendly/pgc/content/queryUserProfilePageContentsWithFilter`); + postsUrl.searchParams.set('targetSquareUid', squareUid); + postsUrl.searchParams.set('timeOffset', String(Date.now())); + postsUrl.searchParams.set('filterType', filterType); + + const response = await ofetch(postsUrl.toString(), { + headers: buildHeaders(username, language), + }); + + if (response.code !== '000000' || response.success === false) { + throw new Error(response.message || 'Failed to fetch Binance Square posts'); + } + + return response; +}; + +export const route: Route = { + path: '/square/user/:username/:routeParams?', + categories: ['social-media'], + view: ViewType.SocialMedia, + example: '/binance/square/user/cz', + parameters: { + username: 'Binance Square username, as shown in the profile URL', + routeParams: 'Extra parameters. Use filter and lang to customize post types and language.', + }, + features: { + requireConfig: false, + requirePuppeteer: false, + antiCrawler: true, + supportBT: false, + supportPodcast: false, + supportScihub: false, + }, + radar: [ + { + source: ['www.binance.com/square/profile/:username'], + target: '/square/user/:username', + }, + { + source: ['www.binance.com/:lang/square/profile/:username'], + target: '/square/user/:username/lang=:lang', + }, + ], + name: 'Square Profile', + description: `Posts from a Binance Square user profile. + +| Parameter | Value | Description | +| --------- | ----- | ------------------- | +| filter | all | All posts (default) | +| filter | quote | Quote posts only | +| filter | live | Live posts only | +| lang | en | English (default) | +| lang | zh-CN | Simplified Chinese | +| lang | zh-TW | Traditional Chinese | +| lang | ja | Japanese | + +Examples: + +- \`/binance/square/user/cz/filter=quote\` +- \`/binance/square/user/cz/lang=zh-CN\` +- \`/binance/square/user/cz/filter=quote&lang=zh-CN\``, + maintainers: ['enpitsulin', 'DIYgod'], + handler, +}; + +async function handler(ctx) { + const username = ctx.req.param('username'); + const { filterType, language } = parseRouteOptions(ctx.req.param('routeParams')); + + const limit = Number.parseInt(ctx.req.query('limit') ?? '20', 10); + const pageSize = Number.isNaN(limit) || limit <= 0 ? 20 : limit; + + const profile: SquareUserProfile = await fetchUserProfile(username, language); + const squareUid = profile.squareUid!; + const profileUsername = profile.username || username; + const profileUrl = `${BASE_URL}/${language}/square/profile/${profileUsername}`; + + const postsResponse = await fetchUserPosts(squareUid, username, filterType, language); + const contents = postsResponse.data?.contents ?? []; + + const item = contents.slice(0, pageSize).map((post) => ({ + title: getPostTitle(post, language), + link: post.webLink || `${BASE_URL}/${language}/square/post/${post.id}`, + pubDate: post.createTime ? parseDate(post.createTime) : undefined, + author: post.displayName, + category: post.hashtagList?.map((tag) => tag.trim()).filter(Boolean), + description: buildPostDescription(post, language), + comments: post.commentCount, + upvotes: post.likeCount, + })); + + const displayName = profile.displayName || profileUsername; + const avatar = profile.avatar || undefined; + + ctx.set('json', { + profile, + postsResponse, + language, + }); + + return { + title: `${displayName} (@${profileUsername}) — Binance Square`, + link: profileUrl, + description: profile.biography || undefined, + image: avatar, + icon: avatar, + logo: avatar, + item, + allowEmpty: true, + }; +} diff --git a/lib/routes/binance/types.ts b/lib/routes/binance/types.ts index 4078150e634a..d9b37a91584a 100644 --- a/lib/routes/binance/types.ts +++ b/lib/routes/binance/types.ts @@ -24,3 +24,70 @@ export interface AnnouncementArticle { type: number; releaseDate: number; } + +export type SquareFilterType = 'ALL' | 'QUOTE' | 'LIVE'; + +export interface SquareTranslatedData { + title?: string | null; + content?: string | null; + body?: string | null; + bodyTextOnly?: string | null; +} + +export interface SquareImageMeta { + url: string; + width?: number; + height?: number; +} + +export interface SquareQuoteContent { + id?: string; + title?: string; + bodyTextOnly?: string; + webLink?: string; + imageLink?: string; + coverMeta?: SquareImageMeta | null; + translatedData?: SquareTranslatedData | null; +} + +export interface SquarePost { + id: number; + title?: string; + bodyTextOnly?: string; + contentType?: number; + createTime?: number; + webLink?: string; + displayName?: string; + imageList?: string[]; + imageMetaList?: SquareImageMeta[]; + coverMeta?: SquareImageMeta | null; + hashtagList?: string[]; + quoteContent?: SquareQuoteContent | null; + translatedData?: SquareTranslatedData | null; + commentCount?: number; + likeCount?: number; +} + +export interface SquarePostsResponse { + code: string; + message?: string | null; + data: { + contents?: SquarePost[]; + timeOffset?: number; + } | null; + success?: boolean; +} + +export interface SquareUserProfile { + squareUid?: string | null; + avatar?: string | null; + displayName?: string | null; + biography?: string | null; + username?: string | null; +} + +export interface SquareUserProfileResponse { + code: string; + data: SquareUserProfile | null; + success?: boolean; +}