fix: Twitter 适配器改用搜索端点获取热搜话题对应的真实推文
旧逻辑只返回热搜话题名称(无封面、无互动数据),现在改为: 1. 获取热搜话题列表 2. 取前 5 个话题并行搜索热门推文 3. 去重、按点赞排序后返回完整推文卡片 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ceadeca4eb
commit
95627e3924
@ -17,137 +17,122 @@ describe("TwitterAdapter", () => {
|
||||
});
|
||||
|
||||
describe("fetchTrending", () => {
|
||||
it("returns mapped ContentItem[] from tweets format", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
tweets: [
|
||||
{
|
||||
id_str: "1234567890",
|
||||
full_text: "This is a trending tweet!",
|
||||
user: {
|
||||
name: "Test User",
|
||||
screen_name: "testuser",
|
||||
profile_image_url_https: "https://pbs.twimg.com/avatar.jpg",
|
||||
},
|
||||
favorite_count: 5000,
|
||||
retweet_count: 2000,
|
||||
reply_count: 300,
|
||||
created_at: "Mon Jan 15 08:00:00 +0000 2024",
|
||||
entities: {
|
||||
hashtags: [{ text: "trending" }, { text: "test" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const items = await adapter.fetchTrending(20);
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].id).toBe("1234567890");
|
||||
expect(items[0].title).toBe("This is a trending tweet!");
|
||||
expect(items[0].platform).toBe("twitter");
|
||||
expect(items[0].author_name).toBe("Test User");
|
||||
expect(items[0].like_count).toBe(5000);
|
||||
expect(items[0].share_count).toBe(2000);
|
||||
expect(items[0].tags).toEqual(["trending", "test"]);
|
||||
});
|
||||
|
||||
it("returns mapped ContentItem[] from GraphQL format", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
timeline: {
|
||||
instructions: [
|
||||
{
|
||||
entries: [
|
||||
{
|
||||
content: {
|
||||
itemContent: {
|
||||
tweet_results: {
|
||||
result: {
|
||||
legacy: {
|
||||
id_str: "gql-001",
|
||||
full_text: "GraphQL tweet",
|
||||
user: { name: "GQL User" },
|
||||
favorite_count: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const items = await adapter.fetchTrending(20);
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].id).toBe("gql-001");
|
||||
expect(items[0].title).toBe("GraphQL tweet");
|
||||
});
|
||||
|
||||
it("returns mapped ContentItem[] from trends format", async () => {
|
||||
it("fetches trending topics then searches tweets for each", async () => {
|
||||
// First call: fetch_trending returns topic names
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
trends: [
|
||||
{ name: "#Topic1" },
|
||||
{ name: "#Topic2" },
|
||||
],
|
||||
});
|
||||
// Second call: search for #Topic1
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
timeline: [
|
||||
{
|
||||
name: "#TrendingTopic",
|
||||
tweet_volume: 50000,
|
||||
url: "https://twitter.com/search?q=%23TrendingTopic",
|
||||
type: "tweet",
|
||||
tweet_id: "100",
|
||||
text: "Tweet about Topic1",
|
||||
screen_name: "user1",
|
||||
favorites: 5000,
|
||||
retweets: 1000,
|
||||
replies: 50,
|
||||
bookmarks: 20,
|
||||
views: "80000",
|
||||
created_at: "Mon Jan 15 08:00:00 +0000 2024",
|
||||
entities: { hashtags: [{ text: "Topic1" }] },
|
||||
user_info: { name: "User One", profile_image_url_https: "https://pbs.twimg.com/a.jpg" },
|
||||
media: { photo: [{ media_url_https: "https://pbs.twimg.com/media/img.jpg" }] },
|
||||
},
|
||||
],
|
||||
});
|
||||
// Third call: search for #Topic2
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
timeline: [
|
||||
{
|
||||
type: "tweet",
|
||||
tweet_id: "200",
|
||||
text: "Tweet about Topic2",
|
||||
screen_name: "user2",
|
||||
favorites: 3000,
|
||||
retweets: 500,
|
||||
user_info: { name: "User Two" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const items = await adapter.fetchTrending(20);
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].title).toBe("#TrendingTopic");
|
||||
expect(items[0].play_count).toBe(50000);
|
||||
expect(items[0].author_name).toBe("Twitter Trending");
|
||||
expect(items).toHaveLength(2);
|
||||
// Sorted by likes desc
|
||||
expect(items[0].id).toBe("100");
|
||||
expect(items[0].title).toBe("Tweet about Topic1");
|
||||
expect(items[0].like_count).toBe(5000);
|
||||
expect(items[0].share_count).toBe(1000);
|
||||
expect(items[0].play_count).toBe(80000);
|
||||
expect(items[0].cover_url).toBe("https://pbs.twimg.com/media/img.jpg");
|
||||
expect(items[0].author_name).toBe("User One");
|
||||
expect(items[0].tags).toEqual(["Topic1"]);
|
||||
|
||||
expect(items[1].id).toBe("200");
|
||||
expect(items[1].like_count).toBe(3000);
|
||||
});
|
||||
|
||||
it("handles empty API response", async () => {
|
||||
it("deduplicates tweets across topics", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
trends: [{ name: "#A" }, { name: "#B" }],
|
||||
});
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
timeline: [
|
||||
{ type: "tweet", tweet_id: "111", text: "Same tweet", favorites: 1000, user_info: {} },
|
||||
],
|
||||
});
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
timeline: [
|
||||
{ type: "tweet", tweet_id: "111", text: "Same tweet", favorites: 1000, user_info: {} },
|
||||
{ type: "tweet", tweet_id: "222", text: "Other tweet", favorites: 500, user_info: {} },
|
||||
],
|
||||
});
|
||||
|
||||
const items = await adapter.fetchTrending(20);
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0].id).toBe("111");
|
||||
expect(items[1].id).toBe("222");
|
||||
});
|
||||
|
||||
it("handles empty trending response", async () => {
|
||||
mockFetch.mockResolvedValueOnce({});
|
||||
const items = await adapter.fetchTrending(20);
|
||||
expect(items).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles search failures gracefully", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
trends: [{ name: "#Fail" }],
|
||||
});
|
||||
mockFetch.mockRejectedValueOnce(new Error("search failed"));
|
||||
|
||||
const items = await adapter.fetchTrending(20);
|
||||
expect(items).toEqual([]);
|
||||
});
|
||||
|
||||
it("strips HTML from tweet text", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
tweets: [
|
||||
{
|
||||
id_str: "html-001",
|
||||
full_text: "Check out <a href='https://t.co/test'>this link</a>!",
|
||||
user: { name: "HTML User" },
|
||||
},
|
||||
],
|
||||
trends: [{ name: "#HTML" }],
|
||||
});
|
||||
|
||||
const items = await adapter.fetchTrending(20);
|
||||
expect(items[0].title).toBe("Check out this link!");
|
||||
});
|
||||
|
||||
it("extracts media from extended_entities", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
tweets: [
|
||||
timeline: [
|
||||
{
|
||||
id_str: "media-001",
|
||||
full_text: "Tweet with media",
|
||||
user: { name: "Media User" },
|
||||
extended_entities: {
|
||||
media: [
|
||||
{
|
||||
media_url_https: "https://pbs.twimg.com/media/test.jpg",
|
||||
video_info: { variants: [{ url: "https://video.twimg.com/test.mp4" }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
type: "tweet",
|
||||
tweet_id: "300",
|
||||
text: "Check <a href='url'>this</a>!",
|
||||
favorites: 100,
|
||||
user_info: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const items = await adapter.fetchTrending(20);
|
||||
expect(items[0].cover_url).toBe("https://pbs.twimg.com/media/test.jpg");
|
||||
expect(items[0].video_url).toBe("https://video.twimg.com/test.mp4");
|
||||
expect(items[0].title).toBe("Check this!");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -13,46 +13,65 @@ function parseTwitterDate(dateStr: string): string {
|
||||
|
||||
export class TwitterAdapter implements PlatformAdapter {
|
||||
async fetchTrending(count: number): Promise<ContentItem[]> {
|
||||
// Step 1: get trending topic names
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = await tikhubFetch<any>(
|
||||
const trendData = await tikhubFetch<any>(
|
||||
"/api/v1/twitter/web/fetch_trending"
|
||||
);
|
||||
|
||||
// Response formats:
|
||||
// 1. { trends: [{ name, description, context }] } — trending topics
|
||||
// 2. { tweets: [...] } — tweet objects
|
||||
// 3. GraphQL timeline.instructions[].entries[]
|
||||
let items: unknown[] = [];
|
||||
const trends: string[] = [];
|
||||
if (Array.isArray(trendData?.trends)) {
|
||||
for (const t of trendData.trends) {
|
||||
if (t?.name) trends.push(t.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(data?.trends)) {
|
||||
items = data.trends;
|
||||
} else if (Array.isArray(data?.tweets)) {
|
||||
items = data.tweets;
|
||||
} else if (data?.timeline?.instructions) {
|
||||
const instructions = data.timeline.instructions;
|
||||
for (const inst of instructions) {
|
||||
const entries = inst?.entries || [];
|
||||
for (const entry of entries) {
|
||||
const tweet =
|
||||
entry?.content?.itemContent?.tweet_results?.result?.legacy ||
|
||||
entry?.content?.itemContent?.tweet_results?.result ||
|
||||
null;
|
||||
if (tweet) items.push(tweet);
|
||||
if (trends.length === 0) return [];
|
||||
|
||||
// Step 2: search top tweets for a few trending topics in parallel
|
||||
const topicsToSearch = trends.slice(0, 5);
|
||||
const searchResults = await Promise.allSettled(
|
||||
topicsToSearch.map((keyword) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
tikhubFetch<any>(
|
||||
"/api/v1/twitter/web/fetch_search_timeline",
|
||||
{ keyword, search_type: "Top" }
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Step 3: collect tweets from all search results
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const allTweets: any[] = [];
|
||||
for (const result of searchResults) {
|
||||
if (result.status !== "fulfilled") continue;
|
||||
const timeline = result.value?.timeline;
|
||||
if (Array.isArray(timeline)) {
|
||||
for (const item of timeline) {
|
||||
if (item?.type === "tweet" && item?.tweet_id) {
|
||||
allTweets.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
.slice(0, count)
|
||||
.map((item: unknown, index: number) =>
|
||||
this.mapToContentItem(item as Record<string, unknown>, index)
|
||||
);
|
||||
// Step 4: deduplicate, sort by likes, return top N
|
||||
const seen = new Set<string>();
|
||||
return allTweets
|
||||
.map((tweet, index) => this.mapSearchTweet(tweet, index))
|
||||
.filter((item) => {
|
||||
if (seen.has(item.id)) return false;
|
||||
seen.add(item.id);
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => (b.like_count ?? 0) - (a.like_count ?? 0))
|
||||
.slice(0, count);
|
||||
}
|
||||
|
||||
async fetchDetail(id: string): Promise<ContentItem> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = await tikhubFetch<any>(
|
||||
"/api/v1/twitter/web/fetch_tweet_detail",
|
||||
"/api/v1/twitter/web/fetch_post_detail",
|
||||
{ tweet_id: id }
|
||||
);
|
||||
|
||||
@ -69,33 +88,58 @@ export class TwitterAdapter implements PlatformAdapter {
|
||||
data?.tweet?.core?.user_results?.result?.legacy ||
|
||||
null;
|
||||
|
||||
return this.mapToContentItem(tweetData, 0, userResult);
|
||||
return this.mapLegacyTweet(tweetData, 0, userResult);
|
||||
}
|
||||
|
||||
/** Map a tweet from the search_timeline endpoint */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private mapToContentItem(raw: any, index: number, userOverride?: any): ContentItem {
|
||||
const tweetId = raw?.id_str || raw?.rest_id || raw?.id || `tw-${index}`;
|
||||
private mapSearchTweet(raw: any, index: number): ContentItem {
|
||||
const tweetId = raw?.tweet_id || `tw-${index}`;
|
||||
const text = stripHtml(raw?.text || "").slice(0, 200) || "Untitled";
|
||||
|
||||
// For trend items (from fetch_trending: { name, description, context })
|
||||
if (raw?.name && !raw?.full_text && !raw?.text) {
|
||||
return {
|
||||
id: String(tweetId || `tw-trend-${index}`),
|
||||
title: raw.name,
|
||||
cover_url: undefined,
|
||||
video_url: undefined,
|
||||
author_name: raw?.context || "Twitter Trending",
|
||||
author_avatar: undefined,
|
||||
play_count: raw?.tweet_volume ?? undefined,
|
||||
like_count: undefined,
|
||||
collect_count: undefined,
|
||||
comment_count: undefined,
|
||||
share_count: undefined,
|
||||
publish_time: new Date().toISOString(),
|
||||
platform: "twitter",
|
||||
original_url: raw?.url || `https://twitter.com/search?q=${encodeURIComponent(raw.name)}`,
|
||||
tags: undefined,
|
||||
};
|
||||
}
|
||||
const userInfo = raw?.user_info || {};
|
||||
|
||||
// media is { video: [...], photo: [...] } or array or null
|
||||
const mediaObj = raw?.media || {};
|
||||
const firstVideo = mediaObj?.video?.[0] || null;
|
||||
const firstPhoto = mediaObj?.photo?.[0] || null;
|
||||
const coverUrl =
|
||||
firstVideo?.media_url_https ||
|
||||
firstPhoto?.media_url_https ||
|
||||
undefined;
|
||||
const videoUrl = firstVideo?.variants?.find(
|
||||
(v: { content_type?: string }) => v.content_type === "video/mp4"
|
||||
)?.url || undefined;
|
||||
|
||||
const hashtags = raw?.entities?.hashtags;
|
||||
|
||||
return {
|
||||
id: String(tweetId),
|
||||
title: text,
|
||||
cover_url: coverUrl,
|
||||
video_url: videoUrl,
|
||||
author_name: userInfo?.name || raw?.screen_name || "Unknown",
|
||||
author_avatar: userInfo?.profile_image_url_https || undefined,
|
||||
play_count: raw?.views ? parseInt(raw.views, 10) : undefined,
|
||||
like_count: raw?.favorites ?? undefined,
|
||||
collect_count: raw?.bookmarks ?? undefined,
|
||||
comment_count: raw?.replies ?? undefined,
|
||||
share_count: raw?.retweets ?? undefined,
|
||||
publish_time: raw?.created_at
|
||||
? parseTwitterDate(raw.created_at)
|
||||
: new Date().toISOString(),
|
||||
platform: "twitter",
|
||||
original_url: `https://twitter.com/i/status/${tweetId}`,
|
||||
tags: Array.isArray(hashtags)
|
||||
? hashtags.map((h: { text?: string }) => h.text).filter(Boolean)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Map a tweet from the legacy/GraphQL detail endpoint */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private mapLegacyTweet(raw: any, index: number, userOverride?: any): ContentItem {
|
||||
const tweetId = raw?.id_str || raw?.rest_id || raw?.id || `tw-${index}`;
|
||||
|
||||
const text = raw?.full_text || raw?.text || "";
|
||||
const title = stripHtml(text).slice(0, 200) || "Untitled";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user