Update: 2026-07-06 17:00:43
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Siro Pricing Engine CLI
|
||||
*
|
||||
* Usage:
|
||||
* npm run analyze Full analysis all competitors
|
||||
* npm run analyze:taxif TaxiF only
|
||||
* npm run analyze -- --competitor=com.taxif.passenger --country=JO
|
||||
* npm run dev -- --mode=surge Surge-only analysis
|
||||
*
|
||||
* Cron integration: see crontab examples in package.json scripts
|
||||
*/
|
||||
|
||||
import { getMySQL, fetchSamples, saveFormulas, saveSurgeInsights, closeConnections } from './db/connection';
|
||||
import { runAnalysis } from './analysis/engine';
|
||||
import { Pool, RowDataPacket } from 'mysql2/promise';
|
||||
|
||||
interface CLIOptions {
|
||||
mode: 'full' | 'report';
|
||||
competitor?: string;
|
||||
country?: string;
|
||||
hoursBack?: number;
|
||||
}
|
||||
|
||||
function parseArgs(): CLIOptions {
|
||||
const args = process.argv.slice(2);
|
||||
const opts: CLIOptions = { mode: 'full' };
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg.startsWith('--mode=')) {
|
||||
const mode = arg.split('=')[1];
|
||||
if (mode === 'full' || mode === 'report') {
|
||||
opts.mode = mode;
|
||||
}
|
||||
} else if (arg.startsWith('--competitor=')) {
|
||||
opts.competitor = arg.split('=')[1];
|
||||
} else if (arg.startsWith('--country=')) {
|
||||
opts.country = arg.split('=')[1];
|
||||
} else if (arg.startsWith('--hours=')) {
|
||||
opts.hoursBack = parseInt(arg.split('=')[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
interface CompetitorEntry {
|
||||
competitor_name: string;
|
||||
country_code: string;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const opts = parseArgs();
|
||||
const startTime = Date.now();
|
||||
|
||||
console.log(`🚀 Siro Pricing Engine v1.0`);
|
||||
console.log(` Mode: ${opts.mode}`);
|
||||
if (opts.competitor) console.log(` Competitor: ${opts.competitor}`);
|
||||
if (opts.country) console.log(` Country: ${opts.country}`);
|
||||
console.log('');
|
||||
|
||||
try {
|
||||
const pool = await getMySQL();
|
||||
|
||||
const competitors = await fetchCompetitors(pool, opts);
|
||||
|
||||
if (competitors.length === 0) {
|
||||
console.log('❌ No competitors found with sufficient data.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Process competitors in parallel for speed
|
||||
const results = await Promise.allSettled(
|
||||
competitors.map(comp => processCompetitor(pool, comp, opts))
|
||||
);
|
||||
|
||||
const succeeded = results.filter(r => r.status === 'fulfilled').length;
|
||||
const failed = results.filter(r => r.status === 'rejected').length;
|
||||
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
console.log(`\n✨ Analysis complete in ${elapsed}s (${succeeded} succeeded, ${failed} failed)`);
|
||||
|
||||
if (failed > 0) {
|
||||
console.log('\n❌ Failures:');
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'rejected') {
|
||||
console.log(` ${competitors[i].competitor_name} (${competitors[i].country_code}): ${r.reason}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('❌ Fatal error:', err);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await closeConnections();
|
||||
}
|
||||
}
|
||||
|
||||
async function processCompetitor(
|
||||
pool: Pool,
|
||||
comp: CompetitorEntry,
|
||||
opts: CLIOptions
|
||||
): Promise<void> {
|
||||
console.log(`\n📥 Fetching data for ${comp.competitor_name} (${comp.country_code})...`);
|
||||
const rows = await fetchSamples(pool, comp.competitor_name, comp.country_code, opts.hoursBack);
|
||||
|
||||
if (rows.length < 10) {
|
||||
console.log(` ⏩ Only ${rows.length} samples — skipping (need 10+)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const samples = rows.map((row: RowDataPacket) => ({
|
||||
distance_km: parseFloat(row.distance_km),
|
||||
duration_min: parseFloat(row.duration_min),
|
||||
price: parseFloat(row.price_amount),
|
||||
ppk: parseFloat(row.price_per_km),
|
||||
startLat: parseFloat(row.start_lat),
|
||||
startLng: parseFloat(row.start_lng),
|
||||
endLat: parseFloat(row.end_lat),
|
||||
endLng: parseFloat(row.end_lng),
|
||||
scrapedAt: new Date(row.scraped_at),
|
||||
competitorName: row.competitor_name,
|
||||
countryCode: row.country_code,
|
||||
}));
|
||||
|
||||
const report = await runAnalysis(samples, {
|
||||
competitorName: comp.competitor_name,
|
||||
countryCode: comp.country_code,
|
||||
cleanOutliers: true,
|
||||
surgeThreshold: 0.12,
|
||||
tierCount: 3,
|
||||
});
|
||||
|
||||
// Save tier formulas
|
||||
const formulas = report.tiers
|
||||
.filter(t => t.regression !== null && t.regression!.sampleCount >= 5)
|
||||
.map(tier => ({
|
||||
competitorName: comp.competitor_name,
|
||||
countryCode: comp.country_code,
|
||||
tier: tier.label,
|
||||
baseFare: tier.regression!.baseFare,
|
||||
kmRate: tier.regression!.kmRate,
|
||||
minRate: tier.regression!.minRate,
|
||||
minFare: tier.regression!.minFare,
|
||||
rmse: tier.regression!.rmse,
|
||||
rSquared: tier.regression!.rSquared,
|
||||
sampleCount: tier.regression!.sampleCount,
|
||||
surgeMultiplier: 1.0,
|
||||
peakHours: '[]',
|
||||
}));
|
||||
|
||||
if (formulas.length > 0) {
|
||||
await saveFormulas(pool, formulas);
|
||||
console.log(` ✅ Saved ${formulas.length} tier formulas`);
|
||||
}
|
||||
|
||||
// Save surge insights — use the average multiplier across all detected routes
|
||||
if (opts.mode !== 'report' && report.surgePatterns.length > 0) {
|
||||
const avgMultiplier = report.surgePatterns
|
||||
.reduce((sum, sr) => sum + sr.maxMultiplier, 0) / report.surgePatterns.length;
|
||||
|
||||
// Find peak hour range from aggregate pattern
|
||||
const allHours = report.surgePatterns.flatMap(sr =>
|
||||
sr.surgePrices
|
||||
.filter(sp => sp.multiplier > 1.05)
|
||||
.map(sp => parseInt(sp.time.split(':')[0]))
|
||||
);
|
||||
|
||||
const peakStart = allHours.length > 0 ? Math.min(...allHours) : 0;
|
||||
const peakEnd = allHours.length > 0 ? Math.max(...allHours) : 23;
|
||||
|
||||
const surgeInsights = [{
|
||||
competitorName: comp.competitor_name,
|
||||
countryCode: comp.country_code,
|
||||
surgeMultiplier: Math.round(avgMultiplier * 1000) / 1000,
|
||||
peakStartHour: peakStart,
|
||||
peakEndHour: peakEnd,
|
||||
sampleCount: report.surgePatterns.length,
|
||||
}];
|
||||
|
||||
await saveSurgeInsights(pool, surgeInsights);
|
||||
console.log(` ✅ Saved surge insight: avg ${(avgMultiplier).toFixed(3)}x, hours ${peakStart}:00-${peakEnd}:00`);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCompetitors(
|
||||
pool: Pool,
|
||||
opts: CLIOptions
|
||||
): Promise<CompetitorEntry[]> {
|
||||
if (opts.competitor) {
|
||||
const countryClause = opts.country ? 'AND country_code = ?' : '';
|
||||
const params: (string | number)[] = opts.country
|
||||
? [opts.competitor, opts.country]
|
||||
: [opts.competitor];
|
||||
|
||||
const [rows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT DISTINCT competitor_name, country_code
|
||||
FROM scraped_competitor_prices
|
||||
WHERE competitor_name = ?
|
||||
AND distance_km > 0 AND duration_min > 0 AND price_amount > 0
|
||||
${countryClause}
|
||||
LIMIT 10`,
|
||||
params
|
||||
);
|
||||
return rows as CompetitorEntry[];
|
||||
}
|
||||
|
||||
const [rows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT competitor_name, country_code, COUNT(*) as cnt
|
||||
FROM scraped_competitor_prices
|
||||
WHERE distance_km > 0 AND duration_min > 0 AND price_amount > 0
|
||||
GROUP BY competitor_name, country_code
|
||||
HAVING cnt >= 10
|
||||
ORDER BY cnt DESC`
|
||||
);
|
||||
return rows as CompetitorEntry[];
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user