API返回空数组提示引发Discord Bot TypeError问题排查
问题描述
我开发了一个从reddit的r/axolotls板块抓取帖子的API,触发API更新帖子数组的请求后立即发起请求时,API返回“Filtered array only contains 0 items.”,导致Discord Bot出现Cannot read properties of undefined (reading 'media')的TypeError。预期API会在至少抓取一次帖子后才注册路由,且更新数组时不应为空。
Discord Bot的ImageCommand.ts(报错位于updatePartial()函数)
import { ChatInputCommandInteraction, Client, EmbedBuilder, TextChannel } from "discord.js"; import Axios from "axios"; import CommandInterface from "../Interfaces/CommandInterface"; import { MediaInterface, EntryInterface } from "../Utils/axolotlapi"; let sfwEmbeds: Array<EmbedBuilder> = []; let nsfwEmbeds: Array<EmbedBuilder> = []; let lastUpdated: number = 0; async function updatePartial(nsfw: boolean): Promise<void> { let newEmbeds: Array<EmbedBuilder> = []; let payload = "?minImages=1&flair=Just Showing Off 😍" + (nsfw ? "&nsfw=1" : ""); const count = (await Axios.get("https://axolotlapi.kirondevcoder.repl.co/reddit/count" + payload)).data.data; payload += `&count=${count}`; const response = (await Axios.get("https://axolotlapi.kirondevcoder.repl.co/reddit" + payload)).data; for (let i = 0; i < count; i++) { const post: EntryInterface = response.data[i]; if (!post) { console.log("Post: " + response.data[i]); console.log("i: " + i); console.log("Count: " + count); console.log("Response: " + JSON.stringify(response)); } const images: Array<string> = post.media.filter((m: MediaInterface) => m.kind === "image").map((m: MediaInterface) => m.url); for (let j = 0; j < images.length; j++) { newEmbeds.push(new EmbedBuilder() .setTitle(post.title) .setAuthor({ name: `u/${post.author}`, url: `https://reddit.com/u/${post.author}` }) .setURL(post.link) .setTimestamp(post.created_utc * 1000) .setImage(images[j]) .setColor("#ff00ff") .setDescription("Score: " + post.score) ); } } if (nsfw) nsfwEmbeds = newEmbeds; else sfwEmbeds = newEmbeds; } function isAllowedToUpdate(): boolean { return Date.now() > lastUpdated + 60 * 30 * 1000; // Update once every 30 minutes at most } async function update(): Promise<void> { if (!isAllowedToUpdate()) return; lastUpdated = Date.now(); await updatePartial(false); await updatePartial(true); console.log("Updated images"); } export default async (client: Client, commandsArray: Array<CommandInterface>): Promise<void> => { await update(); commandsArray.push({ name: "image", description: "Sends you a cute axolotl picture from Reddit.", executor: async (interaction: ChatInputCommandInteraction<"cached">, client: Client): Promise<void> => { const channel: TextChannel = interaction.channel as TextChannel; let embed: EmbedBuilder; if (channel.nsfw) embed = nsfwEmbeds[Math.floor(Math.random() * nsfwEmbeds.length)]; else embed = sfwEmbeds[Math.floor(Math.random() * sfwEmbeds.length)]; await interaction.reply({ embeds: [ embed ]}); update(); } }); }
API的reddit.ts
import { Router, Request, Response } from "express"; import Axios from "axios"; import { Log, Warn, Note, SendResponse } from "../utils"; interface MediaInterface { kind: "image" | "video", url: string } interface EntryInterface { title: string, score: number, link: string, author: string, awards: number, comments: number, spoiler: boolean, archived: boolean, crosspostable: boolean, pinned: boolean, locked: boolean, created_utc: number, id: string, flair: string, nsfw: boolean, text: string, media: Array<MediaInterface> } interface EntriesInterface { new: Array<EntryInterface>, hot: Array<EntryInterface>, rising: Array<EntryInterface>, any: Array<EntryInterface> } let entries: EntriesInterface = { new: [], hot: [], rising: [], any: [] }; let flairs: Array<string> = []; let lastUpdated: number = 0; const SORTS: Array<string> = ["new", "hot", "rising"]; function isAllowedToUpdate(): boolean { return Date.now() > lastUpdated + 60 * 30 * 1000; // Update once every 30 minutes at most } async function update(): Promise<void> { if (!isAllowedToUpdate()) return; lastUpdated = Date.now(); let newEntries: EntriesInterface = { new: [], hot: [], rising: [], any: [] }; let newFlairs: Array<string> = []; let i: number = 0; SORTS.forEach(async (sort): Promise<void> => { const res: any = await Axios.get(`https://www.reddit.com/r/axolotls/${sort}.json?limit=100`); res.data.data.children.forEach((child: any): void => { const data = child.data; const { title, score, author, total_awards_received, num_comments, spoiler, archived, is_crosspostable, pinned, locked, created_utc, id } = data; const link: string = "https://www.reddit.com" + data.permalink; const images: Array<string> = []; const videos: Array<string> = []; if (data.url.startsWith("https://i.redd.it/")) images.push(data.url); else if (data.media_data) { Object.values(data.media_data).forEach((media: any) => { if (media.status !== "valid") return Warn(`Invalid value: ${JSON.stringify(media)}`); switch (media.e) { case "Image": images.push(media.s.u.split("?")[0].replace("preview.redd.it", "i.redd.it")); break; case "RedditVideo": Note("TODO: Figure out solution for this"); Log(media); Log(data); break; } }); } else if (data.media?.reddit_video) { videos.push(data.media.reddit_video.fallback_url.split("?")[0]); } else if (data.media?.oembed) { images.push(data.media.oembed.thumbnail_url.split("?")[0]); } else if (data.media) { Note("TODO: Handle this unhandled media"); Log(data.media); } const payload: EntryInterface = { title: title, score: score, link: link, author: author, awards: total_awards_received, comments: num_comments, spoiler: spoiler, archived: archived, crosspostable: is_crosspostable, pinned: pinned, locked: locked, created_utc: created_utc, id: id, flair: data.link_flair_text, nsfw: data.over_18, text: data.selftext, media: [] }; for (const image of images) { payload.media.push({ kind: "image", url: image }); } for (const video of videos) { payload.media.push({ kind: "video", url: video }); } newEntries[sort as keyof EntriesInterface].push(payload); if (!newEntries.any.map((e) => e.id).includes(id)) newEntries.any.push(payload); if (!flairs.includes(payload.flair)) flairs.push(payload.flair); }); i++; if (i == SORTS.length) { entries.any = newEntries.any; entries.hot = newEntries.hot; entries.new = newEntries.new; entries.rising = newEntries.rising; flairs = newFlairs; } }); } function getFilteredArray( sort: string, minScore: number, minImages: number, minVideos: number, minAwards: number, minComments: number, maxScore: number, maxImages: number, maxVideos: number, maxAwards: number, maxComments: number, author: string | null, flair: string | null, nsfw: number, spoiler: number, archived: number, is_crosspostable: number, pinned: number, locked: number ): Array<EntryInterface> { let arr = entries[sort as keyof EntriesInterface] .filter((e: EntryInterface) => e.score >= minScore && e.score <= maxScore) .filter((e: EntryInterface) => e.media.filter((m: MediaInterface) => m.kind == "image").length >= minImages && e.media.filter((m: MediaInterface) => m.kind == "image").length <= maxImages) .filter((e: EntryInterface) => e.media.filter((m: MediaInterface) => m.kind == "video").length >= minVideos && e.media.filter((m: MediaInterface) => m.kind == "video").length <= maxVideos) .filter((e: EntryInterface) => e.awards >= minAwards && e.awards <= maxAwards) .filter((e: EntryInterface) => e.comments >= minComments && e.comments <= maxComments) ; if (author !== null) arr = arr.filter((e: EntryInterface) => e.author == author); if (flair !== null) arr = arr.filter((e: EntryInterface) => e.flair == flair); for (const e of Object.entries({ nsfw, spoiler, archived, is_crosspostable, pinned, locked})) { switch (e[1].toString()) { case "0": arr = arr.filter((e2: EntryInterface) => e2[e[0] as keyof EntryInterface] === false); break; //case "1": break; default: break; case "2": arr = arr.filter((e2: EntryInterface) => e2[e[0] as keyof EntryInterface] === true); break; } } return arr; } function getFilteredArrayFromRequest(req: Request): Array<EntryInterface> { let sort: string = req.query.sort as string; if (!SORTS.includes(sort)) sort = "any"; return getFilteredArray( sort, Number.parseInt(req.query.minScore as string) || 0, Number.parseInt(req.query.minImages as string) || 0, Number.parseInt(req.query.minVideos as string) || 0, Number.parseInt(req.query.minAwards as string) || 0, Number.parseInt(req.query.minComments as string) || 0, Number.parseInt(req.query.maxScore as string) || Infinity, Number.parseInt(req.query.maxImages as string) || Infinity, Number.parseInt(req.query.maxVideos as string) || Infinity, Number.parseInt(req.query.maxAwards as string) || Infinity, Number.parseInt(req.query.maxComments as string) || Infinity, req.query.author as string || null, req.query.flair as string || null, Number.parseInt(req.query.nsfw as string) || 0, Number.parseInt(req.query.spoiler as string) || 0, Number.parseInt(req.query.archived as string) || 1, Number.parseInt(req.query.crosspostable as string) || 1, Number.parseInt(req.query.pinned as string) || 1, Number.parseInt(req.query.locked as string) || 1 ); } export default async (router: Router): Promise<void> => { await update(); router.get("/", (req: Request, res: Response) => { let count: number = Number.parseInt(req.query.count as string) || 1; if (count < 1) count = 1; const arr: Array<EntryInterface> = getFilteredArrayFromRequest(req); if (arr.length < count) SendResponse(res, 200, arr, `Filtered array only contains ${arr.length} items.`); else SendResponse(res, 200, [...arr].sort(() => 0.5 - Math.random()).slice(0, count)); update(); }); router.get("/flairs", (req: Request, res: Response) => { SendResponse(res, 200, flairs); update(); }); router.get("/count", (req: Request, res: Response) => { const arr: Array<EntryInterface> = getFilteredArrayFromRequest(req); SendResponse(res, 200, arr.length); update(); }); router.get("/id", (req: Request, res: Response) => { if (req.query.id === undefined) return SendResponse(res, 400, null); const id: string = req.query.id as string; if (!entries.any.map((e) => e.id).includes(id)) SendResponse(res, 400, "Invalid ID provided."); else SendResponse(res, 200, entries.any.find((e) => e.id === id)); update(); }); }
Discord Bot报错日志
Listening on port 8080. CommandLoader: InviteCommand.js Updated images CommandLoader: ImageCommand.js Success reloading application (/) commands. Logged in as Xondalf2#7884! HandlerLoader: interactionCreate.js Post: undefined i: 0 Count: 19 Response: {"message":"Filtered array only contains 0 items.","data":[]} /home/runner/Xondalf2/dist/Commands/ImageCommand.js:35 const images = post.media.filter((m) => m.kind === "image").map((m) => m.url); ^ TypeError: Cannot read properties of undefined (reading 'media') at /home/runner/Xondalf2/dist/Commands/ImageCommand.js:35:33 at Generator.next (<anonymous>) at fulfilled (/home/runner/Xondalf2/dist/Commands/ImageCommand.js:5:58) at process.processTicksAndRejections (node:internal/process/task_queues:95:5) Node.js v18.12.1 exit status 1
问题排查与修复
1. API端核心问题:异步循环导致初始化未完成
问题根源
API的update()函数使用SORTS.forEach(async (sort) => { ... }),这是典型的异步陷阱:forEach不会等待内部的异步请求完成,导致await update()在实际抓取完Reddit数据前就执行完毕,路由提前注册,此时全局entries仍是空数组,请求自然返回0条数据。另外,原代码中用i++判断是否完成所有抓取的逻辑不可靠,异步操作的执行顺序不固定,无法保证最后完成的是最后一个sort。
修复方案
将forEach替换为for...of循环,确保每个Reddit请求按顺序完成,所有数据抓取完成后再统一更新全局变量:
async function update(): Promise<void> { if (!isAllowedToUpdate()) return; lastUpdated = Date.now(); let newEntries: EntriesInterface = { new: [], hot: [], rising: [], any: [] }; let newFlairs: Array<string> = []; // 替换forEach为for...of,确保异步操作顺序执行 for (const sort of SORTS) { const res: any = await Axios.get(`https://www.reddit.com/r/axolotls/${sort}.json?limit=100`); res.data.data.children.forEach((child: any): void => { // 原数据处理逻辑保持不变 const data = child.data; const { title, score, author, total_awards_received, num_comments, spoiler, archived, is_crosspostable, pinned, locked, created_utc, id } = data; const link: string = "https://www.reddit.com" + data.permalink; const images: Array<string> = []; const videos: Array<string> = []; if (data.url.startsWith("https://i.redd.it/")) images.push(data.url); else if (data.media_data) { Object.values(data.media_data).forEach((media: any) => { if (media.status !== "valid") return Warn(`Invalid value: ${JSON.stringify(media)}`); switch (media.e) { case "Image": images.push(media.s.u.split("?")[0].replace("preview.redd.it", "i.redd.it")); break;
相关产品推荐
相关产品推荐

