From 1da2cbc52365125ac962ac958f77cd49b2bdd8fc Mon Sep 17 00:00:00 2001 From: an-lee Date: Sun, 7 Jun 2026 13:19:32 +0800 Subject: [PATCH 1/2] feat(route/binance) add binance square user --- lib/binance-square-user.test.ts | 194 +++++++++++++++++++++++++++ lib/routes/binance/square-user.ts | 209 ++++++++++++++++++++++++++++++ lib/routes/binance/types.ts | 58 +++++++++ 3 files changed, 461 insertions(+) create mode 100644 lib/binance-square-user.test.ts create mode 100644 lib/routes/binance/square-user.ts diff --git a/lib/binance-square-user.test.ts b/lib/binance-square-user.test.ts new file mode 100644 index 000000000000..d55ac2c74cd2 --- /dev/null +++ b/lib/binance-square-user.test.ts @@ -0,0 +1,194 @@ +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/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); + }); +}); diff --git a/lib/routes/binance/square-user.ts b/lib/routes/binance/square-user.ts new file mode 100644 index 000000000000..fd22a03ed95c --- /dev/null +++ b/lib/routes/binance/square-user.ts @@ -0,0 +1,209 @@ +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, SquareUserProfile, SquareUserProfileResponse } from './types'; + +const BASE_URL = 'https://www.binance.com'; + +const FILTER_MAP: Record = { + all: 'ALL', + quote: 'QUOTE', + live: 'LIVE', +}; + +const buildHeaders = (username: string) => ({ + Referer: `${BASE_URL}/square/profile/${username}`, + 'User-Agent': config.trueUA, + clienttype: 'web', +}); + +const escapeHtml = (text: string) => text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); + +const textToHtml = (text: string) => escapeHtml(text).replaceAll('\n', '
'); + +const buildQuoteDescription = (quote: SquareQuoteContent) => { + const quoteText = quote.bodyTextOnly || quote.title; + 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) => { + let mainText = post.bodyTextOnly || ''; + if (!mainText && post.contentType === 4) { + mainText = post.title || ''; + } + + 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)); + } + + return parts.join(''); +}; + +const getPostTitle = (post: SquarePost) => post.title || post.bodyTextOnly || post.quoteContent?.title || post.quoteContent?.bodyTextOnly || (post.displayName ? `${post.displayName}'s post` : `Post ${post.id}`); + +const parseFilter = (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; +}; + +const fetchUserProfile = (username: 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), + '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) => { + 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), + }); + + 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?', '/square/profile/: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: 'Filter parameter. Use filter to customize post types.', + }, + 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', + }, + ], + name: 'Square Profile', + description: `Posts from a Binance Square user profile. + +| Filter Value | Description | +| ------------ | ------------------- | +| all | All posts (default) | +| quote | Quote posts only | +| live | Live posts only | + +Default value for filter is \`all\` if not specified. + +Example: + +- \`/binance/square/user/cz/filter=quote\``, + maintainers: ['enpitsulin', 'DIYgod'], + handler, +}; + +async function handler(ctx) { + const username = ctx.req.param('username'); + const filterType = parseFilter(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); + const squareUid = profile.squareUid!; + const profileUsername = profile.username || username; + const profileUrl = `${BASE_URL}/square/profile/${profileUsername}`; + + const postsResponse = await fetchUserPosts(squareUid, username, filterType); + const contents = postsResponse.data?.contents ?? []; + + const item = contents.slice(0, pageSize).map((post) => ({ + title: getPostTitle(post), + link: post.webLink || `${BASE_URL}/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), + comments: post.commentCount, + upvotes: post.likeCount, + })); + + const displayName = profile.displayName || profileUsername; + const avatar = profile.avatar || undefined; + + ctx.set('json', { + profile, + postsResponse, + }); + + 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..5542a5f8bff5 100644 --- a/lib/routes/binance/types.ts +++ b/lib/routes/binance/types.ts @@ -24,3 +24,61 @@ export interface AnnouncementArticle { type: number; releaseDate: number; } + +export type SquareFilterType = 'ALL' | 'QUOTE' | 'LIVE'; + +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; +} + +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; + 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; +} From 4752db1fa323b0995bf4873420dd2207c20e66de Mon Sep 17 00:00:00 2001 From: an-lee Date: Sun, 7 Jun 2026 13:32:23 +0800 Subject: [PATCH 2/2] feat(route/binance) support `lang` for square user route --- lib/binance-square-user.test.ts | 114 ++++++++++++++++++++++++++++- lib/routes/binance/square-user.ts | 118 ++++++++++++++++++++---------- lib/routes/binance/types.ts | 9 +++ 3 files changed, 202 insertions(+), 39 deletions(-) diff --git a/lib/binance-square-user.test.ts b/lib/binance-square-user.test.ts index d55ac2c74cd2..1a45e6f9ef9a 100644 --- a/lib/binance-square-user.test.ts +++ b/lib/binance-square-user.test.ts @@ -96,7 +96,7 @@ describe('/binance/square/user/:username', () => { const result = await route.handler(ctx as any); expect(result.title).toBe('CZ (@CZ) — Binance Square'); - expect(result.link).toBe('https://www.binance.com/square/profile/CZ'); + 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"); @@ -191,4 +191,116 @@ describe('/binance/square/user/:username', () => { 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 index fd22a03ed95c..cf4c226f4f44 100644 --- a/lib/routes/binance/square-user.ts +++ b/lib/routes/binance/square-user.ts @@ -7,7 +7,7 @@ import cache from '@/utils/cache'; import ofetch from '@/utils/ofetch'; import { parseDate } from '@/utils/parse-date'; -import type { SquareFilterType, SquarePost, SquarePostsResponse, SquareQuoteContent, SquareUserProfile, SquareUserProfileResponse } from './types'; +import type { SquareFilterType, SquarePost, SquarePostsResponse, SquareQuoteContent, SquareTranslatedData, SquareUserProfile, SquareUserProfileResponse } from './types'; const BASE_URL = 'https://www.binance.com'; @@ -17,18 +17,47 @@ const FILTER_MAP: Record = { live: 'LIVE', }; -const buildHeaders = (username: string) => ({ - Referer: `${BASE_URL}/square/profile/${username}`, +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 buildQuoteDescription = (quote: SquareQuoteContent) => { - const quoteText = quote.bodyTextOnly || quote.title; +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 ''; } @@ -45,11 +74,8 @@ const buildQuoteDescription = (quote: SquareQuoteContent) => { return html; }; -const buildPostDescription = (post: SquarePost) => { - let mainText = post.bodyTextOnly || ''; - if (!mainText && post.contentType === 4) { - mainText = post.title || ''; - } +const buildPostDescription = (post: SquarePost, language: string) => { + const mainText = getPostBody(post, language); const parts: string[] = []; if (mainText) { @@ -66,15 +92,19 @@ const buildPostDescription = (post: SquarePost) => { } if (post.quoteContent) { - parts.push(buildQuoteDescription(post.quoteContent)); + parts.push(buildQuoteDescription(post.quoteContent, language)); } return parts.join(''); }; -const getPostTitle = (post: SquarePost) => post.title || post.bodyTextOnly || post.quoteContent?.title || post.quoteContent?.bodyTextOnly || (post.displayName ? `${post.displayName}'s post` : `Post ${post.id}`); +const getPostTitle = (post: SquarePost, language: string) => { + const quoteText = post.quoteContent ? getQuoteText(post.quoteContent, language) : undefined; -const parseFilter = (routeParams?: string) => { + 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]; @@ -83,15 +113,18 @@ const parseFilter = (routeParams?: string) => { throw new Error(`Filter "${rawFilter}" is not supported. Use all, quote, or live.`); } - return filterType; + return { + filterType, + language: normalizeLanguage(String(parsed.lang || 'en')), + }; }; -const fetchUserProfile = (username: string) => +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), + ...buildHeaders(username, language), 'Content-Type': 'application/json', }, body: { username }, @@ -104,14 +137,14 @@ const fetchUserProfile = (username: string) => return response.data; }); -const fetchUserPosts = async (squareUid: string, username: string, filterType: SquareFilterType) => { +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), + headers: buildHeaders(username, language), }); if (response.code !== '000000' || response.success === false) { @@ -122,13 +155,13 @@ const fetchUserPosts = async (squareUid: string, username: string, filterType: S }; export const route: Route = { - path: ['/square/user/:username/:routeParams?', '/square/profile/:username/:routeParams?'], + 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: 'Filter parameter. Use filter to customize post types.', + routeParams: 'Extra parameters. Use filter and lang to customize post types and language.', }, features: { requireConfig: false, @@ -143,47 +176,55 @@ export const route: Route = { 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. -| Filter Value | Description | -| ------------ | ------------------- | -| all | All posts (default) | -| quote | Quote posts only | -| live | Live posts only | - -Default value for filter is \`all\` if not specified. - -Example: - -- \`/binance/square/user/cz/filter=quote\``, +| 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 = parseFilter(ctx.req.param('routeParams')); + 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); + const profile: SquareUserProfile = await fetchUserProfile(username, language); const squareUid = profile.squareUid!; const profileUsername = profile.username || username; - const profileUrl = `${BASE_URL}/square/profile/${profileUsername}`; + const profileUrl = `${BASE_URL}/${language}/square/profile/${profileUsername}`; - const postsResponse = await fetchUserPosts(squareUid, username, filterType); + const postsResponse = await fetchUserPosts(squareUid, username, filterType, language); const contents = postsResponse.data?.contents ?? []; const item = contents.slice(0, pageSize).map((post) => ({ - title: getPostTitle(post), - link: post.webLink || `${BASE_URL}/square/post/${post.id}`, + 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), + description: buildPostDescription(post, language), comments: post.commentCount, upvotes: post.likeCount, })); @@ -194,6 +235,7 @@ async function handler(ctx) { ctx.set('json', { profile, postsResponse, + language, }); return { diff --git a/lib/routes/binance/types.ts b/lib/routes/binance/types.ts index 5542a5f8bff5..d9b37a91584a 100644 --- a/lib/routes/binance/types.ts +++ b/lib/routes/binance/types.ts @@ -27,6 +27,13 @@ export interface AnnouncementArticle { 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; @@ -40,6 +47,7 @@ export interface SquareQuoteContent { webLink?: string; imageLink?: string; coverMeta?: SquareImageMeta | null; + translatedData?: SquareTranslatedData | null; } export interface SquarePost { @@ -55,6 +63,7 @@ export interface SquarePost { coverMeta?: SquareImageMeta | null; hashtagList?: string[]; quoteContent?: SquareQuoteContent | null; + translatedData?: SquareTranslatedData | null; commentCount?: number; likeCount?: number; }