mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-26 11:00:38 +00:00
fix(type-safety): replace manual typeguards with valibot schemas in SPA and reddit-fetch (#3313)
Replace all `as Record<string, unknown>` casts and manual multi-level typeguard chains with proper valibot schema validation in: - main.ts: Reddit token response, error parsing, jQuery comment URL extraction - reddit-fetch.ts: Reddit auth, listing extraction, user comment fetching Adds RedditTokenSchema, RedditListingSchema, RedditChildDataSchema, and RedditCommentDataSchema with v.safeParse() for all external API data. Closes #3200 Co-authored-by: Claude <claude@anthropic.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
513d3448d4
commit
b290a3bb10
2 changed files with 79 additions and 50 deletions
|
|
@ -10,6 +10,37 @@
|
|||
|
||||
import { Database } from "bun:sqlite";
|
||||
import { existsSync } from "node:fs";
|
||||
import * as v from "valibot";
|
||||
|
||||
/** Valibot schemas for Reddit API responses. */
|
||||
const RedditTokenSchema = v.object({
|
||||
access_token: v.string(),
|
||||
});
|
||||
|
||||
const RedditChildDataSchema = v.looseObject({
|
||||
name: v.pipe(v.unknown(), v.transform((x) => String(x ?? ""))),
|
||||
title: v.pipe(v.unknown(), v.transform((x) => String(x ?? ""))),
|
||||
permalink: v.pipe(v.unknown(), v.transform((x) => String(x ?? ""))),
|
||||
subreddit: v.pipe(v.unknown(), v.transform((x) => String(x ?? ""))),
|
||||
score: v.pipe(v.unknown(), v.transform((x) => Number(x ?? 0))),
|
||||
num_comments: v.pipe(v.unknown(), v.transform((x) => Number(x ?? 0))),
|
||||
created_utc: v.pipe(v.unknown(), v.transform((x) => Number(x ?? 0))),
|
||||
selftext: v.pipe(v.unknown(), v.transform((x) => String(x ?? ""))),
|
||||
author: v.pipe(v.unknown(), v.transform((x) => String(x ?? ""))),
|
||||
});
|
||||
|
||||
const RedditListingSchema = v.object({
|
||||
data: v.object({
|
||||
children: v.array(v.object({
|
||||
data: RedditChildDataSchema,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
const RedditCommentDataSchema = v.looseObject({
|
||||
body: v.pipe(v.unknown(), v.transform((x) => String(x ?? ""))),
|
||||
subreddit: v.pipe(v.unknown(), v.transform((x) => String(x ?? ""))),
|
||||
});
|
||||
|
||||
const CLIENT_ID = process.env.REDDIT_CLIENT_ID ?? "";
|
||||
const CLIENT_SECRET = process.env.REDDIT_CLIENT_SECRET ?? "";
|
||||
|
|
@ -156,13 +187,13 @@ async function getToken(): Promise<string> {
|
|||
},
|
||||
body: `grant_type=password&username=${encodeURIComponent(USERNAME)}&password=${encodeURIComponent(PASSWORD)}`,
|
||||
});
|
||||
const data = (await res.json()) as Record<string, unknown>;
|
||||
const token = typeof data.access_token === "string" ? data.access_token : "";
|
||||
if (!token) {
|
||||
console.error("Reddit auth failed:", JSON.stringify(data));
|
||||
const json: unknown = await res.json();
|
||||
const parsed = v.safeParse(RedditTokenSchema, json);
|
||||
if (!parsed.success) {
|
||||
console.error("Reddit auth failed:", JSON.stringify(json));
|
||||
process.exit(1);
|
||||
}
|
||||
return token;
|
||||
return parsed.output.access_token;
|
||||
}
|
||||
|
||||
/** Fetch a Reddit API endpoint with auth. */
|
||||
|
|
@ -183,29 +214,23 @@ async function redditGet(token: string, path: string): Promise<unknown> {
|
|||
/** Extract posts from a Reddit listing response. */
|
||||
function extractPosts(data: unknown): Map<string, RedditPost> {
|
||||
const posts = new Map<string, RedditPost>();
|
||||
if (!data || typeof data !== "object") return posts;
|
||||
const listing = data as Record<string, unknown>;
|
||||
const listingData = listing.data as Record<string, unknown> | undefined;
|
||||
const children = listingData?.children;
|
||||
if (!Array.isArray(children)) return posts;
|
||||
const parsed = v.safeParse(RedditListingSchema, data);
|
||||
if (!parsed.success) return posts;
|
||||
|
||||
for (const child of children) {
|
||||
const c = child as Record<string, unknown>;
|
||||
const d = c.data as Record<string, unknown> | undefined;
|
||||
if (!d) continue;
|
||||
const id = String(d.name ?? "");
|
||||
if (!id || posts.has(id)) continue;
|
||||
for (const child of parsed.output.data.children) {
|
||||
const d = child.data;
|
||||
if (!d.name || posts.has(d.name)) continue;
|
||||
|
||||
posts.set(id, {
|
||||
title: String(d.title ?? ""),
|
||||
permalink: String(d.permalink ?? ""),
|
||||
subreddit: String(d.subreddit ?? ""),
|
||||
postId: id,
|
||||
score: Number(d.score ?? 0),
|
||||
numComments: Number(d.num_comments ?? 0),
|
||||
createdUtc: Number(d.created_utc ?? 0),
|
||||
selftext: String(d.selftext ?? "").slice(0, 2000),
|
||||
authorName: String(d.author ?? ""),
|
||||
posts.set(d.name, {
|
||||
title: d.title,
|
||||
permalink: d.permalink,
|
||||
subreddit: d.subreddit,
|
||||
postId: d.name,
|
||||
score: d.score,
|
||||
numComments: d.num_comments,
|
||||
createdUtc: d.created_utc,
|
||||
selftext: d.selftext.slice(0, 2000),
|
||||
authorName: d.author,
|
||||
authorComments: [],
|
||||
});
|
||||
}
|
||||
|
|
@ -221,18 +246,15 @@ async function fetchUserComments(token: string, username: string): Promise<strin
|
|||
// depth.
|
||||
if (!REDDIT_USERNAME_RE.test(username)) return [];
|
||||
const data = await redditGet(token, `/user/${encodeURIComponent(username)}/comments?limit=25&sort=new`);
|
||||
if (!data || typeof data !== "object") return [];
|
||||
const listing = data as Record<string, unknown>;
|
||||
const listingData = listing.data as Record<string, unknown> | undefined;
|
||||
const children = listingData?.children;
|
||||
if (!Array.isArray(children)) return [];
|
||||
const parsed = v.safeParse(RedditListingSchema, data);
|
||||
if (!parsed.success) return [];
|
||||
|
||||
return children
|
||||
return parsed.output.data.children
|
||||
.map((child) => {
|
||||
const c = child as Record<string, unknown>;
|
||||
const d = c.data as Record<string, unknown> | undefined;
|
||||
const body = String(d?.body ?? "").slice(0, 500);
|
||||
const sub = String(d?.subreddit ?? "");
|
||||
const cp = v.safeParse(RedditCommentDataSchema, child.data);
|
||||
if (!cp.success) return "";
|
||||
const body = cp.output.body.slice(0, 500);
|
||||
const sub = cp.output.subreddit;
|
||||
return sub ? `[r/${sub}] ${body}` : body;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
|
|
|||
|
|
@ -1549,8 +1549,9 @@ async function getRedditToken(): Promise<string | null> {
|
|||
},
|
||||
body: `grant_type=password&username=${encodeURIComponent(REDDIT_USERNAME)}&password=${encodeURIComponent(REDDIT_PASSWORD)}`,
|
||||
});
|
||||
const data = (await res.json()) as Record<string, unknown>;
|
||||
return typeof data.access_token === "string" ? data.access_token : null;
|
||||
const json: unknown = await res.json();
|
||||
const parsed = v.safeParse(v.object({ access_token: v.string() }), json);
|
||||
return parsed.success ? parsed.output.access_token : null;
|
||||
}
|
||||
|
||||
/** Post a reply to a Reddit thread. Returns the comment URL or an error. */
|
||||
|
|
@ -1578,10 +1579,11 @@ async function postRedditReply(postId: string, replyText: string): Promise<Respo
|
|||
body: `thing_id=${encodeURIComponent(postId)}&text=${encodeURIComponent(replyText)}`,
|
||||
});
|
||||
|
||||
const data = (await res.json()) as Record<string, unknown>;
|
||||
const json: unknown = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
const errMsg = typeof data.message === "string" ? data.message : `HTTP ${res.status}`;
|
||||
const errParsed = v.safeParse(v.object({ message: v.string() }), json);
|
||||
const errMsg = errParsed.success ? errParsed.output.message : `HTTP ${res.status}`;
|
||||
console.error(`[spa] Reddit reply failed: ${errMsg}`);
|
||||
return Response.json(
|
||||
{
|
||||
|
|
@ -1594,19 +1596,24 @@ async function postRedditReply(postId: string, replyText: string): Promise<Respo
|
|||
);
|
||||
}
|
||||
|
||||
// Extract the comment URL from the response
|
||||
const jquery = data.jquery as unknown[] | undefined;
|
||||
// Reddit's legacy "comment" endpoint returns a jQuery-style response.
|
||||
// Extract the permalink from nested arrays: jquery[n][3][m].data.permalink
|
||||
const JqueryCommentSchema = v.object({
|
||||
jquery: v.array(v.unknown()),
|
||||
});
|
||||
const JqueryInnerSchema = v.object({
|
||||
data: v.object({ permalink: v.string() }),
|
||||
});
|
||||
|
||||
let commentUrl = "";
|
||||
if (Array.isArray(jquery)) {
|
||||
for (const item of jquery) {
|
||||
const jqParsed = v.safeParse(JqueryCommentSchema, json);
|
||||
if (jqParsed.success) {
|
||||
for (const item of jqParsed.output.jquery) {
|
||||
if (Array.isArray(item) && item.length >= 4 && Array.isArray(item[3])) {
|
||||
for (const inner of item[3]) {
|
||||
const rec = inner as Record<string, unknown> | undefined;
|
||||
if (rec && typeof rec.data === "object" && rec.data !== null) {
|
||||
const d = rec.data as Record<string, unknown>;
|
||||
if (typeof d.permalink === "string") {
|
||||
commentUrl = `https://reddit.com${d.permalink}`;
|
||||
}
|
||||
const innerParsed = v.safeParse(JqueryInnerSchema, inner);
|
||||
if (innerParsed.success) {
|
||||
commentUrl = `https://reddit.com${innerParsed.output.data.permalink}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue