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/.commandcode/taste/taste/taste.md b/.commandcode/taste/taste/taste.md index 719608b0..53191df8 100644 --- a/.commandcode/taste/taste/taste.md +++ b/.commandcode/taste/taste/taste.md @@ -32,7 +32,7 @@ - Prefers minimal, non-noisy UI copy: dislikes long/verbose disclosure text placed directly on the page (calls it "太吵" / too noisy), and values restraint over richness ("有时候丰富不代表好") — favor trimming or dropping informative-but-cluttering content instead of adding more. Confidence: 0.8 - Scopes a screen to its actual purpose: when a view's real use is a single action (e.g. IG stories are for downloading the media), wants the unrelated actions (screenshot/translate/copy) removed from that surface rather than offered for completeness. Confidence: 0.6 - When a feature or UI copy is removed, also delete the now-orphaned tests that assert it — keep the test suite in sync with what actually ships rather than leaving stale assertions behind. Confidence: 0.7 -- Wants third-party SDK/library behavior verified empirically before building on it: explicitly asks the agent to "跑跑那个 SDK 看看有啥输出" — write a throwaway probe that runs the library against real inputs and dumps the actual message/metadata shapes, instead of inferring behavior from type definitions or assumptions. Confidence: 0.7 +- Wants third-party SDK/library behavior verified empirically before building on it: explicitly asks the agent to "跑跑那个 SDK 看看有啥输出" — write a throwaway probe that runs the library against real inputs and dumps the actual message/metadata shapes, instead of inferring behavior from type definitions or assumptions. The same applies when adding support for a new content/entity type: expects the agent to first look at the real upstream payload (the original API response / provided sample HTML) to derive the actual shape — "加个功能支持它,可以先看看它的原始返回". Confidence: 0.75 - Throwaway/verification scripts belong inside the project's own (gitignored) `tmp/` directory, not the agent's external scratchpad or OS temp dir — the project location is what lets them resolve the repo's `node_modules` and pick up repo config. Confidence: 0.75 - Expects the agent to source credentials/config from the repo's root `.env` and to check there before declaring something unavailable (e.g. "cookie 是在这的 `I:\dev\anonTweet\.env`"). Confidence: 0.65 - When an installed dependency's behavior is unclear or suspected, wants the agent to go read that library's own source repository (kept as a local checkout, e.g. `I:\dev\gallery-dl-ins`) — `src/`, tests, `scripts/` and any recorded sample data/output — instead of inferring from the installed `dist/` or the type definitions. Confidence: 0.7 @@ -48,3 +48,7 @@ - Wants the standard GitHub community-health surface to actually exist and be cross-linked (issue forms, PR template, SECURITY.md with a private disclosure channel, CODE_OF_CONDUCT.md) instead of only being promised/referenced — when offered a scope menu he took all of it rather than the minimum. Confidence: 0.6 - Treats a changelog as a historical record: reformatting it into a standard structure (Keep a Changelog sections, its original grouping demoted to sub-headings) is fine, but the recorded entries' content must not be rewritten. Confidence: 0.55 rouping demoted to sub-headings) is fine, but the recorded entries' content must not be rewritten. Confidence: 0.55 +- For a missing/unmapped data field, expects the fix to run the concrete pipeline end to end — temp probe against the real upstream, add the value into the corresponding field/type, then render it — instead of broad codebase exploration or an upfront design discussion; points at the closest existing repo file (e.g. `app/lib/react-tweet/utils/get-tweet.ts`) to copy as the pattern. Confidence: 0.65 +- Expects assumptions about external services/URL schemes to be validated against the live endpoint per variant rather than over-generalized from one working example — reports back after real-world testing (e.g. twimg `/media/` works as both `?format=jpg` and `/slug.jpg`, but `/card_img//` only resolves in query form and 404s as `/slug.jpg`, so a blanket normalization broke link-card images). Confidence: 0.65 +- Expects degraded/edge states of external data to be surfaced in the UI rather than the element silently disappearing: after verifying the happy path himself, flags that deleted/unavailable resources or replay-disabled cases have "没有对应的UI提示" and wants a visible fallback/tombstone (or an explicit non-actionable state such as "不可回放") instead of a bare or missing element. Confidence: 0.7 +- When reporting a gap or reviewing a feature, supplies a concrete real-world sample (a live URL / specific resource id) to reproduce against rather than describing the case abstractly. Confidence: 0.55 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/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/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/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 } 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/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/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 + } +} 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)) 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/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 +} 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}` 的链接;「播放录音」是视觉元素而非嵌套交互元素 +(避免 `` 套 `