public-api-finder 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Find free/public APIs for agents, prototypes, demos, and integrations.
4
4
 
5
- Powered by multiple sources:
5
+ Powered by multiple sources plus a curated best-known API layer:
6
6
 
7
7
  - [`public-api-lists/public-api-lists`](https://github.com/public-api-lists/public-api-lists) for fast curated JSON discovery
8
8
  - [`public-apis/public-apis`](https://github.com/public-apis/public-apis) for the larger canonical README list
@@ -11,10 +11,10 @@ Powered by multiple sources:
11
11
  ## Quick start
12
12
 
13
13
  ```bash
14
- npx --package public-api-finder public-api-finder "weather forecast" --no-auth --https
15
- npx --package public-api-finder public-api-finder "crypto prices" --category Cryptocurrency --limit 5
16
- npx --package public-api-finder public-api-finder "jobs" --json
17
- npx --package public-api-finder public-api-finder "payments" --openapi
14
+ npx --yes --package=public-api-finder -- public-api-finder "weather forecast" --no-auth --https
15
+ npx --yes --package=public-api-finder -- public-api-finder "crypto prices" --category Cryptocurrency --limit 5
16
+ npx --yes --package=public-api-finder -- public-api-finder "jobs" --json
17
+ npx --yes --package=public-api-finder -- public-api-finder "payments" --openapi
18
18
  ```
19
19
 
20
20
  ## Why
@@ -35,7 +35,7 @@ The skill tells agents to prefer the CLI first, then live-check docs/endpoints b
35
35
 
36
36
  ```text
37
37
  --category <name> Filter by category substring
38
- --source <name> Filter by source: public-api-lists, public-apis, apis-guru
38
+ --source <name> Filter by source: public-api-lists, public-apis, apis-guru, curated
39
39
  --no-auth Only APIs with Auth = No
40
40
  --https Only HTTPS APIs
41
41
  --cors <value> Filter by CORS: Yes, No, Unknown
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "public-api-finder",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Find free/public APIs for agents and prototypes.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,15 +5,15 @@ description: Find and evaluate free/public APIs for projects, demos, agents, pro
5
5
 
6
6
  # Public API Finder
7
7
 
8
- Use this skill when a task needs a public API candidate. The CLI searches multiple sources: public-api-lists, public-apis, and APIs.guru OpenAPI directory. Use the CLI first, then live-check docs/endpoints before coding.
8
+ Use this skill when a task needs a public API candidate. The CLI searches multiple sources: public-api-lists, public-apis, APIs.guru OpenAPI directory, and a curated best-known API layer for common domains like crypto, stocks, weather, maps, jobs, sports, media, news, government, and commerce. Use the CLI first, then live-check docs/endpoints before coding.
9
9
 
10
10
  ## Quick command
11
11
 
12
12
  ```bash
13
- npx --package public-api-finder public-api-finder "weather forecast" --no-auth --https
14
- npx --package public-api-finder public-api-finder "crypto prices" --category Cryptocurrency --limit 5
15
- npx --package public-api-finder public-api-finder "jobs" --json
16
- npx --package public-api-finder public-api-finder "payments" --openapi
13
+ npx --yes --package=public-api-finder -- public-api-finder "weather forecast" --no-auth --https
14
+ npx --yes --package=public-api-finder -- public-api-finder "crypto prices" --category Cryptocurrency --limit 5
15
+ npx --yes --package=public-api-finder -- public-api-finder "jobs" --json
16
+ npx --yes --package=public-api-finder -- public-api-finder "payments" --openapi
17
17
  ```
18
18
 
19
19
  If npm is unavailable, use the bundled fallback script:
package/src/cli.js CHANGED
@@ -13,21 +13,116 @@ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
13
13
 
14
14
  const DOMAIN_PROFILES = {
15
15
  crypto: {
16
- triggers: ['crypto', 'cryptocurrency', 'cryptocurrencies', 'bitcoin', 'ethereum', 'blockchain', 'defi', 'token', 'tokens', 'coin', 'coins', 'wallet'],
17
- categories: ['cryptocurrency', 'currency exchange', 'finance', 'financial'],
18
- categoryWeights: { cryptocurrency: 16, 'currency exchange': 5, finance: 3, financial: 3 },
19
- boostTerms: ['crypto', 'cryptocurrency', 'bitcoin', 'ethereum', 'blockchain', 'defi', 'token', 'coin', 'exchange', 'price', 'market'],
16
+ triggers: ['crypto', 'cryptocurrency', 'cryptocurrencies', 'bitcoin', 'ethereum', 'solana', 'blockchain', 'defi', 'token', 'tokens', 'coin', 'coins', 'wallet'],
17
+ categoryWeights: { cryptocurrency: 18, 'currency exchange': 5, finance: 3, financial: 3 },
18
+ boostTerms: ['crypto', 'cryptocurrency', 'bitcoin', 'ethereum', 'solana', 'blockchain', 'defi', 'token', 'coin', 'exchange', 'price', 'market', 'wallet'],
20
19
  weakTerms: ['price', 'prices'],
21
20
  },
22
21
  finance: {
23
- triggers: ['stock', 'stocks', 'equity', 'equities', 'market', 'trading', 'ticker', 'tickers', 'quote', 'quotes', 'etf', 'forex', 'portfolio'],
24
- categories: ['finance', 'financial', 'currency exchange'],
25
- categoryWeights: { finance: 14, financial: 14, 'currency exchange': 5 },
26
- boostTerms: ['stock', 'stocks', 'equity', 'market', 'trading', 'ticker', 'quote', 'quotes', 'forex', 'portfolio'],
22
+ triggers: ['stock', 'stocks', 'equity', 'equities', 'market', 'trading', 'ticker', 'tickers', 'quote', 'quotes', 'etf', 'forex', 'portfolio', 'options'],
23
+ categoryWeights: { finance: 16, financial: 16, 'currency exchange': 5 },
24
+ boostTerms: ['stock', 'stocks', 'equity', 'market', 'trading', 'ticker', 'quote', 'quotes', 'forex', 'portfolio', 'options', 'historical'],
27
25
  weakTerms: ['quote', 'quotes', 'price', 'prices', 'market'],
28
26
  },
27
+ weather: {
28
+ triggers: ['weather', 'forecast', 'radar', 'temperature', 'climate', 'alerts', 'precipitation', 'hourly', 'daily'],
29
+ categoryWeights: { weather: 18, location: 4 },
30
+ boostTerms: ['weather', 'forecast', 'radar', 'temperature', 'climate', 'alerts', 'precipitation', 'meteorological'],
31
+ weakTerms: ['daily', 'hourly'],
32
+ },
33
+ maps: {
34
+ triggers: ['maps', 'map', 'geocoding', 'reverse', 'address', 'coordinates', 'places', 'routing', 'distance', 'timezone', 'location', 'navigation'],
35
+ categoryWeights: { geocoding: 18, location: 12, 'open data': 3 },
36
+ boostTerms: ['map', 'maps', 'geocoding', 'geocoder', 'reverse', 'address', 'coordinates', 'routing', 'places', 'location', 'navigation', 'timezone'],
37
+ weakTerms: ['location'],
38
+ },
39
+ jobs: {
40
+ triggers: ['jobs', 'careers', 'employment', 'hiring', 'salary', 'resume', 'remote', 'companies', 'internships', 'recruitment'],
41
+ categoryWeights: { jobs: 20 },
42
+ boostTerms: ['jobs', 'careers', 'employment', 'hiring', 'salary', 'resume', 'remote', 'recruitment'],
43
+ weakTerms: ['remote'],
44
+ },
45
+ sports: {
46
+ triggers: ['sports', 'scores', 'teams', 'leagues', 'fixtures', 'odds', 'football', 'basketball', 'baseball', 'soccer', 'standings', 'stats'],
47
+ categoryWeights: { 'sports & fitness': 18, entertainment: 4 },
48
+ boostTerms: ['sports', 'scores', 'teams', 'leagues', 'fixtures', 'odds', 'football', 'basketball', 'baseball', 'soccer', 'standings', 'stats'],
49
+ weakTerms: ['scores', 'stats'],
50
+ },
51
+ media: {
52
+ triggers: ['movies', 'movie', 'tv', 'shows', 'streaming', 'actors', 'ratings', 'posters', 'trailers', 'anime', 'imdb', 'films', 'episodes'],
53
+ categoryWeights: { entertainment: 16, anime: 14, media: 12, video: 8 },
54
+ boostTerms: ['movie', 'movies', 'tv', 'show', 'shows', 'streaming', 'actors', 'ratings', 'posters', 'trailers', 'anime', 'imdb', 'film', 'films', 'episodes'],
55
+ weakTerms: ['media'],
56
+ },
57
+ news: {
58
+ triggers: ['news', 'headlines', 'articles', 'breaking', 'media', 'newspapers', 'topics', 'politics', 'world', 'latest'],
59
+ categoryWeights: { news: 20, media: 6 },
60
+ boostTerms: ['news', 'headlines', 'articles', 'breaking', 'newspapers', 'topics', 'politics', 'world', 'latest'],
61
+ weakTerms: ['media', 'search'],
62
+ },
63
+ government: {
64
+ triggers: ['government', 'census', 'legislation', 'representatives', 'elections', 'bills', 'federal', 'agencies', 'public', 'civic'],
65
+ categoryWeights: { government: 18, 'open data': 10 },
66
+ boostTerms: ['government', 'census', 'legislation', 'representatives', 'elections', 'bills', 'federal', 'agencies', 'public data', 'civic'],
67
+ weakTerms: ['public', 'data'],
68
+ },
69
+ commerce: {
70
+ triggers: ['commerce', 'products', 'product', 'prices', 'deals', 'coupons', 'barcode', 'ecommerce', 'shopping', 'reviews', 'inventory', 'catalog', 'store'],
71
+ categoryWeights: { ecommerce: 18, shopping: 16, 'test data': 14, food: 10, 'food & drink': 10, 'open data': 3 },
72
+ boostTerms: ['commerce', 'products', 'product', 'prices', 'deals', 'coupons', 'barcode', 'ecommerce', 'shopping', 'reviews', 'inventory', 'catalog', 'store'],
73
+ weakTerms: ['price', 'prices'],
74
+ },
29
75
  };
30
76
 
77
+ const CURATED_APIS = [
78
+ { name: 'CoinMarketCap', url: 'https://coinmarketcap.com/api/', description: 'Popular cryptocurrency market data, rankings, quotes, metadata, and exchange data.', auth: 'apiKey', https: true, cors: 'Unknown', category: 'Cryptocurrency', source: 'curated', sourceWeight: 5 },
79
+ { name: 'CoinGecko', url: 'https://www.coingecko.com/en/api', description: 'Popular cryptocurrency prices, market data, tokens, exchanges, and DeFi data.', auth: 'apiKey', https: true, cors: 'Yes', category: 'Cryptocurrency', source: 'curated', sourceWeight: 5 },
80
+
81
+ { name: 'Coinpaprika', url: 'https://api.coinpaprika.com/', description: 'Cryptocurrency prices, coins, market data, exchanges, and historical data.', auth: 'No', https: true, cors: 'Yes', category: 'Cryptocurrency', source: 'curated', sourceWeight: 5 },
82
+ { name: 'CoinCap', url: 'https://docs.coincap.io/', description: 'Real-time cryptocurrency prices, assets, rates, exchanges, and markets.', auth: 'No', https: true, cors: 'Unknown', category: 'Cryptocurrency', source: 'curated', sourceWeight: 5 },
83
+ { name: 'Coinbase', url: 'https://docs.cloud.coinbase.com/', description: 'Coinbase crypto exchange, wallet, price, account, and trading APIs.', auth: 'apiKey', https: true, cors: 'Unknown', category: 'Cryptocurrency', source: 'curated', sourceWeight: 5 },
84
+ { name: 'Alpha Vantage', url: 'https://www.alphavantage.co/documentation/', description: 'Stock, ETF, forex, crypto, technical indicators, and market data API.', auth: 'apiKey', https: true, cors: 'Unknown', category: 'Finance', source: 'curated', sourceWeight: 5 },
85
+ { name: 'Polygon', url: 'https://polygon.io/docs/', description: 'Stock market, options, forex, crypto, tickers, trades, aggregates, and historical market data.', auth: 'apiKey', https: true, cors: 'Unknown', category: 'Finance', source: 'curated', sourceWeight: 5 },
86
+ { name: 'Twelve Data', url: 'https://twelvedata.com/docs', description: 'Stock, forex, ETF, index, and crypto market data with real-time and historical prices.', auth: 'apiKey', https: true, cors: 'Unknown', category: 'Finance', source: 'curated', sourceWeight: 5 },
87
+ { name: 'Tradier', url: 'https://developer.tradier.com/', description: 'US equity, options, quotes, market data, trading, and brokerage API.', auth: 'OAuth', https: true, cors: 'Yes', category: 'Finance', source: 'curated', sourceWeight: 5 },
88
+ { name: 'Finnhub', url: 'https://finnhub.io/docs/api', description: 'Real-time stock, forex, crypto, company fundamentals, news, and alternative market data.', auth: 'apiKey', https: true, cors: 'Unknown', category: 'Finance', source: 'curated', sourceWeight: 5 },
89
+ { name: 'Open-Meteo', url: 'https://open-meteo.com/en/docs', description: 'Free weather forecast, historical weather, climate, geocoding, and marine weather APIs.', auth: 'No', https: true, cors: 'Yes', category: 'Weather', source: 'curated', sourceWeight: 5 },
90
+ { name: 'National Weather Service API', url: 'https://www.weather.gov/documentation/services-web-api', description: 'US weather alerts, forecasts, observations, radar stations, and gridpoint weather data.', auth: 'No', https: true, cors: 'Yes', category: 'Weather', source: 'curated', sourceWeight: 5 },
91
+ { name: 'Pirate Weather', url: 'https://pirateweather.net/en/latest/', description: 'Weather forecast API compatible with Dark Sky-style forecast data.', auth: 'No', https: true, cors: 'Yes', category: 'Weather', source: 'curated', sourceWeight: 5 },
92
+ { name: 'Geocod.io', url: 'https://www.geocod.io/docs/', description: 'Forward and reverse geocoding, address parsing, coordinates, and census data.', auth: 'apiKey', https: true, cors: 'Unknown', category: 'Geocoding', source: 'curated', sourceWeight: 5 },
93
+ { name: 'GraphHopper', url: 'https://docs.graphhopper.com/', description: 'Routing, navigation, route optimization, matrix, geocoding, and map matching APIs.', auth: 'apiKey', https: true, cors: 'Unknown', category: 'Geocoding', source: 'curated', sourceWeight: 5 },
94
+ { name: 'GraphQL Jobs', url: 'https://graphql.jobs/docs/api/', description: 'GraphQL job listings and employment search API.', auth: 'No', https: true, cors: 'Yes', category: 'Jobs', source: 'curated', sourceWeight: 5 },
95
+ { name: 'Search.gov Jobs', url: 'https://search.gov/developer/jobs.html', description: 'US government job openings and federal jobs search API.', auth: 'No', https: true, cors: 'Unknown', category: 'Jobs', source: 'curated', sourceWeight: 5 },
96
+ { name: 'API-FOOTBALL', url: 'https://www.api-football.com/documentation-v3', description: 'Football/soccer fixtures, standings, teams, players, odds, predictions, and stats.', auth: 'apiKey', https: true, cors: 'Unknown', category: 'Sports & Fitness', source: 'curated', sourceWeight: 5 },
97
+ { name: 'Football-Data', url: 'https://www.football-data.org/documentation/quickstart', description: 'Football competitions, teams, fixtures, matches, standings, and scores.', auth: 'apiKey', https: true, cors: 'Unknown', category: 'Sports & Fitness', source: 'curated', sourceWeight: 5 },
98
+ { name: 'TVMaze', url: 'https://www.tvmaze.com/api', description: 'TV shows, episodes, schedules, cast, search, and show metadata API.', auth: 'No', https: true, cors: 'Yes', category: 'Entertainment', source: 'curated', sourceWeight: 5 },
99
+ { name: 'Jikan', url: 'https://docs.api.jikan.moe/', description: 'Unofficial MyAnimeList anime, manga, characters, rankings, and search API.', auth: 'No', https: true, cors: 'Yes', category: 'Anime', source: 'curated', sourceWeight: 5 },
100
+ { name: 'AniList', url: 'https://docs.anilist.co/', description: 'Anime and manga GraphQL API for titles, characters, studios, staff, and lists.', auth: 'OAuth', https: true, cors: 'Yes', category: 'Anime', source: 'curated', sourceWeight: 5 },
101
+ { name: 'GNews', url: 'https://gnews.io/docs/', description: 'News headlines, article search, topics, countries, and languages API.', auth: 'apiKey', https: true, cors: 'Unknown', category: 'News', source: 'curated', sourceWeight: 5 },
102
+ { name: 'Currents', url: 'https://currentsapi.services/en/docs/', description: 'Latest news, headlines, search, topics, regions, and languages API.', auth: 'apiKey', https: true, cors: 'Unknown', category: 'News', source: 'curated', sourceWeight: 5 },
103
+ { name: 'Data.gov', url: 'https://api.data.gov/docs/', description: 'US government API catalog and API key access for federal public data APIs.', auth: 'apiKey', https: true, cors: 'Unknown', category: 'Government', source: 'curated', sourceWeight: 5 },
104
+ { name: 'Open Food Facts', url: 'https://world.openfoodfacts.org/data', description: 'Food product database with barcodes, ingredients, nutrition, labels, and product metadata.', auth: 'No', https: true, cors: 'Yes', category: 'Food & Drink', source: 'curated', sourceWeight: 5 },
105
+ { name: 'Barcode Lookup', url: 'https://www.barcodelookup.com/api', description: 'Barcode, UPC, EAN, product lookup, catalog, pricing, images, and store product data.', auth: 'apiKey', https: true, cors: 'Unknown', category: 'Shopping', source: 'curated', sourceWeight: 5 },
106
+ { name: 'OpenWeather', url: 'https://openweathermap.org/api', description: 'Weather forecasts, current weather, historical weather, alerts, geocoding, and maps.', auth: 'apiKey', https: true, cors: 'Unknown', category: 'Weather', source: 'curated', sourceWeight: 5 },
107
+ { name: 'News API', url: 'https://newsapi.org/', description: 'News headlines and article search across publishers and topics.', auth: 'apiKey', https: true, cors: 'Unknown', category: 'News', source: 'curated', sourceWeight: 5 },
108
+ { name: 'The Guardian Open Platform', url: 'https://open-platform.theguardian.com/', description: 'Guardian articles, sections, tags, search, and content API.', auth: 'apiKey', https: true, cors: 'Unknown', category: 'News', source: 'curated', sourceWeight: 5 },
109
+ { name: 'TMDb', url: 'https://developer.themoviedb.org/docs', description: 'Movie and TV metadata, ratings, posters, images, actors, and discovery.', auth: 'apiKey', https: true, cors: 'Unknown', category: 'Entertainment', source: 'curated', sourceWeight: 5 },
110
+ { name: 'OMDb', url: 'https://www.omdbapi.com/', description: 'Movie and TV metadata by IMDb ID or title.', auth: 'apiKey', https: true, cors: 'Unknown', category: 'Entertainment', source: 'curated', sourceWeight: 5 },
111
+ { name: 'OpenStreetMap Nominatim', url: 'https://nominatim.org/release-docs/latest/api/Overview/', description: 'OpenStreetMap geocoding and reverse geocoding API.', auth: 'No', https: true, cors: 'Yes', category: 'Geocoding', source: 'curated', sourceWeight: 5 },
112
+ { name: 'Mapbox', url: 'https://docs.mapbox.com/api/', description: 'Maps, geocoding, routing, navigation, tiles, and location APIs.', auth: 'apiKey', https: true, cors: 'Yes', category: 'Geocoding', source: 'curated', sourceWeight: 5 },
113
+ { name: 'USAJOBS', url: 'https://developer.usajobs.gov/', description: 'US federal government job listings and hiring data.', auth: 'apiKey', https: true, cors: 'Unknown', category: 'Jobs', source: 'curated', sourceWeight: 5 },
114
+ { name: 'Adzuna', url: 'https://developer.adzuna.com/', description: 'Job search, salary, vacancies, and employment market data.', auth: 'apiKey', https: true, cors: 'Unknown', category: 'Jobs', source: 'curated', sourceWeight: 5 },
115
+ { name: 'TheSportsDB', url: 'https://www.thesportsdb.com/api.php', description: 'Sports teams, leagues, events, scores, players, and media.', auth: 'apiKey', https: true, cors: 'Yes', category: 'Sports & Fitness', source: 'curated', sourceWeight: 5 },
116
+ { name: 'balldontlie', url: 'https://www.balldontlie.io/', description: 'Basketball/NBA teams, players, games, stats, and seasons.', auth: 'No', https: true, cors: 'Yes', category: 'Sports & Fitness', source: 'curated', sourceWeight: 5 },
117
+ { name: 'Census Data API', url: 'https://www.census.gov/data/developers/data-sets.html', description: 'US Census datasets, demographics, geography, ACS, and economic data.', auth: 'No', https: true, cors: 'Yes', category: 'Government', source: 'curated', sourceWeight: 5 },
118
+ { name: 'OpenFEC', url: 'https://api.open.fec.gov/developers/', description: 'US campaign finance, candidates, committees, filings, and election data.', auth: 'apiKey', https: true, cors: 'Unknown', category: 'Government', source: 'curated', sourceWeight: 5 },
119
+ { name: 'Fake Store API', url: 'https://fakestoreapi.com/', description: 'Fake ecommerce products, carts, users, and categories for demos and prototypes.', auth: 'No', https: true, cors: 'Yes', category: 'Shopping', source: 'curated', sourceWeight: 5 },
120
+ { name: 'Open Food Facts', url: 'https://world.openfoodfacts.org/data', description: 'Food product database with barcodes, ingredients, nutrition, and labels.', auth: 'No', https: true, cors: 'Yes', category: 'Food', source: 'curated', sourceWeight: 5 },
121
+ ];
122
+
123
+ function compactName(value) { return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, ''); }
124
+ const KNOWN_BEST_NAMES = new Map(CURATED_APIS.map(api => [compactName(api.name), 8]));
125
+
31
126
  function detectDomains(queryTokens) {
32
127
  return Object.entries(DOMAIN_PROFILES)
33
128
  .filter(([, profile]) => profile.triggers.some(t => queryTokens.has(t)))
@@ -67,7 +162,7 @@ Usage:
67
162
 
68
163
  Options:
69
164
  --category <name> Filter by category substring
70
- --source <name> Filter by source: public-api-lists, public-apis, apis-guru
165
+ --source <name> Filter by source: public-api-lists, public-apis, apis-guru, curated
71
166
  --no-auth Only APIs with Auth = No
72
167
  --https Only HTTPS APIs
73
168
  --cors <value> Filter by CORS: Yes, No, Unknown
@@ -126,12 +221,26 @@ function textScore(entry, queryTokens) {
126
221
  + intersectionCount(queryTokens, all);
127
222
  }
128
223
 
224
+ function asciiRatio(value) {
225
+ const s = String(value || '');
226
+ if (!s.length) return 1;
227
+ let ascii = 0;
228
+ for (const ch of s) if (ch.charCodeAt(0) <= 127) ascii++;
229
+ return ascii / s.length;
230
+ }
231
+
129
232
  function score(entry, queryTokens) {
130
233
  let base = textScore(entry, queryTokens);
131
234
  if (entry.openapiUrl) base += 2;
132
235
  if (entry.sources?.length > 1) base += 2;
133
236
  if (entry.auth === 'No') base += 1;
134
237
  if (entry.https) base += 1;
238
+ if (asciiRatio(entry.name) < 0.7) base -= 28;
239
+ else if (asciiRatio(`${entry.name || ''} ${entry.description || ''}`) < 0.65) base -= 18;
240
+ const compactEntryName = compactName(entry.name);
241
+ for (const [name, weight] of KNOWN_BEST_NAMES) {
242
+ if (compactEntryName.includes(name) || name.includes(compactEntryName)) base += weight;
243
+ }
135
244
  return base + domainAdjustment(entry, queryTokens);
136
245
  }
137
246
 
@@ -252,12 +361,16 @@ async function buildData() {
252
361
  sourceStatus['apis-guru'] = rows.length;
253
362
  entries.push(...rows);
254
363
  } else sourceStatus['apis-guru'] = `error: ${guru.reason.message}`;
364
+ sourceStatus.curated = CURATED_APIS.length;
365
+ entries.push(...CURATED_APIS);
255
366
  return { generatedAt: new Date().toISOString(), sourceStatus, entries: dedupe(entries) };
256
367
  }
257
368
 
258
369
  function keyFor(entry) {
370
+ const compact = compactName(entry.name);
371
+ if (KNOWN_BEST_NAMES.has(compact)) return `known:${compact}`;
259
372
  const host = String(entry.url || '').toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0];
260
- return `${String(entry.name || '').toLowerCase().replace(/[^a-z0-9]+/g, '')}|${host}`;
373
+ return `${compact}|${host}`;
261
374
  }
262
375
 
263
376
  function mergeEntry(a, b) {