feat: scaffold workspace with search, fetch, and extract packages
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
STEAM_API_KEY: z.string().min(1, 'STEAM_API_KEY is required'),
|
||||
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
/** Hono bindings type so `c.env` is typed in route handlers. */
|
||||
export interface Bindings {
|
||||
STEAM_API_KEY: string;
|
||||
PORT: number;
|
||||
}
|
||||
|
||||
export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env {
|
||||
const parsed = envSchema.safeParse(source);
|
||||
if (!parsed.success) {
|
||||
const issues = parsed.error.issues
|
||||
.map((i) => `${i.path.join('.')}: ${i.message}`)
|
||||
.join('; ');
|
||||
throw new Error(`Invalid environment: ${issues}`);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { serve } from '@hono/node-server';
|
||||
import { cors } from 'hono/cors';
|
||||
import { Hono } from 'hono';
|
||||
import { loadEnv, type Bindings } from './env.js';
|
||||
import health from './routes/health.js';
|
||||
import items from './routes/items.js';
|
||||
import search from './routes/search.js';
|
||||
|
||||
const env = loadEnv();
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
app.use('*', cors());
|
||||
app.route('/health', health);
|
||||
app.route('/search', search);
|
||||
app.route('/items', items);
|
||||
|
||||
serve(
|
||||
{
|
||||
fetch: app.fetch,
|
||||
port: env.PORT,
|
||||
overrideGlobalObjects: true,
|
||||
},
|
||||
(info) => {
|
||||
console.log(`TTS proxy listening on http://localhost:${info.port}`);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Hono } from 'hono';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get('/', (c) => c.json({ status: 'ok' }));
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Hono } from 'hono';
|
||||
import { itemIdSchema, type TTSMod } from '@tts/shared';
|
||||
import { fetchMod, fetchModFile, TtsError } from '@tts/tts';
|
||||
import type { Bindings } from '../env.js';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
app.get('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
if (!itemIdSchema.safeParse(id).success) {
|
||||
return c.json({ error: 'Item ID must be a number' }, 400);
|
||||
}
|
||||
|
||||
const apiKey = c.env.STEAM_API_KEY;
|
||||
if (!apiKey) {
|
||||
return c.json({ error: 'STEAM_API_KEY is not configured' }, 500);
|
||||
}
|
||||
|
||||
try {
|
||||
const mod = await fetchMod(id, apiKey);
|
||||
return c.json<TTSMod>(mod);
|
||||
} catch (err) {
|
||||
if (err instanceof TtsError) {
|
||||
return c.json({ error: err.message }, err.status as 400 | 404 | 500 | 502);
|
||||
}
|
||||
return c.json({ error: String(err) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/:id/file', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
if (!itemIdSchema.safeParse(id).success) {
|
||||
return c.json({ error: 'Item ID must be a number' }, 400);
|
||||
}
|
||||
|
||||
const apiKey = c.env.STEAM_API_KEY;
|
||||
if (!apiKey) {
|
||||
return c.json({ error: 'STEAM_API_KEY is not configured' }, 500);
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, filename } = await fetchModFile(id, apiKey);
|
||||
return new Response(data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof TtsError) {
|
||||
return c.json({ error: err.message }, err.status as 400 | 404 | 500 | 502);
|
||||
}
|
||||
return c.json({ error: String(err) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Hono } from 'hono';
|
||||
import { searchQuerySchema, type SearchResult } from '@tts/shared';
|
||||
|
||||
const BROWSE_URL = 'https://steamcommunity.com/workshop/browse/';
|
||||
const TTS_APP_ID = '286160';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
interface BrowseResult {
|
||||
publishedfileid: string;
|
||||
title: string;
|
||||
preview_url: string;
|
||||
file_url?: string;
|
||||
tags?: { tag: string }[];
|
||||
time_created?: number;
|
||||
time_updated?: number;
|
||||
}
|
||||
|
||||
interface BrowseData {
|
||||
current_page: number;
|
||||
total_pages: number;
|
||||
total_count: number;
|
||||
results: BrowseResult[];
|
||||
next_cursor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Steam embeds the Workshop browse results as a JSON blob inside
|
||||
* `window.SSR.renderContext`. The renderContext is a JS-escaped JSON string
|
||||
* whose `queryData` field is itself a JSON string containing React Query
|
||||
* cache entries. We locate the `workshop_browse` query and read its results.
|
||||
*/
|
||||
function parseBrowseResults(html: string): BrowseData | null {
|
||||
const scriptMatch = /<script[^>]*>([\s\S]*?)<\/script>/g;
|
||||
const ASSIGNMENT = 'window.SSR.renderContext=JSON.parse(';
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = scriptMatch.exec(html))) {
|
||||
const script = m[1] ?? '';
|
||||
const start = script.indexOf(ASSIGNMENT);
|
||||
if (start === -1) continue;
|
||||
|
||||
const openQuote = start + ASSIGNMENT.length + 1;
|
||||
const lastTerm = script.lastIndexOf('");');
|
||||
if (lastTerm === -1 || lastTerm <= openQuote) continue;
|
||||
|
||||
const raw = script.slice(openQuote, lastTerm);
|
||||
const jsonText = JSON.parse('"' + raw + '"');
|
||||
const renderContext = JSON.parse(jsonText);
|
||||
const queryData = JSON.parse(renderContext.queryData);
|
||||
const browse = queryData.queries.find((q: { queryKey: unknown[] }) =>
|
||||
JSON.stringify(q.queryKey).includes('workshop_browse'),
|
||||
);
|
||||
if (browse?.state?.data?.results) {
|
||||
return browse.state.data as BrowseData;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
app.get('/', async (c) => {
|
||||
const parsed = searchQuerySchema.safeParse(c.req.query());
|
||||
if (!parsed.success) {
|
||||
return c.json({ error: parsed.error.issues[0]?.message }, 400);
|
||||
}
|
||||
const { q, page } = parsed.data;
|
||||
|
||||
const url = new URL(BROWSE_URL);
|
||||
url.searchParams.set('appid', TTS_APP_ID);
|
||||
url.searchParams.set('searchtext', q);
|
||||
url.searchParams.set('pagenum', String(page));
|
||||
|
||||
let html: string;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
return c.json({ error: `Steam browse page responded ${res.status}` }, 502);
|
||||
}
|
||||
html = await res.text();
|
||||
} catch (err) {
|
||||
return c.json({ error: `Failed to fetch browse page: ${String(err)}` }, 502);
|
||||
}
|
||||
|
||||
const data = parseBrowseResults(html);
|
||||
if (!data) {
|
||||
return c.json({ error: 'Could not parse Workshop browse results' }, 502);
|
||||
}
|
||||
|
||||
const items: SearchResult['items'] = data.results.map((r) => ({
|
||||
id: r.publishedfileid,
|
||||
title: r.title,
|
||||
author: '',
|
||||
previewImageUrl: r.preview_url,
|
||||
fileUrl: r.file_url,
|
||||
tags: r.tags?.map((t) => t.tag),
|
||||
timeCreated: r.time_created,
|
||||
timeUpdated: r.time_updated,
|
||||
}));
|
||||
|
||||
return c.json<SearchResult>({
|
||||
items,
|
||||
page: data.current_page,
|
||||
hasMore: data.current_page < data.total_pages,
|
||||
});
|
||||
});
|
||||
|
||||
export default app;
|
||||
Reference in New Issue
Block a user