From 7cf1761535516227212e5dc0c06cf8761bf0d427 Mon Sep 17 00:00:00 2001 From: Chilfish Date: Thu, 17 Sep 2026 01:42:02 +0800 Subject: [PATCH 1/7] feat(space): add AudioSpaceById request resource to rettiwt-api MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Space 推文的 card 是空壳(binding 只有 tweet_id / id / card_url,无 title/description/图片),标题/主播/人数/时长全在另一个 GraphQL 端点里, 故新增资源与请求构造: - ResourceType.SPACE_DETAILS + SpaceRequests.details(id) (queryId HPEisOmj1epUNLCWTYhUWw;withReplays: true 才能拿到已结束场次) - Requests / FetchResourcesGroup / index.ts 三处注册 (漏注册会被 _validateArgs 静默判废) - 补 data.audioSpace 响应壳类型(metadata 结构对齐既有 Details.ts) Co-authored-by: CommandCodeBot --- app/lib/rettiwt-api/collections/Groups.ts | 1 + app/lib/rettiwt-api/collections/Requests.ts | 3 ++ app/lib/rettiwt-api/enums/Resource.ts | 3 ++ app/lib/rettiwt-api/index.ts | 2 + app/lib/rettiwt-api/requests/Space.ts | 53 +++++++++++++++++++ .../rettiwt-api/types/raw/space/Details.ts | 24 +++++++++ 6 files changed, 86 insertions(+) create mode 100644 app/lib/rettiwt-api/requests/Space.ts diff --git a/app/lib/rettiwt-api/collections/Groups.ts b/app/lib/rettiwt-api/collections/Groups.ts index 9db6deed..57268e47 100644 --- a/app/lib/rettiwt-api/collections/Groups.ts +++ b/app/lib/rettiwt-api/collections/Groups.ts @@ -24,6 +24,7 @@ export const FetchResourcesGroup = [ ResourceType.DM_CONVERSATION, ResourceType.DM_INBOX_INITIAL_STATE, ResourceType.DM_INBOX_TIMELINE, + ResourceType.SPACE_DETAILS, ResourceType.TWEET_DETAILS, ResourceType.TWEET_DETAILS_ALT, ResourceType.TWEET_DETAILS_BULK, diff --git a/app/lib/rettiwt-api/collections/Requests.ts b/app/lib/rettiwt-api/collections/Requests.ts index 61d03df0..728214bc 100644 --- a/app/lib/rettiwt-api/collections/Requests.ts +++ b/app/lib/rettiwt-api/collections/Requests.ts @@ -6,6 +6,7 @@ import type { IPostArgs } from '../types/args/PostArgs' import { DMRequests } from '../requests/DirectMessage' import { ListRequests } from '../requests/List' import { MediaRequests } from '../requests/Media' +import { SpaceRequests } from '../requests/Space' import { TweetRequests } from '../requests/Tweet' import { UserRequests } from '../requests/User' @@ -33,6 +34,8 @@ export const Requests: { [key in keyof typeof ResourceType]: (args: IFetchArgs | DM_INBOX_TIMELINE: (args: IFetchArgs) => DMRequests.inboxTimeline(args.maxId), DM_DELETE_CONVERSATION: (args: IPostArgs) => DMRequests.deleteConversation(args.conversationId!), + SPACE_DETAILS: (args: IFetchArgs) => SpaceRequests.details(args.id!), + TWEET_BOOKMARK: (args: IPostArgs) => TweetRequests.bookmark(args.id!), TWEET_DETAILS: (args: IFetchArgs) => TweetRequests.details(args.id!), TWEET_DETAILS_ALT: (args: IFetchArgs) => TweetRequests.replies(args.id!), diff --git a/app/lib/rettiwt-api/enums/Resource.ts b/app/lib/rettiwt-api/enums/Resource.ts index 6854cc46..a33c247e 100644 --- a/app/lib/rettiwt-api/enums/Resource.ts +++ b/app/lib/rettiwt-api/enums/Resource.ts @@ -22,6 +22,9 @@ export enum ResourceType { DM_INBOX_TIMELINE = 'DM_INBOX_TIMELINE', DM_DELETE_CONVERSATION = 'DM_DELETE_CONVERSATION', + // SPACE + SPACE_DETAILS = 'SPACE_DETAILS', + // TWEET TWEET_BOOKMARK = 'TWEET_BOOKMARK', TWEET_DETAILS = 'TWEET_DETAILS', diff --git a/app/lib/rettiwt-api/index.ts b/app/lib/rettiwt-api/index.ts index 70555c73..ccfbe98d 100644 --- a/app/lib/rettiwt-api/index.ts +++ b/app/lib/rettiwt-api/index.ts @@ -32,6 +32,7 @@ export * from './requests/DirectMessage' export * from './requests/List' export * from './requests/Media' +export * from './requests/Space' export * from './requests/Tweet' export * from './requests/User' // MAIN @@ -88,6 +89,7 @@ export type { IListTweetsResponse as IRawListTweetsResponse } from './types/raw/ export type { IMediaFinalizeUploadResponse as IRawMediaFinalizeUploadResponse } from './types/raw/media/FinalizeUpload' export type { IMediaInitializeUploadResponse as IRawMediaInitializeUploadResponse } from './types/raw/media/InitalizeUpload' export type { IMediaLiveVideoStreamResponse as IRawMediaLiveVideoStreamResponse } from './types/raw/media/LiveVideoStream' +export type { IAudioSpace as IRawAudioSpace, IAudioSpaceByIdResponse as IRawAudioSpaceByIdResponse, ISpaceDetailsResponse as IRawSpaceDetailsResponse } from './types/raw/space/Details' export type { ITweetDetailsResponse as IRawTweetDetailsResponse } from './types/raw/tweet/Details' export type { ITweetDetailsBulkResponse as IRawTweetDetailsBulkResponse } from './types/raw/tweet/DetailsBulk' export type { ITweetLikeResponse as IRawTweetLikeResponse } from './types/raw/tweet/Like' diff --git a/app/lib/rettiwt-api/requests/Space.ts b/app/lib/rettiwt-api/requests/Space.ts new file mode 100644 index 00000000..c970c005 --- /dev/null +++ b/app/lib/rettiwt-api/requests/Space.ts @@ -0,0 +1,53 @@ +import type { AxiosRequestConfig } from 'axios' + +/** + * Collection of requests related to Spaces. + * + * @public + */ +export class SpaceRequests { + /** + * @param id - The id of the Space whose details are to be fetched. + * + * @remarks + * - `withReplays: true` 是拿到已结束场次(`Ended` / `TimedOut`)元数据的前提, + * 缺省时 X 只返回进行中的场次。 + * - 响应结构为 `data.audioSpace.{ metadata, participants }`,卡片所需字段全在 + * `metadata`(title / state / started_at / ended_at / total_*_listeners / + * creator_results)。录音本身的 m3u8 不在此响应内(需再打 v1.1 + * `live_video_stream/status/{media_key}`),本期不做站内播放,故不请求。 + */ + public static details(id: string): AxiosRequestConfig { + return { + method: 'get', + url: 'https://x.com/i/api/graphql/HPEisOmj1epUNLCWTYhUWw/AudioSpaceById', + params: { + + variables: JSON.stringify({ + id, + isMetatagsQuery: true, + withDownvotePerspective: false, + withReactionsMetadata: false, + withReactionsPerspective: false, + withReplays: true, + withSuperFollowsUserFields: true, + withSuperFollowsTweetFields: true, + }), + features: JSON.stringify({ + dont_mention_me_view_api_enabled: true, + interactive_text_enabled: true, + responsive_web_edit_tweet_api_enabled: true, + responsive_web_enhance_cards_enabled: true, + responsive_web_uc_gql_enabled: true, + spaces_2022_h2_clipping: true, + spaces_2022_h2_spaces_communities: false, + standardized_nudges_misinfo: true, + tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: false, + vibe_api_enabled: true, + }), + + }, + paramsSerializer: { encode: encodeURIComponent }, + } + } +} diff --git a/app/lib/rettiwt-api/types/raw/space/Details.ts b/app/lib/rettiwt-api/types/raw/space/Details.ts index d6042314..409d9c23 100644 --- a/app/lib/rettiwt-api/types/raw/space/Details.ts +++ b/app/lib/rettiwt-api/types/raw/space/Details.ts @@ -355,3 +355,27 @@ interface Url7 { url: string indices: number[] } + +/** + * The `data.audioSpace` node returned by the `AudioSpaceById` GraphQL operation. + * + * @public + */ +export interface IAudioSpace { + is_subscribed: boolean + /** 卡片所需的全部字段(title / state / started_at / ended_at / total_* / creator_results) */ + metadata: ISpaceDetailsResponse + /** 参与者(admins / speakers / listeners);官方卡片不展示,故此处不作细类型约束 */ + participants?: unknown +} + +/** + * The raw data received when fetching a Space by its id. + * + * @public + */ +export interface IAudioSpaceByIdResponse { + data: { + audioSpace?: IAudioSpace + } +} From 1d97a36826f6d076a3be343a0f1a35bb746e5ef6 Mon Sep 17 00:00:00 2001 From: Chilfish Date: Thu, 17 Sep 2026 01:42:12 +0800 Subject: [PATCH 2/7] feat(space): add SpaceDetails types and pure mapping helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 Postmortem #002(纯逻辑必须脱离 React 才能验证)把所有判定下沉为 无 IO 纯函数,组件只做渲染: - SpaceDetails / SpaceHost / SpaceAvailability 类型,EnrichedTweet.space 可选 - extractAudiospaceId:从 card binding 取 Space id - extractSpaceIdFromUrl / resolveSpaceId:无卡片推文回退正文链接解析 - mapSpaceDetails:metadata → 卡片字段(标题/主播/人数/时长/可播放状态) - resolveSpacePlaybackState:availability 兜底(兼容缺该字段的旧缓存) - formatSpaceDuration / formatSpaceDate / formatSpaceListeners 字段口径对齐官方卡片实测值:listenersCount = 直播收听 + 回放(2,478)、 durationMs = ended_at - started_at(42:40)、created_at → 9月17日。 Co-authored-by: CommandCodeBot --- app/lib/obs-log.ts | 1 + app/lib/react-tweet/utils/index.ts | 1 + app/lib/react-tweet/utils/space.ts | 279 +++++++++++++++++++++++++++++ app/types/index.ts | 3 + app/types/space.ts | 57 ++++++ 5 files changed, 341 insertions(+) create mode 100644 app/lib/react-tweet/utils/space.ts create mode 100644 app/types/space.ts diff --git a/app/lib/obs-log.ts b/app/lib/obs-log.ts index 9a1e63c4..9aef7c5c 100644 --- a/app/lib/obs-log.ts +++ b/app/lib/obs-log.ts @@ -14,6 +14,7 @@ export type ObsEvent | 'pool.rotate' // RettiwtPool 冷却轮换:keySuffix / failStreak / cooldownMs / reason | 'pool.exhaust' // RettiwtPool 全部 Key 耗尽:attempts / states | 'jetfuel.parse.fallback' // jetfuel payload 解析失败回退 unified_card:card / payloadHash / reason + | 'space.fetch.failed' // Space 元数据(AudioSpaceById)获取失败,卡片降级不展示:spaceId / tweetId / reason export interface ObsFields { [key: string]: string | number | boolean | null | undefined diff --git a/app/lib/react-tweet/utils/index.ts b/app/lib/react-tweet/utils/index.ts index 7b26ccfd..cb3710f6 100644 --- a/app/lib/react-tweet/utils/index.ts +++ b/app/lib/react-tweet/utils/index.ts @@ -6,6 +6,7 @@ export * from './date-utils' export * from './entitytParser' // export * from './get-tweet' export * from './parseTweet' +export * from './space' export interface TweetCoreProps { id: string diff --git a/app/lib/react-tweet/utils/space.ts b/app/lib/react-tweet/utils/space.ts new file mode 100644 index 00000000..1baf90b5 --- /dev/null +++ b/app/lib/react-tweet/utils/space.ts @@ -0,0 +1,279 @@ +import type { IRawAudioSpace, IRawSpaceDetailsResponse } from '~/lib/rettiwt-api' +import type { SpaceAvailability, SpaceDetails, SpaceHost, TweetUser } from '~/types' +import { formatDate } from './date-utils' + +/** + * Space(语音直播/录音回放)卡片纯逻辑。 + * + * 上游两段数据来源不同、结构不同,故这里只做「纯映射」,不做任何 IO: + * - **卡片识别**:`extractAudiospaceId()` 读推文自带的 `card.name = '…:audiospace'` + * (其 `binding_values` 只有 `tweet_id` / `id` / `card_url` 等,无 title/图片, + * 因此会被 `mapTwitterCard` 的最终校验丢弃——这正是 Space 卡片过去完全不显示的原因)。 + * - **元数据**:`mapSpaceDetails()` 把 `AudioSpaceById` 的 `audioSpace.metadata` + * 映射为 {@link SpaceDetails}(标题/状态/起止时间/收听人数/主播)。 + * + * 字段口径全部对齐官方卡片实测值(样本:Space `1yoKMPnjEbOxQ`,Ended + 可回放)。 + * 下沉为纯函数以便单测(Postmortem #002:纯逻辑必须脱离 React 才能验证)。 + */ + +/** 官方 Space 卡片名形如 `3691233323:audiospace`(前缀为卡片模板号,随版本变动) */ +const AUDIOSPACE_CARD_SUFFIX = 'audiospace' + +/** 正文中的 Space 链接(`x.com/i/spaces/` / `twitter.com/i/spaces/`) */ +const SPACE_URL_RE = /^https?:\/\/(?:www\.|mobile\.)?(?:twitter|x)\.com\/i\/spaces\/([0-9A-Za-z]+)/ + +/** + * 从推文原始 `card` 中取出 Space id。 + * + * @param cardData - 原始 `TweetResultByRestId` 响应里的 `card` 节点 + * @returns Space id;非 audiospace 卡或缺 `id` binding 时返回 null + */ +export function extractAudiospaceId(cardData: unknown): string | null { + const legacy = (cardData as { legacy?: unknown })?.legacy ?? cardData + const name = (legacy as { name?: unknown })?.name + const bindingValues = (legacy as { binding_values?: unknown })?.binding_values + + if (typeof name !== 'string' || !name.endsWith(AUDIOSPACE_CARD_SUFFIX) || !Array.isArray(bindingValues)) + return null + + const id = bindingValues + .find((v: any) => v?.key === 'id') + ?.value + ?.string_value + + return typeof id === 'string' && id ? id : null +} + +/** 该 URL 是否指向 X Space 页面(正文据此避免与卡片重复展示同一个跳转) */ +export function isSpaceUrl(url?: string | null): boolean { + return typeof url === 'string' && SPACE_URL_RE.test(url) +} + +/** 从 Space 链接里取出 Space id;不是 Space 链接时返回 null */ +export function extractSpaceIdFromUrl(url?: string | null): string | null { + if (typeof url !== 'string') + return null + return SPACE_URL_RE.exec(url)?.[1] ?? null +} + +/** + * 解析推文对应的 Space id,**优先卡片、回退正文链接**。 + * + * 两种推文都要能识别: + * - 带 `…:audiospace` 卡片(常见形态,id 在 binding 里) + * - **无卡片**、只在正文里贴了 `x.com/i/spaces/…` 链接(实测样本:推文 + * `1871586443388420240` 完全没有 card,只有一个 url 实体)——这类过去会被整条漏掉 + */ +export function resolveSpaceId( + cardData: unknown, + entities?: ReadonlyArray | null, +): string | null { + const fromCard = extractAudiospaceId(cardData) + if (fromCard) + return fromCard + + // 实体是联合类型(text/hashtag/… 多数没有 href),故按 unknown 收进来再窄化 + for (const entity of entities ?? []) { + const id = extractSpaceIdFromUrl((entity as { href?: string } | null | undefined)?.href) + if (id) + return id + } + + return null +} + +/** 上游时间戳混用秒/毫秒,`< 1e12` 视为秒;空值/非法值统一为 null */ +function toMillis(value: unknown): number | null { + if (value === null || value === undefined || value === '') + return null + const n = Number(value) + if (!Number.isFinite(n) || n <= 0) + return null + return n < 1e12 ? Math.round(n * 1000) : n +} + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === 'object' ? value as Record : undefined +} + +/** + * 由 `state` + `is_space_available_for_replay` 推导卡片可播放状态。 + * + * 关键分支是「已结束但未开启回放」:此时**没有**录音可放,绝不能给播放入口 + * (官方卡片同样不给)。 + */ +function resolveAvailability(state: string, isReplayAvailable: boolean): SpaceAvailability { + const normalized = state.toLowerCase() + if (normalized === 'running') + return 'live' + if (normalized === 'notstarted') + return 'upcoming' + // Ended / TimedOut / 未知一律按「已结束」处理,能否播由回放开关决定 + return isReplayAvailable ? 'replayable' : 'no-replay' +} + +const EMPTY_HOST: SpaceHost = { + id_str: '', + name: '', + screen_name: '', + profile_image_url_https: '', + verified: false, + is_blue_verified: false, +} + +/** + * 把 `AudioSpaceById` 的 `data.audioSpace` 节点映射为卡片所需的 {@link SpaceDetails}。 + * + * @param spaceId - 卡片 binding 里的 Space id(墓碑态也要靠它构造 URL,故必须先于元数据拿到) + * @param audioSpace - GraphQL `data.audioSpace` 节点 + * @param tweetAuthor - 推文作者(可选)。当作者就是主播时,用推文侧更完整的用户对象 + * 补齐认证信息与头像——`AudioSpaceById` 的 `creator_results` 是精简对象,缺 + * `is_blue_verified` / `verified_type`,只靠它渲染会丢掉官方卡片上的认证徽标。 + * @returns `spaceId` 为空时返回 null;**上游无 metadata 时返回 `unavailable` 墓碑态** + * ——已删除 / 不可访问的 Space 也要有 UI 提示,而不是静默什么都不显示 + * (实测这类 id 的响应是 `{ is_subscribed: false }`,没有 metadata) + */ +export function mapSpaceDetails( + spaceId: string, + audioSpace?: IRawAudioSpace | Record | null, + tweetAuthor?: TweetUser | null, +): SpaceDetails | null { + if (!spaceId) + return null + + const url = `https://x.com/i/spaces/${spaceId}` + const metadata = asRecord(asRecord(audioSpace)?.metadata) as IRawSpaceDetailsResponse | undefined + + if (!metadata) { + return { + id: spaceId, + url, + title: '', + state: '', + availability: 'unavailable', + createdAt: 0, + startedAt: null, + endedAt: null, + durationMs: null, + listenersCount: 0, + liveListenersCount: 0, + replayCount: 0, + isReplayAvailable: false, + host: { ...EMPTY_HOST }, + } + } + + const title = typeof metadata.title === 'string' ? metadata.title.trim() : '' + + const creator = asRecord(asRecord(metadata.creator_results)?.result) + const creatorLegacy = asRecord(creator?.legacy) + + const host: SpaceHost = { + id_str: typeof creator?.rest_id === 'string' ? creator.rest_id : '', + name: typeof creatorLegacy?.name === 'string' ? creatorLegacy.name : '', + screen_name: typeof creatorLegacy?.screen_name === 'string' ? creatorLegacy.screen_name : '', + profile_image_url_https: typeof creatorLegacy?.profile_image_url_https === 'string' + ? creatorLegacy.profile_image_url_https + : '', + verified: creatorLegacy?.verified === true, + is_blue_verified: creator?.is_blue_verified === true, + verified_type: creatorLegacy?.verified_type, + } + + if (tweetAuthor?.screen_name && tweetAuthor.screen_name === host.screen_name) { + // 上游会省略 `verified` 字段(仅在真为 true 时出现),故一律用 `=== true` 收敛为布尔 + host.verified = host.verified || tweetAuthor.verified === true + host.is_blue_verified = host.is_blue_verified || tweetAuthor.is_blue_verified === true + host.verified_type ??= tweetAuthor.verified_type + host.profile_image_url_https ||= tweetAuthor.profile_image_url_https + } + + const startedAt = toMillis(metadata.started_at) + const endedAt = toMillis(metadata.ended_at) + const liveListenersCount = Number(metadata.total_live_listeners) || 0 + const replayCount = Number(metadata.total_replay_watched) || 0 + const isReplayAvailable = metadata.is_space_available_for_replay === true + const state = typeof metadata.state === 'string' ? metadata.state : '' + + return { + id: spaceId, + url, + title, + state, + availability: resolveAvailability(state, isReplayAvailable), + createdAt: toMillis(metadata.created_at) ?? 0, + startedAt, + endedAt, + durationMs: startedAt !== null && endedAt !== null && endedAt > startedAt + ? endedAt - startedAt + : null, + listenersCount: liveListenersCount + replayCount, + liveListenersCount, + replayCount, + isReplayAvailable, + host, + } +} + +/** 场次时长 → 官方口径 `42:40`(≥1 小时为 `1:02:03`);无效时长返回空串 */ +export function formatSpaceDuration(ms?: number | null): string { + if (ms === null || ms === undefined || !Number.isFinite(ms) || ms <= 0) + return '' + + const totalSeconds = Math.floor(ms / 1000) + const seconds = totalSeconds % 60 + const minutes = Math.floor(totalSeconds / 60) % 60 + const hours = Math.floor(totalSeconds / 3600) + const pad = (n: number) => n.toString().padStart(2, '0') + + return hours > 0 + ? `${hours}:${pad(minutes)}:${pad(seconds)}` + : `${minutes}:${pad(seconds)}` +} + +/** 场次日期 → 官方口径 `9月17日`(时区口径复用 formatDate 的 Asia/Shanghai,不补前导零) */ +export function formatSpaceDate(ms?: number | null): string { + if (ms === null || ms === undefined || !Number.isFinite(ms) || ms <= 0) + return '' + + const [month, day] = formatDate(ms, 'MM-dd').split('-') + if (!month || !day) + return '' + + return `${Number(month)}月${Number(day)}日` +} + +/** 收听/回放人数 → 千分位 `2,478` */ +export function formatSpaceListeners(count: number): string { + if (!Number.isFinite(count) || count <= 0) + return '0' + return count.toLocaleString('en-US') +} + +/** 除墓碑态外的可播放状态(卡片行动区只在这四种里选) */ +export type SpacePlaybackState = Exclude + +const SPACE_PLAYBACK_STATES: Record = { + 'replayable': true, + 'live': true, + 'upcoming': true, + 'no-replay': true, +} + +/** + * 解析卡片行动区状态,**兼容旧缓存**。 + * + * `availability` 是后加字段:改动前落地的 `space`(memory LRU / 本地文件 / DB jsonContent) + * 没有它,直接查表会拿到 `undefined` 并在渲染时崩(实测于 dev)。故此处按早期就存在的 + * `isReplayAvailable` 回退推导;再退化(字段也缺失)时保守取 `no-replay`—— + * 宁可不说「可播放」,也不给一个播不了的承诺。 + */ +export function resolveSpacePlaybackState( + space: Pick, +): SpacePlaybackState { + const { availability } = space + if (availability && availability !== 'unavailable' && availability in SPACE_PLAYBACK_STATES) + return availability + + return space.isReplayAvailable ? 'replayable' : 'no-replay' +} diff --git a/app/types/index.ts b/app/types/index.ts index 9b1628c2..fd4185e4 100644 --- a/app/types/index.ts +++ b/app/types/index.ts @@ -1,5 +1,6 @@ import type { LinkPreviewCard } from './card' import type { Entity } from './entities' +import type { SpaceDetails } from './space' import type { Tweet } from './tweet' import type { AIVisionInfo } from './vision' import type { IUser } from '~/lib/rettiwt-api' @@ -11,6 +12,7 @@ export type * from './entities' export type { IGAudio, IGMedia, IGPost, IGPostData, IGStoryLink } from './ins' export type * from './media' export type * from './photo' +export type * from './space' export type * from './user' export type * from './video' @@ -28,6 +30,7 @@ export type EnrichedTweet = Omit & { quoted_tweet_id?: string quotedTweet?: EnrichedTweet card?: LinkPreviewCard + space?: SpaceDetails retweetedOrignalId?: string isInlineMeida?: boolean comments?: EnrichedTweet[] diff --git a/app/types/space.ts b/app/types/space.ts new file mode 100644 index 00000000..5884c99c --- /dev/null +++ b/app/types/space.ts @@ -0,0 +1,57 @@ +import type { TweetUser } from './user' + +/** + * Space 主播(对应元数据 `creator_results.result`)。 + * + * 认证字段与 {@link TweetUser} 同名同义,便于直接复用 `VerifiedBadge` 渲染。 + */ +export interface SpaceHost extends Pick { + id_str: string + name: string + screen_name: string + profile_image_url_https: string +} + +/** `metadata.state` 原样值:`NotStarted` / `Running` / `Ended` / `TimedOut` */ +export type SpaceState = string + +/** + * 卡片的可播放状态(由 `state` + `is_space_available_for_replay` 推导): + * + * - `replayable`:已结束且可回放 → 「播放录音」 + * - `live`:进行中 → 尚未产生录音,只能去 X 收听 + * - `upcoming`:未开始 + * - `no-replay`:已结束但主办方未开启回放 → **录音不可回放**(不能给播放入口) + * - `unavailable`:Space 已删除 / 不可访问(上游无 `metadata`)→ 只给不可用提示 + */ +export type SpaceAvailability = 'replayable' | 'live' | 'upcoming' | 'no-replay' | 'unavailable' + +/** + * Space 卡片展示所需的全部信息(由 `AudioSpaceById` 的 `audioSpace.metadata` 映射而来)。 + * + * 字段口径对齐官方卡片:`listenersCount` 为「直播收听 + 回放」总数(官方文案 + * 「N 人がリスニング/リプレイ」),`durationMs` 为已结束场次的 `ended_at - started_at`。 + * + * `availability === 'unavailable'` 时为**上游无元数据**的墓碑态:标题/主播/人数全部为空, + * 仅 `id` / `url` 可用(见 {@link SpaceAvailability})。 + */ +export interface SpaceDetails { + id: string + /** Space 页面地址(卡片跳转目标;本期站内不播放录音) */ + url: string + /** 标题;`unavailable` 时为空串 */ + title: string + state: SpaceState + availability: SpaceAvailability + /** 场次创建时间(ms),官方卡片用于显示「9月17日」 */ + createdAt: number + startedAt: number | null + endedAt: number | null + /** 已结束场次时长(ms);进行中/未开始/缺 `ended_at` 时为 null */ + durationMs: number | null + listenersCount: number + liveListenersCount: number + replayCount: number + isReplayAvailable: boolean + host: SpaceHost +} From 75c7e3793eb51965735c7f9b32f201b597b0d4b7 Mon Sep 17 00:00:00 2001 From: Chilfish Date: Thu, 17 Sep 2026 01:42:21 +0800 Subject: [PATCH 3/7] feat(space): resolve space on fetch and backfill cached tweets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 取数与降级(不触碰 parseTweet.ts —— Postmortem #001 的头号高危文件, Space 识别放在 enrichTweet 之外的异步层读原始 card): - fetchSpaceDetails / resolveSpaceById:按 id 取 AudioSpaceById 并映射 - attachSpaceDetails:仅单推文路径挂 space;请求失败不挂(否则会把限流 误报成「已删除」),仅记 obsLog('space.fetch.failed') - getLocalTweet 出口回填:space 是后加字段,命中缓存的旧推文不会再走 getEnrichedTweet,于是卡片永远不出现(实测线上仍是裸链接)。缓存不保留 card 但链接始终在实体里,故按 resolveSpaceId(null, entities) 回填/升级, best-effort 写回 localCache + DB,避免每次浏览重打上游 Co-authored-by: CommandCodeBot --- app/lib/react-tweet/utils/get-tweet.ts | 78 +++++++++++++++++++++++++- app/lib/service/getTweet.server.ts | 56 +++++++++++++++++- 2 files changed, 131 insertions(+), 3 deletions(-) diff --git a/app/lib/react-tweet/utils/get-tweet.ts b/app/lib/react-tweet/utils/get-tweet.ts index ce28e0c6..7d671b63 100644 --- a/app/lib/react-tweet/utils/get-tweet.ts +++ b/app/lib/react-tweet/utils/get-tweet.ts @@ -1,15 +1,18 @@ import type { IListTweetsResponse } from '~/lib/rettiwt-api/types/raw/list/Tweets' +import type { IAudioSpaceByIdResponse } from '~/lib/rettiwt-api/types/raw/space/Details' import type { ITweetDetailsResponse } from '~/lib/rettiwt-api/types/raw/tweet/Details' import type { ITweetRepliesResponse } from '~/lib/rettiwt-api/types/raw/tweet/Replies' import type { ITweetSearchResponse } from '~/lib/rettiwt-api/types/raw/tweet/Search' import type { IUserDetailsResponse } from '~/lib/rettiwt-api/types/raw/user/Details' import type { IUserTweetsResponse } from '~/lib/rettiwt-api/types/raw/user/Tweets' -import type { EnrichedTweet, RawTweet, RawUser } from '~/types' +import type { EnrichedTweet, RawTweet, RawUser, SpaceDetails } from '~/types' +import { obsLog } from '~/lib/obs-log' import { ResourceType, TweetRepliesSortType } from '~/lib/rettiwt-api' import { Extractors } from '~/lib/rettiwt-api/collections/Extractors' import { findByFilter } from '~/lib/rettiwt-api/helper/JsonUtils' import { RettiwtPool } from '~/lib/SmartPool' import { enrichTweet } from './parseTweet' +import { mapSpaceDetails, resolveSpaceId } from './space' // import { writeFile } from 'node:fs/promises' // config.ts @@ -29,6 +32,24 @@ export async function fetchTweet(id: string): Promise { }) } +/** + * 获取 Space 详情(`AudioSpaceById`)。 + * + * Space 推文的 `card.name` 为 `…:audiospace`,其 `binding_values` 只有 `tweet_id` / + * `id` / `card_url`,没有标题与图片,因此推文响应本身**拿不到**卡片要显示的内容, + * 必须再打一次这个 GraphQL 端点。查不到(已删除/无权限)时返回 null。 + */ +export async function fetchSpaceDetails(spaceId: string) { + return twitterPool.run(async (fetcher) => { + const response = await fetcher.request( + ResourceType.SPACE_DETAILS, + { id: spaceId }, + ) + + return response.data.audioSpace ?? null + }) +} + export async function fetchListTweets(id: string): Promise { return twitterPool.run(async fetcher => fetcher .request(ResourceType.LIST_TWEETS, { id }) @@ -234,6 +255,57 @@ export async function getEnrichedUserTweet(userId: string): Promise { + try { + const audioSpace = await fetchSpaceDetails(spaceId) + return mapSpaceDetails(spaceId, audioSpace, tweetAuthor) + } + catch (error) { + obsLog('space.fetch.failed', { + spaceId, + reason: error instanceof Error ? error.message : String(error), + }) + return null + } +} + +/** + * 补挂 Space 卡片数据(推文带 `…:audiospace` 卡片,或正文里贴了 Space 链接)。 + * + * 三种结果: + * - 取到 metadata → 正常卡片(可回放 / 直播中 / 未开始 / 不可回放,由 `availability` 区分) + * - 上游说「没有这个 Space」(无 metadata)→ 挂 `unavailable` 墓碑态,给出「已删除或不可访问」提示 + * - 请求本身失败(429 / 网络)→ 不挂 `space`,正文里的 Space 链接照常渲染。此时**不能**当墓碑, + * 否则会把限流误报成「已删除」;仅记 `obsLog('space.fetch.failed')`。 + */ +async function attachSpaceDetails(enrichedTweet: EnrichedTweet, rawTweet: RawTweet): Promise { + // 优先取 card binding 的 id;无卡片时回退正文里的 x.com/i/spaces/… 链接 + // (实测推文 1871586443388420240 完全没有 card,只有 url 实体) + const spaceId = resolveSpaceId(unwrapCardSource(rawTweet)?.card, enrichedTweet.entities) + if (!spaceId) + return + + const space = await resolveSpaceById(spaceId, enrichedTweet.user) + if (space) + enrichedTweet.space = space +} + export async function getEnrichedTweet( id: string, ): Promise { @@ -244,6 +316,10 @@ export async function getEnrichedTweet( // await writeFile('build/tweet.json', JSON.stringify(tweet, null, 2), 'utf8') try { const richTweet = enrichTweet(tweet) + if (!richTweet) { + return null + } + await attachSpaceDetails(richTweet, tweet) return richTweet } catch (error) { diff --git a/app/lib/service/getTweet.server.ts b/app/lib/service/getTweet.server.ts index 8f90d151..5f1410b9 100644 --- a/app/lib/service/getTweet.server.ts +++ b/app/lib/service/getTweet.server.ts @@ -3,10 +3,62 @@ import type { AIVisionInfo } from '~/types/vision' import { eq } from 'drizzle-orm' import { getDbClient, isDbAvailable } from '~/lib/database/db.server' import { tweet, tweetEntities } from '~/lib/database/schema' -import { getEnrichedTweet } from '~/lib/react-tweet/utils/get-tweet' +import { getEnrichedTweet, resolveSpaceById } from '~/lib/react-tweet/utils/get-tweet' +import { resolveSpaceId } from '~/lib/react-tweet/utils/space' import { getLocalCache, setLocalCache } from '../localCache' -export const getLocalTweet = (tweetId: string) => getLocalCache({ id: tweetId, type: 'tweet', getter: () => getDBTweet(tweetId) }) +/** + * 为**已缓存**的推文回填 / 升级 `space` 字段。 + * + * 背景:`space`(及其 `availability`)是后加字段,改动前落地的缓存 + * (memory LRU / 本地文件 / DB jsonContent)没有它/是旧结构;而命中缓存时不会再走 + * `getEnrichedTweet`,于是 Space 卡片/墓碑**永远不出现**(实测:已删除的 Space 推文 + * 在缓存命中时仍然只渲染一个裸链接,`cache.get … hit:true`)。 + * + * 缓存不保留原始 `card`,但 Space 链接始终存在于实体里,故用 `resolveSpaceId(null, entities)` + * 即可覆盖「带卡片」与「无卡片」两种推文。回填结果 best-effort 写回两层缓存, + * 避免每次浏览都重打上游;取数失败(如 429)保持原样返回。 + */ +async function backfillSpaceDetails(tweet: EnrichedTweet): Promise { + // 结构已是最新(带 availability)才视为无需处理;缺字段的旧结构顺带升级一次 + if (tweet.space?.availability) + return tweet + + const spaceId = resolveSpaceId(null, tweet.entities) + if (!spaceId) + return tweet + + const space = await resolveSpaceById(spaceId, tweet.user) + if (!space) + return tweet + + const patched: EnrichedTweet = { ...tweet, space } + + await Promise.allSettled([ + setLocalCache({ id: tweet.id_str, type: 'tweet', value: patched }), + insertToTweetDB([patched]), + ]) + + return patched +} + +/** + * 读单条推文:localCache → DB → 上游。 + * + * 出口统一做一次 Space 回填(见 `backfillSpaceDetails`),使旧缓存与新抓取走同一呈现路径。 + */ +export async function getLocalTweet(tweetId: string): Promise { + const tweet = await getLocalCache({ + id: tweetId, + type: 'tweet', + getter: () => getDBTweet(tweetId), + }) + + if (!tweet) + return tweet + + return backfillSpaceDetails(tweet) +} export function mergeTranslationEntities(enrichedTweet: EnrichedTweet, entities: TranslationEntity[]) { const baseIndexSet = new Set(enrichedTweet.entities.map(e => e.index)) From 75ed5430a846e5013c93ff02738cd3dac3b2d030 Mon Sep 17 00:00:00 2001 From: Chilfish Date: Thu, 17 Sep 2026 01:42:30 +0800 Subject: [PATCH 4/7] feat(space): render Space card with availability states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对标官方渲染 DOM(紫底白字、主播行、标题、收听/回放 · 日期 · 时长、 行动按钮),并覆盖边界态: - replayable → 播放录音(唯一给播放入口的分支) - no-replay → 录音不可回放(没录音却摆播放入口 = 把人送进播不了的页面) - live / upcoming → 直播中 / 尚未开始 - unavailable(上游无 metadata,已删除/不可访问)→ 中性墓碑条 「Space 已删除或不可访问」+「在 X 查看」,不再只剩一个裸链接 整卡即指向 Space 页面的链接(本期不做站内播放:m3u8 只给 x.com 放行 CORS),故行动区是视觉元素而非嵌套交互元素。挂载于 TweetNode / PlainTweet;正文里同一条 Space 链接不再重复渲染。VerifiedBadge 的 user 入参放宽为认证三字段(安全放宽,Space 主播可复用)。 Co-authored-by: CommandCodeBot --- app/components/tweet/PlainTweet.tsx | 2 + app/components/tweet/TweetNode.tsx | 2 + app/components/tweet/TweetSpaceCard.tsx | 175 ++++++++++++++++++ app/components/tweet/index.ts | 1 + .../react-tweet/twitter-theme/tweet-body.tsx | 5 + .../twitter-theme/verified-badge.tsx | 3 +- 6 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 app/components/tweet/TweetSpaceCard.tsx diff --git a/app/components/tweet/PlainTweet.tsx b/app/components/tweet/PlainTweet.tsx index e75153e1..49ac037d 100644 --- a/app/components/tweet/PlainTweet.tsx +++ b/app/components/tweet/PlainTweet.tsx @@ -13,6 +13,7 @@ import { cn } from '~/lib/utils' import { AIVisionBlock } from './AIVisionBlock' import { TweetLinkCard } from './TweetCard' import { TweetMediaAlt } from './TweetMediaAlt' +import { TweetSpaceCard } from './TweetSpaceCard' function TweetTextBody({ tweet, enableTranslation }: { tweet: EnrichedTweet, enableTranslation: boolean }) { if (!enableTranslation) { @@ -111,6 +112,7 @@ function UnifiedTweet({ tweet, variant, enableTranslation }: UnifiedTweetProps) {tweet.card && } + {tweet.space && } {quotedTweet && ( (({ {tweet.card && } + {tweet.space && } {tweet.quotedTweet && ( , + { label: string, aria: string, primary: boolean, icon: 'play' | 'mic-off' | null } +> = { + 'replayable': { label: '播放录音', aria: '播放录音', primary: true, icon: 'play' }, + 'live': { label: '直播中', aria: '正在直播', primary: true, icon: null }, + 'upcoming': { label: '尚未开始', aria: '尚未开始', primary: false, icon: null }, + 'no-replay': { label: '录音不可回放', aria: '录音不可回放', primary: false, icon: 'mic-off' }, +} + +/** + * X Space 卡片(语音直播 / 录音回放)。 + * + * 对标官方渲染(`tmp/録音を再生….html` 实测 DOM):紫色底 + 白字,主播行(头像 + 名称 + + * 认证徽标)、标题、`收听/回放 · 日期 · 时长` 元信息行、行动按钮。 + * + * 边界态(本卡片存在的主要理由): + * - **Space 已删除 / 不可访问**(上游无 metadata)→ 中性墓碑条「Space 已删除或不可访问」, + * 不再只剩正文里一个裸链接 + * - **已结束但未开启回放** → 标题照常展示,行动区降级为「录音不可回放」(不给播放入口) + * - 进行中 / 未开始 → 行动区分别为「直播中」/「尚未开始」 + * + * 范围裁定(owner 2026-09-16):**卡片 + 跳转 X 播放**。整卡是指向 Space 页面的链接—— + * 站内播放需要 hls.js + 服务端媒体代理(pscp.tv 的 m3u8 只给 `x.com` 放行 CORS),本期不做, + * 故行动区是视觉元素而非嵌套的可交互元素(避免交互元素嵌套)。 + */ +export function TweetSpaceCard({ tweet, className }: TweetSpaceCardProps) { + const proxyMedia = useProxyMedia() + const space = tweet.space + + const data = useMemo(() => { + if (!space) + return null + + const avatarUrl = space.host.profile_image_url_https + + return { + duration: formatSpaceDuration(space.durationMs), + listeners: `${formatSpaceListeners(space.listenersCount)} 人收听/回放`, + date: formatSpaceDate(space.createdAt), + avatarSrc: avatarUrl ? proxyMedia(normalizeMediaUrl(avatarUrl)) : '', + } + }, [space, proxyMedia]) + + if (!space || !data) + return null + + // 墓碑态:上游已明确「没有这个 Space」,没有任何可展示的元数据 + if (space.availability === 'unavailable') { + return ( +
+
+ ) + } + + const { duration, listeners, date, avatarSrc } = data + // 经 resolveSpacePlaybackState 兜底:旧缓存里的 space 没有 availability 字段 + const action = ACTION_BY_AVAILABILITY[resolveSpacePlaybackState(space)] + + return ( + +
+
+
+ {avatarSrc + ? ( + + ) + : null} +
+ +
+ + {space.host.name} + + +
+
+ + {space.title + ? ( +
+ {space.title} +
+ ) + : null} + +
+ {listeners} + + {date} + {duration + ? ( + <> + + {duration} + + ) + : null} +
+ + + {action.icon === 'play' + ? +
+
+ ) +} diff --git a/app/components/tweet/index.ts b/app/components/tweet/index.ts index af258085..f7a5ad0e 100644 --- a/app/components/tweet/index.ts +++ b/app/components/tweet/index.ts @@ -19,4 +19,5 @@ export { TweetInputForm } from './TweetInputForm' export { TweetMediaAlt } from './TweetMediaAlt' export { TweetNode } from './TweetNode' export { TweetOptionsMenu } from './TweetOptionsMenu' +export { TweetSpaceCard } from './TweetSpaceCard' export { TweetTextBody } from './TweetTextBody' diff --git a/app/lib/react-tweet/twitter-theme/tweet-body.tsx b/app/lib/react-tweet/twitter-theme/tweet-body.tsx index e076299f..623c7cec 100644 --- a/app/lib/react-tweet/twitter-theme/tweet-body.tsx +++ b/app/lib/react-tweet/twitter-theme/tweet-body.tsx @@ -1,5 +1,6 @@ import type { EnrichedTweet } from '~/types' import { cn } from '~/lib/utils' +import { isSpaceUrl } from '../utils/space' import { TweetLink } from './tweet-link' interface TweetBodyProps { @@ -23,6 +24,10 @@ export function TweetBody({ tweet, isTranslated, lang, className }: TweetBodyPro switch (item.type) { case 'url': + // Space 卡片(TweetSpaceCard)已承载同一跳转,官方前端同样不重复展示该链接。 + // 仅在卡片数据确实渲染出来时才跳过——元数据获取失败时链接照常显示(优雅降级)。 + if (tweet.space && isSpaceUrl(item.href)) + return null return ( {text.length > 36 ? item.display_url : text} diff --git a/app/lib/react-tweet/twitter-theme/verified-badge.tsx b/app/lib/react-tweet/twitter-theme/verified-badge.tsx index 6d387003..c1eeff30 100644 --- a/app/lib/react-tweet/twitter-theme/verified-badge.tsx +++ b/app/lib/react-tweet/twitter-theme/verified-badge.tsx @@ -7,7 +7,8 @@ import { } from './icons/index' interface Props { - user: TweetUser + /** 只依赖认证三字段,故不限 `TweetUser` 本体(Space 主播等精简用户对象也可直接传入) */ + user: Pick className?: string } From 6d92d8ae8c457b750db2b17dfbd15e290607ae38 Mon Sep 17 00:00:00 2001 From: Chilfish Date: Thu, 17 Sep 2026 01:42:41 +0800 Subject: [PATCH 5/7] test(space): add AC-SPACE-001~011 with fixtures, render tests and story MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 全离线可跑:上游响应固化为 fixture(真实 TweetResultByRestId / AudioSpaceById 响应裁剪),无需 TWEET_KEYS 或服务器。 - AC-SPACE-001~004/008/009/010 纯函数单测(含旧缓存缺 availability 的崩溃 回归:Cannot read properties of undefined (reading 'aria')) - AC-SPACE-005~007/009/010 渲染测试(renderToString + HTML 断言) - AC-SPACE-011 服务层回填契约(mock 缓存层与上游取数) - fixture:Space 卡片 binding、Ended 元数据、无 metadata 的已删除样本、 无卡片的推文样本,以及由线上 enrichTweet 派生的完整 EnrichedTweet - Storybook Tweet/SpaceCard 五态(可回放 / 直播中 / 不可回放 / 已删除 / 超长标题) - verify CLI 注册 `--module space`(AC-SPACE 前缀) Co-authored-by: CommandCodeBot --- app/stories/TweetSpaceCard.stories.tsx | 64 ++++ app/stories/tweet.fixtures.ts | 126 ++++++- test/acceptance/space-render.spec.ts | 192 +++++++++++ test/fixtures/space/audio-space-card.json | 58 ++++ .../space/audio-space-cardless-tweet.json | 19 ++ test/fixtures/space/audio-space-ended.json | 152 +++++++++ .../space/audio-space-unavailable.json | 11 + test/fixtures/tweets/with-space-ja.json | 60 ++++ test/unit/getLocalTweet.spec.ts | 160 +++++++++ test/unit/space.spec.ts | 313 ++++++++++++++++++ verify/acceptance-criteria/AC-space.md | 207 ++++++++++++ verify/index.ts | 1 + 12 files changed, 1362 insertions(+), 1 deletion(-) create mode 100644 app/stories/TweetSpaceCard.stories.tsx create mode 100644 test/acceptance/space-render.spec.ts create mode 100644 test/fixtures/space/audio-space-card.json create mode 100644 test/fixtures/space/audio-space-cardless-tweet.json create mode 100644 test/fixtures/space/audio-space-ended.json create mode 100644 test/fixtures/space/audio-space-unavailable.json create mode 100644 test/fixtures/tweets/with-space-ja.json create mode 100644 test/unit/getLocalTweet.spec.ts create mode 100644 test/unit/space.spec.ts create mode 100644 verify/acceptance-criteria/AC-space.md diff --git a/app/stories/TweetSpaceCard.stories.tsx b/app/stories/TweetSpaceCard.stories.tsx new file mode 100644 index 00000000..d6824523 --- /dev/null +++ b/app/stories/TweetSpaceCard.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { TweetSpaceCard } from '~/components/tweet/TweetSpaceCard' +import { + tweetWithLiveSpace, + tweetWithLongSpaceTitle, + tweetWithNoReplaySpace, + tweetWithSpace, + tweetWithUnavailableSpace, +} from './tweet.fixtures' + +/** + * X Space(语音直播 / 录音回放)卡片五态: + * 已结束可回放(真实数据)/ 进行中(无时长)/ 未开启回放 / 已删除 / 超长标题 + 大数字。 + * 视觉输入 = 真实 fixture(Space `1yoKMPnjEbOxQ` 与已删除的 `1DGLdvzVZmLGm`),对照官方渲染 DOM。 + */ +const meta = { + title: 'Tweet/SpaceCard', + parameters: { layout: 'centered' }, + decorators: [ + Story => ( +
+ +
+ ), + ], +} satisfies Meta + +export default meta + +type Story = StoryObj + +/** 已结束 + 可回放(官方卡片:2,478 人收听/回放 · 9月17日 · 42:40) */ +export const EndedWithReplay: Story = { + render: () => , +} + +/** 进行中:无结束时间 → 不显示时长,行动区为「直播中」 */ +export const Live: Story = { + render: () => , +} + +/** 已结束但未开启回放:不给播放入口(「录音不可回放」) */ +export const ReplayDisabled: Story = { + render: () => , +} + +/** Space 已删除 / 不可访问:中性墓碑条,不再只剩一个裸链接 */ +export const Deleted: Story = { + render: () => , +} + +/** 超长标题(line-clamp-2)+ 百万级人数(千分位) */ +export const LongTitleLargeCount: Story = { + render: () => , +} + +/** 移动端窄屏(<360px):标题换行、元信息换行不与按钮抢位 */ +export const MobileNarrow: Story = { + render: () => ( +
+ +
+ ), +} diff --git a/app/stories/tweet.fixtures.ts b/app/stories/tweet.fixtures.ts index affff478..22855ad3 100644 --- a/app/stories/tweet.fixtures.ts +++ b/app/stories/tweet.fixtures.ts @@ -1,4 +1,4 @@ -import type { AIVisionInfo, EnrichedTweet, TrendingCardInfo, TweetUser } from '~/types' +import type { AIVisionInfo, EnrichedTweet, SpaceDetails, TrendingCardInfo, TweetUser } from '~/types' /** * app/stories/tweet.fixtures.ts —— tweet 组件故事共享数据 @@ -70,6 +70,7 @@ interface MakeTweetOptions { lang?: string entities?: EnrichedTweet['entities'] card?: EnrichedTweet['card'] + space?: EnrichedTweet['space'] mediaDetails?: EnrichedTweet['mediaDetails'] visionInfo?: AIVisionInfo[] quotedTweet?: EnrichedTweet @@ -91,6 +92,7 @@ function makeTweet(opts: MakeTweetOptions = {}): EnrichedTweet { : { ...user, name: opts.name ?? user.name, screen_name: opts.screenName ?? user.screen_name }, entities: opts.entities ?? [{ type: 'text', text: '『#渡瀬結月 の #6げんめっ!』#15', index: 0 }], card: opts.card, + space: opts.space, mediaDetails: opts.mediaDetails, visionInfo: opts.visionInfo, quotedTweet: opts.quotedTweet, @@ -128,6 +130,128 @@ export const tweetWithTextOnlyCard = makeTweet({ /** 完全无卡片数据(空渲染) */ export const tweetNoCard = makeTweet({}) +/** + * 真实 Space 数据(Space `1yoKMPnjEbOxQ`:Ended + 可回放,与 test/fixtures/space 同源)。 + * 官方卡片实测文案:2,478 人がリスニング/リプレイ / 9月17日 / 42:40。 + */ +export const spaceDetails: SpaceDetails = { + id: '1yoKMPnjEbOxQ', + url: 'https://x.com/i/spaces/1yoKMPnjEbOxQ', + title: '#ゆめみた合宿3日目!ついに最終日!✨コメントはハッシュタグにてお願いします✨', + state: 'Ended', + availability: 'replayable', + createdAt: 1758117617817, + startedAt: 1758117620659, + endedAt: 1758120181605, + durationMs: 2560946, + listenersCount: 2478, + liveListenersCount: 1245, + replayCount: 1233, + isReplayAvailable: true, + host: { + id_str: '1546362523561390081', + name: '夢限大みゅーたいぷ', + screen_name: 'BDP_yumemita', + profile_image_url_https: 'https://pbs.twimg.com/profile_images/1907233594067881984/8eZPMLR5_normal.jpg', + verified: false, + is_blue_verified: true, + verified_type: 'Business', + }, +} + +/** Space 卡片(已结束 + 可回放) */ +export const tweetWithSpace = makeTweet({ + id: '1968314084207788302', + lang: 'zxx', + text: 'https://t.co/gOS5Qc3DS4', + name: '夢限大みゅーたいぷ', + screenName: 'BDP_yumemita', + createdAt: 'Wed Sep 17 14:00:21 +0000 2025', + entities: [{ + type: 'url', + index: 0, + url: 'https://t.co/gOS5Qc3DS4', + text: 'https://t.co/gOS5Qc3DS4', + display_url: 'x.com/i/spaces/1yoKM…', + expanded_url: 'https://x.com/i/spaces/1yoKMPnjEbOxQ', + href: 'https://x.com/i/spaces/1yoKMPnjEbOxQ', + }], + space: spaceDetails, +}) + +/** 进行中的 Space:无结束时间 → 无时长,回放数为 0 */ +export const tweetWithLiveSpace = makeTweet({ + ...tweetWithSpace, + id: '1968314084207788399', + space: { + ...spaceDetails, + id: '1yoKMPnjEbOxQ', + url: 'https://x.com/i/spaces/1yoKMPnjEbOxQ', + state: 'Running', + endedAt: null, + durationMs: null, + replayCount: 0, + listenersCount: 1245, + isReplayAvailable: false, + }, +}) + +/** 超长标题 + 大数字(窄屏截断与千分位) */ +export const tweetWithLongSpaceTitle = makeTweet({ + ...tweetWithSpace, + id: '1968314084207788400', + space: { + ...spaceDetails, + title: '【告知】新アルバム「夢限大みゅーたいぷ」リリース記念スペシャル座談会〜メンバー全員で語り尽くす夜〜', + listenersCount: 1234567, + liveListenersCount: 1000000, + replayCount: 234567, + }, +}) + +/** 已结束但主办方未开启回放:没有录音可放,行动区降级为「录音不可回放」 */ +export const tweetWithNoReplaySpace = makeTweet({ + ...tweetWithSpace, + id: '2057046686871232599', + space: { + ...spaceDetails, + isReplayAvailable: false, + availability: 'no-replay', + }, +}) + +/** + * Space 已删除 / 不可访问(真实样本:推文 2057046686871232598 → Space 1DGLdvzVZmLGm, + * `AudioSpaceById` 只回 `{ is_subscribed: false }`):渲染中性墓碑条而非紫色卡片。 + */ +export const tweetWithUnavailableSpace = makeTweet({ + ...tweetWithSpace, + id: '2057046686871232598', + space: { + id: '1DGLdvzVZmLGm', + url: 'https://x.com/i/spaces/1DGLdvzVZmLGm', + title: '', + state: '', + availability: 'unavailable', + createdAt: 0, + startedAt: null, + endedAt: null, + durationMs: null, + listenersCount: 0, + liveListenersCount: 0, + replayCount: 0, + isReplayAvailable: false, + host: { + id_str: '', + name: '', + screen_name: '', + profile_image_url_https: '', + verified: false, + is_blue_verified: false, + }, + }, +}) + /** 三张配图 + Alt 文本(对齐 vision fixture;含 media_alt 翻译实体,index 20000+i) */ export const tweetWithPhotos = makeTweet({ id: '9000000000000000001', diff --git a/test/acceptance/space-render.spec.ts b/test/acceptance/space-render.spec.ts new file mode 100644 index 00000000..a5aaa5c6 --- /dev/null +++ b/test/acceptance/space-render.spec.ts @@ -0,0 +1,192 @@ +import type { IRawAudioSpace } from '~/lib/rettiwt-api' +import type { EnrichedTweet, SpaceDetails } from '~/types' +import { createElement } from 'react' +import { renderToString } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { TweetSpaceCard } from '~/components/tweet/TweetSpaceCard' +import { TweetBody } from '~/lib/react-tweet' +import { mapSpaceDetails } from '~/lib/react-tweet/utils/space' +import { loadFixture } from '../helpers/load-fixture' + +/** + * test/acceptance/space-render.spec.ts + * + * AC-SPACE-005~007 / 009~010 —— Space 卡片与正文去重的真实渲染测试。 + * + * 输入为真实 fixture(`tweets/with-space-ja.json`,由线上 enrichTweet + + * mapSpaceDetails 派生;墓碑态用已删除 Space 的真实响应 `space/audio-space-unavailable.json`), + * 断言对齐官方卡片实测 DOM:紫色底白字、主播行、标题、`收听/回放 · 日期 · 时长`、录音按钮, + * 且正文不再重复渲染同一个 Space 链接。 + */ + +const baseTweet = loadFixture('tweets/with-space-ja.json') + +const SPACE_URL = 'https://x.com/i/spaces/1yoKMPnjEbOxQ' + +// 外链必须带 noopener(安全约定) +const REL_NOOPENER_RE = /rel="[^"]*noopener/ + +function render(tweet: EnrichedTweet): string { + return renderToString(createElement(TweetSpaceCard, { tweet })) +} + +/** 只替换 space 字段,其余保持真实推文数据 */ +function withSpace(space: SpaceDetails): EnrichedTweet { + return { ...baseTweet, space } +} + +describe('AC-SPACE-005: space card real render', () => { + const html = render(baseTweet) + + it('AC-SPACE-005: renders the official card structure from real metadata', () => { + // 整卡即跳转(本期不站内播放) + expect(html).toContain(`href="${SPACE_URL}"`) + expect(html).toContain('target="_blank"') + expect(html).toMatch(REL_NOOPENER_RE) + + // 主播行 + 标题 + expect(html).toContain('夢限大みゅーたいぷ') + expect(html).toContain('ゆめみた合宿3日目!ついに最終日!') + expect(html).toContain('コメントはハッシュタグにてお願いします') + + // 元信息行(官方卡片实测文案) + expect(html).toContain('2,478') + expect(html).toContain('收听/回放') + expect(html).toContain('9月17日') + expect(html).toContain('42:40') + + // 录音按钮 + 无障碍名称 + expect(html).toContain('播放录音') + expect(html).toContain('aria-label="播放录音: ') + expect(html).toContain('aria-label="时长 42:40"') + }) + + it('AC-SPACE-005: keeps decorative images silent and drops noisy labels', () => { + // 主播头像为装饰图(名称文本已承载可访问名称,避免读屏重复朗读) + expect(html).toContain('alt=""') + // 不渲染冗余标签与不可辨识的小徽标 + expect(html).not.toContain('主播') + expect(html).not.toContain('affiliate') + }) +}) + +describe('AC-SPACE-006: no space data renders empty string', () => { + it('AC-SPACE-006: returns empty output when space is absent', () => { + expect(render({ ...baseTweet, space: undefined })).toBe('') + }) +}) + +describe('AC-SPACE-007: body does not duplicate the space link', () => { + it('AC-SPACE-007: hides the space url when the card is rendered', () => { + const html = renderToString(createElement(TweetBody, { tweet: baseTweet, isTranslated: false })) + + expect(html).not.toContain(SPACE_URL) + expect(html).not.toContain('x.com/i/spaces/1yoKM…') + }) + + it('AC-SPACE-007: keeps the space url when metadata could not be fetched', () => { + const html = renderToString(createElement(TweetBody, { + tweet: { ...baseTweet, space: undefined }, + isTranslated: false, + })) + + expect(html).toContain(SPACE_URL) + }) + + it('AC-SPACE-007: leaves other url entities untouched', () => { + const tweet: EnrichedTweet = { + ...baseTweet, + entities: [{ + type: 'url', + index: 0, + url: 'https://t.co/other', + text: 'https://t.co/other', + display_url: 'example.com/a', + expanded_url: 'https://example.com/a', + href: 'https://example.com/a', + }], + } + + expect(renderToString(createElement(TweetBody, { tweet, isTranslated: false }))) + .toContain('https://example.com/a') + }) +}) + +describe('AC-SPACE-009: deleted space renders an explanatory tombstone', () => { + // 真实样本:推文 2057046686871232598 → Space 1DGLdvzVZmLGm 已删除(无 metadata) + const unavailable = mapSpaceDetails( + '1DGLdvzVZmLGm', + loadFixture('space/audio-space-unavailable.json'), + )! + + it('AC-SPACE-009: renders an explanation instead of a bare link', () => { + const html = render(withSpace(unavailable)) + + expect(html).toContain('Space 已删除或不可访问') + // 仍给一个可核实的入口(Space 地址由 card binding 的 id 构造) + expect(html).toContain('在 X 查看') + expect(html).toContain('href="https://x.com/i/spaces/1DGLdvzVZmLGm"') + // 不可用态不得出现播放入口 + expect(html).not.toContain('播放录音') + expect(html).not.toContain('42:40') + }) + + it('AC-SPACE-009: does not use the loud purple card for a tombstone', () => { + const html = render(withSpace(unavailable)) + + // 墓碑走中性色(bg-muted),不再用 Space 品牌紫大喊大叫 + expect(html).not.toContain('bg-[#9c63fa]') + expect(html).toContain('bg-muted/40') + }) +}) + +describe('AC-SPACE-010: replay-disabled space offers no playback', () => { + const endedSpace = loadFixture('space/audio-space-ended.json') + // 以真实元数据为底,仅关闭回放开关 —— availability 交由 mapper 推导,保持链路真实 + const replayDisabled = mapSpaceDetails('1yoKMPnjEbOxQ', { + ...endedSpace, + metadata: { ...endedSpace.metadata, is_space_available_for_replay: false }, + })! + + it('AC-SPACE-010: shows "录音不可回放" and no play affordance', () => { + expect(replayDisabled.availability).toBe('no-replay') + + const html = render(withSpace(replayDisabled)) + + expect(html).toContain('录音不可回放') + expect(html).not.toContain('播放录音') + // 元数据照常展示(官方卡片也仍列出人数/时长) + expect(html).toContain('2,478') + expect(html).toContain('42:40') + expect(html).toContain('夢限大みゅーたいぷ') + }) + + it('AC-SPACE-010: labels live and upcoming spaces correctly', () => { + const live = mapSpaceDetails('1yoKMPnjEbOxQ', { + ...endedSpace, + metadata: { ...endedSpace.metadata, state: 'Running' }, + })! + const upcoming = mapSpaceDetails('1yoKMPnjEbOxQ', { + ...endedSpace, + metadata: { ...endedSpace.metadata, state: 'NotStarted' }, + })! + + expect(live.availability).toBe('live') + expect(render(withSpace(live))).toContain('直播中') + + expect(upcoming.availability).toBe('upcoming') + expect(render(withSpace(upcoming))).toContain('尚未开始') + }) + + // 回归:availability 是后加字段,改动前落地的缓存里没有它 —— 曾经直接查表拿到 undefined + // 并在渲染时抛 `Cannot read properties of undefined (reading 'aria')`(dev 实测崩溃)。 + it('AC-SPACE-010: a cache entry written before availability existed still renders', () => { + const stale = { ...replayDisabled, availability: undefined } as unknown as SpaceDetails + + expect(render(withSpace({ ...stale, isReplayAvailable: true }))).toContain('播放录音') + + // 连 isReplayAvailable 也缺失时保守降级,不承诺播放 + const unknown = { ...stale, isReplayAvailable: undefined } as unknown as SpaceDetails + expect(render(withSpace(unknown))).toContain('录音不可回放') + }) +}) diff --git a/test/fixtures/space/audio-space-card.json b/test/fixtures/space/audio-space-card.json new file mode 100644 index 00000000..b999858f --- /dev/null +++ b/test/fixtures/space/audio-space-card.json @@ -0,0 +1,58 @@ +{ + "_meta": { + "source": "TweetResultByRestId / AudioSpaceById (x.com private GraphQL)", + "exportedAt": "2026-09-16", + "schema": "twitter-card-audiospace", + "note": "真实推文 1968314084207788302 的 card" + }, + "data": { + "legacy": { + "binding_values": [ + { + "key": "tweet_id", + "value": { + "string_value": "1968314084207788302", + "type": "STRING" + } + }, + { + "key": "narrow_cast_space_type", + "value": { + "string_value": "0", + "type": "STRING" + } + }, + { + "key": "id", + "value": { + "string_value": "1yoKMPnjEbOxQ", + "type": "STRING" + } + }, + { + "key": "card_url", + "value": { + "scribe_key": "card_url", + "string_value": "https://t.co/gOS5Qc3DS4", + "type": "STRING" + } + } + ], + "card_platform": { + "platform": { + "audience": { + "name": "production" + }, + "device": { + "name": "Swift", + "version": "12" + } + } + }, + "name": "3691233323:audiospace", + "url": "https://t.co/gOS5Qc3DS4", + "user_refs_results": [] + }, + "rest_id": "https://t.co/gOS5Qc3DS4" + } +} diff --git a/test/fixtures/space/audio-space-cardless-tweet.json b/test/fixtures/space/audio-space-cardless-tweet.json new file mode 100644 index 00000000..1a950926 --- /dev/null +++ b/test/fixtures/space/audio-space-cardless-tweet.json @@ -0,0 +1,19 @@ +{ + "_meta": { + "source": "TweetResultByRestId (x.com private GraphQL)", + "exportedAt": "2026-09-16", + "schema": "tweet-space-link", + "note": "真实样本:推文 1871586443388420240 没有任何 card,正文里只有一个指向 Space 1djGXroNWDExZ 的 url 实体(该 Space 已删除,AudioSpaceById 无 metadata)" + }, + "data": { + "card": null, + "entities": [ + { + "display_url": "x.com/i/spaces/1djGX…", + "expanded_url": "https://x.com/i/spaces/1djGXroNWDExZ", + "url": "https://t.co/ELCNFX5yGy", + "indices": [0, 23] + } + ] + } +} diff --git a/test/fixtures/space/audio-space-ended.json b/test/fixtures/space/audio-space-ended.json new file mode 100644 index 00000000..1b4cb8c7 --- /dev/null +++ b/test/fixtures/space/audio-space-ended.json @@ -0,0 +1,152 @@ +{ + "_meta": { + "source": "TweetResultByRestId / AudioSpaceById (x.com private GraphQL)", + "exportedAt": "2026-09-16", + "schema": "audio-space", + "note": "Space 1yoKMPnjEbOxQ(Ended + 可回放)" + }, + "data": { + "is_subscribed": false, + "metadata": { + "conversation_controls": 0, + "created_at": 1758117617817, + "creator_results": { + "result": { + "__typename": "User", + "affiliates_highlighted_label": { + "label": { + "badge": { + "url": "https://pbs.twimg.com/profile_images/2082827306335371265/XeH4bms6_bigger.jpg" + }, + "description": "バンドリ! BanG Dream! 公式", + "url": { + "url": "https://twitter.com/bang_dream_info", + "urlType": "DeepLink" + }, + "userLabelType": "BusinessLabel" + } + }, + "has_nft_avatar": false, + "id": "VXNlcjoxNTQ2MzYyNTIzNTYxMzkwMDgx", + "legacy": { + "blocked_by": false, + "blocking": false, + "can_dm": false, + "can_media_tag": false, + "created_at": "Mon Jul 11 05:15:42 +0000 2022", + "default_profile": true, + "default_profile_image": false, + "description": "夢(バーチャル)と現実(リアル)を飛び越える運命共同体(バンド)「夢限大みゅーたいぷ」。TVアニメ「バンドリ! ゆめ∞みた」毎週木曜23時より、TOKYO MXほかにて放送中!\nアニメ公式HP:https://t.co/d74j4ZW3Ul\n#バンドリ #アニメゆめみた #ゆめみた", + "entities": { + "description": { + "urls": [ + { + "display_url": "anime.bang-dream.com/yumemita/", + "expanded_url": "https://anime.bang-dream.com/yumemita/", + "indices": [ + 97, + 120 + ], + "url": "https://t.co/d74j4ZW3Ul" + } + ] + }, + "url": { + "urls": [ + { + "display_url": "bio.to/yumemita", + "expanded_url": "https://bio.to/yumemita", + "indices": [ + 0, + 23 + ], + "url": "https://t.co/QGaaGZLtqE" + } + ] + } + }, + "fast_followers_count": 0, + "favourites_count": 16, + "follow_request_sent": false, + "followed_by": false, + "followers_count": 56055, + "following": true, + "friends_count": 11, + "has_custom_timelines": false, + "is_translator": false, + "listed_count": 363, + "location": "", + "media_count": 2670, + "muting": false, + "name": "夢限大みゅーたいぷ", + "needs_phone_verification": false, + "normal_followers_count": 56055, + "notifications": true, + "pinned_tweet_ids_str": [ + "2098057825541136762" + ], + "possibly_sensitive": false, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/1546362523561390081/1783098208", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/1907233594067881984/8eZPMLR5_normal.jpg", + "profile_interstitial_type": "", + "protected": false, + "screen_name": "BDP_yumemita", + "statuses_count": 5571, + "time_zone": "", + "translator_type": "none", + "url": "https://t.co/QGaaGZLtqE", + "utc_offset": 0, + "verified": false, + "want_retweets": true, + "withheld_description": "", + "withheld_scope": "" + }, + "rest_id": "1546362523561390081", + "super_follow_eligible": false, + "super_followed_by": false, + "super_following": false + } + }, + "disallow_join": false, + "ended_at": "1758120181605", + "is_employee_only": false, + "is_locked": false, + "is_space_available_for_clipping": true, + "is_space_available_for_replay": true, + "media_key": "28_1968314067455733760", + "narrow_cast_space_type": 0, + "rest_id": "1yoKMPnjEbOxQ", + "started_at": 1758117620659, + "state": "Ended", + "title": "#ゆめみた合宿3日目!ついに最終日!✨コメントはハッシュタグにてお願いします✨", + "total_live_listeners": 1245, + "total_replay_watched": 1233, + "updated_at": 1758120182162 + }, + "participants": { + "admins": [ + { + "avatar_url": "https://pbs.twimg.com/profile_images/1907233594067881984/8eZPMLR5_normal.jpg", + "display_name": "夢限大みゅーたいぷ", + "is_muted_by_admin": false, + "is_muted_by_guest": false, + "is_verified": true, + "periscope_user_id": "1oNQlqPDpenKw", + "twitter_screen_name": "BDP_yumemita", + "user": { + "rest_id": "1546362523561390081" + }, + "user_results": { + "result": { + "__typename": "User", + "has_nft_avatar": false + } + } + } + ], + "listeners": [], + "speakers": [], + "total": 0 + } + } +} diff --git a/test/fixtures/space/audio-space-unavailable.json b/test/fixtures/space/audio-space-unavailable.json new file mode 100644 index 00000000..94b745af --- /dev/null +++ b/test/fixtures/space/audio-space-unavailable.json @@ -0,0 +1,11 @@ +{ + "_meta": { + "source": "TweetResultByRestId / AudioSpaceById (x.com private GraphQL)", + "exportedAt": "2026-09-16", + "schema": "audio-space", + "note": "audioSpace 存在但无 metadata → 已删除 / 不可访问(真实样本 1DGLdvzVZmLGm)" + }, + "data": { + "is_subscribed": false + } +} diff --git a/test/fixtures/tweets/with-space-ja.json b/test/fixtures/tweets/with-space-ja.json new file mode 100644 index 00000000..683b8bdc --- /dev/null +++ b/test/fixtures/tweets/with-space-ja.json @@ -0,0 +1,60 @@ +{ + "_meta": { + "source": "x.com TweetResultByRestId + AudioSpaceById (tweet 1968314084207788302)", + "exportedAt": "2026-09-16", + "schema": "enriched-tweet-with-space" + }, + "data": { + "id_str": "1968314084207788302", + "lang": "zxx", + "url": "https://twitter.com/BDP_yumemita/status/1968314084207788302", + "created_at": "Wed Sep 17 14:00:21 +0000 2025", + "__typename": "Tweet", + "text": "https://t.co/gOS5Qc3DS4", + "user": { + "id_str": "1546362523561390081", + "name": "夢限大みゅーたいぷ", + "screen_name": "BDP_yumemita", + "is_blue_verified": true, + "profile_image_shape": "Square", + "verified_type": "Business", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/1907233594067881984/8eZPMLR5.jpg" + }, + "entities": [ + { + "display_url": "x.com/i/spaces/1yoKM…", + "expanded_url": "https://x.com/i/spaces/1yoKMPnjEbOxQ", + "url": "https://t.co/gOS5Qc3DS4", + "type": "url", + "index": 0, + "text": "x.com/i/spaces/1yoKM…", + "href": "https://x.com/i/spaces/1yoKMPnjEbOxQ" + } + ], + "isInlineMeida": false, + "space": { + "id": "1yoKMPnjEbOxQ", + "url": "https://x.com/i/spaces/1yoKMPnjEbOxQ", + "title": "#ゆめみた合宿3日目!ついに最終日!✨コメントはハッシュタグにてお願いします✨", + "state": "Ended", + "availability": "replayable", + "createdAt": 1758117617817, + "startedAt": 1758117620659, + "endedAt": 1758120181605, + "durationMs": 2560946, + "listenersCount": 2478, + "liveListenersCount": 1245, + "replayCount": 1233, + "isReplayAvailable": true, + "host": { + "id_str": "1546362523561390081", + "name": "夢限大みゅーたいぷ", + "screen_name": "BDP_yumemita", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/1907233594067881984/8eZPMLR5_normal.jpg", + "verified": false, + "is_blue_verified": true, + "verified_type": "Business" + } + } + } +} diff --git a/test/unit/getLocalTweet.spec.ts b/test/unit/getLocalTweet.spec.ts new file mode 100644 index 00000000..7a6ebb1b --- /dev/null +++ b/test/unit/getLocalTweet.spec.ts @@ -0,0 +1,160 @@ +import type { EnrichedTweet } from '~/types' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * test/unit/getLocalTweet.spec.ts + * + * AC-SPACE-011 —— 旧缓存回填 `space`。 + * + * `space` 是后加字段:改动前落地的缓存(memory LRU / 本地文件 / DB jsonContent)没有它, + * 而命中缓存时不会再走 `getEnrichedTweet`,于是 Space 卡片/墓碑永远不出现 + * (实测表现:已删除的 Space 推文仍然只渲染一个裸链接)。 + * + * 这里 mock 掉缓存层与上游取数,只验证 `getLocalTweet` 出口的回填契约。 + */ + +const getLocalCache = vi.fn() +const setLocalCache = vi.fn() +const resolveSpaceById = vi.fn() + +vi.mock('~/lib/localCache', () => ({ + getLocalCache: (...args: unknown[]) => getLocalCache(...args), + setLocalCache: (...args: unknown[]) => setLocalCache(...args), +})) + +vi.mock('~/lib/react-tweet/utils/get-tweet', () => ({ + getEnrichedTweet: vi.fn(), + resolveSpaceById: (...args: unknown[]) => resolveSpaceById(...args), +})) + +const { getLocalTweet } = await import('~/lib/service/getTweet.server') + +/** 旧缓存里的推文:无 card、无 space,只有一条 Space 链接实体(真实样本形态) */ +function cachedSpaceTweet(overrides: Partial = {}): EnrichedTweet { + return { + id_str: '1871586443388420240', + lang: 'zxx', + url: 'https://twitter.com/nonoka_yumemita/status/1871586443388420240', + created_at: 'Tue Dec 24 14:59:15 +0000 2024', + __typename: 'Tweet', + text: 'https://t.co/ELCNFX5yGy', + user: { + id_str: '1', + name: '宮永ののか', + screen_name: 'nonoka_yumemita', + profile_image_url_https: '', + profile_image_shape: 'Circle', + verified: false, + is_blue_verified: false, + }, + entities: [{ + type: 'url', + index: 0, + url: 'https://t.co/ELCNFX5yGy', + text: 'x.com/i/spaces/1djGX…', + display_url: 'x.com/i/spaces/1djGX…', + expanded_url: 'https://x.com/i/spaces/1djGXroNWDExZ', + href: 'https://x.com/i/spaces/1djGXroNWDExZ', + }], + ...overrides, + } as EnrichedTweet +} + +const tombstone = { + id: '1djGXroNWDExZ', + url: 'https://x.com/i/spaces/1djGXroNWDExZ', + title: '', + state: '', + availability: 'unavailable' as const, + createdAt: 0, + startedAt: null, + endedAt: null, + durationMs: null, + listenersCount: 0, + liveListenersCount: 0, + replayCount: 0, + isReplayAvailable: false, + host: { + id_str: '', + name: '', + screen_name: '', + profile_image_url_https: '', + verified: false, + is_blue_verified: false, + }, +} + +beforeEach(() => { + vi.clearAllMocks() + setLocalCache.mockResolvedValue(undefined) +}) + +describe('AC-SPACE-011: backfills space for tweets cached before the field existed', () => { + it('AC-SPACE-011: attaches the space resolved from the body link and persists it', async () => { + getLocalCache.mockResolvedValue(cachedSpaceTweet()) + resolveSpaceById.mockResolvedValue(tombstone) + + const tweet = await getLocalTweet('1871586443388420240') + + expect(resolveSpaceById).toHaveBeenCalledWith('1djGXroNWDExZ', expect.anything()) + expect(tweet?.space).toMatchObject({ availability: 'unavailable' }) + // 写回缓存,避免每次浏览都重打上游 + expect(setLocalCache).toHaveBeenCalledWith(expect.objectContaining({ + id: '1871586443388420240', + type: 'tweet', + value: expect.objectContaining({ space: expect.objectContaining({ id: '1djGXroNWDExZ' }) }), + })) + }) + + it('AC-SPACE-011: leaves tweets that already have a current-shape space untouched', async () => { + getLocalCache.mockResolvedValue(cachedSpaceTweet({ space: tombstone })) + + const tweet = await getLocalTweet('1871586443388420240') + + expect(resolveSpaceById).not.toHaveBeenCalled() + expect(setLocalCache).not.toHaveBeenCalled() + expect(tweet?.space).toEqual(tombstone) + }) + + // 早期缓存里落下的 space 没有 availability(后加字段)→ 顺带升级一次,而不是只靠渲染兜底 + it('AC-SPACE-011: upgrades a stale space entry that lacks availability', async () => { + const stale = { ...tombstone, availability: undefined } as unknown as typeof tombstone + getLocalCache.mockResolvedValue(cachedSpaceTweet({ space: stale })) + resolveSpaceById.mockResolvedValue(tombstone) + + const tweet = await getLocalTweet('1871586443388420240') + + expect(resolveSpaceById).toHaveBeenCalledWith('1djGXroNWDExZ', expect.anything()) + expect(tweet?.space?.availability).toBe('unavailable') + expect(setLocalCache).toHaveBeenCalled() + }) + + it('AC-SPACE-011: ignores tweets without any space link', async () => { + getLocalCache.mockResolvedValue(cachedSpaceTweet({ + entities: [{ type: 'text', index: 0, text: 'hello' }], + })) + + const tweet = await getLocalTweet('1') + + expect(resolveSpaceById).not.toHaveBeenCalled() + expect(tweet?.space).toBeUndefined() + }) + + // 请求失败(429 / 网络)不得被当成「已删除」——否则限流会被误报成墓碑 + it('AC-SPACE-011: does not fabricate a tombstone when the upstream call fails', async () => { + getLocalCache.mockResolvedValue(cachedSpaceTweet()) + resolveSpaceById.mockResolvedValue(null) + + const tweet = await getLocalTweet('1871586443388420240') + + expect(tweet?.space).toBeUndefined() + expect(setLocalCache).not.toHaveBeenCalled() + }) + + it('AC-SPACE-011: returns null for a cache miss', async () => { + getLocalCache.mockResolvedValue(null) + + expect(await getLocalTweet('1')).toBeNull() + expect(resolveSpaceById).not.toHaveBeenCalled() + }) +}) diff --git a/test/unit/space.spec.ts b/test/unit/space.spec.ts new file mode 100644 index 00000000..330664b4 --- /dev/null +++ b/test/unit/space.spec.ts @@ -0,0 +1,313 @@ +import type { IRawAudioSpace } from '~/lib/rettiwt-api' +import type { EnrichedTweet } from '~/types' +import { describe, expect, it } from 'vitest' +import { + extractAudiospaceId, + extractSpaceIdFromUrl, + formatSpaceDate, + formatSpaceDuration, + formatSpaceListeners, + isSpaceUrl, + mapSpaceDetails, + resolveSpaceId, + resolveSpacePlaybackState, +} from '~/lib/react-tweet/utils/space' +import { ResourceType, SpaceRequests } from '~/lib/rettiwt-api' +import { FetchResourcesGroup } from '~/lib/rettiwt-api/collections/Groups' +import { Requests } from '~/lib/rettiwt-api/collections/Requests' +import { loadFixture } from '../helpers/load-fixture' + +/** + * test/unit/space.spec.ts + * + * AC-SPACE-001~004 / 008~010 —— Space 卡片纯逻辑(无网络、无 React)。 + * + * 样本全部为真实上游响应固化:推文 `1968314084207788302` 的 `card`(audiospace)、 + * Space `1yoKMPnjEbOxQ` 的 `AudioSpaceById` 响应,以及已删除 Space `1DGLdvzVZmLGm` + * 的响应(只有 `is_subscribed`、无 metadata)。断言值对齐官方卡片实测文案 + * (`2,478 人がリスニング/リプレイ` / `9月17日` / `42:40`)。 + */ + +const SPACE_ID = '1yoKMPnjEbOxQ' + +const card = loadFixture('space/audio-space-card.json') +const audioSpace = loadFixture('space/audio-space-ended.json') +const unavailableSpace = loadFixture('space/audio-space-unavailable.json') +const tweet = loadFixture('tweets/with-space-ja.json') + +/** 以真实 metadata 为底派生待测场次:只翻转被考察的字段,保留真实结构 */ +function derived(over: Record): Record { + return { + metadata: { ...audioSpace.metadata, ...over }, + } +} + +describe('AC-SPACE-001: audiospace card detection', () => { + it('AC-SPACE-001: extracts the space id from the real audiospace card', () => { + expect(extractAudiospaceId(card)).toBe(SPACE_ID) + }) + + it('AC-SPACE-001: returns null for anything that is not an audiospace card', () => { + expect(extractAudiospaceId(undefined)).toBeNull() + expect(extractAudiospaceId({})).toBeNull() + expect(extractAudiospaceId({ legacy: { name: 'summary_large_image', binding_values: [] } })).toBeNull() + // 名称像 audiospace 但没有 id binding / 没有 binding_values + expect(extractAudiospaceId({ + legacy: { name: '3691233323:audiospace', binding_values: [{ key: 'card_url', value: { string_value: 'x' } }] }, + })).toBeNull() + expect(extractAudiospaceId({ legacy: { name: '3691233323:audiospace' } })).toBeNull() + // 也支持已是 legacy 层的直接传入(无 legacy 包裹) + expect(extractAudiospaceId({ name: '3691233323:audiospace', binding_values: [{ key: 'id', value: { string_value: 'abc' } }] })).toBe('abc') + }) +}) + +describe('AC-SPACE-002: AudioSpaceById metadata mapping', () => { + const space = mapSpaceDetails(SPACE_ID, audioSpace, tweet.user) + + it('AC-SPACE-002: maps the real metadata onto the card fields', () => { + expect(space).not.toBeNull() + + expect(space!.id).toBe(SPACE_ID) + expect(space!.url).toBe('https://x.com/i/spaces/1yoKMPnjEbOxQ') + // 标题含 CJK + emoji,未被截断 + expect(space!.title).toBe('#ゆめみた合宿3日目!ついに最終日!✨コメントはハッシュタグにてお願いします✨') + expect(space!.state).toBe('Ended') + expect(space!.isReplayAvailable).toBe(true) + expect(space!.availability).toBe('replayable') + + // 官方卡片 42:40 = ended_at - started_at + expect(space!.startedAt).toBe(1758117620659) + expect(space!.endedAt).toBe(1758120181605) + expect(space!.durationMs).toBe(2560946) + + // 官方卡片 2,478 人がリスニング/リプレイ = 直播收听 + 回放 + expect(space!.liveListenersCount).toBe(1245) + expect(space!.replayCount).toBe(1233) + expect(space!.listenersCount).toBe(2478) + + expect(space!.host.name).toBe('夢限大みゅーたいぷ') + expect(space!.host.screen_name).toBe('BDP_yumemita') + expect(space!.host.id_str).toBe('1546362523561390081') + expect(space!.host.profile_image_url_https).toContain('pbs.twimg.com/profile_images/') + }) + + it('AC-SPACE-002: fills verification from the tweet author when the host is the author', () => { + // AudioSpaceById 的 creator_results 是精简对象:不含 is_blue_verified / verified_type + const creator = (audioSpace.metadata.creator_results as any).result + expect(creator.is_blue_verified).toBeUndefined() + expect(creator.legacy.verified_type).toBeUndefined() + + // 推文作者对象更完整,同账号时补齐——否则官方卡片上的认证徽标会丢 + expect(space!.host.is_blue_verified).toBe(true) + expect(space!.host.verified_type).toBe('Business') + + // 不同账号(非主播)不借用认证 + const other = mapSpaceDetails(SPACE_ID, audioSpace, { + ...tweet.user, + screen_name: 'someone_else', + is_blue_verified: true, + verified_type: 'Government', + }) + expect(other!.host.is_blue_verified).toBe(false) + expect(other!.host.verified_type).toBeUndefined() + }) +}) + +describe('AC-SPACE-003: malformed metadata degrades without throwing', () => { + it('AC-SPACE-003: drops the card only when the space id itself is unknown', () => { + // 没有 id 就无法构造任何 URL —— 唯一返回 null 的情形 + expect(mapSpaceDetails('', audioSpace)).toBeNull() + expect(mapSpaceDetails('', undefined)).toBeNull() + }) + + it('AC-SPACE-003: degrades partial metadata instead of throwing', () => { + // 无标题:仍返回(标题空),不整块丢弃 + const noTitle = mapSpaceDetails(SPACE_ID, derived({ title: '' })) + expect(noTitle).not.toBeNull() + expect(noTitle!.title).toBe('') + expect(noTitle!.availability).toBe('replayable') + + // 进行中场次(无 ended_at)→ 不抛错,时长缺失 + const live = mapSpaceDetails(SPACE_ID, derived({ state: 'Running', ended_at: undefined })) + expect(live!.durationMs).toBeNull() + expect(live!.state).toBe('Running') + + // 缺 creator_results → 主播信息为空,但标题/人数仍在 + const noCreator = mapSpaceDetails(SPACE_ID, derived({ creator_results: undefined })) + expect(noCreator!.title).toContain('ゆめみた合宿3日目') + expect(noCreator!.host.screen_name).toBe('') + expect(noCreator!.host.name).toBe('') + expect(noCreator!.listenersCount).toBe(2478) + + // 人数缺失 → 归一为 0,不产生 NaN + const noCounts = mapSpaceDetails(SPACE_ID, derived({ + total_live_listeners: undefined, + total_replay_watched: undefined, + })) + expect(noCounts!.listenersCount).toBe(0) + + // 上游时间戳非法 → 时长缺失,不抛错 + expect(mapSpaceDetails(SPACE_ID, derived({ ended_at: 'not-a-number' }))!.durationMs).toBeNull() + }) +}) + +describe('AC-SPACE-004: display formatting matches the official card', () => { + it('AC-SPACE-004: formats duration, date and listener count', () => { + expect(formatSpaceDuration(2560946)).toBe('42:40') + expect(formatSpaceDuration(3723000)).toBe('1:02:03') + expect(formatSpaceDuration(59000)).toBe('0:59') + expect(formatSpaceDuration(null)).toBe('') + expect(formatSpaceDuration(0)).toBe('') + expect(formatSpaceDuration(Number.NaN)).toBe('') + + // 实测样本:2025-09-17 22:00 (Asia/Shanghai) → 官方卡片「9月17日」,不补前导零 + expect(formatSpaceDate(1758117620659)).toBe('9月17日') + expect(formatSpaceDate(new Date('2025-09-05T04:00:00Z').getTime())).toBe('9月5日') + expect(formatSpaceDate(null)).toBe('') + + expect(formatSpaceListeners(2478)).toBe('2,478') + expect(formatSpaceListeners(1245)).toBe('1,245') + expect(formatSpaceListeners(0)).toBe('0') + }) + + it('AC-SPACE-004: recognises space urls for body de-duplication', () => { + expect(isSpaceUrl('https://x.com/i/spaces/1yoKMPnjEbOxQ')).toBe(true) + expect(isSpaceUrl('https://twitter.com/i/spaces/1yoKMPnjEbOxQ')).toBe(true) + expect(isSpaceUrl('https://x.com/i/spaces/1yoKMPnjEbOxQ?foo=1')).toBe(true) + expect(isSpaceUrl('https://t.co/gOS5Qc3DS4')).toBe(false) + expect(isSpaceUrl('https://x.com/BDP_yumemita')).toBe(false) + expect(isSpaceUrl(undefined)).toBe(false) + }) +}) + +describe('AC-SPACE-008: request layer targets AudioSpaceById', () => { + it('AC-SPACE-008: builds an AudioSpaceById request with replays enabled', () => { + const config = SpaceRequests.details(SPACE_ID) + + expect(config.method).toBe('get') + expect(config.url).toContain('/graphql/HPEisOmj1epUNLCWTYhUWw/AudioSpaceById') + + const variables = JSON.parse(config.params.variables) + expect(variables.id).toBe(SPACE_ID) + expect(variables.withReplays).toBe(true) + }) + + it('AC-SPACE-008: the resource is registered for fetching (wiring guard)', () => { + expect(ResourceType.SPACE_DETAILS).toBe('SPACE_DETAILS') + // 未注册进 FetchResourcesGroup 会被 _validateArgs 判为无效资源,请求静默失败 + expect(FetchResourcesGroup).toContain(ResourceType.SPACE_DETAILS) + expect(Requests[ResourceType.SPACE_DETAILS]({ id: SPACE_ID }).url) + .toContain('AudioSpaceById') + }) +}) + +describe('AC-SPACE-009: deleted / inaccessible space becomes a tombstone', () => { + // 真实样本:推文 2057046686871232598 → Space 1DGLdvzVZmLGm, + // AudioSpaceById 只回 { is_subscribed: false },没有 metadata + it('AC-SPACE-009: maps a metadata-less response to the unavailable state', () => { + expect(unavailableSpace.metadata).toBeUndefined() + + const space = mapSpaceDetails('1DGLdvzVZmLGm', unavailableSpace) + + expect(space).not.toBeNull() + expect(space!.availability).toBe('unavailable') + expect(space!.id).toBe('1DGLdvzVZmLGm') + // 墓碑仍需可跳转地址(由 card binding 的 id 构造,不依赖 metadata) + expect(space!.url).toBe('https://x.com/i/spaces/1DGLdvzVZmLGm') + expect(space!.title).toBe('') + expect(space!.listenersCount).toBe(0) + expect(space!.durationMs).toBeNull() + expect(space!.isReplayAvailable).toBe(false) + expect(space!.host.name).toBe('') + expect(space!.host.screen_name).toBe('') + }) + + it('AC-SPACE-009: treats a missing audioSpace node the same way', () => { + expect(mapSpaceDetails('1DGLdvzVZmLGm', null)!.availability).toBe('unavailable') + expect(mapSpaceDetails('1DGLdvzVZmLGm', undefined)!.availability).toBe('unavailable') + expect(mapSpaceDetails('1DGLdvzVZmLGm', {})!.availability).toBe('unavailable') + }) + + // 回归:无卡片的 Space 推文(实测 1871586443388420240)过去会被整条漏掉 —— 只认 card binding 的 id + it('AC-SPACE-009: resolves the space id from the body link when there is no card', () => { + const cardless = loadFixture<{ + card: unknown + entities: Array<{ expanded_url: string, href?: string }> + }>('space/audio-space-cardless-tweet.json') + + expect(cardless.card).toBeNull() + // 只有卡片时才有 id,此处为空 + expect(extractAudiospaceId(cardless.card)).toBeNull() + + const entities = cardless.entities.map(e => ({ ...e, href: e.expanded_url })) + expect(resolveSpaceId(cardless.card, entities)).toBe('1djGXroNWDExZ') + + // 卡片优先于正文链接 + expect(resolveSpaceId(card, entities)).toBe(SPACE_ID) + + // 既无卡片也无 Space 链接 → null + expect(resolveSpaceId(null, [{ href: 'https://example.com/a' }])).toBeNull() + expect(resolveSpaceId(null, [])).toBeNull() + expect(resolveSpaceId(undefined, undefined)).toBeNull() + + // 该 Space 同样已删除 → 墓碑态 + const space = mapSpaceDetails('1djGXroNWDExZ', unavailableSpace) + expect(space!.availability).toBe('unavailable') + expect(space!.url).toBe('https://x.com/i/spaces/1djGXroNWDExZ') + }) + + it('AC-SPACE-009: extracts the space id from a space url', () => { + expect(extractSpaceIdFromUrl('https://x.com/i/spaces/1djGXroNWDExZ')).toBe('1djGXroNWDExZ') + expect(extractSpaceIdFromUrl('https://twitter.com/i/spaces/1yoKMPnjEbOxQ?foo=1')).toBe('1yoKMPnjEbOxQ') + expect(extractSpaceIdFromUrl('https://x.com/BDP_yumemita')).toBeNull() + expect(extractSpaceIdFromUrl('https://t.co/ELCNFX5yGy')).toBeNull() + expect(extractSpaceIdFromUrl(undefined)).toBeNull() + }) +}) + +describe('AC-SPACE-010: availability drives whether playback is offered', () => { + it('AC-SPACE-010: only an ended space with replay enabled is replayable', () => { + expect(mapSpaceDetails(SPACE_ID, audioSpace)!.availability).toBe('replayable') + // 已结束但主办方未开启回放 —— 没有录音可放,不得给播放入口 + expect(mapSpaceDetails(SPACE_ID, derived({ is_space_available_for_replay: false }))!.availability) + .toBe('no-replay') + expect(mapSpaceDetails(SPACE_ID, derived({ state: 'TimedOut', is_space_available_for_replay: false }))!.availability) + .toBe('no-replay') + }) + + it('AC-SPACE-010: classifies live and upcoming spaces', () => { + expect(mapSpaceDetails(SPACE_ID, derived({ state: 'Running' }))!.availability).toBe('live') + expect(mapSpaceDetails(SPACE_ID, derived({ state: 'NotStarted' }))!.availability).toBe('upcoming') + // 大小写不敏感(上游 state 大小写不保证) + expect(mapSpaceDetails(SPACE_ID, derived({ state: 'running' }))!.availability).toBe('live') + }) + + it('AC-SPACE-010: replay-disabled space still shows its metadata', () => { + const noReplay = mapSpaceDetails(SPACE_ID, derived({ is_space_available_for_replay: false }))! + // 回放关闭只是「不能播」,元数据照常展示(官方卡片也仍列出人数/时长) + expect(noReplay.title).toContain('ゆめみた合宿3日目') + expect(noReplay.listenersCount).toBe(2478) + expect(noReplay.durationMs).toBe(2560946) + expect(noReplay.host.name).toBe('夢限大みゅーたいぷ') + }) + + // 回归:availability 是后加字段,改动前落地的 space(memory / 本地文件 / DB)没有它。 + // 组件曾直接查表 → undefined,渲染时抛 `Cannot read properties of undefined (reading 'aria')`。 + it('AC-SPACE-010: resolves a playback state for cache entries without availability', () => { + // 正常数据:直接用 availability + expect(resolveSpacePlaybackState({ availability: 'live', isReplayAvailable: false })).toBe('live') + expect(resolveSpacePlaybackState({ availability: 'upcoming', isReplayAvailable: false })).toBe('upcoming') + + // 旧缓存缺 availability → 按早期就存在的 isReplayAvailable 回退 + const stale = { isReplayAvailable: true } as unknown as Parameters[0] + expect(resolveSpacePlaybackState(stale)).toBe('replayable') + expect(resolveSpacePlaybackState({ ...stale, isReplayAvailable: false })).toBe('no-replay') + + // 连 isReplayAvailable 都没有 → 保守不说「可播放」 + expect(resolveSpacePlaybackState({} as unknown as Parameters[0])) + .toBe('no-replay') + + // 墓碑态(unavailable)不是行动区状态,一并保守处理 + expect(resolveSpacePlaybackState({ availability: 'unavailable', isReplayAvailable: false })).toBe('no-replay') + }) +}) diff --git a/verify/acceptance-criteria/AC-space.md b/verify/acceptance-criteria/AC-space.md new file mode 100644 index 00000000..a62f7b59 --- /dev/null +++ b/verify/acceptance-criteria/AC-space.md @@ -0,0 +1,207 @@ +# X Space(语音直播/录音回放)卡片验收标准 + +> 版本:1.0 | 日期:2026-09-16 +> 对应 Postmortem:001 (Tweet Parsing), 002 (Translation System) +> 关联方案:`docs/features/tweet/space-card.md` +> 执行命令:`bun run verify/index.ts --module space` +> 样本:真实推文 `1968314084207788302`(夢限大みゅーたいぷ 合宿 3 日目 Space,`Ended` + 可回放) +> +> 背景:Space 推文的 `card.name` 为 `3691233323:audiospace`,`binding_values` 只有 +> `tweet_id` / `narrow_cast_space_type` / `id` / `card_url`,**既无 title/description 也无图片**, +> 因此 `mapTwitterCard` 的最终校验会把它整个丢弃(`/api/tweet/get` 实测无 `card`)。 +> 卡片所需的标题/主播/人数/时长全部在 GraphQL `AudioSpaceById` 的 `audioSpace.metadata` 里, +> 必须走一次独立上游请求。 +> +> 范围裁定(owner 2026-09-16):**只做卡片 + 跳转 X 播放**。站内播放需要 `hls.js` + +> 服务端媒体代理(pscp.tv 的 m3u8 实测 `Access-Control-Allow-Origin: https://x.com`, +> 浏览器跨源取流被拦),本期不做。 + +--- + +## AC-SPACE-001:audiospace 卡片识别 + +- **输入**:真实推文 `1968314084207788302` 的原始 `card`(`test/fixtures/space/audio-space-card.json`) +- **预期输出**:`extractAudiospaceId(card)` 返回 Space id +- **验证方法**:`bun run verify/index.ts --ac AC-SPACE-001`(`test/unit/space.spec.ts`) +- **Pass 条件**: + - 返回 `1yoKMPnjEbOxQ` + - 非 audiospace 卡(如 `summary_large_image`)返回 `null` + - 缺失 `binding_values` / 缺失 `id` / `card` 为 `undefined` 时返回 `null`,不抛错 + +--- + +## AC-SPACE-002:AudioSpaceById 元数据映射 + +- **输入**:真实 `AudioSpaceById` 响应(`test/fixtures/space/audio-space-ended.json`) +- **预期输出**:`mapSpaceDetails(raw)` 返回结构化 `SpaceDetails` +- **验证方法**:`bun run verify/index.ts --ac AC-SPACE-002` +- **Pass 条件**: + - `id === '1yoKMPnjEbOxQ'`,`url === 'https://x.com/i/spaces/1yoKMPnjEbOxQ'` + - `title === '#ゆめみた合宿3日目!ついに最終日!✨コメントはハッシュタグにてお願いします✨'`(CJK 与 emoji 未被截断) + - `state === 'Ended'`,`isReplayAvailable === true` + - `durationMs === 2560946`(`ended_at - started_at`,对应官方卡片 `42:40`) + - `liveListenersCount === 1245`、`replayCount === 1233`、`listenersCount === 2478`(官方卡片 `2,478 人がリスニング/リプレイ`) + - `host.name === '夢限大みゅーたいぷ'`、`host.screenName === 'BDP_yumemita'` + - `host.avatarUrl` 指向 `pbs.twimg.com/profile_images/…` + - 主播即推文作者时,认证信息由推文侧用户对象补齐(`is_blue_verified === true`、 + `verified_type === 'Business'`)——`AudioSpaceById` 的 `creator_results` 是精简对象, + 自身不含这两个字段 + +--- + +## AC-SPACE-003:畸形/缺失元数据不抛错 + +- **输入**:空 spaceId、缺 `title`、缺 `ended_at`、缺 `creator_results`、非法时间戳 +- **预期输出**:`mapSpaceDetails()` 返回 `null` 或降级对象,绝不抛错 +- **验证方法**:`bun run verify/index.ts --ac AC-SPACE-003` +- **Pass 条件**: + - **仅当 spaceId 为空**时返回 `null`(没有 id 就构造不出任何 URL) + - 缺 `title` 的 metadata → 不抛错,`title === ''`(不整块丢弃) + - 缺 `ended_at`(进行中场次)→ 不抛错,`durationMs === null` + - 缺 `creator_results` → `host.screen_name` 为空串,`title` / `listenersCount` 仍可用 + - 非法时间戳(如 `ended_at: 'not-a-number'`)→ `durationMs === null`,不抛错 + +--- + +## AC-SPACE-004:展示格式化(时长 / 日期 / 人数) + +- **输入**:AC-SPACE-002 的 `SpaceDetails` +- **预期输出**:`formatSpaceDuration` / `formatSpaceDate` / `formatSpaceListeners` +- **验证方法**:`bun run verify/index.ts --ac AC-SPACE-004` +- **Pass 条件**: + - `formatSpaceDuration(2560946) === '42:40'`;`>= 1 小时` → `1:02:03` 形态;`null` → 空串 + - `formatSpaceDate(1758117620659) === '9月17日'`(实测数据;不补前导零,与官方卡片一致) + - `formatSpaceListeners(2478) === '2,478'`(千分位) + +--- + +## AC-SPACE-005:Space 卡片组件真实渲染 + +- **输入**:由真实 fixture 映射出的 `EnrichedTweet`(`space` 字段非空) +- **预期输出**:`TweetSpaceCard` 渲染官方结构 +- **验证方法**:`renderToString(createElement(TweetSpaceCard, ...))` + HTML 断言 + (`test/acceptance/space-render.spec.ts`) +- **Pass 条件**: + - 外层跳转为 `https://x.com/i/spaces/1yoKMPnjEbOxQ`,`target="_blank"` + `rel` 含 `noopener` + - 渲染主播名、标题、`2,478`、`9月17日`、`42:40`、`播放录音` + - 主播头像 `alt=""`(装饰图,名称文本已承载可访问名称) + - 卡片跳转是无障碍名称来源(`aria-label` 含标题) + - 不渲染冗余装饰/标签:无「主播」标记、无联合账号小徽标(14px 下不可辨识的噪音) + +--- + +## AC-SPACE-006:无 space 数据空渲染 + +- **输入**:`space` 为 `undefined` 的 `EnrichedTweet` +- **预期输出**:`TweetSpaceCard` 返回空串(不渲染、不报错) +- **验证方法**:`renderToString` 断言(`space-render.spec.ts`) + +--- + +## AC-SPACE-007:正文不重复渲染 Space 链接 + +- **输入**:`space` 非空、`entities` 含指向 `x.com/i/spaces/…` 的 url 实体的 `EnrichedTweet` +- **预期输出**:`TweetBody` 跳过该链接文本(官方前端同样只显示卡片) +- **验证方法**:`renderToString` 断言(`space-render.spec.ts`) +- **Pass 条件**: + - `space` 非空时,正文不出现该 Space 的 `display_url` / `href` + - `space` 为空(元数据获取失败)时,链接**照常渲染**(优雅降级,不静默吞链接) + - 其他 url 实体(非 Space)不受影响 + +--- + +## AC-SPACE-008:请求层锁定 AudioSpaceById + +- **输入**:`SpaceRequests.details('1yoKMPnjEbOxQ')` 生成的 AxiosRequestConfig +- **预期输出**:GraphQL 请求指向 AudioSpaceById,variables 含 `withReplays: true` +- **验证方法**:单测断言请求配置(`test/unit/space.spec.ts`) +- **Pass 条件**: + - `url` 以 `/graphql/HPEisOmj1epUNLCWTYhUWw/AudioSpaceById` 结尾 + - `variables` 解析后 `id === '1yoKMPnjEbOxQ'` 且 `withReplays === true` + - `method === 'get'` + +--- + +## AC-SPACE-009:已删除 / 不可访问的 Space 给墓碑提示 + +- **背景**:改动前这类 Space 会静默什么都不显示,正文只剩一个裸 `t.co` 链接 —— + 用户无法区分「没做这个功能」和「这个 Space 没了」。 +- **输入**: + - `test/fixtures/space/audio-space-unavailable.json` —— 推文 `2057046686871232598` → + Space `1DGLdvzVZmLGm`;`AudioSpaceById` 只回 `{ is_subscribed: false }`,**没有 metadata** + - `test/fixtures/space/audio-space-cardless-tweet.json` —— 推文 `1871586443388420240` + **完全没有 card**,正文里只有一个指向 Space `1djGXroNWDExZ` 的 url 实体(同样已删除) +- **预期输出**:`mapSpaceDetails()` 返回 `availability === 'unavailable'` 的墓碑对象; + `TweetSpaceCard` 渲染中性提示条 +- **验证方法**:`bun run verify/index.ts --ac AC-SPACE-009` +- **Pass 条件**: + - `availability === 'unavailable'`,`id` / `url` 由 **card binding 的 id** 构造(不依赖 metadata) + - `title === ''`、`listenersCount === 0`、`durationMs === null`、`host.name === ''` + - `audioSpace` 节点整体缺失(`null` / `undefined` / `{}`)时同样得到墓碑态 + - **Space id 来源双通道**:`resolveSpaceId(card, entities)` 先取 card binding 的 id, + 无卡片时回退正文里 `x.com/i/spaces/…` 链接解析出的 id;两者都无 → `null` + (无卡片推文过去会被整条漏掉) + - 渲染:出现「Space 已删除或不可访问」+「在 X 查看」链接; + **不出现**「播放录音」与时长 + - 墓碑用中性色(`bg-muted/40`),不复用 Space 品牌紫(`bg-[#9c63fa]`) + +--- + +## AC-SPACE-010:未开启回放时不给播放入口 + +- **背景**:已结束但主办方未开启回放的 Space **没有录音可放**;给「播放录音」按钮 + 会把人送进一个播不了的页面。 +- **输入**:真实 `audio-space-ended.json` 元数据,按 AC-SPACE-001 的派生规则翻转 + `is_space_available_for_replay` / `state` +- **预期输出**:`availability` 由 `state` + 回放开关推导;行动区随之切换 +- **验证方法**:`bun run verify/index.ts --ac AC-SPACE-010` +- **Pass 条件**: + - `Ended` + 回放开 → `replayable`;`Ended`/`TimedOut` + 回放关 → `no-replay` + - `Running` → `live`;`NotStarted` → `upcoming`(`state` 比较大小写不敏感) + - 渲染:`replayable` →「播放录音」;`no-replay` →「录音不可回放」(**无**播放入口); + `live` →「直播中」;`upcoming` →「尚未开始」 + - `no-replay` 仍展示元数据(标题/人数/时长/主播) + - **旧缓存兼容(回归)**:`availability` 是后加字段,早期落地的 `space` + (memory / 本地文件 / DB)没有它。`resolveSpacePlaybackState()` 必须按 + `isReplayAvailable` 回退推导;字段全缺时保守取 `no-replay`,绝不抛错 + (曾实测抛 `Cannot read properties of undefined (reading 'aria')`) + +--- + +## AC-SPACE-011:旧缓存回填 `space` + +- **背景**:`space` 是后加字段。改动前落地的缓存(memory LRU / 本地文件 / DB jsonContent)没有它, + 而命中缓存时不会再走 `getEnrichedTweet` —— 于是 Space 卡片/墓碑**永远不出现** + (实测线上表现:已删除的 Space 推文仍然只渲染一个裸链接)。缓存不保留原始 `card`, + 但 Space 链接始终在实体里,故用 `resolveSpaceId(null, entities)` 覆盖「带卡片」与「无卡片」两种推文。 +- **输入**:`getLocalTweet()` 出口(mock 缓存层与上游取数,`test/unit/getLocalTweet.spec.ts`) +- **预期输出**:命中缓存且 `space` 缺失或为旧结构时回填/升级,并 best-effort 写回缓存 +- **Pass 条件**: + - 命中缓存、无 `space`、实体里有 Space 链接 → 返回带 `space` 的推文,且 `setLocalCache` 被调用 + - 已有**当前结构**的 `space`(带 `availability`)→ 原样返回,不重复请求、不重复写缓存 + - `space` 存在但缺 `availability`(早期缓存落下的旧结构)→ 顺带**升级**一次并写回缓存 + - 实体里没有 Space 链接 → 原样返回,完全不调上游 + - 上游取数失败(返回 `null`)→ 原样返回,**不伪造墓碑**、不写缓存 + - 缓存未命中 → 返回 `null` + +--- + +## 总计:11 条 AC + +| AC | 分类 | 依赖外部 API | 依赖 AI | +| ------------ | -------------- | ------------ | ------- | +| AC-SPACE-001 | 纯函数/离线 | 否 | 否 | +| AC-SPACE-002 | 纯函数/离线 | 否 | 否 | +| AC-SPACE-003 | 纯函数/离线 | 否 | 否 | +| AC-SPACE-004 | 纯函数/离线 | 否 | 否 | +| AC-SPACE-005 | 组件/真实渲染 | 否 | 否 | +| AC-SPACE-006 | 组件/真实渲染 | 否 | 否 | +| AC-SPACE-007 | 组件/真实渲染 | 否 | 否 | +| AC-SPACE-008 | 纯函数/离线 | 否 | 否 | +| AC-SPACE-009 | 纯函数 + 渲染 | 否 | 否 | +| AC-SPACE-010 | 纯函数 + 渲染 | 否 | 否 | +| AC-SPACE-011 | 服务层(mock) | 否 | 否 | + +> 全部 AC 离线可跑:上游响应已固化为 fixture(`test/fixtures/space/*.json`,取自真实 +> `AudioSpaceById` / `TweetResultByRestId` 响应裁剪),无需 `TWEET_KEYS` 或服务器。 +> 「真实上游连通性」不在本期 AC 范围(偶发 429,不适合当门禁)。 diff --git a/verify/index.ts b/verify/index.ts index da526667..5d0e4a1b 100644 --- a/verify/index.ts +++ b/verify/index.ts @@ -53,6 +53,7 @@ const MODULE_PATTERNS: Record = { obs: 'AC-OBS', pwa: 'AC-PWA', card: 'AC-CARD', + space: 'AC-SPACE', ui: 'AC-UI', test: 'AC-TEST', } From c4cdff2a3b0632c2f61b35c3b3742dfbf0de69c4 Mon Sep 17 00:00:00 2001 From: Chilfish Date: Thu, 17 Sep 2026 01:42:51 +0800 Subject: [PATCH 6/7] docs(space): document space card design, dev log and API schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增功能文档 docs/features/tweet/space-card.md:问题(空壳 card)、 数据链、边界态状态机、范围裁定(只做卡片 + 跳转 X 播放)、实现落点、 已知限制 - 开发日志 2026-09-16:含边界态补全与旧缓存回填两轮返工的踩坑 - backlog 归档记录 + development-log 索引 - llms.ts 的 EnrichedTweet 补 space 字段(Data Shapes 与 OpenAPI schema), 随附 skill 快照重新生成(diff 仅 +4 行,无version 漂移) Co-authored-by: CommandCodeBot --- .../references/anon-tweet-openapi.json | 4 + app/lib/llms.ts | 3 +- docs/development-log/2026-09-16.md | 100 ++++++++++++ docs/development-log/README.md | 1 + docs/features/tweet/space-card.md | 142 ++++++++++++++++++ docs/planning/backlog.md | 1 + 6 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 docs/development-log/2026-09-16.md create mode 100644 docs/features/tweet/space-card.md diff --git a/.agents/skills/anon-tweet/references/anon-tweet-openapi.json b/.agents/skills/anon-tweet/references/anon-tweet-openapi.json index c4f291e6..77178fe2 100644 --- a/.agents/skills/anon-tweet/references/anon-tweet-openapi.json +++ b/.agents/skills/anon-tweet/references/anon-tweet-openapi.json @@ -1692,6 +1692,10 @@ "type": "object", "description": "预览卡片(LinkPreviewCard)" }, + "space": { + "type": "object", + "description": "X Space 卡片(SpaceDetails,见 app/types/space.ts;仅 Space 推文有)" + }, "comments": { "type": "array", "items": { diff --git a/app/lib/llms.ts b/app/lib/llms.ts index c0f3a8b9..bb48abed 100644 --- a/app/lib/llms.ts +++ b/app/lib/llms.ts @@ -106,7 +106,7 @@ ${apiBullets} 后端主要接口(搜索 / 获取推文)的出参是 EnrichedTweet 数组,字段对齐 react-tweet: -- EnrichedTweet: id_str(推文 ID)、text(推文文本)、url(完整链接)、lang(语言码)、created_at(ISO 8601 创建时间)、user(作者 TweetUser)、entities(实体 Entity[])、visionInfo(可选,AI 视觉描述)、quotedTweet(可选,引用推文)、card(可选,预览卡片) +- EnrichedTweet: id_str(推文 ID)、text(推文文本)、url(完整链接)、lang(语言码)、created_at(ISO 8601 创建时间)、user(作者 TweetUser)、entities(实体 Entity[])、visionInfo(可选,AI 视觉描述)、quotedTweet(可选,引用推文)、card(可选,预览卡片)、space(可选,X Space 卡片:标题/主播/收听人数/时长) - TweetUser: id_str、name、screen_name、profile_image_url_https(头像)、verified、is_blue_verified - Entity: type(text / hashtag / mention / url / media / symbol / media_alt / separator)、text、index(文本偏移)、href(多数类型带链接)、translation(手动翻译)、aiTranslation(AI 翻译) - SearchResponse / RepliesResponse: { tweets: EnrichedTweet[], nextCursor }(nextCursor 为 string 或 null,null 表示没有更多) @@ -729,6 +729,7 @@ export function buildOpenApiDoc(baseUrl: string): Record { quoted_tweet_id: { type: 'string', description: '引用推文 ID' }, quotedTweet: { $ref: '#/components/schemas/EnrichedTweet', description: '引用推文(递归)' }, card: { type: 'object', description: '预览卡片(LinkPreviewCard)' }, + space: { type: 'object', description: 'X Space 卡片(SpaceDetails,见 app/types/space.ts;仅 Space 推文有)' }, comments: { type: 'array', items: { $ref: '#/components/schemas/EnrichedTweet' }, description: '内联评论线程' }, in_reply_to_status_id_str: { type: 'string' }, mediaDetails: { type: 'array', items: { type: 'object', description: '媒体详情(图片/视频,见 app/types/media.ts)' } }, diff --git a/docs/development-log/2026-09-16.md b/docs/development-log/2026-09-16.md new file mode 100644 index 00000000..7f635a0c --- /dev/null +++ b/docs/development-log/2026-09-16.md @@ -0,0 +1,100 @@ +# 2026-09-16 + +## X Space 卡片接入(✅ 已完成) + +**背景**:`/api/tweet/get` 实测对 Space 推文(`1968314084207788302`)不返回任何卡片 —— 其 +`card.name = '3691233323:audiospace'` 的 `binding_values` 只有 `tweet_id` / `id` / `card_url`, +无标题无图片,被 `mapTwitterCard()` 的最终校验整个丢弃,页面只剩一个裸 `t.co` 链接。 + +**数据链**(先写临时探针脚本核实上游,再动手): + +- `TweetResultByRestId` → card `id` binding = Space id +- `AudioSpaceById`(queryId `HPEisOmj1epUNLCWTYhUWw`,`withReplays: true`)→ + `data.audioSpace.metadata` 提供全部卡片字段 +- 实测对齐官方卡片:`2,478 人がリスニング/リプレイ` = `total_live_listeners(1245) + total_replay_watched(1233)`; + `42:40` = `ended_at - started_at`;`9月17日` = `created_at` + +**范围裁定(owner)**:**只做卡片 + 跳转 X 播放**。站内播放被否 —— 录音是 HLS,m3u8 响应头为 +`Access-Control-Allow-Origin: https://x.com`(跨源取流被拦),站内播放需服务端媒体代理 + +`hls.js` 依赖 + 相应带宽,成本远超卡片本身。 + +**实现**: + +- `rettiwt-api` 新增 `SpaceRequests.details()` + `ResourceType.SPACE_DETAILS` + (`Requests` / `FetchResourcesGroup` / `index.ts` 三处注册;漏注册会被 `_validateArgs` 静默判废) +- 新增 `app/types/space.ts`(`SpaceDetails` / `SpaceHost`),`EnrichedTweet.space` 可选 +- 新增纯逻辑 `app/lib/react-tweet/utils/space.ts`:`extractAudiospaceId` / `mapSpaceDetails` / + `formatSpaceDuration|Date|Listeners` / `isSpaceUrl` +- `get-tweet.ts` 增 `fetchSpaceDetails()` + `attachSpaceDetails()`(仅单推文路径;失败 `obsLog('space.fetch.failed')` 并静默降级) +- 新增 `TweetSpaceCard`(紫底白字,挂载 `TweetNode` / `PlainTweet`)+ 正文去重(`space` 非空时不再重复渲染同一条 Space 链接) +- `VerifiedBadge` 的 `user` 入参放宽为 `Pick` + (安全放宽,Space 主播可复用) + +**踩坑 / 取舍**: + +1. **`creator_results` 是精简用户对象**:不含 `is_blue_verified` / `verified_type`,直接用会丢掉官方卡片上的 + 认证徽标。主播即推文作者时从推文侧 `TweetUser` 补齐;顺带发现上游会**省略** `verified` 字段(非 `false` + 而是 `undefined`),故一律以 `=== true` 收敛为布尔。 +2. **不改 `parseTweet.ts`**:它是 Postmortem #001 的头号高危文件(10 次 fix),Space 识别放在异步层读原始 + `card`,保持解析器零改动。 +3. **清理渲染噪音**:初版照搬官方 DOM,渲染了「主播」右对齐标签与 `affiliates_highlighted_label` 的 14px + 联合账号徽标;截图复核后两者都删(14px 下不可辨识的纯噪音),录音按钮底色也由 X 的冷灰 `#eff3f4` + 换成白(紫底上更干净)。 +4. **临时探针脚本**(`tmp/probe-space.ts` 等):用 `Requests` 运行时猴补丁注册临时资源, + 先核实上游返回形态再落地,避免猜字段。 + +**验证**:新增 AC-SPACE-001~008(全离线,上游响应固化为 `test/fixtures/space/*.json`)+ +`test/unit/space.spec.ts`(9)+ `test/acceptance/space-render.spec.ts`(6)+ Storybook `Tweet/SpaceCard` 四态。 +门禁:`typecheck` / `lint` / `verify --exit-on-fail`(429 passed)全绿;真实上游 E2E 复核 +(`getEnrichedTweet('1968314084207788302')` 正确挂载 `space`,普通推文不受影响)。 +`llms.ts` 与随附 OpenAPI 快照同步补 `space` 字段(快照重新生成,diff 仅 +4 行)。 + +**文档**:`docs/features/tweet/space-card.md` + +## 边界态补全(✅ 已完成,同日追加) + +初版只覆盖「已结束 + 可回放」。复核时由 owner 指出两类缺失,均补上并配 AC-SPACE-009/010: + +**1. 可播放状态机**(`SpaceAvailability`,由 `state` + `is_space_available_for_replay` 推导) + +| availability | 条件 | 卡片表现 | +| ------------------- | -------------------------------------- | -------------------------------------------------- | +| `replayable` | 已结束 + 回放开 | 「播放录音」(唯一给播放入口的分支) | +| `live` / `upcoming` | `Running` / `NotStarted` | 「直播中」/「尚未开始」 | +| `no-replay` | 已结束但**未开启回放** | 「录音不可回放」,**不给**播放入口 | +| `unavailable` | 上游**无 metadata**(已删除/不可访问) | 中性墓碑条「Space 已删除或不可访问」+「在 X 查看」 | + +关键取舍:没录音却摆「播放录音」= 把人送进播不了的页面;墓碑也不该用品牌紫大喊大叫(走 `bg-muted/40`)。 + +**2. 上游要区分「没有」与「取不到」**:已删除的 Space,`AudioSpaceById` 实测返回 +`{ data: { audioSpace: { is_subscribed: false } } }`(节点在、metadata 无);而请求失败 +(429/网络)是抛错。**只有前者**渲染墓碑——否则会把限流误报成「已删除」; +后者不挂 `space`,正文链接照常渲染并记 `obsLog('space.fetch.failed')`。 + +**3. Space id 双通道识别**:实测推文 `1871586443388420240` **完全没有 card**,正文里只有一条 +`x.com/i/spaces/1djGXroNWDExZ` 链接(该 Space 也已删除)——初版只认 card binding 的 id, +这类推文会被整条漏掉。改为 `resolveSpaceId(card, entities)`:优先卡片、回退正文 url 实体。 + +**踩坑(已复现的崩溃)**:`availability` 是后加字段,改动前落地的 `space`(memory LRU / 本地文件 / +DB jsonContent)没有它,组件直接 `ACTION_BY_AVAILABILITY[space.availability]` 拿到 `undefined`, +渲染时抛 `Cannot read properties of undefined (reading 'aria')`(dev 实测)。修法: +`resolveSpacePlaybackState()` 纯函数按早期就存在的 `isReplayAvailable` 回退推导,字段全缺时保守取 +`no-replay`,并补回归测试(AC-SPACE-010)。 + +**验证**:上游 E2E 复核三条真实推文 —— 带卡片的已删除 Space(`2057046686871232598`)、 +无卡片的已删除 Space(`1871586443388420240`)、已结束可回放(`1968314084207788302`), +availability 分别 `unavailable` / `unavailable` / `replayable`。 + +**4. 旧缓存回填(AC-SPACE-011,owner 复测指出)**:owner 实测「还是渲染了链接」——根因是 +**缓存命中**:三条推文都从 NodeFS 命中改动前落库的旧结构(日志 `cache.get … hit:true`), +压根不会再走 `getEnrichedTweet`,于是新逻辑从未执行。 + +修法:在唯一读出口 `getLocalTweet()` 出口做一次回填/升级 —— 缓存不保留原始 `card`,但 Space +链接始终在实体里,`resolveSpaceId(null, entities)` 覆盖「带卡片 / 无卡片」两种推文;结果 +best-effort 写回 localCache + DB(避免每次浏览重打上游);取数失败保持原样,**不伪造墓碑**。 +顺带把「`space` 存在但缺 `availability`」的旧结构也纳入自愈范围。 + +复测:`getLocalTweet` 对上述三条(全部 `hit:true`)现在分别返回 `unavailable` / `unavailable` / +带完整标题的卡片,二次读取仍为 `unavailable`。 + +**最终**:AC-SPACE 共 11 条;`typecheck` / `lint` / `verify --exit-on-fail`(448 passed)全绿。 diff --git a/docs/development-log/README.md b/docs/development-log/README.md index 5504e54e..6f088a25 100644 --- a/docs/development-log/README.md +++ b/docs/development-log/README.md @@ -6,6 +6,7 @@ | 日期 | 主题 | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [2026-09-16](2026-09-16.md) | X Space 卡片接入(audiospace 空壳卡片 → `AudioSpaceById` 补全标题/主播/人数/时长;范围裁定「卡片 + 跳转 X 播放」;AC-SPACE-001~008;不改高危解析器) | | [2026-09-12](2026-09-12.md) | 验证诚信修复落地(F1 dev server `NODE_ENV` 泄漏 / F2 零匹配守卫 + 模块别名 / F3 死断言 / F4 AC↔测试名元测试 / F5 workflow 步骤解析 / F8 去 fixture 自证 / F9 AC-TRANS 命名 / F10 断言 lint / F12 README / F13 deprecated 参数)+ postmortem 011 | | [2026-09-11](2026-09-11.md) | anon-tweet Agent Skill 升级 v1.2.0(随附 PowerShell 封装 + OpenAPI 快照;随附 spec 与 llms.ts 14/14 奇偶一致)+ 版本单源治理(应用版本归 package.json / skill 版本归 SKILL.md,去手写副本)+ 验证套件诚信审查(AC 空跑报绿 / 静态扫描冒充行为断言;修复 backlog F1~F13) | | [2026-09-06](2026-09-06.md) | 可安装 PWA + Web Share Target(系统分享直达推文/IG;极简网络透传 SW + 首页 GET 接收复用 TweetInputForm) | diff --git a/docs/features/tweet/space-card.md b/docs/features/tweet/space-card.md new file mode 100644 index 00000000..14ae86a6 --- /dev/null +++ b/docs/features/tweet/space-card.md @@ -0,0 +1,142 @@ +# X Space 卡片(语音直播 / 录音回放) + +> 状态:✅ 已完成(2026-09-16) +> AC:[`verify/acceptance-criteria/AC-space.md`](../../../verify/acceptance-criteria/AC-space.md)(AC-SPACE-001~008) +> 关联 Postmortem:001(Tweet Parsing)、002(纯逻辑耦合 React) + +## 1. 问题 + +Space 推文的文本通常只有一条 `t.co` 短链,视觉信息全在卡片上。但这类推文的 +`card` 是**空壳**: + +```jsonc +{ + "legacy": { + "name": "3691233323:audiospace", + "binding_values": [ + { "key": "tweet_id", "value": { "string_value": "1968314084207788302" } }, + { "key": "narrow_cast_space_type", "value": { "string_value": "0" } }, + { "key": "id", "value": { "string_value": "1yoKMPnjEbOxQ" } }, // ← 唯一的有效信息 + { "key": "card_url", "value": { "string_value": "https://t.co/gOS5Qc3DS4" } } + ] + } +} +``` + +没有 `title` / `description` / 任何图片 binding,于是 `mapTwitterCard()` 的最终校验 +(「至少有标题或描述或图片」)把它整个丢弃 —— **改动前 `/api/tweet/get` 对这条推文 +返回的 `card` 是 `undefined`**,页面只剩一个裸链接。 + +## 2. 数据链 + +卡片所需字段全部在另一个 GraphQL 端点里,必须额外打一次上游: + +``` +TweetResultByRestId(推文本身) + └─ card.name = '…:audiospace' → binding id = Space id + │ + ▼ +AudioSpaceById(x.com/i/api/graphql/HPEisOmj1epUNLCWTYhUWw/AudioSpaceById) + └─ data.audioSpace.metadata + title / state / started_at / ended_at / is_space_available_for_replay + total_live_listeners + total_replay_watched / creator_results.result.legacy +``` + +字段口径与官方卡片实测值一一对应(样本 Space `1yoKMPnjEbOxQ`): + +| 官方卡片文案 | 来源 | +| --------------------------------------- | --------------------------------------------------------- | +| `#ゆめみた合宿3日目!ついに最終日!✨…` | `metadata.title`(CJK + emoji 原样) | +| `2,478 人がリスニング/リプレイ` | `total_live_listeners(1245) + total_replay_watched(1233)` | +| `9月17日` | `metadata.created_at`(Asia/Shanghai 口径) | +| `42:40` | `ended_at - started_at = 2560946ms` | +| 主播行(头像 + 名称 + 认证徽标) | `creator_results.result.legacy` | + +`creator_results` 是**精简用户对象**:不含 `is_blue_verified` / `verified_type` +(实测两者均缺省)。主播就是推文作者时,用推文侧更完整的 `TweetUser` 补齐认证信息, +否则官方卡片上的认证徽标会丢。 + +## 3. 范围裁定(owner 2026-09-16) + +**只做卡片 + 跳转 X 播放,不做站内播放。** 原因(实测): + +- 录音是 HLS:`live_video_stream/status/{media_key}` → `…/audio-space/playlist_*.m3u8?type=replay` +- 该 m3u8 响应头为 `Access-Control-Allow-Origin: https://x.com` —— 浏览器跨源取流被拦 +- 因此站内播放 = 服务端媒体代理(m3u8 + 分片重写)+ `hls.js` 依赖(Chrome/Firefox 不原生支持 HLS) + - 相应带宽与滥用面,成本远超卡片本身 + +故整卡是一个指向 `https://x.com/i/spaces/{id}` 的链接;「播放录音」是视觉元素而非嵌套交互元素 +(避免 `` 套 `