-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgameFinder.js
More file actions
295 lines (236 loc) · 10.2 KB
/
Copy pathgameFinder.js
File metadata and controls
295 lines (236 loc) · 10.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
// Game Finder discovery orchestrator. Steam's Early Access top sellers are the
// spine of the list — studios with real momentum worth tracking. Twitch's top
// games are folded in as a bonus signal: live viewers lift a game's score but
// aren't required, so a strong EA seller nobody streams still surfaces. (Pure
// Twitch games that aren't EA top sellers are dropped — that's the noise the EA
// filter exists to cut.) Survivors are enriched with developer/date + the current
// concurrent-player count, scored as Steam rank + Twitch viewers + players, with
// already-tracked games dropped and admin-ignored ones hidden. Server-side only:
// Twitch needs the client secret and Steam sends no CORS headers, so neither can
// be called from the browser.
import { resolveGameIds, getGameViewers } from './twitch.js';
import { search as searchSteam, enrich as enrichSteam, currentPlayers, APP_URL as STEAM_APP_URL } from './steam.js';
import * as ignoreStore from './ignoreStore.js';
const API_BASE = 'https://api.developertracker.com';
// Pool is built larger than the visible result count so ignoring games simply
// promotes the next-ranked ones (backfill) instead of shrinking the list.
const POOL_SIZE = 60;
const RESULT_LIMIT = 30;
// Politeness gap between the per-app Steam enrichment calls.
const ENRICH_DELAY_MS = 120;
// Cache a scan for this long so reopening the page, ignoring a game, or a stray
// refresh is instant; the Rescan button forces a fresh scan.
const CACHE_TTL_MS = 10 * 60 * 1000;
let cache = null;
const sleep = function sleep ( ms ) {
return new Promise( ( resolve ) => {
setTimeout( resolve, ms );
} );
};
// Collapse a game title to a comparison key: lowercase, drop everything that
// isn't a letter or digit. Used for dedup, cross-source merge identity, and the
// ignore list. Matching is deliberately loose — the page only suggests
// candidates to a human, so over-suggesting is cheap while a false match that
// hides a genuinely new game is the expensive failure.
const normalizeName = function normalizeName ( value ) {
return String( value || '' ).toLowerCase().replace( /[^a-z0-9]/g, '' );
};
const normalizeService = function normalizeService ( service ) {
return String( service ).toLowerCase().replace( /[\s.]/g, '-' );
};
const sectionsOf = function sectionsOf ( source ) {
const sections = source.findSections || source.allowedSections || [];
return Array.isArray( sections ) ? sections : [ sections ];
};
// Fetch every tracked game and reduce it to two lookup sets: normalized
// names, and the Steam app ids / Twitch category ids from each game's
// config.sources.
const loadTracked = async function loadTracked () {
const response = await fetch( `${ API_BASE }/games`, {
headers: {
Authorization: `Bearer ${ process.env.API_TOKEN }`,
},
} );
if ( !response.ok ) {
throw new Error( `${ API_BASE }/games returned ${ response.status }` );
}
const games = ( await response.json() ).data || [];
const names = new Set();
const ids = new Set();
for ( const game of games ) {
names.add( normalizeName( game.name ) );
names.add( normalizeName( game.shortName ) );
names.add( normalizeName( game.identifier ) );
const sources = game.config && game.config.sources;
if ( !sources ) {
continue;
}
for ( const service in sources ) {
if ( !Reflect.apply( {}.hasOwnProperty, sources, [ service ] ) ) {
continue;
}
const key = normalizeService( service );
if ( key !== 'steam' && key !== 'steam-feed' && key !== 'twitch' ) {
continue;
}
for ( const section of sectionsOf( sources[ service ] ) ) {
ids.add( String( section ) );
}
if ( sources[ service ].name ) {
ids.add( String( sources[ service ].name ) );
}
}
}
names.delete( '' );
return {
ids,
names,
};
};
const makeIsTracked = function makeIsTracked ( tracked ) {
return function isTracked ( id, name ) {
if ( id && tracked.ids.has( String( id ) ) ) {
return true;
}
return tracked.names.has( normalizeName( name ) );
};
};
// Twitch directory slugs are the lowercased name with spaces as hyphens.
const directoryUrl = function directoryUrl ( name ) {
const slug = encodeURIComponent( String( name ).toLowerCase().replace( /\s+/g, '-' ) );
return `https://www.twitch.tv/directory/category/${ slug }`;
};
// Per-source 0-100 sub-scores. Twitch viewers are log-scaled so a mid-tier
// 15k-viewer game isn't crushed by a 100k megagame while magnitude still
// counts; Steam uses its top-seller chart position directly.
const twitchScore = function twitchScore ( viewers, maxViewers ) {
if ( maxViewers <= 0 ) {
return 0;
}
return 100 * ( Math.log( viewers + 1 ) / Math.log( maxViewers + 1 ) );
};
const steamScore = function steamScore ( index, count ) {
return 100 * ( ( count - index ) / count );
};
// Concurrent players, log-scaled across the surviving candidates for the same
// reason as Twitch viewers: a healthy 5k-player game shouldn't read as zero next
// to a 200k juggernaut. A candidate with no player reading contributes 0.
const playerScore = function playerScore ( players, maxPlayers ) {
if ( maxPlayers <= 0 || players <= 0 ) {
return 0;
}
return 100 * ( Math.log( players + 1 ) / Math.log( maxPlayers + 1 ) );
};
// Build the scored pool. Steam's Early Access top sellers are the candidate set
// (ranked by chart position); already-tracked games are dropped and the top
// POOL_SIZE survive. Each is then enriched with developer/date, current players,
// and — looked up per game, not from Twitch's global stream firehose — its live
// Twitch viewers. Viewers + players fold into the score so a game with traction
// on multiple fronts rises, but neither is required.
const scan = async function scan () {
const tracked = await loadTracked();
const isTracked = makeIsTracked( tracked );
const steamRows = await searchSteam();
const steamCount = steamRows.length;
const seen = new Set();
const pool = [];
steamRows.forEach( ( row, index ) => {
const key = normalizeName( row.name );
if ( seen.has( key ) || isTracked( row.id, row.name ) ) {
return;
}
seen.add( key );
pool.push( {
appId: row.id,
developer: null,
key: key,
name: row.name,
players: null,
released: null,
releasedAt: null,
score: steamScore( index, steamCount ),
steamUrl: `${ STEAM_APP_URL }/${ row.id }`,
twitchUrl: null,
viewers: null,
} );
} );
pool.splice( POOL_SIZE );
// One batched lookup turns the pool's names into Twitch game ids; games
// Twitch doesn't know stay absent (viewers: null -> "—").
const twitchIds = await resolveGameIds( pool.map( ( candidate ) => {
return candidate.name;
} ) );
// Enrich only the pool: developer/date + current players + live Twitch
// viewers. The three calls hit different hosts, so run them together and
// pace between candidates to stay polite to Steam's storefront.
for ( const candidate of pool ) {
const twitchGame = twitchIds.get( candidate.key );
const [ details, players, viewers ] = await Promise.all( [
enrichSteam( candidate.appId ),
currentPlayers( candidate.appId ),
twitchGame ? getGameViewers( twitchGame.id ) : Promise.resolve( null ),
] );
candidate.developer = details.developer;
candidate.released = details.released;
candidate.releasedAt = details.releasedAt;
candidate.players = players;
candidate.viewers = viewers;
// A resolved game (even with zero current viewers) links to its Twitch
// directory category from the viewers column.
if ( twitchGame && typeof viewers === 'number' ) {
candidate.twitchUrl = directoryUrl( twitchGame.name );
}
await sleep( ENRICH_DELAY_MS );
}
// Fold the (log-scaled) viewer + player counts into the score now that the
// pool has both readings, then re-rank.
const maxViewers = pool.reduce( ( max, candidate ) => {
return Math.max( max, candidate.viewers || 0 );
}, 0 );
const maxPlayers = pool.reduce( ( max, candidate ) => {
return Math.max( max, candidate.players || 0 );
}, 0 );
for ( const candidate of pool ) {
candidate.score = candidate.score
+ twitchScore( candidate.viewers || 0, maxViewers )
+ playerScore( candidate.players || 0, maxPlayers );
}
return pool.sort( ( first, second ) => {
return second.score - first.score;
} );
};
// Public entry point. Serves the scored pool from cache (unless `force` or
// expiry), then applies the current ignore list and slices the visible result
// count, so ignoring a game promotes the next one in.
export const discover = async function discover ( options ) {
const force = Boolean( options && options.force );
const now = Date.now();
if ( force || !cache || cache.expiresAt <= now ) {
const pool = await scan();
cache = {
expiresAt: now + CACHE_TTL_MS,
pool: pool,
scannedAt: new Date( now ).toISOString(),
};
}
const ignoredNames = [ ...ignoreStore.load() ];
const ignoredKeys = new Set( ignoredNames.map( normalizeName ) );
const candidates = cache.pool
.filter( ( candidate ) => {
return !ignoredKeys.has( candidate.key );
} )
.slice( 0, RESULT_LIMIT );
return {
candidates: candidates,
ignored: ignoredNames.sort(),
scannedAt: cache.scannedAt,
};
};
// Add / remove a display name from the persistent ignore list. Returns the
// updated, sorted list of ignored names.
export const addIgnore = function addIgnore ( name ) {
return [ ...ignoreStore.add( String( name ).trim() ) ].sort();
};
export const removeIgnore = function removeIgnore ( name ) {
return [ ...ignoreStore.remove( String( name ).trim() ) ].sort();
};