mcp-tenant-credit-scorer
Version:
MCP server for tenant credit scoring based on S&P corporate methodology
263 lines (217 loc) • 7.45 kB
JavaScript
import { readFile } from 'fs/promises';
import fetch from 'node-fetch';
import * as cheerio from 'cheerio';
export class DataParser {
constructor() {
this.financialPatterns = {
revenue: /(?:revenue|sales|turnover)[\s:]+\$?([\d,]+\.?\d*)\s*(?:million|M|thousand|K)?/i,
ebitda: /(?:ebitda|earnings before)[\s:]+\$?([\d,]+\.?\d*)\s*(?:million|M|thousand|K)?/i,
netIncome: /(?:net income|net profit|net earnings)[\s:]+\$?([\d,]+\.?\d*)\s*(?:million|M|thousand|K)?/i,
totalDebt: /(?:total debt|long.?term debt)[\s:]+\$?([\d,]+\.?\d*)\s*(?:million|M|thousand|K)?/i,
currentAssets: /(?:current assets)[\s:]+\$?([\d,]+\.?\d*)\s*(?:million|M|thousand|K)?/i,
currentLiabilities: /(?:current liabilities)[\s:]+\$?([\d,]+\.?\d*)\s*(?:million|M|thousand|K)?/i,
};
}
async parsePDF(pdfPath) {
try {
// Lazy load pdf-parse to avoid initialization issues
const { default: pdfParse } = await import('pdf-parse');
const dataBuffer = await readFile(pdfPath);
const data = await pdfParse(dataBuffer);
const text = data.text;
// Extract financial data from PDF text
const financials = this.extractFinancialData(text);
// Try to identify multiple years of data
const multiYearData = this.extractMultiYearData(text);
return {
rawText: text,
extractedFinancials: financials,
multiYearData: multiYearData,
pageCount: data.numpages
};
} catch (error) {
throw new Error(`Failed to parse PDF: ${error.message}`);
}
}
async parseWebsite(url) {
try {
const response = await fetch(url);
const html = await response.text();
const $ = cheerio.load(html);
// Extract company information
const companyInfo = {
title: $('title').text(),
description: $('meta[name="description"]').attr('content') || '',
about: this.extractAboutText($),
industry: this.extractIndustryHints($),
employees: this.extractEmployeeCount($),
locations: this.extractLocations($),
products: this.extractProducts($)
};
return companyInfo;
} catch (error) {
console.error(`Failed to parse website: ${error.message}`);
return {};
}
}
extractFinancialData(text) {
const financials = {};
for (const [key, pattern] of Object.entries(this.financialPatterns)) {
const match = text.match(pattern);
if (match) {
let value = parseFloat(match[1].replace(/,/g, ''));
// Handle multipliers
if (match[0].toLowerCase().includes('million') || match[0].includes('M')) {
value *= 1000000;
} else if (match[0].toLowerCase().includes('thousand') || match[0].includes('K')) {
value *= 1000;
}
financials[key] = value;
}
}
// Calculate derived metrics
if (financials.currentAssets && financials.currentLiabilities) {
financials.currentRatio = financials.currentAssets / financials.currentLiabilities;
}
if (financials.ebitda && financials.revenue) {
financials.ebitdaMargin = (financials.ebitda / financials.revenue) * 100;
}
return financials;
}
extractMultiYearData(text) {
// Look for year patterns (e.g., 2021, 2022, 2023)
const yearPattern = /20\d{2}/g;
const years = [...new Set(text.match(yearPattern))].sort().slice(-3);
if (years.length < 2) {
return null;
}
const multiYearData = {};
years.forEach(year => {
// Try to extract data for each year
const yearSection = text.split(year)[1]?.substring(0, 1000) || '';
multiYearData[year] = this.extractFinancialData(yearSection);
});
return multiYearData;
}
extractAboutText($) {
// Common selectors for about sections
const selectors = [
'.about-us',
'#about',
'[class*="about"]',
'section:contains("About")',
'div:contains("Who We Are")'
];
for (const selector of selectors) {
const text = $(selector).text().trim();
if (text && text.length > 50) {
return text.substring(0, 500);
}
}
return '';
}
extractIndustryHints($) {
const hints = [];
// Look for industry keywords in various places
const industryKeywords = [
'technology', 'software', 'healthcare', 'manufacturing', 'retail',
'financial services', 'real estate', 'energy', 'construction',
'transportation', 'hospitality', 'education', 'consulting'
];
const textContent = $('body').text().toLowerCase();
industryKeywords.forEach(keyword => {
if (textContent.includes(keyword)) {
hints.push(keyword);
}
});
return hints;
}
extractEmployeeCount($) {
const employeePatterns = [
/(\d+)\s*employees/i,
/team of\s*(\d+)/i,
/staff of\s*(\d+)/i
];
const bodyText = $('body').text();
for (const pattern of employeePatterns) {
const match = bodyText.match(pattern);
if (match) {
return parseInt(match[1]);
}
}
return null;
}
extractLocations($) {
const locations = [];
// Look for address patterns
const addressSelectors = [
'address',
'.address',
'[class*="location"]',
'[class*="office"]'
];
addressSelectors.forEach(selector => {
$(selector).each((i, elem) => {
const text = $(elem).text().trim();
if (text && text.length > 10) {
locations.push(text);
}
});
});
return [...new Set(locations)];
}
extractProducts($) {
const products = [];
// Look for product/service sections
const productSelectors = [
'.products',
'.services',
'[class*="product"]',
'[class*="service"]',
'section:contains("What We Do")',
'section:contains("Our Services")'
];
productSelectors.forEach(selector => {
$(selector).find('h2, h3, h4, li').each((i, elem) => {
const text = $(elem).text().trim();
if (text && text.length > 5 && text.length < 100) {
products.push(text);
}
});
});
return [...new Set(products)].slice(0, 10);
}
normalizeFinancialData(data) {
// Ensure all required fields exist with defaults
const normalized = {
revenue: 0,
ebitda: 0,
ebitdaMargin: 0,
netIncome: 0,
totalDebt: 0,
cash: 0,
currentAssets: 0,
currentLiabilities: 0,
currentRatio: 0,
interestExpense: 0,
...data
};
// Calculate missing metrics if possible
if (normalized.revenue && normalized.ebitda && !normalized.ebitdaMargin) {
normalized.ebitdaMargin = (normalized.ebitda / normalized.revenue) * 100;
}
if (normalized.currentAssets && normalized.currentLiabilities && !normalized.currentRatio) {
normalized.currentRatio = normalized.currentAssets / normalized.currentLiabilities;
}
if (normalized.totalDebt && normalized.cash) {
normalized.netDebt = normalized.totalDebt - normalized.cash;
}
if (normalized.ebitda && normalized.interestExpense) {
normalized.ebitdaToInterest = normalized.ebitda / normalized.interestExpense;
}
if (normalized.ebitda && normalized.netDebt) {
normalized.netDebtToEbitda = normalized.netDebt / normalized.ebitda;
}
return normalized;
}
}