UNPKG

@bottobot/td-mcp

Version:

TouchDesigner Documentation MCP Server v2.3 - Pure MCP server for VS Code/Codium with comprehensive operator documentation for 629 operators + 7 tutorials across all categories (TOP, CHOP, SOP, DAT, MAT, COMP, POP). Features experimental POP operators wit

286 lines (243 loc) 10.6 kB
/** * PERFECT HTM Parser - 100% accurate parsing for TouchDesigner documentation * Based on reverse engineering of actual HTM file patterns from local documentation */ import * as cheerio from 'cheerio'; import { promises as fs } from 'fs'; import { basename } from 'path'; import WikiEntry from '../models/wiki-entry.js'; import Parameter from '../models/parameter.js'; export class HtmParserPerfect { constructor(options = {}) { this.options = { encoding: options.encoding || 'utf-8', ...options }; this.stats = { filesProcessed: 0, entriesCreated: 0, parametersExtracted: 0, errors: 0, warnings: 0 }; } /** * Parse a single HTM file */ async parseFile(filePath) { this.stats.filesProcessed++; try { const content = await fs.readFile(filePath, this.options.encoding); const $ = cheerio.load(content); const fileName = basename(filePath); // Skip non-operator files if (!this.isOperatorFile(fileName)) { return null; } // Extract operator info const operatorInfo = this.extractOperatorInfo($, fileName); if (!operatorInfo) { return null; } // Extract parameters using the proven DIV method const parameters = this.extractParameters($); // Create WikiEntry const entry = new WikiEntry({ id: operatorInfo.id, name: operatorInfo.name, displayName: operatorInfo.displayName, category: operatorInfo.category, subcategory: operatorInfo.subcategory, description: operatorInfo.description, summary: operatorInfo.summary, parameters: parameters, keywords: this.extractKeywords(operatorInfo.name, operatorInfo.description), tags: [operatorInfo.category, 'TouchDesigner', operatorInfo.name], sourceFile: filePath, lastUpdated: new Date().toISOString() }); this.stats.entriesCreated++; this.stats.parametersExtracted += parameters.length; console.log(`[HtmParserPerfect] Parsed ${operatorInfo.displayName} (${operatorInfo.category}) - ${parameters.length} parameters`); return entry; } catch (error) { this.stats.errors++; console.error(`[HtmParserPerfect] Error parsing ${filePath}:`, error.message); return null; } } /** * Determine if this is an operator documentation file */ isOperatorFile(fileName) { // Check for standard operator patterns const operatorPatterns = [ /_CHOP\.htm$/i, /_TOP\.htm$/i, /_SOP\.htm$/i, /_DAT\.htm$/i, /_MAT\.htm$/i, /_COMP\.htm$/i, /_POP\.htm$/i, // Added POP operators! /CHOP_Class\.htm$/i, /TOP_Class\.htm$/i, /SOP_Class\.htm$/i, /DAT_Class\.htm$/i, /MAT_Class\.htm$/i, /COMP_Class\.htm$/i, /POP_Class\.htm$/i // Added POP class files! ]; return operatorPatterns.some(pattern => pattern.test(fileName)); } /** * Extract operator information from the HTML */ extractOperatorInfo($, fileName) { // Get H1 title - most reliable source const h1Title = $('h1').first().text().trim(); if (!h1Title) { return null; } // Extract category from H1 using pattern matching const categoryMatch = h1Title.match(/\b(CHOP|TOP|SOP|DAT|MAT|COMP|POP)\b/); if (!categoryMatch) { // Try to extract from filename as fallback const fileMatch = fileName.match(/_(CHOP|TOP|SOP|DAT|MAT|COMP|POP)/i); if (!fileMatch) { return null; } return { id: h1Title.toLowerCase().replace(/\s+/g, '_'), name: h1Title, displayName: h1Title, category: fileMatch[1].toUpperCase(), subcategory: 'General', description: $('p').first().text().trim() || `${h1Title} operator`, summary: $('p').first().text().trim().substring(0, 200) }; } const category = categoryMatch[1]; const operatorName = h1Title; // Get description const description = $('p').first().text().trim() || `${operatorName} operator for TouchDesigner`; return { id: `${operatorName.toLowerCase().replace(/\s+/g, '_')}`, name: operatorName, displayName: operatorName, category: category, subcategory: this.determineSubcategory(operatorName, category), description: description, summary: description.substring(0, 200) }; } /** * Extract parameters from DIV elements */ extractParameters($) { const parameters = []; // Use the proven DIV-based extraction method $('div[id]').each((i, elem) => { const $div = $(elem); const divId = $div.attr('id'); // Skip non-parameter divs if (!divId || divId === 'Palette:palette') return; // Find parameter name span with all possible class names const $paramName = $div.find('span.parNameSOP, span.parNameCHOP, span.parNameTOP, span.parNameDAT, span.parNameMAT, span.parNameCOMP, span.parNamePOP').first(); if ($paramName.length > 0) { const paramName = $paramName.text().trim(); if (paramName) { // Get parameter description const $clone = $div.clone(); $clone.find('span').remove(); const paramDescription = $clone.text().trim(); parameters.push(new Parameter({ name: paramName, displayName: paramName, type: 'float', // Default type defaultValue: null, description: paramDescription || `${paramName} parameter`, group: 'General' })); } } }); return parameters; } /** * Determine subcategory based on operator name and category */ determineSubcategory(operatorName, category) { const name = operatorName.toLowerCase(); if (category === 'CHOP') { if (name.includes('audio')) return 'Audio'; if (name.includes('midi')) return 'MIDI'; if (name.includes('osc')) return 'Network'; if (name.includes('filter')) return 'Filters'; if (name.includes('math')) return 'Math'; return 'Generators'; } if (category === 'TOP') { if (name.includes('blur') || name.includes('filter')) return 'Filters'; if (name.includes('noise') || name.includes('ramp')) return 'Generators'; if (name.includes('composite') || name.includes('over')) return 'Compositing'; if (name.includes('nvidia')) return 'NVIDIA'; return 'Filters'; } if (category === 'SOP') { if (name.includes('primitive') || name.includes('sphere') || name.includes('box')) return 'Primitives'; if (name.includes('deform') || name.includes('twist')) return 'Deformers'; if (name.includes('copy') || name.includes('merge')) return 'Modifiers'; return 'Generators'; } if (category === 'DAT') { if (name.includes('script') || name.includes('execute')) return 'Scripts'; if (name.includes('web') || name.includes('tcp')) return 'Network'; if (name.includes('table') || name.includes('text')) return 'Data'; return 'Utilities'; } if (category === 'MAT') { if (name.includes('pbr')) return 'PBR'; if (name.includes('phong') || name.includes('constant')) return 'Classic'; return 'Materials'; } if (category === 'COMP') { if (name.includes('ui') || name.includes('button') || name.includes('slider')) return 'UI'; if (name.includes('light') || name.includes('camera')) return '3D'; if (name.includes('container')) return 'Containers'; return 'Components'; } if (category === 'POP') { if (name.includes('force') || name.includes('wind')) return 'Forces'; if (name.includes('sprite') || name.includes('render')) return 'Rendering'; if (name.includes('limit') || name.includes('collision')) return 'Dynamics'; return 'Particles'; } return 'General'; } /** * Extract keywords from operator name and description */ extractKeywords(operatorName, description) { const keywords = []; // Add words from operator name const nameWords = operatorName.toLowerCase().split(/\s+/); keywords.push(...nameWords); // Add important words from description (skip common words) const commonWords = new Set(['the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'from', 'this', 'that', 'these', 'those', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'must', 'can', 'operator']); const descWords = description.toLowerCase() .split(/\s+/) .filter(word => word.length > 3 && !commonWords.has(word)) .slice(0, 10); keywords.push(...descWords); // Remove duplicates return [...new Set(keywords)]; } /** * Get parser statistics */ getStats() { return { ...this.stats }; } } // Default export for compatibility export default HtmParserPerfect;