netakhoj-api / services /prsService.js
Rakeshops71
Deploy app with LFS for large files
3eedfc9
Raw
History Blame Contribute Delete
9.49 kB
import * as cheerio from 'cheerio';
import NodeCache from 'node-cache';
import { fetchHTML } from '../webextract.mjs';
const cache = new NodeCache({ stdTTL: 3600, checkperiod: 600 });
const inFlightRequests = new Map();
class PRSService {
constructor() {
this.FETCH_TIMEOUT = 15000;
this.MAX_SEARCH_TIME = 20000;
this.stats = {
cacheHits: 0,
cacheMisses: 0,
totalRequests: 0,
avgResponseTime: 0,
urlsChecked: 0,
firstUrlSuccess: 0
};
}
async getMemberData(name, type, constituency = null, state = null) {
const cacheKey = `prs:${type}:${name.toLowerCase()}`;
this.stats.totalRequests++;
const cached = cache.get(cacheKey);
if (cached) {
this.stats.cacheHits++;
console.log(`✅ [CACHE HIT] ${name} (${this.stats.cacheHits}/${this.stats.totalRequests})`);
return cached;
}
this.stats.cacheMisses++;
if (inFlightRequests.has(cacheKey)) {
console.log(`⏳ [DEDUP] Waiting for in-flight request: ${name}`);
return await inFlightRequests.get(cacheKey);
}
const requestPromise = this._executeSearch(name, type, cacheKey);
inFlightRequests.set(cacheKey, requestPromise);
try {
return await requestPromise;
} finally {
inFlightRequests.delete(cacheKey);
}
}
async _executeSearch(name, type, cacheKey) {
const startTime = Date.now();
try {
console.log(`🔍 [PRS] Searching ${name} (${type})...`);
const searchPromise = this._sequentialSearch(name, type);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Search timeout')), this.MAX_SEARCH_TIME)
);
const result = await Promise.race([searchPromise, timeoutPromise]);
if (!result.found) {
const alternateType = type === 'MLA' ? 'MP' : 'MLA';
console.log(`⚡ [FALLBACK] Quick ${alternateType} check...`);
const quickSearchPromise = this._sequentialSearch(name, alternateType, true);
const quickTimeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Quick search timeout')), 8000)
);
try {
const altResult = await Promise.race([quickSearchPromise, quickTimeoutPromise]);
if (altResult.found) {
altResult.searchedAs = type;
altResult.foundAs = alternateType;
cache.set(cacheKey, altResult);
this._updateStats(Date.now() - startTime);
return altResult;
}
} catch (fallbackError) {
console.log(`⚠️ [FALLBACK] Timeout: ${fallbackError.message}`);
}
} else {
result.searchedAs = type;
result.foundAs = type;
cache.set(cacheKey, result);
this._updateStats(Date.now() - startTime);
return result;
}
return this.getEmptyResponse();
} catch (error) {
console.error(`❌ [PRS] Error: ${error.message}`);
return this.getEmptyResponse();
}
}
async _sequentialSearch(name, type, quickMode = false) {
const slug = this._generateSlug(name);
const urls = this._generateURLs(slug, type, quickMode);
console.log(`📋 [SEARCH] ${name} → "${slug}" → ${urls.length} URLs`);
for (let i = 0; i < urls.length; i++) {
const url = urls[i];
const shortUrl = url.split('/').slice(-2).join('/');
console.log(`🔄 [${i+1}/${urls.length}] ${shortUrl}`);
try {
const startTime = Date.now();
const html = await this._fetchWithTimeout(url, this.FETCH_TIMEOUT);
const duration = Date.now() - startTime;
this.stats.urlsChecked++;
if (!html || html.length < 1000) {
console.log(` ⏭️ Too short (${duration}ms)`);
continue;
}
if (!this._quickValidate(html)) {
console.log(` ⏭️ Invalid page (${duration}ms)`);
continue;
}
const parsedData = this._parseHTML(html, type);
if (!this._validateParsedData(parsedData)) {
console.log(` ⏭️ Insufficient data (${duration}ms)`);
continue;
}
console.log(` ✅ FOUND in ${duration}ms`);
if (i === 0) this.stats.firstUrlSuccess++;
return {
found: true,
html,
url,
memberType: type,
urlIndex: i,
...parsedData
};
} catch (err) {
console.log(` ❌ ${err.message}`);
continue;
}
}
console.log(`❌ [NOT FOUND] After ${urls.length} attempts`);
return { found: false };
}
_generateSlug(name) {
const slug = name
.trim()
.toLowerCase()
.replace(/^(dr\.?|shri|smt\.?|prof\.?|mr\.?|mrs\.?|ms\.?)\s+/gi, '')
.replace(/[^a-z0-9\s]/g, ' ')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
console.log(` 🔤 "${name}" → "${slug}"`);
return slug;
}
_generateURLs(slug, type, quickMode = false) {
const urls = [];
if (type === 'MP') {
urls.push(`https://prsindia.org/mptrack/18th-lok-sabha/${slug}`);
if (!quickMode) {
urls.push(`https://prsindia.org/mptrack/17th-lok-sabha/${slug}`);
urls.push(`https://prsindia.org/mptrack/16th-lok-sabha/${slug}`);
urls.push(`https://prsindia.org/mptrack/18th-lok-sabha/${slug}-1`);
urls.push(`https://prsindia.org/mptrack/18th-lok-sabha/${slug}-2`);
urls.push(`https://prsindia.org/mptrack/17th-lok-sabha/${slug}-1`);
}
} else if (type === 'MLA') {
urls.push(`https://prsindia.org/mlatrack/${slug}`);
if (!quickMode) {
urls.push(`https://prsindia.org/mlatrack/${slug}-1`);
urls.push(`https://prsindia.org/mlatrack/${slug}-2`);
urls.push(`https://prsindia.org/mlatrack/${slug}-3`);
}
}
return urls;
}
async _fetchWithTimeout(url, timeout) {
return Promise.race([
fetchHTML(url, 1, timeout - 1000),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), timeout)
)
]);
}
_quickValidate(html) {
return (
(html.includes('mp_state') || html.includes('mla_state')) &&
(html.includes('Party') || html.includes('Constituency'))
);
}
_validateParsedData(data) {
return (
data &&
data.party &&
data.party !== 'Unknown' &&
data.constituency &&
data.constituency !== 'Unknown'
);
}
_parseHTML(html, type) {
const $ = cheerio.load(html);
const imageUrl = this._extractImage($);
const state = this._extractField($, '.mp_state, .mla_state', 'State :');
const constituency = this._extractField($, '.mp_constituency, .mla_constituency', 'Constituency :');
const party = this._extractField($, '.mp_state, .mla_state', 'Party :');
return {
imageUrl,
state: state || 'Unknown',
constituency: constituency || 'Unknown',
party: party || 'Unknown',
performance: {},
personal: {}
};
}
_extractImage($) {
const img = $('.field-name-field-image img, .field-name-field-mla-profile-image img').first().attr('src');
if (!img) return '';
return img.startsWith('http') ? img : `https://prsindia.org${img.startsWith('/') ? '' : '/'}${img}`;
}
_extractField($, selector, label) {
const elem = $(selector).filter((i, el) => $(el).text().includes(label)).first();
if (!elem.length) return null;
const link = elem.find('a').first();
let text = link.length ? link.text() : elem.text();
return text
.replace(label, '')
.replace(/\(\s*\d+\s*more\s*(MPs?|MLAs?)\s*\)/gi, '')
.trim();
}
_updateStats(duration) {
const { avgResponseTime, totalRequests } = this.stats;
this.stats.avgResponseTime = ((avgResponseTime * (totalRequests - 1)) + duration) / totalRequests;
}
getEmptyResponse() {
return {
found: false,
html: '',
url: null,
imageUrl: '',
state: 'Unknown',
constituency: 'Unknown',
party: 'Unknown',
performance: {},
personal: {},
searchedAs: null,
foundAs: null
};
}
clearCache(pattern) {
if (pattern) {
const keys = cache.keys().filter(key => key.includes(pattern));
cache.del(keys);
console.log(`🗑️ Cleared ${keys.length} cache entries`);
} else {
cache.flushAll();
inFlightRequests.clear();
console.log(`🗑️ Full cache cleared`);
}
}
getStats() {
return {
...cache.getStats(),
custom: {
totalRequests: this.stats.totalRequests,
cacheHits: this.stats.cacheHits,
cacheMisses: this.stats.cacheMisses,
hitRate: this.stats.totalRequests > 0
? `${((this.stats.cacheHits / this.stats.totalRequests) * 100).toFixed(1)}%`
: '0%',
avgResponseTime: `${this.stats.avgResponseTime.toFixed(0)}ms`,
urlsChecked: this.stats.urlsChecked,
firstUrlSuccess: this.stats.firstUrlSuccess,
firstUrlSuccessRate: this.stats.totalRequests > 0
? `${((this.stats.firstUrlSuccess / this.stats.totalRequests) * 100).toFixed(1)}%`
: '0%',
inFlightRequests: inFlightRequests.size
}
};
}
}
export default new PRSService();