Files
skillhub/services/indexer/src/crawl.ts
airano 3e22d2e238 sync: AI review scores, reviewed page, search improvements, score filter
- Surface AI review scores across skill pages, browse, and search
- Add dedicated /reviewed page with score filtering
- Add recommended sort using Meilisearch relevance + AI scores
- Fix search sort defaulting to stars instead of recommended
- Fix CLI TypeScript null check for aiScore in search command
- Adjust cache TTL for reviewed content

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 05:05:37 +03:30

1125 lines
43 KiB
JavaScript

#!/usr/bin/env node
/**
* CLI script to manually trigger a crawl job or sync operations
*/
import { INSTRUCTION_FILE_PATTERNS, type SourceFormat } from 'skillhub-core';
import { scheduleFullCrawl, scheduleIncrementalCrawl, getQueueStats, getQueue } from './queue.js';
import { syncAllSkillsToMeilisearch, checkMeilisearchHealth } from './meilisearch-sync.js';
import { createDb, skillQueries, categoryQueries, discoveredRepoQueries, awesomeListQueries, addRequestQueries, userQueries, sql } from '@skillhub/db';
import { createStrategyOrchestrator, createDeepScanCrawler, createAwesomeListCrawler, createPopularReposCrawler, createCommitsSearchCrawler } from './strategies/index.js';
import { createCrawler } from './crawler.js';
import { indexSkill } from './skill-indexer.js';
import { TokenManager } from './token-manager.js';
/**
* Parse a GitHub repository URL into owner, repo, and optional branch.
* Handles: github.com/owner/repo, github.com/owner/repo/tree/branch,
* github.com/owner/repo/tree/branch/some/path
*/
export function parseGitHubRepoUrl(urlStr: string): {
owner: string;
repo: string;
branch: string | null;
} {
const url = new URL(urlStr);
const parts = url.pathname.split('/').filter(Boolean);
const owner = parts[0];
const repo = parts[1];
if (!owner || !repo) {
throw new Error(`Invalid GitHub URL: ${urlStr}`);
}
// Check for /tree/{branch} path segment
if (parts[2] === 'tree' && parts[3]) {
return { owner, repo, branch: parts[3] };
}
return { owner, repo, branch: null };
}
const command = process.argv[2] || 'stats';
async function main() {
switch (command) {
case 'full': {
console.log('Scheduling full crawl...');
const fullJobId = await scheduleFullCrawl({
minStars: parseInt(process.env.INDEXER_MIN_STARS || process.env.MIN_STARS || '0'),
});
console.log(`Full crawl scheduled with job ID: ${fullJobId}`);
break;
}
case 'incremental': {
console.log('Scheduling incremental crawl...');
const incJobId = await scheduleIncrementalCrawl();
console.log(`Incremental crawl scheduled with job ID: ${incJobId}`);
break;
}
case 'sync-meili': {
console.log('Syncing all skills to Meilisearch (streaming mode)...\n');
// Check Meilisearch connection
const healthy = await checkMeilisearchHealth();
if (!healthy) {
console.error('Meilisearch is not reachable. Please check MEILI_URL and MEILI_MASTER_KEY');
process.exit(1);
}
// Stream skills from database to Meilisearch in batches
// This approach is memory-efficient and scales to any number of skills
const db = createDb(process.env.DATABASE_URL);
const SYNC_BATCH_SIZE = 5000;
let syncOffset = 0;
let totalSuccess = 0;
let totalFailed = 0;
let batchNumber = 0;
// First, get total count for progress display
const totalCount = await skillQueries.count(db, {});
console.log(`Total skills in database: ${totalCount}`);
if (totalCount === 0) {
console.log('No skills found in database');
process.exit(0);
}
const totalBatches = Math.ceil(totalCount / SYNC_BATCH_SIZE);
console.log(`Will process in ${totalBatches} batches of ${SYNC_BATCH_SIZE}\n`);
for (;;) {
// Fetch batch from database
const batch = await skillQueries.search(db, { limit: SYNC_BATCH_SIZE, offset: syncOffset });
if (batch.length === 0) break;
batchNumber++;
// Immediately sync this batch to Meilisearch (no memory accumulation)
const results = await syncAllSkillsToMeilisearch(batch);
totalSuccess += results.success;
totalFailed += results.failed;
const progress = Math.min(100, Math.round((syncOffset + batch.length) / totalCount * 100));
console.log(`Batch ${batchNumber}/${totalBatches}: ${results.success} synced, ${results.failed} failed (${progress}% complete)`);
if (batch.length < SYNC_BATCH_SIZE) break;
syncOffset += SYNC_BATCH_SIZE;
}
console.log(`\n════════════════════════════════════════`);
console.log(`Sync complete:`);
console.log(` Total processed: ${totalSuccess + totalFailed}`);
console.log(` Success: ${totalSuccess}`);
console.log(` Failed: ${totalFailed}`);
break;
}
case 'stats': {
const q = getQueue();
console.log('Queue statistics:');
const stats = await getQueueStats();
console.log(` Waiting: ${stats.waiting}`);
console.log(` Active: ${stats.active}`);
console.log(` Completed: ${stats.completed}`);
console.log(` Failed: ${stats.failed}`);
console.log(` Delayed: ${stats.delayed}`);
// Show failed job details if any
if (stats.failed > 0) {
const failedJobs = await q.getFailed(0, 10);
if (failedJobs.length > 0) {
console.log('\nFailed jobs:');
for (const job of failedJobs) {
const failedAt = job.finishedOn ? new Date(job.finishedOn).toISOString() : 'unknown';
console.log(` - [${job.data.type}] ${job.name} (${failedAt})`);
if (job.failedReason) {
console.log(` Reason: ${job.failedReason.slice(0, 120)}`);
}
}
}
}
break;
}
case 'token-status': {
console.log('\n═══ GitHub Token Status ═══');
const tokenManager = TokenManager.getInstance();
// Refresh all tokens from GitHub API first
console.log('Fetching latest status from GitHub API...\n');
const tokenStatus = tokenManager.getStatus();
for (const tokenInfo of tokenStatus.tokens) {
await tokenManager.refreshRateLimit(tokenInfo.token);
}
// Get updated status
const updatedStatus = tokenManager.getStatus();
console.log(`\nTotal Tokens: ${updatedStatus.totalTokens}`);
console.log(`Available Tokens: ${updatedStatus.availableTokens}`);
console.log(`Global Remaining: ${updatedStatus.globalRemaining}`);
console.log(`Next Reset: ${new Date(updatedStatus.nextReset).toLocaleTimeString()}`);
console.log('\nToken Details:');
for (const token of updatedStatus.tokens) {
const status = token.isExhausted ? '❌ EXHAUSTED' : '✅ AVAILABLE';
const resetTime = new Date(token.reset).toLocaleTimeString();
console.log(` [${token.name}] ${status}`);
console.log(` Remaining: ${token.remaining}/${token.limit}`);
console.log(` Resets at: ${resetTime}`);
console.log(` Last used: ${token.lastUsed ? new Date(token.lastUsed).toLocaleString() : 'Never'}`);
}
break;
}
case 'link-categories':
case 'recategorize': {
console.log('Re-categorizing all skills with 23 categories (streaming mode)...\n');
const dbCat = createDb(process.env.DATABASE_URL);
// First, get total count for progress display
const linkTotalCount = await skillQueries.count(dbCat, {});
console.log(`Total skills in database: ${linkTotalCount}`);
if (linkTotalCount === 0) {
console.log('No skills found in database');
process.exit(0);
}
// Stream skills from database and link categories in batches
const LINK_BATCH_SIZE = 1000;
let linkOffset = 0;
let linked = 0;
let failed = 0;
let batchNum = 0;
const totalBatches = Math.ceil(linkTotalCount / LINK_BATCH_SIZE);
console.log(`Will process in ${totalBatches} batches of ${LINK_BATCH_SIZE}\n`);
for (;;) {
const batch = await skillQueries.search(dbCat, { limit: LINK_BATCH_SIZE, offset: linkOffset });
if (batch.length === 0) break;
batchNum++;
// Process this batch immediately (no memory accumulation)
let batchLinked = 0;
let batchFailed = 0;
for (const skill of batch) {
try {
await categoryQueries.linkSkillToCategories(
dbCat,
skill.id,
skill.name,
skill.description || ''
);
batchLinked++;
} catch {
batchFailed++;
}
}
linked += batchLinked;
failed += batchFailed;
const progress = Math.min(100, Math.round((linkOffset + batch.length) / linkTotalCount * 100));
console.log(`Batch ${batchNum}/${totalBatches}: ${batchLinked} linked, ${batchFailed} failed (${progress}% complete)`);
if (batch.length < LINK_BATCH_SIZE) break;
linkOffset += LINK_BATCH_SIZE;
}
// No need to manually update category counts - database trigger handles this automatically
// (See init-db.sql lines 356-373: update_category_count trigger)
console.log(`\n════════════════════════════════════════`);
console.log(`Linking complete:`);
console.log(` Total processed: ${linked + failed}`);
console.log(` Linked: ${linked}`);
console.log(` Failed: ${failed}`);
break;
}
case 'discover-repos': {
console.log('Running all discovery strategies...\n');
const discoverDb = createDb(process.env.DATABASE_URL);
const discoverBudgetCrawler = createCrawler();
const orchestrator = createStrategyOrchestrator();
// Check budget before starting (reserve 20% for website users)
const discoverBudget = await discoverBudgetCrawler.checkBudget(0.20);
console.log(`API Budget: ${discoverBudget.remaining}/${discoverBudget.limit} remaining (reserve 20%)`);
if (!discoverBudget.ok) {
console.log(`\nAPI budget too low. Waiting for reset...`);
await discoverBudgetCrawler.waitForBudget(0.20);
}
const { repos: discoveredRepos, stats: discoverStats } = await orchestrator.runAllStrategies();
console.log(`\nSaving ${discoveredRepos.length} discovered repos to database...`);
let savedRepos = 0;
for (const repo of discoveredRepos) {
try {
await discoveredRepoQueries.upsert(discoverDb, {
id: `${repo.owner}/${repo.repo}`,
owner: repo.owner,
repo: repo.repo,
discoveredVia: repo.discoveredVia,
githubStars: repo.stars,
});
savedRepos++;
} catch {
// Skip duplicates
}
}
console.log(`Saved ${savedRepos} new repositories`);
console.log(`\nDiscovery complete in ${(discoverStats.duration / 1000).toFixed(1)}s`);
break;
}
case 'awesome-lists': {
console.log('Running awesome list discovery...\n');
const awesomeDb = createDb(process.env.DATABASE_URL);
const awesomeBudgetCrawler = createCrawler();
const awesomeCrawler = createAwesomeListCrawler();
// Check budget before starting (reserve 20% for website users)
const awesomeBudget = await awesomeBudgetCrawler.checkBudget(0.20);
console.log(`API Budget: ${awesomeBudget.remaining}/${awesomeBudget.limit} remaining (reserve 20%)`);
if (!awesomeBudget.ok) {
console.log(`\nAPI budget too low. Waiting for reset...`);
await awesomeBudgetCrawler.waitForBudget(0.20);
}
// Save known lists to DB
for (const list of awesomeCrawler.getKnownLists()) {
await awesomeListQueries.upsert(awesomeDb, {
id: `${list.owner}/${list.repo}`,
owner: list.owner,
repo: list.repo,
name: `${list.owner}/${list.repo}`,
});
}
const listResults = await awesomeCrawler.crawlAllLists();
let awesomeTotal = 0;
for (const [listId, repoRefs] of listResults.entries()) {
console.log(`\n${listId}: ${repoRefs.length} repos`);
// Update list stats
await awesomeListQueries.markParsed(awesomeDb, listId, repoRefs.length);
// Save repos
for (const ref of repoRefs) {
try {
await discoveredRepoQueries.upsert(awesomeDb, {
id: `${ref.owner}/${ref.repo}`,
owner: ref.owner,
repo: ref.repo,
discoveredVia: 'awesome-list',
sourceUrl: `https://github.com/${listId}`,
});
awesomeTotal++;
} catch {
// Skip duplicates
}
}
}
console.log(`\nSaved ${awesomeTotal} repositories from awesome lists`);
break;
}
case 'discover-popular': {
console.log('Discovering popular GitHub repositories...\n');
const popDb = createDb(process.env.DATABASE_URL);
const popCrawler = createPopularReposCrawler();
const popBudgetCrawler = createCrawler();
// Parse min-stars option (default 1000)
const minStarsArg = process.argv.find(a => a.startsWith('--min-stars='));
const minStars = minStarsArg ? parseInt(minStarsArg.split('=')[1]) : 1000;
// Check budget
const popBudget = await popBudgetCrawler.checkBudget(0.20);
console.log(`API Budget: ${popBudget.remaining}/${popBudget.limit} remaining`);
if (!popBudget.ok) {
console.log(`\nAPI budget too low. Waiting for reset...`);
await popBudgetCrawler.waitForBudget(0.20);
}
const repos = await popCrawler.discoverPopularRepos(minStars);
console.log(`\nSaving ${repos.length} popular repos to discovered_repos...`);
let popSaved = 0;
for (const repo of repos) {
try {
await discoveredRepoQueries.upsert(popDb, {
id: `${repo.owner}/${repo.repo}`,
owner: repo.owner,
repo: repo.repo,
discoveredVia: 'popular-scan',
githubStars: repo.stars,
githubForks: repo.forks,
defaultBranch: repo.defaultBranch,
isArchived: repo.isArchived,
});
popSaved++;
} catch {
// Skip errors
}
}
console.log(`Saved ${popSaved} repositories`);
console.log('Run "deep-scan" next to scan their branches for skills');
break;
}
case 'commits-search': {
console.log('Searching for repos with recent SKILL.md commits...\n');
const csDb = createDb(process.env.DATABASE_URL);
const csCrawler = createCommitsSearchCrawler();
const csBudgetCrawler = createCrawler();
// Parse days-back option (default 30)
const daysBackArg = process.argv.find(a => a.startsWith('--days='));
const daysBack = daysBackArg ? parseInt(daysBackArg.split('=')[1]) : 30;
// Check budget
const csBudget = await csBudgetCrawler.checkBudget(0.20);
console.log(`API Budget: ${csBudget.remaining}/${csBudget.limit} remaining`);
if (!csBudget.ok) {
console.log(`\nAPI budget too low. Waiting for reset...`);
await csBudgetCrawler.waitForBudget(0.20);
}
const csRepos = await csCrawler.discoverReposFromCommits(daysBack);
console.log(`\nSaving ${csRepos.length} repos to discovered_repos...`);
let csSaved = 0;
for (const repo of csRepos) {
try {
await discoveredRepoQueries.upsert(csDb, {
id: `${repo.owner}/${repo.repo}`,
owner: repo.owner,
repo: repo.repo,
discoveredVia: 'commits-search',
githubStars: repo.stars,
});
csSaved++;
} catch {
// Skip errors
}
}
console.log(`Saved ${csSaved} repositories`);
console.log('Run "deep-scan" next to scan their branches for skills');
break;
}
case 'deep-scan': {
console.log('Deep scanning discovered repositories...\n');
const deepDb = createDb(process.env.DATABASE_URL);
const deepCrawler = createDeepScanCrawler();
const skillCrawler = createCrawler();
// Parse budget option (default 20% reserve for website users)
const deepBudgetArg = process.argv.find(a => a.startsWith('--budget='));
const deepBudgetPct = deepBudgetArg ? parseInt(deepBudgetArg.split('=')[1]) / 100 : 0.20;
// Parse branch scanning options
const allBranchesFlag = process.argv.includes('--all-branches');
const branchesArg = process.argv.find(a => a.startsWith('--branches='));
const extraBranchPatterns: string[] = branchesArg
? branchesArg.split('=')[1].split(',').map(p => p.trim()).filter(Boolean)
: [];
const deepScanOptions = { allBranches: allBranchesFlag, extraBranchPatterns };
if (allBranchesFlag) {
console.log('Branch mode: all branches');
} else if (extraBranchPatterns.length > 0) {
console.log(`Branch mode: smart + extra patterns [${extraBranchPatterns.join(', ')}]`);
} else {
console.log('Branch mode: smart (default + version/release branches)');
}
// Check initial budget
const deepInitialBudget = await skillCrawler.checkBudget(deepBudgetPct);
console.log(`API Budget: ${deepInitialBudget.remaining}/${deepInitialBudget.limit} remaining (reserve ${Math.round(deepBudgetPct * 100)}%)`);
if (!deepInitialBudget.ok) {
console.log(`\nAPI budget too low. Waiting for reset...`);
await skillCrawler.waitForBudget(deepBudgetPct);
}
// Get repos that need scanning (never scanned or stale)
const oneWeekAgo = new Date();
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
const reposToScan = await discoveredRepoQueries.getNeedingScanning(deepDb, oneWeekAgo, 100);
if (reposToScan.length === 0) {
console.log('No repositories need scanning');
break;
}
console.log(`Found ${reposToScan.length} repositories to scan\n`);
let scannedCount = 0;
let skillsDiscovered = 0;
let skillsIndexed = 0;
for (const repo of reposToScan) {
// Check budget every 10 repos
if (scannedCount > 0 && scannedCount % 10 === 0) {
const midBudget = await skillCrawler.checkBudget(deepBudgetPct);
if (!midBudget.ok) {
console.log(`\n Budget low (${midBudget.remaining}/${midBudget.limit}). Pausing for reset...`);
await skillCrawler.waitForBudget(deepBudgetPct);
}
}
try {
console.log(`\nScanning ${repo.owner}/${repo.repo}...`);
const skills = await deepCrawler.scanRepository(repo.owner, repo.repo, deepScanOptions);
// Mark as scanned
await discoveredRepoQueries.markScanned(
deepDb,
repo.id,
skills.length,
skills.length > 0
);
scannedCount++;
skillsDiscovered += skills.length;
// If skills found, index them to the database
if (skills.length > 0) {
console.log(` Found ${skills.length} skills in ${repo.owner}/${repo.repo}, processing...`);
let indexed = 0;
let skipped = 0;
let failed = 0;
for (const skillSource of skills) {
try {
const skillId = await indexSkill(
skillCrawler,
skillSource,
false // don't force update if content unchanged
);
if (skillId) {
indexed++;
console.log(` ✓ Indexed: ${skillSource.path}`);
} else {
skipped++;
console.log(` → Skipped: ${skillSource.path} (unchanged or invalid)`);
}
} catch (error) {
failed++;
const errorMsg = error instanceof Error ? error.message : String(error);
console.log(` ✗ Failed: ${skillSource.path} - ${errorMsg}`);
}
}
skillsIndexed += indexed;
console.log(` Summary: ${indexed} indexed, ${skipped} skipped, ${failed} failed`);
}
} catch (error) {
console.log(` Error scanning: ${error}`);
await discoveredRepoQueries.markScanned(deepDb, repo.id, 0, false, String(error));
}
}
// Show final API status
const deepFinalBudget = await skillCrawler.checkBudget(deepBudgetPct);
console.log(`\n API remaining: ${deepFinalBudget.remaining}/${deepFinalBudget.limit}`);
console.log(`\nDeep scan complete:`);
console.log(` Repositories scanned: ${scannedCount}`);
console.log(` Skills discovered: ${skillsDiscovered}`);
console.log(` Skills indexed: ${skillsIndexed}`);
break;
}
case 'multi-platform': {
console.log('Searching for multi-platform instruction files...\n');
const mpCrawler = createCrawler();
// Parse options
const mpMaxPagesArg = process.argv.find(a => a.startsWith('--max-pages='));
const mpMaxPages = mpMaxPagesArg ? parseInt(mpMaxPagesArg.split('=')[1]) : 10;
const mpBudgetArg = process.argv.find(a => a.startsWith('--budget='));
const mpBudgetPct = mpBudgetArg ? parseInt(mpBudgetArg.split('=')[1]) / 100 : 0.33;
// Only search for non-SKILL.md formats
const nonSkillPatterns = INSTRUCTION_FILE_PATTERNS.filter(p => p.format !== 'skill.md');
console.log(`Searching for ${nonSkillPatterns.length} formats: ${nonSkillPatterns.map(p => p.filename).join(', ')}`);
console.log(`Options: max-pages=${mpMaxPages}, budget=${Math.round(mpBudgetPct * 100)}% reserve\n`);
// Check initial budget
const initialBudget = await mpCrawler.checkBudget(mpBudgetPct);
console.log(`API Budget: ${initialBudget.remaining}/${initialBudget.limit} remaining (reserve ${Math.round(mpBudgetPct * 100)}%)\n`);
const mpResults: Record<string, { discovered: number; indexed: number; failed: number; skipped: number }> = {};
for (const pattern of nonSkillPatterns) {
mpResults[pattern.format] = { discovered: 0, indexed: 0, failed: 0, skipped: 0 };
}
// Search for each format with budget awareness
for (const pattern of nonSkillPatterns) {
// Check budget before each format
const budget = await mpCrawler.checkBudget(mpBudgetPct);
if (!budget.ok) {
console.log(`\n⚠️ API budget low (${budget.remaining}/${budget.limit}). Waiting for reset...`);
await mpCrawler.waitForBudget(mpBudgetPct);
}
console.log(`\n═══ Searching for ${pattern.filename} ═══`);
try {
const sources = await mpCrawler.searchGitHubForSkillsByFormat(
pattern.format as SourceFormat,
{ maxPages: mpMaxPages }
);
mpResults[pattern.format].discovered = sources.length;
console.log(`Found ${sources.length} ${pattern.filename} files\n`);
// Index each discovered file (sequentially to respect API budget)
for (const source of sources) {
// Check budget periodically (every 20 skills)
const formatStats = mpResults[pattern.format];
const processed = formatStats.indexed + formatStats.skipped + formatStats.failed;
if (processed > 0 && processed % 20 === 0) {
const midBudget = await mpCrawler.checkBudget(mpBudgetPct);
if (!midBudget.ok) {
console.log(` ⚠️ Budget low mid-indexing (${midBudget.remaining}/${midBudget.limit}). Pausing...`);
await mpCrawler.waitForBudget(mpBudgetPct);
}
}
try {
const skillId = await indexSkill(mpCrawler, source, false);
if (skillId) {
mpResults[pattern.format].indexed++;
} else {
mpResults[pattern.format].skipped++;
}
} catch {
mpResults[pattern.format].failed++;
}
}
} catch (error) {
console.error(`Error searching for ${pattern.filename}:`, error);
}
}
// Print summary
console.log('\n' + '='.repeat(60));
console.log('Multi-Platform Crawl Results');
console.log('='.repeat(60));
let totalDiscovered = 0;
let totalIndexed = 0;
for (const [format, stats] of Object.entries(mpResults)) {
console.log(` ${format}: ${stats.discovered} discovered, ${stats.indexed} indexed, ${stats.skipped} skipped, ${stats.failed} failed`);
totalDiscovered += stats.discovered;
totalIndexed += stats.indexed;
}
console.log(` Total: ${totalDiscovered} discovered, ${totalIndexed} indexed`);
// Show final API status
const finalBudget = await mpCrawler.checkBudget(mpBudgetPct);
console.log(`\n API remaining: ${finalBudget.remaining}/${finalBudget.limit}`);
console.log('='.repeat(60));
break;
}
case 'discovery-stats': {
console.log('Discovery statistics:\n');
const statsDb = createDb(process.env.DATABASE_URL);
const repoStats = await discoveredRepoQueries.getStats(statsDb);
const awesomeLists = await awesomeListQueries.getAll(statsDb);
console.log('Discovered Repositories:');
console.log(` Total: ${repoStats.total}`);
console.log(` Scanned: ${repoStats.scanned}`);
console.log(` With skills: ${repoStats.withSkills}`);
console.log('\nBy Discovery Source:');
for (const source of repoStats.bySource) {
console.log(` ${source.source}: ${source.count} repos (${source.withSkills} with skills)`);
}
if (awesomeLists.length > 0) {
console.log('\nAwesome Lists:');
for (const list of awesomeLists) {
const status = list.isActive ? 'active' : 'inactive';
console.log(` ${list.id}: ${list.repoCount} repos (${status})`);
}
}
break;
}
case 'full-enhanced': {
console.log('Running full enhanced crawl (discovery + scan + index)...\n');
const enhancedDb = createDb(process.env.DATABASE_URL);
const enhancedBudgetCrawler = createCrawler();
// Check budget before starting (reserve 20% for website users)
const enhancedBudget = await enhancedBudgetCrawler.checkBudget(0.20);
console.log(`API Budget: ${enhancedBudget.remaining}/${enhancedBudget.limit} remaining (reserve 20%)\n`);
if (!enhancedBudget.ok) {
console.log(`API budget too low. Waiting for reset...`);
await enhancedBudgetCrawler.waitForBudget(0.20);
}
// Step 1: Run all discovery strategies
console.log('Step 1: Running discovery strategies...');
const enhancedOrchestrator = createStrategyOrchestrator();
const { repos: allDiscovered } = await enhancedOrchestrator.runAllStrategies();
console.log(`\nSaving ${allDiscovered.length} discovered repos...`);
for (const repo of allDiscovered) {
try {
await discoveredRepoQueries.upsert(enhancedDb, {
id: `${repo.owner}/${repo.repo}`,
owner: repo.owner,
repo: repo.repo,
discoveredVia: repo.discoveredVia,
githubStars: repo.stars,
});
} catch {
// Skip
}
}
// Step 2: Schedule full crawl (uses existing crawler with code search)
console.log('\nStep 2: Scheduling full crawl...');
const enhancedJobId = await scheduleFullCrawl({
minStars: parseInt(process.env.INDEXER_MIN_STARS || '0'),
});
console.log(`Full crawl scheduled with job ID: ${enhancedJobId}`);
console.log('\nFull enhanced crawl initiated. Monitor with: pnpm crawl stats');
break;
}
case 'process-add-requests': {
console.log('Processing pending add requests...\n');
const addDb = createDb(process.env.DATABASE_URL);
const addCrawler = createCrawler();
// Check queue status first
const queueStats = await getQueueStats();
if (queueStats.active > 0) {
console.log(`⚠️ Warning: ${queueStats.active} active jobs in queue`);
console.log(' Processing add requests may be slower due to concurrent operations.\n');
}
// Check GitHub rate limit
try {
const rateLimitInfo = await addCrawler.getRateLimitStatus();
console.log(`GitHub API: ${rateLimitInfo.remaining}/${rateLimitInfo.limit} requests remaining`);
if (rateLimitInfo.remaining < 100) {
const resetTime = new Date(rateLimitInfo.resetAt);
console.log(`⚠️ Warning: Low API quota. Resets at ${resetTime.toLocaleTimeString()}`);
if (rateLimitInfo.remaining < 10) {
console.log('❌ Aborting: Not enough API quota. Try again after reset.');
break;
}
}
console.log('');
} catch {
console.log('⚠️ Could not check GitHub rate limit. Proceeding anyway...\n');
}
// Get all pending add requests
const pendingRequests = await addRequestQueries.getAllPending(addDb);
if (pendingRequests.length === 0) {
console.log('✓ No pending add requests found');
break;
}
console.log(`Found ${pendingRequests.length} pending request(s)\n`);
let processedCount = 0;
let skillsIndexed = 0;
let skillsAlreadyIndexed = 0;
let skillsSkipped = 0;
let failedCount = 0;
let rateLimitHit = false;
for (const request of pendingRequests) {
// Stop if we hit rate limit
if (rateLimitHit) {
console.log(`⏳ Rate limit hit. Remaining requests will be processed later.`);
break;
}
console.log(`Processing request ${request.id.slice(0, 8)}...`);
console.log(` Repository: ${request.repositoryUrl}`);
console.log(` Skill paths: ${request.skillPath || 'auto-detect'}`);
// Skip if no skill paths found
if (!request.hasSkillMd || !request.skillPath) {
console.log(' → No SKILL.md paths found, marking for manual review');
await addRequestQueries.updateStatus(addDb, request.id, {
status: 'approved',
errorMessage: 'No SKILL.md found - requires manual review',
});
continue;
}
try {
// Parse repository URL (may include branch in /tree/{branch} path)
const { owner, repo, branch: urlBranch } = parseGitHubRepoUrl(request.repositoryUrl);
// Get repo metadata for default branch
const repoMeta = await addCrawler.getRepoMetadata(owner, repo);
const branch = urlBranch ?? repoMeta.defaultBranch;
if (urlBranch) {
console.log(` Branch: ${branch} (from URL)`);
}
// Parse skill paths (comma-separated)
const skillPaths = request.skillPath.split(',').map((p: string) => p.trim());
const indexedSkillIds: string[] = [];
const existingSkillIds: string[] = [];
for (const skillPath of skillPaths) {
try {
// Check if skill already exists before indexing
const skillName = skillPath.split('/').pop() || 'skill';
const potentialSkillId = `${owner}/${repo}/${skillName}`;
const existingSkill = await skillQueries.getById(addDb, potentialSkillId);
if (existingSkill && !existingSkill.isBlocked) {
// Skill already indexed
existingSkillIds.push(potentialSkillId);
skillsAlreadyIndexed++;
console.log(` ${skillPath || 'root'}: Already indexed ✓`);
continue;
}
// If skill is blocked, check if requester is the owner
if (existingSkill && existingSkill.isBlocked) {
// Get requester's GitHub username
const requester = await userQueries.getById(addDb, request.userId);
const requesterUsername = requester?.username?.toLowerCase();
const repoOwner = owner.toLowerCase();
if (requesterUsername && requesterUsername === repoOwner) {
// Owner is re-adding their skill, unblock it
console.log(` ${skillPath || 'root'}: Unblocking (owner re-add request)...`);
await skillQueries.unblock(addDb, potentialSkillId);
existingSkillIds.push(potentialSkillId);
skillsAlreadyIndexed++;
console.log(` → Unblocked: ${potentialSkillId}`);
continue;
} else {
// Not the owner, skip blocked skill
console.log(` ${skillPath || 'root'}: Blocked by owner, skipping`);
skillsSkipped++;
continue;
}
}
console.log(` ${skillPath || 'root'}: Indexing...`);
const skillId = await indexSkill(
addCrawler,
{
owner,
repo,
path: skillPath || '.',
branch,
},
true // force update
);
if (skillId) {
indexedSkillIds.push(skillId);
skillsIndexed++;
console.log(` → Indexed: ${skillId}`);
} else {
skillsSkipped++;
console.log(` → Skipped (invalid skill)`);
}
} catch (skillError: unknown) {
// Check for rate limit error
const errorMessage = skillError instanceof Error ? skillError.message : String(skillError);
if (errorMessage.includes('rate limit') || errorMessage.includes('403')) {
rateLimitHit = true;
console.log(` → Rate limit hit, will retry later`);
break;
}
console.error(` → Failed: ${errorMessage}`);
skillsSkipped++;
}
}
// Combine all skill IDs (new + existing)
const allSkillIds = [...indexedSkillIds, ...existingSkillIds];
// Update request status
if (allSkillIds.length > 0) {
await addRequestQueries.updateStatus(addDb, request.id, {
status: 'indexed',
indexedSkillId: allSkillIds.join(','),
});
// Mark all skills from this repo as owner-claimed so they're
// exempt from duplicate hiding on the owner page and browse pages
await skillQueries.setOwnerClaimed(addDb, owner, repo);
const newCount = indexedSkillIds.length;
const existingCount = existingSkillIds.length;
if (newCount > 0 && existingCount > 0) {
console.log(` ✓ Indexed ${newCount} new + ${existingCount} already existed`);
} else if (newCount > 0) {
console.log(` ✓ Indexed ${newCount} skill(s)`);
} else {
console.log(` ✓ All ${existingCount} skill(s) were already indexed`);
}
} else if (rateLimitHit) {
// Leave as pending if we hit rate limit
console.log(` ⏳ Left as pending (rate limit)`);
} else {
await addRequestQueries.updateStatus(addDb, request.id, {
status: 'rejected',
errorMessage: 'No valid skills could be indexed',
});
console.log(` ✗ Rejected (no valid skills)`);
}
processedCount++;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
// Check for rate limit error
if (errorMessage.includes('rate limit') || errorMessage.includes('403')) {
rateLimitHit = true;
console.log(` ⏳ Rate limit hit, will retry later`);
continue;
}
console.error(` ✗ Error: ${errorMessage}`);
failedCount++;
await addRequestQueries.updateStatus(addDb, request.id, {
status: 'rejected',
errorMessage,
});
}
console.log(''); // Empty line between requests
}
console.log(`═══════════════════════════════════════`);
console.log(`Processing Summary:`);
console.log(` Requests processed: ${processedCount}`);
console.log(` Skills newly indexed: ${skillsIndexed}`);
console.log(` Skills already indexed: ${skillsAlreadyIndexed}`);
console.log(` Skills skipped/invalid: ${skillsSkipped}`);
console.log(` Requests failed: ${failedCount}`);
if (rateLimitHit) {
console.log(`\n⚠️ Rate limit reached. Some requests were left pending.`);
console.log(` Run 'process-add-requests' again after GitHub rate limit resets.`);
}
break;
}
case 'add-repo': {
const input = process.argv[3];
if (!input) {
console.error('Usage: node dist/crawl.js add-repo <owner/repo or https://github.com/owner/repo>');
process.exit(1);
}
let arOwner: string;
let arRepo: string;
try {
if (input.startsWith('https://') || input.startsWith('http://')) {
const arUrl = new URL(input);
const arParts = arUrl.pathname.split('/').filter(Boolean);
arOwner = arParts[0];
arRepo = arParts[1];
} else {
const arParts = input.split('/').filter(Boolean);
arOwner = arParts[0];
arRepo = arParts[1];
}
} catch {
console.error('Could not parse owner/repo from input:', input);
process.exit(1);
}
if (!arOwner! || !arRepo!) {
console.error('Could not parse owner/repo from input:', input);
process.exit(1);
}
console.log(`Validating ${arOwner}/${arRepo} on GitHub...`);
const arGhRes = await fetch(`https://api.github.com/repos/${arOwner}/${arRepo}`, {
headers: {
Authorization: `token ${process.env.GITHUB_TOKEN}`,
'User-Agent': 'SkillHub',
Accept: 'application/vnd.github.v3+json',
},
signal: AbortSignal.timeout(10000),
});
if (!arGhRes.ok) {
console.error(`Repo ${arOwner}/${arRepo} not found on GitHub (HTTP ${arGhRes.status})`);
process.exit(1);
}
const arGhData = await arGhRes.json() as {
stargazers_count: number;
forks_count: number;
default_branch: string;
archived: boolean;
};
const arDb = createDb(process.env.DATABASE_URL);
await discoveredRepoQueries.upsert(arDb, {
id: `${arOwner}/${arRepo}`,
owner: arOwner,
repo: arRepo,
discoveredVia: 'manual',
sourceUrl: `https://github.com/${arOwner}/${arRepo}`,
githubStars: arGhData.stargazers_count ?? 0,
githubForks: arGhData.forks_count ?? 0,
defaultBranch: arGhData.default_branch ?? 'main',
isArchived: arGhData.archived ?? false,
});
console.log(`✅ Added ${arOwner}/${arRepo} to discovered_repos.`);
console.log(` Run 'deep-scan' to index skills from this repository.`);
break;
}
case 'stale-check': {
console.log('Checking for stale skills (removed from GitHub)...\n');
const staleDb = createDb(process.env.DATABASE_URL);
const staleCrawler = createCrawler();
// Parse --limit=N option
const staleLimitArg = process.argv.find(a => a.startsWith('--limit='));
const staleLimit = staleLimitArg ? parseInt(staleLimitArg.split('=')[1]) : 500;
// Check GitHub rate limit
try {
const rateLimitInfo = await staleCrawler.getRateLimitStatus();
console.log(`GitHub API: ${rateLimitInfo.remaining}/${rateLimitInfo.limit} requests remaining`);
if (rateLimitInfo.remaining < 50) {
console.log('Not enough API quota for stale checking. Try again later.');
break;
}
console.log('');
} catch {
console.log('Could not check GitHub rate limit. Proceeding anyway...\n');
}
// Get skills to check (oldest scanned first)
const skillsToCheck = await skillQueries.getSkillsToStaleCheck(staleDb, staleLimit);
console.log(`Checking ${skillsToCheck.length} skills...\n`);
let staleChecked = 0;
let stillAlive = 0;
let newlyStale = 0;
let staleErrors = 0;
for (const skill of skillsToCheck) {
// Rate limit check every 50 skills
if (staleChecked > 0 && staleChecked % 50 === 0) {
try {
const midCheck = await staleCrawler.getRateLimitStatus();
if (midCheck.remaining < 20) {
console.log(`\n API quota low (${midCheck.remaining} remaining). Stopping.`);
break;
}
} catch {
// Continue if rate limit check fails
}
}
try {
const sourceFormat = skill.sourceFormat || 'skill.md';
const pattern = INSTRUCTION_FILE_PATTERNS.find(p => p.format === sourceFormat);
const filename = pattern?.filename || 'SKILL.md';
const filePath = skill.skillPath === '.' ? filename : `${skill.skillPath}/${filename}`;
const exists = await staleCrawler.checkFileExists(
skill.githubOwner,
skill.githubRepo,
filePath,
skill.branch || 'main'
);
if (exists) {
stillAlive++;
// Clear any partial stale state
if ((skill.staleCheckCount ?? 0) > 0) {
await skillQueries.clearStaleState(staleDb, skill.id);
}
} else {
await skillQueries.incrementStaleCheck(staleDb, skill.id);
newlyStale++;
console.log(` STALE: ${skill.id}`);
}
} catch {
staleErrors++;
}
staleChecked++;
// Progress every 100
if (staleChecked % 100 === 0) {
console.log(` Progress: ${staleChecked}/${skillsToCheck.length} checked`);
}
}
console.log(`\nStale check complete:`);
console.log(` Checked: ${staleChecked}`);
console.log(` Still alive: ${stillAlive}`);
console.log(` Stale detected: ${newlyStale}`);
console.log(` Errors: ${staleErrors}`);
// Show total stale count
const totalStale = await skillQueries.countStale(staleDb);
console.log(` Total stale skills in DB: ${totalStale}`);
break;
}
default:
console.log('Usage: pnpm crawl <command>\n');
console.log('Basic Commands:');
console.log(' full - Schedule a full crawl of all skills');
console.log(' incremental - Schedule an incremental crawl (last 24h)');
console.log(' stats - Show queue statistics\n');
console.log('Enhanced Discovery:');
console.log(' full-enhanced - Full crawl with all discovery strategies');
console.log(' discover-repos - Run all discovery strategies (awesome lists, topics, forks)');
console.log(' awesome-lists - Crawl awesome lists for repo discovery');
console.log(' deep-scan - Deep scan discovered repos for SKILL.md files');
console.log(' multi-platform - Search for non-SKILL.md formats (--max-pages=N --budget=N)');
console.log(' discovery-stats - Show discovery statistics\n');
console.log('Maintenance:');
console.log(' sync-meili - Sync all skills from database to Meilisearch');
console.log(' recategorize - Re-categorize all skills with 23 categories (alias: link-categories)');
console.log(' process-add-requests - Process pending add requests (auto-index skills)');
console.log(' stale-check - Check for stale skills removed from GitHub (--limit=N)');
process.exit(1);
}
process.exit(0);
}
main().catch((error) => {
console.error('Error:', error);
process.exit(1);
});