Files
skillhub/services/indexer/src/strategies/topic-search.ts
airano 444a6668d0 fix(indexer): prevent token exhaustion and reserve 20% budget for website users
- Lower exhaustion threshold from <10 to <2 (stop wasting remaining tokens)
- Refresh ALL tokens after wait instead of only one
- Fix token/Octokit mismatch by returning token from getBestInstance()
- Add budget checking (20% reserve) to deep-scan, discover-repos, awesome-lists,
  and full-enhanced commands
- Add rate limit error handling with retry in DeepScanCrawler
- Sync translation and query changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 18:29:39 +03:30

308 lines
8.5 KiB
TypeScript

import type { Octokit } from '@octokit/rest';
import { TokenManager } from '../token-manager.js';import { OctokitPool } from '../octokit-pool.js';
export interface RepoResult {
owner: string;
repo: string;
stars: number;
forks: number;
description: string | null;
topics: string[];
defaultBranch: string;
isArchived: boolean;
updatedAt: string;
}
/**
* Topics that indicate a repository might contain skills
* Includes both compound topics (claude-skills) and simple topics (skill)
*/
const SKILL_TOPICS = [
// Compound skill topics
'claude-skills',
'agent-skills',
'ai-skills',
'claude-code',
'codex-skills',
'copilot-skills',
'skill-md',
'anthropic-skills',
'claude-code-skills',
'ai-agent-skills',
// Simple skill topics (for repos like openclaw/skills with topic "skill")
'skill',
'skills',
// Platform-specific
'clawdhub',
'clawdbot',
'skillhub',
// AI agent related
'ai-agent',
'claude-agent',
'anthropic',
'llm-skills',
'mcp-skills',
// Cursor-specific
'cursor-rules',
'cursorrules',
// Windsurf-specific
'windsurf-rules',
'windsurfrules',
// Copilot-specific
'copilot-instructions',
'github-copilot',
// Codex-specific
'codex-agent',
'openai-codex',
];
/**
* Search queries for repository descriptions and READMEs
*/
const REPO_SEARCH_QUERIES = [
'SKILL.md in:readme',
'claude skills in:description',
'agent skills in:description',
'"agent skill" in:readme',
'claude code skill in:readme',
'codex skill in:readme',
'skill.md file in:readme',
// Additional queries for platforms like clawdhub
'clawdhub in:description',
'clawdbot in:description',
'claude code skills in:description',
'agent skill in:name',
'skills archive in:description',
'anthropic skills in:description',
'.cursorrules in:readme',
'cursor rules in:description',
'AGENTS.md in:readme',
'codex agents in:description',
'copilot-instructions in:readme',
'windsurf rules in:description',
'.windsurfrules in:readme',
];
/**
* Topic Search Strategy
* Discovers repositories using GitHub's topic and description search
*/
export class TopicSearchCrawler {
private octokitPool: OctokitPool;
private tokenManager: TokenManager;
constructor(tokenManager?: TokenManager) {
this.tokenManager = tokenManager || TokenManager.getInstance();
this.octokitPool = new OctokitPool(this.tokenManager);
}
private async getOctokit(): Promise<{ octokit: Octokit; token: string }> {
return this.octokitPool.getBestInstance();
}
/**
* Search by all known skill-related topics
*/
async searchByTopics(): Promise<RepoResult[]> {
const allRepos: RepoResult[] = [];
const seen = new Set<string>();
console.log(`Searching by ${SKILL_TOPICS.length} skill-related topics...`);
for (const topic of SKILL_TOPICS) {
try {
console.log(` Searching topic: ${topic}`);
const { octokit, token } = await this.getOctokit();
const response = await octokit.search.repos({
q: `topic:${topic}`,
sort: 'stars',
order: 'desc',
per_page: 100,
});
this.octokitPool.updateStats(token, response.headers);
console.log(` Found ${response.data.total_count} repos`);
for (const repo of response.data.items) {
if (!repo.owner) continue;
const key = `${repo.owner.login}/${repo.name}`.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
allRepos.push({
owner: repo.owner.login,
repo: repo.name,
stars: repo.stargazers_count,
forks: repo.forks_count,
description: repo.description,
topics: repo.topics || [],
defaultBranch: repo.default_branch,
isArchived: repo.archived,
updatedAt: repo.updated_at,
});
}
// Paginate if more results
if (response.data.total_count > 100) {
const additionalRepos = await this.paginateSearch(`topic:${topic}`, seen, 5);
allRepos.push(...additionalRepos);
}
} catch (error) {
console.warn(` Topic search failed for ${topic}:`, error);
}
}
console.log(`Topic search found ${allRepos.length} unique repositories`);
return allRepos;
}
/**
* Search by description and README content
*/
async searchByDescription(): Promise<RepoResult[]> {
const allRepos: RepoResult[] = [];
const seen = new Set<string>();
console.log(`Searching by ${REPO_SEARCH_QUERIES.length} description queries...`);
for (const query of REPO_SEARCH_QUERIES) {
try {
console.log(` Query: ${query}`);
const { octokit, token } = await this.getOctokit();
const response = await octokit.search.repos({
q: query,
sort: 'stars',
order: 'desc',
per_page: 100,
});
this.octokitPool.updateStats(token, response.headers);
console.log(` Found ${response.data.total_count} repos`);
for (const repo of response.data.items) {
if (!repo.owner) continue;
const key = `${repo.owner.login}/${repo.name}`.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
allRepos.push({
owner: repo.owner.login,
repo: repo.name,
stars: repo.stargazers_count,
forks: repo.forks_count,
description: repo.description,
topics: repo.topics || [],
defaultBranch: repo.default_branch,
isArchived: repo.archived,
updatedAt: repo.updated_at,
});
}
// Paginate if more results
if (response.data.total_count > 100) {
const additionalRepos = await this.paginateSearch(query, seen, 5);
allRepos.push(...additionalRepos);
}
} catch (error) {
console.warn(` Description search failed for "${query}":`, error);
}
}
console.log(`Description search found ${allRepos.length} unique repositories`);
return allRepos;
}
/**
* Paginate through search results
*/
private async paginateSearch(
query: string,
seen: Set<string>,
maxPages: number
): Promise<RepoResult[]> {
const repos: RepoResult[] = [];
for (let page = 2; page <= maxPages; page++) {
try {
const { octokit, token } = await this.getOctokit();
const response = await octokit.search.repos({
q: query,
sort: 'stars',
order: 'desc',
per_page: 100,
page,
});
this.octokitPool.updateStats(token, response.headers);
if (response.data.items.length === 0) break;
for (const repo of response.data.items) {
if (!repo.owner) continue;
const key = `${repo.owner.login}/${repo.name}`.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
repos.push({
owner: repo.owner.login,
repo: repo.name,
stars: repo.stargazers_count,
forks: repo.forks_count,
description: repo.description,
topics: repo.topics || [],
defaultBranch: repo.default_branch,
isArchived: repo.archived,
updatedAt: repo.updated_at,
});
}
} catch (error) {
// Stop on 422 (beyond 1000 results)
if (this.isBeyondResultsLimit(error)) break;
throw error;
}
}
return repos;
}
/**
* Run all topic and description searches
*/
async discoverAll(): Promise<RepoResult[]> {
const topicRepos = await this.searchByTopics();
const descRepos = await this.searchByDescription();
// Merge and deduplicate
const seen = new Set<string>();
const allRepos: RepoResult[] = [];
for (const repo of [...topicRepos, ...descRepos]) {
const key = `${repo.owner}/${repo.repo}`.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
allRepos.push(repo);
}
console.log(`Total unique repositories discovered: ${allRepos.length}`);
return allRepos;
}
private isBeyondResultsLimit(error: unknown): boolean {
return (
error instanceof Error &&
'status' in error &&
(error as { status: number }).status === 422
);
}
}
export function createTopicSearchCrawler(tokenManager?: TokenManager | string): TopicSearchCrawler {
if (typeof tokenManager === 'string') {
return new TopicSearchCrawler(new TokenManager([tokenManager]));
}
return new TopicSearchCrawler(tokenManager);
}