ua-outages 0.1.0 → 0.2.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 +50 -12
- package/dist/providers/posts.d.ts +10 -1
- package/dist/providers/posts.d.ts.map +1 -1
- package/dist/providers/posts.js +7 -4
- package/dist/providers/posts.js.map +1 -1
- package/dist/providers/uz.d.ts.map +1 -1
- package/dist/providers/uz.js +22 -1
- package/dist/providers/uz.js.map +1 -1
- package/dist/providers/zaporizhzhia.d.ts.map +1 -1
- package/dist/providers/zaporizhzhia.js +4 -2
- package/dist/providers/zaporizhzhia.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +133 -39
- package/src/http.ts +73 -12
- package/src/index.ts +7 -1
- package/src/providers/gpv.ts +10 -0
- package/src/providers/image-schedule.ts +3 -0
- package/src/providers/ivano-frankivsk.ts +7 -4
- package/src/providers/khmelnytskyi.ts +13 -4
- package/src/providers/kirovohrad.ts +12 -8
- package/src/providers/lviv.ts +4 -2
- package/src/providers/mykolaiv.ts +5 -3
- package/src/providers/posts.ts +26 -5
- package/src/providers/provider.ts +8 -0
- package/src/providers/sumy.ts +4 -5
- package/src/providers/ternopil.ts +10 -16
- package/src/providers/ua-text.ts +18 -4
- package/src/providers/uz.ts +25 -3
- package/src/providers/zakarpattia.ts +12 -5
- package/src/providers/zaporizhzhia.ts +4 -2
- package/src/schedule.ts +27 -1
- package/src/types.ts +6 -1
- package/src/util.ts +82 -3
package/src/client.ts
CHANGED
|
@@ -18,7 +18,7 @@ import type {
|
|
|
18
18
|
RegionSchedule,
|
|
19
19
|
Street,
|
|
20
20
|
} from './types.js'
|
|
21
|
-
import { mapLimit } from './util.js'
|
|
21
|
+
import { abortable, mapLimit } from './util.js'
|
|
22
22
|
|
|
23
23
|
export interface OutageClientOptions extends HttpOptions {
|
|
24
24
|
/** How long fetched schedules are reused, default 5 min. 0 disables the cache. */
|
|
@@ -38,6 +38,8 @@ export interface HealthCheckOptions {
|
|
|
38
38
|
staleAfterHours?: number
|
|
39
39
|
/** Parallel requests, default 6 */
|
|
40
40
|
concurrency?: number
|
|
41
|
+
/** Cancels the check: no further regions are fetched and the call rejects with the signal's reason */
|
|
42
|
+
signal?: AbortSignal
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
export interface HealthReport {
|
|
@@ -60,7 +62,9 @@ export class OutageClient {
|
|
|
60
62
|
private readonly ctx: ProviderContext
|
|
61
63
|
private readonly providers: ReadonlyMap<RegionId, Provider>
|
|
62
64
|
private readonly cacheTtlMs: number
|
|
63
|
-
private readonly cache = new Map<RegionId, { at: number; value: Promise<RegionSchedule> }>()
|
|
65
|
+
private readonly cache = new Map<RegionId, { at: number; seq: number; value: Promise<RegionSchedule> }>()
|
|
66
|
+
/** Fetches started, so a slower older one never replaces a newer one in the cache */
|
|
67
|
+
private seq = 0
|
|
64
68
|
private readonly searchCache = new Map<string, { at: number; value: Promise<unknown[]> }>()
|
|
65
69
|
|
|
66
70
|
constructor(opts: OutageClientOptions = {}) {
|
|
@@ -90,22 +94,45 @@ export class OutageClient {
|
|
|
90
94
|
|
|
91
95
|
// --- schedules ---
|
|
92
96
|
|
|
93
|
-
/**
|
|
94
|
-
|
|
97
|
+
/**
|
|
98
|
+
* All groups of a region.
|
|
99
|
+
*
|
|
100
|
+
* `signal` cancels the call: it rejects with the signal's reason right away and sends no
|
|
101
|
+
* further requests to the operator (the one in flight is aborted too).
|
|
102
|
+
*/
|
|
103
|
+
async getSchedules(region: RegionId, opts: ScheduleOptions = {}): Promise<RegionSchedule> {
|
|
95
104
|
const p = this.provider(region)
|
|
105
|
+
const { signal } = opts
|
|
106
|
+
signal?.throwIfAborted()
|
|
96
107
|
const hit = this.cache.get(region)
|
|
97
108
|
const now = Date.now()
|
|
98
|
-
if (!opts.fresh && hit && now - hit.at < this.cacheTtlMs) return hit.value
|
|
99
|
-
const value = p.getSchedules(this.
|
|
109
|
+
if (!opts.fresh && hit && now - hit.at < this.cacheTtlMs) return abortable(hit.value, signal)
|
|
110
|
+
const value = p.getSchedules(this.context(signal))
|
|
111
|
+
const entry = { at: now, seq: ++this.seq, value }
|
|
100
112
|
if (this.cacheTtlMs > 0) {
|
|
101
|
-
|
|
102
|
-
|
|
113
|
+
if (signal) {
|
|
114
|
+
// a cancellable call isn't shared while it runs, so another caller never inherits its
|
|
115
|
+
// cancellation; what it fetched is cached once it arrives, unless a fetch started later
|
|
116
|
+
// already is
|
|
117
|
+
value.then(
|
|
118
|
+
() => {
|
|
119
|
+
if ((this.cache.get(region)?.seq ?? 0) < entry.seq) this.cache.set(region, entry)
|
|
120
|
+
},
|
|
121
|
+
() => {},
|
|
122
|
+
)
|
|
123
|
+
} else {
|
|
124
|
+
this.cache.set(region, entry)
|
|
125
|
+
value.catch(() => {
|
|
126
|
+
if (this.cache.get(region) === entry) this.cache.delete(region)
|
|
127
|
+
})
|
|
128
|
+
}
|
|
103
129
|
}
|
|
104
|
-
|
|
130
|
+
// a provider may wait on work it shares with other calls: the caller still leaves at once
|
|
131
|
+
return abortable(value, signal)
|
|
105
132
|
}
|
|
106
133
|
|
|
107
134
|
/** One group. Accepts loose spellings: "3.1", "GPV3.1", "черга 3.1". */
|
|
108
|
-
async getSchedule(region: RegionId, group: string, opts:
|
|
135
|
+
async getSchedule(region: RegionId, group: string, opts: ScheduleOptions = {}): Promise<GroupSchedule> {
|
|
109
136
|
const id = normalizeGroup(group)
|
|
110
137
|
const all = await this.getSchedules(region, opts)
|
|
111
138
|
const days = id ? all.schedules[id] : undefined
|
|
@@ -128,16 +155,26 @@ export class OutageClient {
|
|
|
128
155
|
region: RegionId,
|
|
129
156
|
query: string,
|
|
130
157
|
opts: SearchOptions & { district?: string } = {},
|
|
158
|
+
): Promise<City[]> {
|
|
159
|
+
return this.searchCitiesIn(this.ctx, region, query, opts)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private async searchCitiesIn(
|
|
163
|
+
ctx: ProviderContext,
|
|
164
|
+
region: RegionId,
|
|
165
|
+
query: string,
|
|
166
|
+
opts: SearchOptions & { district?: string },
|
|
131
167
|
): Promise<City[]> {
|
|
132
168
|
const p = this.provider(region)
|
|
133
169
|
if (!p.searchCities) return []
|
|
134
170
|
const found = await this.rankedSearch(
|
|
171
|
+
ctx,
|
|
135
172
|
region,
|
|
136
173
|
'settlement',
|
|
137
174
|
query,
|
|
138
175
|
'cities',
|
|
139
176
|
!!p.remoteSearch?.cities,
|
|
140
|
-
(q) => p.searchCities!(
|
|
177
|
+
(q) => p.searchCities!(ctx, q),
|
|
141
178
|
opts.district ? MAX_CANDIDATES : (opts.limit ?? DEFAULT_LIMIT),
|
|
142
179
|
)
|
|
143
180
|
if (!opts.district) return found
|
|
@@ -156,17 +193,27 @@ export class OutageClient {
|
|
|
156
193
|
region: RegionId,
|
|
157
194
|
query: string,
|
|
158
195
|
opts: SearchOptions & { city?: City } = {},
|
|
196
|
+
): Promise<Street[]> {
|
|
197
|
+
return this.searchStreetsIn(this.ctx, region, query, opts)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private async searchStreetsIn(
|
|
201
|
+
ctx: ProviderContext,
|
|
202
|
+
region: RegionId,
|
|
203
|
+
query: string,
|
|
204
|
+
opts: SearchOptions & { city?: City },
|
|
159
205
|
): Promise<Street[]> {
|
|
160
206
|
const p = this.addressProvider(region)
|
|
161
207
|
if (p.searchCities && !opts.city)
|
|
162
208
|
throw new OutageError('not-found', `${p.info.name}: pick a city first`, { region })
|
|
163
209
|
return this.rankedSearch(
|
|
210
|
+
ctx,
|
|
164
211
|
region,
|
|
165
212
|
'street',
|
|
166
213
|
query,
|
|
167
214
|
`streets:${opts.city?.id ?? ''}`,
|
|
168
215
|
!!p.remoteSearch?.streets,
|
|
169
|
-
(q) => p.searchStreets!(
|
|
216
|
+
(q) => p.searchStreets!(ctx, q, opts.city),
|
|
170
217
|
opts.limit ?? DEFAULT_LIMIT,
|
|
171
218
|
)
|
|
172
219
|
}
|
|
@@ -187,9 +234,9 @@ export class OutageClient {
|
|
|
187
234
|
async prefetch(region: RegionId, opts: { city?: City } = {}): Promise<void> {
|
|
188
235
|
const p = this.provider(region)
|
|
189
236
|
if (p.searchCities && !p.remoteSearch?.cities)
|
|
190
|
-
await this.candidates(region, 'cities', '', false, (q) => p.searchCities!(this.ctx, q))
|
|
237
|
+
await this.candidates(this.ctx, region, 'cities', '', false, (q) => p.searchCities!(this.ctx, q))
|
|
191
238
|
if (opts.city && p.searchStreets && !p.remoteSearch?.streets)
|
|
192
|
-
await this.candidates(region, `streets:${opts.city.id}`, '', false, (q) =>
|
|
239
|
+
await this.candidates(this.ctx, region, `streets:${opts.city.id}`, '', false, (q) =>
|
|
193
240
|
p.searchStreets!(this.ctx, q, opts.city),
|
|
194
241
|
)
|
|
195
242
|
}
|
|
@@ -199,24 +246,35 @@ export class OutageClient {
|
|
|
199
246
|
* or settlement can't be picked with confidence, throws `not-found` listing the suggestions.
|
|
200
247
|
*/
|
|
201
248
|
async findGroup(region: RegionId, address: AddressQuery): Promise<GroupMatch> {
|
|
249
|
+
return this.findGroupIn(this.ctx, region, address)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private async findGroupIn(
|
|
253
|
+
ctx: ProviderContext,
|
|
254
|
+
region: RegionId,
|
|
255
|
+
address: AddressQuery,
|
|
256
|
+
): Promise<GroupMatch> {
|
|
202
257
|
const direct = this.provider(region)
|
|
203
|
-
if (direct.lookupAddress) return direct.lookupAddress(
|
|
258
|
+
if (direct.lookupAddress) return direct.lookupAddress(ctx, address)
|
|
204
259
|
const p = this.addressProvider(region)
|
|
205
260
|
|
|
206
261
|
let city: City | undefined
|
|
207
262
|
if (p.searchCities) {
|
|
208
263
|
if (!address.city)
|
|
209
264
|
throw new OutageError('not-found', `${p.info.name}: specify a city or village`, { region })
|
|
210
|
-
const cities = await this.
|
|
265
|
+
const cities = await this.searchCitiesIn(ctx, region, address.city, {
|
|
266
|
+
limit: 10,
|
|
267
|
+
district: address.district,
|
|
268
|
+
})
|
|
211
269
|
city = choose(address.city, cities, 'city', region, (c) =>
|
|
212
270
|
c.district ? `${c.name} (${c.district})` : c.name,
|
|
213
271
|
)
|
|
214
272
|
}
|
|
215
273
|
|
|
216
|
-
const streets = await this.
|
|
274
|
+
const streets = await this.searchStreetsIn(ctx, region, address.street, { city, limit: 10 })
|
|
217
275
|
const street = choose(address.street, streets, 'street', region, (s) => s.name)
|
|
218
276
|
|
|
219
|
-
const houses = await p.searchHouses!(
|
|
277
|
+
const houses = await p.searchHouses!(ctx, street, address.house)
|
|
220
278
|
const want = normalizeHouse(address.house)
|
|
221
279
|
const house = houses.find((h) => normalizeHouse(h.number) === want)
|
|
222
280
|
if (!house)
|
|
@@ -230,7 +288,7 @@ export class OutageClient {
|
|
|
230
288
|
}`,
|
|
231
289
|
{ region },
|
|
232
290
|
)
|
|
233
|
-
return p.groupOf!(
|
|
291
|
+
return p.groupOf!(ctx, house, street, city)
|
|
234
292
|
}
|
|
235
293
|
|
|
236
294
|
/**
|
|
@@ -239,6 +297,7 @@ export class OutageClient {
|
|
|
239
297
|
* query alone finds little, so a typo in one part of the name still lands.
|
|
240
298
|
*/
|
|
241
299
|
private async rankedSearch<T extends { id: string; name: string }>(
|
|
300
|
+
ctx: ProviderContext,
|
|
242
301
|
region: RegionId,
|
|
243
302
|
kind: NameKind,
|
|
244
303
|
query: string,
|
|
@@ -248,13 +307,17 @@ export class OutageClient {
|
|
|
248
307
|
limit: number,
|
|
249
308
|
): Promise<(T & { score: number })[]> {
|
|
250
309
|
if (!query.trim()) return []
|
|
251
|
-
let pool = await this.candidates(region, scope, query, remote, fetch)
|
|
310
|
+
let pool = await this.candidates(ctx, region, scope, query, remote, fetch)
|
|
252
311
|
let ranked = rankByName(query, pool, (t) => t.name, kind)
|
|
253
312
|
// a typo defeats the operator's substring search: retry with fragments of the word
|
|
254
313
|
if (remote && (ranked[0]?.score ?? 0) < 0.9) {
|
|
255
314
|
const extra = await Promise.all(
|
|
256
315
|
searchFragments(query, kind).map((f) =>
|
|
257
|
-
this.candidates(region, scope, f, true, fetch).catch(() =>
|
|
316
|
+
this.candidates(ctx, region, scope, f, true, fetch).catch(() => {
|
|
317
|
+
// a cancelled lookup stops here; any other failure of a fragment is just no extra hits
|
|
318
|
+
ctx.signal?.throwIfAborted()
|
|
319
|
+
return []
|
|
320
|
+
}),
|
|
258
321
|
),
|
|
259
322
|
)
|
|
260
323
|
const seen = new Map(pool.map((t) => [t.id, t]))
|
|
@@ -267,6 +330,7 @@ export class OutageClient {
|
|
|
267
330
|
|
|
268
331
|
/** Candidate lists, cached briefly: whole directories per scope, server results per term. */
|
|
269
332
|
private candidates<T>(
|
|
333
|
+
ctx: ProviderContext,
|
|
270
334
|
region: RegionId,
|
|
271
335
|
scope: string,
|
|
272
336
|
query: string,
|
|
@@ -275,11 +339,19 @@ export class OutageClient {
|
|
|
275
339
|
): Promise<T[]> {
|
|
276
340
|
const key = `${region}|${scope}|${remote ? query.trim().toLowerCase() : ''}`
|
|
277
341
|
const hit = this.searchCache.get(key)
|
|
278
|
-
if (hit && Date.now() - hit.at < Math.max(this.cacheTtlMs, 60_000))
|
|
342
|
+
if (hit && Date.now() - hit.at < Math.max(this.cacheTtlMs, 60_000))
|
|
343
|
+
return abortable(hit.value as Promise<T[]>, ctx.signal)
|
|
279
344
|
const value = fetch(query)
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
345
|
+
const store = () => {
|
|
346
|
+
if (this.searchCache.size > 500) this.searchCache.clear()
|
|
347
|
+
this.searchCache.set(key, { at: Date.now(), value })
|
|
348
|
+
}
|
|
349
|
+
// a cancellable lookup isn't shared while it runs, so no other caller inherits its cancellation
|
|
350
|
+
if (ctx.signal) value.then(store, () => {})
|
|
351
|
+
else {
|
|
352
|
+
store()
|
|
353
|
+
value.catch(() => this.searchCache.delete(key))
|
|
354
|
+
}
|
|
283
355
|
return value
|
|
284
356
|
}
|
|
285
357
|
|
|
@@ -292,17 +364,25 @@ export class OutageClient {
|
|
|
292
364
|
async healthCheck(opts: HealthCheckOptions = {}): Promise<HealthReport> {
|
|
293
365
|
const ids = opts.regions ?? [...this.providers.keys()]
|
|
294
366
|
const staleMs = (opts.staleAfterHours ?? 72) * 3600_000
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
367
|
+
const { signal } = opts
|
|
368
|
+
const results = await mapLimit(
|
|
369
|
+
ids,
|
|
370
|
+
opts.concurrency ?? 6,
|
|
371
|
+
async (id): Promise<RegionHealth> => {
|
|
372
|
+
const health = await this.checkRegion(id, staleMs, signal)
|
|
373
|
+
const p = this.provider(id)
|
|
374
|
+
if (!opts.address || !p.sampleAddress || !p.info.capabilities.addressSearch) return health
|
|
375
|
+
signal?.throwIfAborted()
|
|
376
|
+
try {
|
|
377
|
+
const m = await abortable(this.findGroupIn(this.context(signal), id, p.sampleAddress), signal)
|
|
378
|
+
return { ...health, address: { ok: true, group: m.group } }
|
|
379
|
+
} catch (e) {
|
|
380
|
+
if (signal?.aborted) throw signal.reason
|
|
381
|
+
return { ...health, address: { ok: false, message: (e as Error).message } }
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
signal,
|
|
385
|
+
)
|
|
306
386
|
return {
|
|
307
387
|
checkedAt: this.ctx.now().toISOString(),
|
|
308
388
|
ok: results.every((r) => r.status !== 'error' && (r.address?.ok ?? true)),
|
|
@@ -310,7 +390,7 @@ export class OutageClient {
|
|
|
310
390
|
}
|
|
311
391
|
}
|
|
312
392
|
|
|
313
|
-
private async checkRegion(region: RegionId, staleMs: number): Promise<RegionHealth> {
|
|
393
|
+
private async checkRegion(region: RegionId, staleMs: number, signal?: AbortSignal): Promise<RegionHealth> {
|
|
314
394
|
const info = this.provider(region).info
|
|
315
395
|
// e.g. operators that publish the schedule only as an image: nothing to check
|
|
316
396
|
if (!info.capabilities.schedule)
|
|
@@ -324,11 +404,12 @@ export class OutageClient {
|
|
|
324
404
|
}
|
|
325
405
|
const started = performance.now()
|
|
326
406
|
try {
|
|
327
|
-
const s = await this.getSchedules(region, { fresh: true })
|
|
407
|
+
const s = await this.getSchedules(region, { fresh: true, signal })
|
|
328
408
|
const latencyMs = Math.round(performance.now() - started)
|
|
329
409
|
const base = { region, latencyMs, groups: s.groups.length, updatedAt: s.updatedAt }
|
|
330
410
|
const hasData = Object.values(s.schedules).some((days) =>
|
|
331
|
-
|
|
411
|
+
// anything the operator published counts, a day without outages included
|
|
412
|
+
days.some((d) => d.status !== 'unknown'),
|
|
332
413
|
)
|
|
333
414
|
if (!s.groups.length || !hasData)
|
|
334
415
|
return { ...base, status: 'empty', message: 'No schedule published right now' }
|
|
@@ -336,6 +417,7 @@ export class OutageClient {
|
|
|
336
417
|
return { ...base, status: 'stale', message: `Last updated ${s.updatedAt}` }
|
|
337
418
|
return { ...base, status: 'ok' }
|
|
338
419
|
} catch (e) {
|
|
420
|
+
if (signal?.aborted) throw signal.reason
|
|
339
421
|
// e.g. a region that publishes only an image the SDK can't read
|
|
340
422
|
if (e instanceof OutageError && e.code === 'unsupported')
|
|
341
423
|
return { region, status: 'empty', latencyMs: 0, groups: 0, updatedAt: null, message: e.message }
|
|
@@ -352,6 +434,11 @@ export class OutageClient {
|
|
|
352
434
|
|
|
353
435
|
// --- internals ---
|
|
354
436
|
|
|
437
|
+
/** Provider context for one call; with a signal, its requests stop when the call is cancelled */
|
|
438
|
+
private context(signal?: AbortSignal): ProviderContext {
|
|
439
|
+
return signal ? { ...this.ctx, http: this.ctx.http.withSignal(signal), signal } : this.ctx
|
|
440
|
+
}
|
|
441
|
+
|
|
355
442
|
private provider(id: RegionId): Provider {
|
|
356
443
|
const p = this.providers.get(id)
|
|
357
444
|
if (!p) throw new OutageError('unsupported', `Unknown region "${id}"`, { region: id })
|
|
@@ -407,6 +494,13 @@ const CONFIDENT = 0.7
|
|
|
407
494
|
/** …and only when the runner-up is further behind than this */
|
|
408
495
|
const TIE = 0.025
|
|
409
496
|
|
|
497
|
+
export interface ScheduleOptions {
|
|
498
|
+
/** Skip the cache and fetch from the operator */
|
|
499
|
+
fresh?: boolean
|
|
500
|
+
/** Cancels the call: it rejects with the signal's reason and sends no further requests */
|
|
501
|
+
signal?: AbortSignal
|
|
502
|
+
}
|
|
503
|
+
|
|
410
504
|
export interface SearchOptions {
|
|
411
505
|
/** Max results, default 10 */
|
|
412
506
|
limit?: number
|
package/src/http.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { OutageError } from './errors.js'
|
|
2
2
|
import { httpsFetchWithCa } from './https-fetch.js'
|
|
3
3
|
import type { RegionId } from './types.js'
|
|
4
|
+
import { abortable } from './util.js'
|
|
4
5
|
|
|
5
6
|
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>
|
|
6
7
|
|
|
@@ -41,6 +42,9 @@ const BLOCK_MARKERS = [
|
|
|
41
42
|
'Attention Required!',
|
|
42
43
|
]
|
|
43
44
|
|
|
45
|
+
/** A bot-protection page rather than the operator's content. Challenge pages are small; real pages may embed the same scripts, so size matters. */
|
|
46
|
+
const isChallenge = (body: string) => body.length < 20_000 && BLOCK_MARKERS.some((m) => body.includes(m))
|
|
47
|
+
|
|
44
48
|
export class Http {
|
|
45
49
|
private readonly fetchImpl: FetchLike
|
|
46
50
|
private readonly customFetch: boolean
|
|
@@ -48,6 +52,8 @@ export class Http {
|
|
|
48
52
|
private readonly timeoutMs: number
|
|
49
53
|
private readonly userAgent: string
|
|
50
54
|
private readonly retries: number
|
|
55
|
+
/** Set on copies made by `withSignal`: every request through them stops when it aborts */
|
|
56
|
+
private signal?: AbortSignal
|
|
51
57
|
|
|
52
58
|
constructor(opts: HttpOptions = {}) {
|
|
53
59
|
this.fetchImpl = opts.fetch ?? ((input, init) => globalThis.fetch(input, init))
|
|
@@ -57,6 +63,17 @@ export class Http {
|
|
|
57
63
|
this.retries = opts.retries ?? 1
|
|
58
64
|
}
|
|
59
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The same client (settings, connections) whose requests all stop when `signal` aborts:
|
|
68
|
+
* none starts after that, and the one in flight is cancelled. `undefined` gives an unbound copy.
|
|
69
|
+
*/
|
|
70
|
+
withSignal(signal: AbortSignal | undefined): Http {
|
|
71
|
+
const copy = Object.create(Http.prototype) as Http
|
|
72
|
+
Object.assign(copy, this)
|
|
73
|
+
copy.signal = signal
|
|
74
|
+
return copy
|
|
75
|
+
}
|
|
76
|
+
|
|
60
77
|
async text(url: string, opts: RequestOptions = {}): Promise<string> {
|
|
61
78
|
return (await this.raw(url, opts)).body
|
|
62
79
|
}
|
|
@@ -67,11 +84,11 @@ export class Http {
|
|
|
67
84
|
opts: RequestOptions = {},
|
|
68
85
|
): Promise<{ body: string; headers: Headers; status: number }> {
|
|
69
86
|
const res = await this.request(url, opts)
|
|
87
|
+
const signal = this.signal
|
|
70
88
|
const body = opts.encoding
|
|
71
|
-
? new TextDecoder(opts.encoding).decode(await res.arrayBuffer())
|
|
72
|
-
: await res.text()
|
|
73
|
-
|
|
74
|
-
if (body.length < 20_000 && BLOCK_MARKERS.some((m) => body.includes(m)))
|
|
89
|
+
? new TextDecoder(opts.encoding).decode(await abortable(res.arrayBuffer(), signal))
|
|
90
|
+
: await abortable(res.text(), signal)
|
|
91
|
+
if (isChallenge(body))
|
|
75
92
|
throw new OutageError('blocked', `Bot protection answered instead of ${host(url)}`, {
|
|
76
93
|
region: opts.region,
|
|
77
94
|
url,
|
|
@@ -88,7 +105,8 @@ export class Http {
|
|
|
88
105
|
/** Binary body with the response headers (e.g. Last-Modified of an image) */
|
|
89
106
|
async file(url: string, opts: RequestOptions = {}): Promise<{ bytes: Uint8Array; headers: Headers }> {
|
|
90
107
|
const res = await this.request(url, opts)
|
|
91
|
-
|
|
108
|
+
const bytes = await abortable(res.arrayBuffer(), this.signal)
|
|
109
|
+
return { bytes: new Uint8Array(bytes), headers: res.headers }
|
|
92
110
|
}
|
|
93
111
|
|
|
94
112
|
async json<T = unknown>(url: string, opts: RequestOptions = {}): Promise<T> {
|
|
@@ -112,6 +130,7 @@ export class Http {
|
|
|
112
130
|
}
|
|
113
131
|
|
|
114
132
|
private async request(url: string, opts: RequestOptions): Promise<Response> {
|
|
133
|
+
const signal = this.signal
|
|
115
134
|
const headers: Record<string, string> = {
|
|
116
135
|
'User-Agent': this.userAgent,
|
|
117
136
|
Accept: 'application/json, text/html;q=0.9, */*;q=0.8',
|
|
@@ -127,16 +146,23 @@ export class Http {
|
|
|
127
146
|
|
|
128
147
|
const fetchImpl = opts.ca && !this.customFetch ? this.fetchWithCa(opts.ca) : this.fetchImpl
|
|
129
148
|
for (let attempt = 0; ; attempt++) {
|
|
149
|
+
// a cancelled call sends nothing more, retries included
|
|
150
|
+
signal?.throwIfAborted()
|
|
151
|
+
const timeout = AbortSignal.timeout(this.timeoutMs)
|
|
130
152
|
let res: Response
|
|
131
153
|
try {
|
|
132
|
-
res = await
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
154
|
+
res = await abortable(
|
|
155
|
+
fetchImpl(url, {
|
|
156
|
+
method: opts.method ?? (body ? 'POST' : 'GET'),
|
|
157
|
+
headers,
|
|
158
|
+
body,
|
|
159
|
+
signal: signal ? withTimeout(signal, timeout) : timeout,
|
|
160
|
+
redirect: 'follow',
|
|
161
|
+
}),
|
|
162
|
+
signal,
|
|
163
|
+
)
|
|
139
164
|
} catch (cause) {
|
|
165
|
+
if (signal?.aborted) throw signal.reason
|
|
140
166
|
if (attempt < this.retries) continue
|
|
141
167
|
const timedOut = (cause as Error)?.name === 'TimeoutError'
|
|
142
168
|
throw new OutageError(
|
|
@@ -146,6 +172,17 @@ export class Http {
|
|
|
146
172
|
)
|
|
147
173
|
}
|
|
148
174
|
if (res.ok || opts.acceptStatus?.includes(res.status)) return res
|
|
175
|
+
// a bot check often comes as a 503: look before retrying, and never ask it twice
|
|
176
|
+
const errorBody = await abortable(res.text(), signal).catch(() => {
|
|
177
|
+
if (signal?.aborted) throw signal.reason
|
|
178
|
+
return ''
|
|
179
|
+
})
|
|
180
|
+
if (isChallenge(errorBody))
|
|
181
|
+
throw new OutageError('blocked', `Bot protection answered instead of ${host(url)}`, {
|
|
182
|
+
region: opts.region,
|
|
183
|
+
url,
|
|
184
|
+
status: res.status,
|
|
185
|
+
})
|
|
149
186
|
if (res.status >= 500 && attempt < this.retries) continue
|
|
150
187
|
throw new OutageError(
|
|
151
188
|
res.status === 403 || res.status === 429 ? 'blocked' : 'http',
|
|
@@ -156,6 +193,30 @@ export class Http {
|
|
|
156
193
|
}
|
|
157
194
|
}
|
|
158
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Aborts on the caller's signal or the per-request timeout: `AbortSignal.any`, which Node 20 has
|
|
198
|
+
* only from 20.3. The fallback's listeners go away when either fires, and the timeout always
|
|
199
|
+
* fires, so a caller's signal shared by many requests doesn't collect them.
|
|
200
|
+
*/
|
|
201
|
+
function withTimeout(caller: AbortSignal, timeout: AbortSignal): AbortSignal {
|
|
202
|
+
if (typeof AbortSignal.any === 'function') return AbortSignal.any([timeout, caller])
|
|
203
|
+
const ctrl = new AbortController()
|
|
204
|
+
if (caller.aborted) ctrl.abort(caller.reason)
|
|
205
|
+
else if (timeout.aborted) ctrl.abort(timeout.reason)
|
|
206
|
+
else {
|
|
207
|
+
const onCaller = () => ctrl.abort(caller.reason)
|
|
208
|
+
const onTimeout = () => ctrl.abort(timeout.reason)
|
|
209
|
+
const cleanup = () => {
|
|
210
|
+
caller.removeEventListener('abort', onCaller)
|
|
211
|
+
timeout.removeEventListener('abort', onTimeout)
|
|
212
|
+
}
|
|
213
|
+
caller.addEventListener('abort', onCaller, { once: true })
|
|
214
|
+
timeout.addEventListener('abort', onTimeout, { once: true })
|
|
215
|
+
ctrl.signal.addEventListener('abort', cleanup, { once: true })
|
|
216
|
+
}
|
|
217
|
+
return ctrl.signal
|
|
218
|
+
}
|
|
219
|
+
|
|
159
220
|
function host(url: string) {
|
|
160
221
|
try {
|
|
161
222
|
return new URL(url).host
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
export { OutageClient } from './client.js'
|
|
2
|
-
export type {
|
|
2
|
+
export type {
|
|
3
|
+
HealthCheckOptions,
|
|
4
|
+
HealthReport,
|
|
5
|
+
OutageClientOptions,
|
|
6
|
+
ScheduleOptions,
|
|
7
|
+
SearchOptions,
|
|
8
|
+
} from './client.js'
|
|
3
9
|
export { OutageError, isOutageError } from './errors.js'
|
|
4
10
|
export { fuzzyScore, rankByName } from './fuzzy.js'
|
|
5
11
|
export type { NameKind } from './fuzzy.js'
|
package/src/providers/gpv.ts
CHANGED
|
@@ -63,6 +63,16 @@ export function parseGpvDays(
|
|
|
63
63
|
for (const [rawGroup, cells] of Object.entries(groups)) {
|
|
64
64
|
const group = normalizeGroup(rawGroup)
|
|
65
65
|
if (!group || !cells || typeof cells !== 'object') continue
|
|
66
|
+
// a group without cells says nothing about its day: unknown, not "power on all day"
|
|
67
|
+
const hours = Object.keys(cells).filter((k) => /^\d+$/.test(k))
|
|
68
|
+
if (!hours.length) {
|
|
69
|
+
;(out[group] ??= []).push({ date, status: 'unknown', intervals: [] })
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
// a cell value nobody has seen is reported, not read as "on"
|
|
73
|
+
const odd = hours.map((k) => String(cells[k]).toLowerCase()).find((v) => v !== 'yes' && !HALF[v])
|
|
74
|
+
if (odd !== undefined)
|
|
75
|
+
throw new OutageError('parse', `GPV: unknown cell value "${odd}" in ${rawGroup}`, { region })
|
|
66
76
|
;(out[group] ??= []).push(makeDay(date, gpvCellsToSlots(cells)))
|
|
67
77
|
}
|
|
68
78
|
}
|
|
@@ -31,10 +31,13 @@ export async function scheduleFromImages(
|
|
|
31
31
|
let lastError = ''
|
|
32
32
|
let read = false
|
|
33
33
|
for (const img of images) {
|
|
34
|
+
ctx.signal?.throwIfAborted()
|
|
34
35
|
let cells
|
|
35
36
|
try {
|
|
36
37
|
cells = readScheduleTable(decodePng(await ctx.http.bytes(url(img.path), { region })), layout)
|
|
37
38
|
} catch (e) {
|
|
39
|
+
// a cancelled call is not an unreadable image
|
|
40
|
+
ctx.signal?.throwIfAborted()
|
|
38
41
|
if (e instanceof OutageError) throw e
|
|
39
42
|
lastError = `${img.path}: ${(e as Error).message}`
|
|
40
43
|
continue
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { OutageError } from '../errors.js'
|
|
2
|
-
import { compareGroups, makeDay, normalizeGroup, type MinuteSlot } from '../schedule.js'
|
|
2
|
+
import { compareGroups, makeDay, normalizeGroup, unpublished, type MinuteSlot } from '../schedule.js'
|
|
3
3
|
import { parseClock, toKyivIso } from '../time.js'
|
|
4
4
|
import type { AddressQuery, DaySchedule, GroupId, GroupMatch, RegionInfo, RegionSchedule } from '../types.js'
|
|
5
5
|
import type { Provider, ProviderContext } from './provider.js'
|
|
@@ -75,8 +75,11 @@ export function ivanoFrankivskProvider(): Provider {
|
|
|
75
75
|
sampleAddress: { city: 'Івано-Франківськ', street: 'Галицька', house: '1' },
|
|
76
76
|
|
|
77
77
|
async getSchedules(ctx) {
|
|
78
|
-
const responses = await mapLimit(
|
|
79
|
-
|
|
78
|
+
const responses = await mapLimit(
|
|
79
|
+
GROUPS,
|
|
80
|
+
CONCURRENCY,
|
|
81
|
+
(g) => ctx.http.json<unknown>(`${API}/schedule-by-queue?queue=${g}`, { region }),
|
|
82
|
+
ctx.signal,
|
|
80
83
|
)
|
|
81
84
|
return parseQueueSchedules(responses, ctx.now())
|
|
82
85
|
},
|
|
@@ -155,7 +158,7 @@ export function parseQueueSchedules(responses: readonly unknown[], now: Date): R
|
|
|
155
158
|
}
|
|
156
159
|
|
|
157
160
|
const dates = [...byDate.keys()].sort()
|
|
158
|
-
if (!dates.length) return
|
|
161
|
+
if (!dates.length) return unpublished(region, GROUPS, now)
|
|
159
162
|
|
|
160
163
|
const groups = [...new Set([...GROUPS, ...dates.flatMap((d) => [...byDate.get(d)!.keys()])])].sort(
|
|
161
164
|
compareGroups,
|
|
@@ -198,8 +198,8 @@ export function textSchedules(
|
|
|
198
198
|
html: string,
|
|
199
199
|
now: Date,
|
|
200
200
|
from: string,
|
|
201
|
-
): Map<string, Record<string, MinuteSlot[]>> {
|
|
202
|
-
const out = new Map<string, Record<string, MinuteSlot[]>>()
|
|
201
|
+
): Map<string, Record<string, MinuteSlot[] | null>> {
|
|
202
|
+
const out = new Map<string, Record<string, MinuteSlot[] | null>>()
|
|
203
203
|
const start = html.indexOf('class="post"')
|
|
204
204
|
if (start === -1) return out
|
|
205
205
|
const end = html.indexOf('Перевірити чергу', start)
|
|
@@ -220,11 +220,20 @@ export function textSchedules(
|
|
|
220
220
|
return out
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
-
function mergeText(
|
|
223
|
+
function mergeText(
|
|
224
|
+
images: RegionSchedule,
|
|
225
|
+
text: Map<string, Record<string, MinuteSlot[] | null>>,
|
|
226
|
+
): RegionSchedule {
|
|
224
227
|
if (!text.size) return images
|
|
225
228
|
const schedules: Record<string, DaySchedule[]> = { ...images.schedules }
|
|
226
229
|
for (const [date, groups] of text)
|
|
227
|
-
for (const g of STANDARD_GROUPS)
|
|
230
|
+
for (const g of STANDARD_GROUPS) {
|
|
231
|
+
const slots = groups[g]
|
|
232
|
+
// named without times or a "power on" statement: not known (an unlisted group has none)
|
|
233
|
+
const day: DaySchedule =
|
|
234
|
+
slots === null ? { date, status: 'unknown', intervals: [] } : makeDay(date, slots ?? [])
|
|
235
|
+
schedules[g] = [...(schedules[g] ?? []), day]
|
|
236
|
+
}
|
|
228
237
|
for (const g of Object.keys(schedules)) schedules[g]!.sort((a, b) => a.date.localeCompare(b.date))
|
|
229
238
|
return { ...images, groups: Object.keys(schedules).sort(compareGroups), schedules }
|
|
230
239
|
}
|
|
@@ -14,7 +14,7 @@ import type {
|
|
|
14
14
|
Street,
|
|
15
15
|
} from '../types.js'
|
|
16
16
|
import type { Provider, ProviderContext } from './provider.js'
|
|
17
|
-
import { cookieHeader, mapLimit } from '../util.js'
|
|
17
|
+
import { SharedTask, cookieHeader, mapLimit } from '../util.js'
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Kirovohradoblenergo (kiroe.com.ua). The /electricity-blackout page hands out a PHP session
|
|
@@ -71,7 +71,7 @@ export interface ApiHouse {
|
|
|
71
71
|
|
|
72
72
|
export function kirovohradProvider(): Provider {
|
|
73
73
|
const region = INFO.id
|
|
74
|
-
let current:
|
|
74
|
+
let current: SharedTask<Session> | null = null
|
|
75
75
|
let currentAt = 0
|
|
76
76
|
let cities: Promise<City[]> | null = null
|
|
77
77
|
// house id → its number and groups, filled by searchHouses so groupOf needs no extra request
|
|
@@ -95,15 +95,19 @@ export function kirovohradProvider(): Provider {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
const session = (ctx: ProviderContext, renew = false): Promise<Session> => {
|
|
98
|
-
if (renew || !current || Date.now() - currentAt > SESSION_TTL_MS) {
|
|
98
|
+
if (renew || !current || current.aborted || Date.now() - currentAt > SESSION_TTL_MS) {
|
|
99
99
|
currentAt = Date.now()
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
100
|
+
// shared by concurrent calls: one caller cancelling doesn't fail the others, and it is
|
|
101
|
+
// cancelled only when every caller waiting for it has given up
|
|
102
|
+
const task = new SharedTask((signal) =>
|
|
103
|
+
bootstrap({ ...ctx, http: ctx.http.withSignal(signal), signal }),
|
|
104
|
+
)
|
|
105
|
+
current = task
|
|
106
|
+
task.promise.catch(() => {
|
|
107
|
+
if (current === task) current = null
|
|
104
108
|
})
|
|
105
109
|
}
|
|
106
|
-
return current
|
|
110
|
+
return current.join(ctx.signal)
|
|
107
111
|
}
|
|
108
112
|
|
|
109
113
|
const post = async (ctx: ProviderContext, s: Session, path: string): Promise<unknown> => {
|