You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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;
                        
相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.29 00:03:11