Skip to main content
Glama
scraper.ts8.19 kB
import * as cheerio from 'cheerio'; const BASE_URL = 'https://typst.app'; const UNIVERSE_URL = `${BASE_URL}/universe`; export interface TypstPackage { name: string; version: string; description: string; url: string; } export interface PackageDetails { name: string; version: string; description: string; fullDescription: string; authors: string[]; categories: string[]; repository?: string; homepage?: string; importCode: string; versionHistory: string[]; url: string; } export interface SearchOptions { query?: string; kind?: 'packages' | 'templates'; category?: string; limit?: number; } /** * Fetches HTML content from a URL */ async function fetchPage(url: string): Promise<string> { const response = await fetch(url, { headers: { 'User-Agent': 'Typst-Universe-MCP-Server/1.0', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', }, }); if (!response.ok) { throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`); } return response.text(); } /** * Searches for packages in Typst Universe */ export async function searchPackages(options: SearchOptions = {}): Promise<TypstPackage[]> { const { query, kind = 'packages', category, limit = 50 } = options; // Build the search URL const searchParams = new URLSearchParams(); searchParams.set('kind', kind); if (query) { searchParams.set('q', query); } if (category) { searchParams.set('category', category); } const searchUrl = `${UNIVERSE_URL}/search/?${searchParams.toString()}`; const html = await fetchPage(searchUrl); const $ = cheerio.load(html); const packages: TypstPackage[] = []; // Parse the package list from the page // The packages are typically listed as links with package info $('a[href^="/universe/package/"]').each((_, element) => { const $el = $(element); const href = $el.attr('href'); if (!href) return; // Extract text content - name, version, and description are typically in the link text const text = $el.text().trim(); // Try to parse the package info from the text // Format is typically: "name0.0.0Description text here" const match = text.match(/^([a-z0-9_-]+)(\d+\.\d+\.\d+)(.*)$/i); if (match) { const [, name, version, description] = match; packages.push({ name: name.trim(), version: version.trim(), description: description.trim(), url: `${BASE_URL}${href}`, }); } }); // Remove duplicates (same package might appear multiple times) const uniquePackages = packages.filter((pkg, index, self) => index === self.findIndex(p => p.name === pkg.name) ); return uniquePackages.slice(0, limit); } /** * Gets detailed information about a specific package */ export async function getPackageDetails(packageName: string): Promise<PackageDetails | null> { const packageUrl = `${UNIVERSE_URL}/package/${packageName}`; try { const html = await fetchPage(packageUrl); const $ = cheerio.load(html); // Extract package name and version from the header const headerText = $('h1').first().text().trim(); // Get the description from meta tag or first paragraph const metaDescription = $('meta[name="description"]').attr('content') || $('meta[property="og:description"]').attr('content') || ''; // Extract the full description from the main content const mainContent = $('main').text().trim(); // Find version in the page const versionMatch = mainContent.match(/(\d+\.\d+\.\d+)/); const version = versionMatch ? versionMatch[1] : 'unknown'; // Extract authors const authors: string[] = []; $('a[href*="author"]').each((_, el) => { const authorName = $(el).text().trim(); if (authorName && !authors.includes(authorName)) { authors.push(authorName); } }); // Extract categories const categories: string[] = []; $('a[href*="category="]').each((_, el) => { const category = $(el).text().trim(); if (category && !categories.includes(category)) { categories.push(category); } }); // Extract repository link let repository: string | undefined; const githubLink = $('a[href*="github.com"]').first().attr('href'); if (githubLink && !githubLink.includes('typst/packages')) { repository = githubLink; } // Extract homepage let homepage: string | undefined; $('a').each((_, el) => { const href = $(el).attr('href'); const text = $(el).text().toLowerCase(); if (href && (text.includes('.io') || text.includes('.com') || text.includes('homepage')) && !href.includes('github.com') && !href.includes('typst.app')) { homepage = href; } }); // Build import code const importCode = `#import "@preview/${packageName}:${version}"`; // Extract version history const versionHistory: string[] = []; $(`a[href^="/universe/package/${packageName}/"]`).each((_, el) => { const versionText = $(el).text().trim(); if (versionText.match(/^\d+\.\d+\.\d+$/)) { versionHistory.push(versionText); } }); return { name: packageName, version, description: metaDescription, fullDescription: mainContent.substring(0, 2000), // Limit full description length authors, categories, repository, homepage, importCode, versionHistory, url: packageUrl, }; } catch (error) { console.error(`Failed to fetch package details for ${packageName}:`, error); return null; } } /** * Gets all available categories */ export async function getCategories(): Promise<string[]> { const html = await fetchPage(UNIVERSE_URL); const $ = cheerio.load(html); const categories: string[] = []; $('a[href*="category="]').each((_, el) => { const href = $(el).attr('href'); if (href) { const match = href.match(/category=([^&]+)/); if (match) { const category = decodeURIComponent(match[1]); if (!categories.includes(category)) { categories.push(category); } } } }); // Also try to find categories in the categories section $('a[href*="/search/?category="]').each((_, el) => { const text = $(el).text().trim(); if (text && !categories.includes(text)) { categories.push(text); } }); return categories.sort(); } /** * Gets featured/popular packages */ export async function getFeaturedPackages(): Promise<TypstPackage[]> { const html = await fetchPage(UNIVERSE_URL); const $ = cheerio.load(html); const packages: TypstPackage[] = []; // Featured packages are typically at the top of the main page $('a[href^="/universe/package/"]').slice(0, 20).each((_, element) => { const $el = $(element); const href = $el.attr('href'); if (!href) return; const text = $el.text().trim(); const match = text.match(/^([a-z0-9_-]+)(\d+\.\d+\.\d+)(.*)$/i); if (match) { const [, name, version, description] = match; packages.push({ name: name.trim(), version: version.trim(), description: description.trim(), url: `${BASE_URL}${href}`, }); } }); // Remove duplicates return packages.filter((pkg, index, self) => index === self.findIndex(p => p.name === pkg.name) ); }

Implementation Reference

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/W1seGit/Typst-Universe-MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server