ua-outages 0.1.1 → 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 CHANGED
@@ -40,7 +40,7 @@ nextOutage(schedule.days) // { from: '2026-11-05T19:30:00+02:00', to: '2026-11-0
40
40
  | `donetsk` | Донецька | ДТЕК Донецькі електромережі | schedule page + /ua/ajax (same provider as Odesa) | ⚠️ code ready | ⚠️ code ready | the site answers automated clients with a bot check, so requests come back `blocked`; untested against live data | [dtek-dem.com.ua](https://www.dtek-dem.com.ua/ua/shutdowns) | [dtek-dem.com.ua](https://www.dtek-dem.com.ua/ua/shutdowns) |
41
41
  | `lviv` | Львівська | Львівобленерго | api.loe.lviv.ua, power-api.loe.lviv.ua | ✅ | ✅ | | [poweron.loe.lviv.ua](https://poweron.loe.lviv.ua/) | [poweron.loe.lviv.ua](https://poweron.loe.lviv.ua/) |
42
42
  | `ternopil` | Тернопільська | Тернопільобленерго | api-poweron.toe.com.ua | ✅ | ✅ | schedule read per group via a reference address | [poweron.toe.com.ua](https://poweron.toe.com.ua/) | [poweron.toe.com.ua](https://poweron.toe.com.ua/) |
43
- | `zakarpattia` | Закарпатська | Закарпаттяобленерго | api-outage-zakarpat-energy.inneti.net | ✅ / image | ✅ | no outages while no picture is published; a published picture is reported as `unsupported` | [zakarpat.energy](https://zakarpat.energy/customers/break-in-electricity-supply/schedule/) | [outage.zakarpat.energy](https://outage.zakarpat.energy/schedule_queues) |
43
+ | `zakarpattia` | Закарпатська | Закарпаттяобленерго | api-outage-zakarpat-energy.inneti.net | ✅ / image | ✅ | groups `unknown` while no picture is published; a published picture is reported as `unsupported` | [zakarpat.energy](https://zakarpat.energy/customers/break-in-electricity-supply/schedule/) | [outage.zakarpat.energy](https://outage.zakarpat.energy/schedule_queues) |
44
44
  | `ivano-frankivsk` | Івано-Франківська | Прикарпаттяобленерго | be-svitlo.oe.if.ua | ✅ | ✅ | full address only, no autocomplete | [svitlo.oe.if.ua](https://svitlo.oe.if.ua/) | [svitlo.oe.if.ua](https://svitlo.oe.if.ua/) |
45
45
  | `chernivtsi` | Чернівецька | Чернівціобленерго | schedule page + district directories | ✅ | ✅ | groups numbered 1–12 | [oblenergo.cv.ua](https://oblenergo.cv.ua/shutdowns/) | [oblenergo.cv.ua](https://oblenergo.cv.ua/shutdowns-search/) |
46
46
  | `rivne` | Рівненська | Рівнеобленерго | schedule page | ✅ | — | addresses only in PDF | [roe.vsei.ua](https://www.roe.vsei.ua/disconnections) | — |
@@ -56,7 +56,7 @@ nextOutage(schedule.days) // { from: '2026-11-05T19:30:00+02:00', to: '2026-11-0
56
56
  | `zaporizhzhia` | Запорізька | Запоріжжяобленерго | news page | ✅ | — | no web lookup | [zoe.com.ua](https://www.zoe.com.ua/outage/) | [Telegram bot](https://t.me/esozoebot) |
57
57
  | `mykolaiv` | Миколаївська | Миколаївобленерго | off.energy.mk.ua API | ✅ | ✅ | | [off.energy.mk.ua](https://off.energy.mk.ua/) | [off.energy.mk.ua](https://off.energy.mk.ua/) |
58
58
 
59
- **АТ «Укрзалізниця»** is a distribution operator too: homes near stations and along the lines are wired to the railway's grid and get its own schedule, which differs from the oblenergo's. It has one region per oblast page on [uz.gov.ua](https://www.uz.gov.ua/about/activity/electropostachannia/electro_consumers/temporary_shutdown/grafikiobmezhen/): `uz-kyiv`, `uz-kyiv-region`, `uz-dnipro`, … (`uz-<region id>`), and two branches each in Vinnytsia (`uz-vinnytsia`, `uz-vinnytsia-odesa`) and Sumy (`uz-sumy`, `uz-sumy-south`). Today's schedule only, read from the HTML table; a page with no table means no outages. The page also lists each group's addresses (no lookup yet).
59
+ **АТ «Укрзалізниця»** is a distribution operator too: homes near stations and along the lines are wired to the railway's grid and get its own schedule, which differs from the oblenergo's. It has one region per oblast page on [uz.gov.ua](https://www.uz.gov.ua/about/activity/electropostachannia/electro_consumers/temporary_shutdown/grafikiobmezhen/): `uz-kyiv`, `uz-kyiv-region`, `uz-dnipro`, … (`uz-<region id>`), and two branches each in Vinnytsia (`uz-vinnytsia`, `uz-vinnytsia-odesa`) and Sumy (`uz-sumy`, `uz-sumy-south`). Today's schedule only, read from the HTML table; a page with no table publishes nothing, so every group comes back `unknown` for today. The page also lists each group's addresses (no lookup yet).
60
60
 
61
61
  **Not covered**
62
62
 
@@ -68,7 +68,7 @@ nextOutage(schedule.days) // { from: '2026-11-05T19:30:00+02:00', to: '2026-11-0
68
68
  Луганська область and Crimea are not covered. "today" means the operator publishes only the current day. "⚠️ code ready" means the provider is implemented but the operator currently blocks automated requests. "image" means the schedule is read from the table picture the operator publishes.
69
69
  <!-- regions:end -->
70
70
 
71
- "Address" means the SDK can turn an address into a group. Zakarpattia publishes its schedule only as a picture: while none is published `getSchedules` returns no groups (no outages announced); once one is, it throws `unsupported` with the picture's address. Its address lookup works either way.
71
+ "Address" means the SDK can turn an address into a group. Zakarpattia publishes its schedule only as a picture: while none is published `getSchedules` lists its groups as `unknown` (not "no outages"); once one is, it throws `unsupported` with the picture's address. Its address lookup works either way.
72
72
 
73
73
  ### Why some regions are missing
74
74
 
@@ -95,14 +95,14 @@ The same option works for proxies, caching, logging and tests. When an operator
95
95
 
96
96
  ### `new OutageClient(options?)`
97
97
 
98
- | option | default | |
99
- | ------------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
100
- | `cacheTtlMs` | `300000` | Reuse a region's schedule for this long. `0` turns caching off. Concurrent requests for one region share a single fetch. |
101
- | `timeoutMs` | `15000` | Per request. |
102
- | `retries` | `1` | Extra attempts after a network error or a 5xx. |
103
- | `fetch` | `globalThis.fetch` | Bring your own (proxy, logging, tests). |
104
- | `userAgent` | `ua-outages (+repo url)` | |
105
- | `providers` | built-in | Replace or extend the regions. |
98
+ | option | default | |
99
+ | ------------ | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
100
+ | `cacheTtlMs` | `300000` | Reuse a region's schedule for this long. `0` turns caching off. Concurrent requests for one region share a single fetch (except calls with a `signal`, see Cancelling). |
101
+ | `timeoutMs` | `15000` | Per request. |
102
+ | `retries` | `1` | Extra attempts after a network error or a 5xx. A bot-check page is never retried, whatever its status. |
103
+ | `fetch` | `globalThis.fetch` | Bring your own (proxy, logging, tests). |
104
+ | `userAgent` | `ua-outages (+repo url)` | |
105
+ | `providers` | built-in | Replace or extend the regions. |
106
106
 
107
107
  ### Schedules
108
108
 
@@ -119,6 +119,44 @@ A `DaySchedule` is `{ date: 'YYYY-MM-DD', status, intervals }`:
119
119
 
120
120
  `updatedAt` is when the operator last changed the schedule, when the source says so.
121
121
 
122
+ #### "No outages" vs "nothing published"
123
+
124
+ The SDK never turns silence into "no outages": a group with no cells, a group named without times ("1.1: графік уточнюється") or a source that publishes nothing comes back `unknown`. Read a result like this:
125
+
126
+ | you get | it means |
127
+ | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
128
+ | a day with `status: 'no-outages'` | The operator said there are no outages for that group that day (a cancellation notice, or a published schedule with nothing in that group). |
129
+ | a day with `status: 'scheduled'`, no `intervals` | A schedule is in force for that day and has no outage for that group. |
130
+ | a day with `status: 'emergency'` | Emergency outages: the hourly schedule doesn't apply, power may go off at any time. Published, but not "no outages". |
131
+ | a day with `status: 'unknown'` | Nothing is published for that group and day. Not "no outages". |
132
+ | every day of every group `unknown` | The operator publishes nothing right now. Regions with a fixed set of groups keep listing them (e.g. Sumy marks every group "no data"). |
133
+ | `groups: []`, `schedules: {}` | The operator publishes nothing right now, and its groups come only with a schedule (e.g. YASNO, DTEK). The same as all `unknown`. |
134
+ | a missing date | Not published. Most operators publish today and, from the evening, tomorrow. |
135
+
136
+ So "no outages" is only ever `no-outages` days or `scheduled` days without intervals. `emergency` means outages without a schedule; anything else (`unknown`, a missing date, no groups) should read as "the schedule isn't published", with a link to the operator's website. `stateAt()` returns `'unknown'` for `unknown` and `emergency` days.
137
+
138
+ **Upgrading from 0.1.1:** `groups: []` used to be documented as "no outages" for some regions (Zakarpattia, the railway pages). It never means that now, and Sumy, Lviv, Zakarpattia, Ivano-Frankivsk, Ternopil, Mykolaiv, Zaporizhzhia and the railway pages list their groups as `unknown` instead when nothing is published. If your code shows "no outages" for an empty `groups` list, change it to something like:
139
+
140
+ ```ts
141
+ const day = schedule.schedules[group]?.find((d) => d.date === kyivDate())
142
+ if (!day || day.status === 'unknown') show('Графік не опубліковано')
143
+ else if (day.status === 'emergency') show('Аварійні відключення, без графіка')
144
+ else if (day.intervals.length === 0)
145
+ show('Відключень немає') // 'no-outages', or 'scheduled' without outages
146
+ else showIntervals(day.intervals)
147
+ ```
148
+
149
+ #### Cancelling
150
+
151
+ ```ts
152
+ // stop as soon as your own deadline passes
153
+ const s = await outages.getSchedules('sumy', { fresh: true, signal: AbortSignal.timeout(20_000) })
154
+ ```
155
+
156
+ `getSchedules`, `getSchedule` and `healthCheck` take a `signal`. When it aborts, the call rejects right away with the signal's reason (an `AbortError` by default, not an `OutageError`). The work that call started is stopped: its request in flight is cancelled, and nothing more is sent to the operator for it, no further groups, pages or retries. With a `Promise.race` cap of your own, pass a signal too, otherwise the losing call keeps running in the background.
157
+
158
+ Work shared with other callers keeps going for them: a call with a signal that is answered from the cache, or that waits for a session another call is opening, just stops waiting. Shared work is cancelled only when every caller waiting for it has given up. To keep one caller's cancellation from reaching others, a call with a signal doesn't share its own fetch through the cache while it runs (its result is cached when it arrives). Address search doesn't take a signal yet.
159
+
122
160
  ### Schedules published as pictures
123
161
 
124
162
  Volyn publishes the schedule only as a table image, and Khmelnytskyi does when it doesn't post it as text on its schedule page (the text wins for its date). The SDK reads those itself: it decodes the PNG, finds the grid and classifies each cell by colour, with no dependencies. When an operator posts a text announcement as an image under the same date, it is skipped. A colour the SDK doesn't know is reported as a `parse` error instead of being guessed.
@@ -177,7 +215,7 @@ const report = await outages.healthCheck({ address: true })
177
215
  // { ok, checkedAt, regions: [{ region, status: 'ok' | 'stale' | 'empty' | 'error', latencyMs, groups, updatedAt, message, address }] }
178
216
  ```
179
217
 
180
- - `empty`: the source works but publishes nothing right now. Usually it means there are no outages.
218
+ - `empty`: the source works but publishes nothing right now (no groups, or every day `unknown`). Often there are no outages then, but the SDK can't tell.
181
219
  - `stale`: the schedule hasn't changed for `staleAfterHours` (default 72).
182
220
  - `error`: a network failure, a bot check (`blocked`), or a format change (`parse`).
183
221
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ua-outages",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "TypeScript SDK for Ukrainian power outage schedules: find your outage group by address and get schedules straight from the grid operators.",
5
5
  "keywords": [
6
6
  "ukraine",
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
- /** All groups of a region. */
94
- async getSchedules(region: RegionId, opts: { fresh?: boolean } = {}): Promise<RegionSchedule> {
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.ctx)
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
- this.cache.set(region, { at: now, value })
102
- value.catch(() => this.cache.delete(region))
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
- return value
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: { fresh?: boolean } = {}): Promise<GroupSchedule> {
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!(this.ctx, q),
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!(this.ctx, q, opts.city),
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(this.ctx, address)
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.searchCities(region, address.city, { limit: 10, district: address.district })
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.searchStreets(region, address.street, { city, limit: 10 })
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!(this.ctx, street, address.house)
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!(this.ctx, house, street, city)
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)) return hit.value as Promise<T[]>
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
- if (this.searchCache.size > 500) this.searchCache.clear()
281
- this.searchCache.set(key, { at: Date.now(), value })
282
- value.catch(() => this.searchCache.delete(key))
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 results = await mapLimit(ids, opts.concurrency ?? 6, async (id): Promise<RegionHealth> => {
296
- const health = await this.checkRegion(id, staleMs)
297
- const p = this.provider(id)
298
- if (!opts.address || !p.sampleAddress || !p.info.capabilities.addressSearch) return health
299
- try {
300
- const m = await this.findGroup(id, p.sampleAddress)
301
- return { ...health, address: { ok: true, group: m.group } }
302
- } catch (e) {
303
- return { ...health, address: { ok: false, message: (e as Error).message } }
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
- days.some((d) => d.intervals.length > 0 || d.status === 'no-outages'),
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
- // challenge pages are small; real pages may embed the same scripts, so size matters
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
- return { bytes: new Uint8Array(await res.arrayBuffer()), headers: res.headers }
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 fetchImpl(url, {
133
- method: opts.method ?? (body ? 'POST' : 'GET'),
134
- headers,
135
- body,
136
- signal: AbortSignal.timeout(this.timeoutMs),
137
- redirect: 'follow',
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 { HealthCheckOptions, HealthReport, OutageClientOptions, SearchOptions } from './client.js'
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'
@@ -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(GROUPS, CONCURRENCY, (g) =>
79
- ctx.http.json<unknown>(`${API}/schedule-by-queue?queue=${g}`, { region }),
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 { region, groups: [], schedules: {}, updatedAt: null, fetchedAt: toKyivIso(now) }
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(images: RegionSchedule, text: Map<string, Record<string, MinuteSlot[]>>): RegionSchedule {
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) schedules[g] = [...(schedules[g] ?? []), makeDay(date, groups[g] ?? [])]
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: Promise<Session> | null = null
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
- const p = bootstrap(ctx)
101
- current = p
102
- p.catch(() => {
103
- if (current === p) current = null
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> => {
@@ -1,5 +1,5 @@
1
1
  import { OutageError } from '../errors.js'
2
- import { compareGroups, makeDay, normalizeGroup } from '../schedule.js'
2
+ import { compareGroups, makeDay, normalizeGroup, unpublished } from '../schedule.js'
3
3
  import { toKyivIso } from '../time.js'
4
4
  import type { DaySchedule, GroupId, RegionInfo, RegionSchedule } from '../types.js'
5
5
  import { parseUaDateTime } from './gpv.js'
@@ -125,7 +125,7 @@ export function parseLvivMenu(data: unknown, now: Date): RegionSchedule {
125
125
  const groups: Record<GroupId, DaySchedule> = {}
126
126
  for (const [raw, slots] of Object.entries(parsed)) {
127
127
  const g = normalizeGroup(raw)
128
- if (g) groups[g] = makeDay(day, slots)
128
+ if (g) groups[g] = slots ? makeDay(day, slots) : { date: day, status: 'unknown', intervals: [] }
129
129
  }
130
130
  if (!Object.keys(groups).length)
131
131
  throw new OutageError('parse', `Lviv ${item.name}: schedule text without groups (format changed?)`, {
@@ -150,6 +150,8 @@ export function parseLvivMenu(data: unknown, now: Date): RegionSchedule {
150
150
  }
151
151
 
152
152
  const dates = [...days.keys()].sort()
153
+ // every slot empty: nothing published; the twelve groups stay listed as unknown
154
+ if (!dates.length) return unpublished(region, STANDARD_GROUPS, now)
153
155
  const groups = [...new Set(dates.flatMap((d) => Object.keys(days.get(d)!.groups)))].sort(compareGroups)
154
156
  const schedules: Record<GroupId, DaySchedule[]> = {}
155
157
  for (const g of groups)
@@ -1,5 +1,5 @@
1
1
  import { OutageError } from '../errors.js'
2
- import { compareGroups, makeDay, type MinuteSlot } from '../schedule.js'
2
+ import { compareGroups, makeDay, unpublished, type MinuteSlot } from '../schedule.js'
3
3
  import { kyivDate, toKyivIso } from '../time.js'
4
4
  import type { City, DaySchedule, GroupMatch, RegionInfo, RegionSchedule } from '../types.js'
5
5
  import type { Provider, ProviderContext } from './provider.js'
@@ -156,12 +156,14 @@ export function parseActive(slots: Slot[], queues: Queue[], active: ActiveDay[],
156
156
  const groups = [...nameOf.values()].sort(compareGroups)
157
157
  const schedules: Record<string, DaySchedule[]> = {}
158
158
  const dates = [...byDate.keys()].sort()
159
+ // no active day: nothing published, which is not "no outages"
160
+ if (!dates.length) return unpublished(region, groups, now)
159
161
  for (const g of groups) schedules[g] = dates.map((d) => makeDay(d, byDate.get(d)!.get(g) ?? []))
160
162
 
161
163
  return {
162
164
  region,
163
- groups: dates.length ? groups : [],
164
- schedules: dates.length ? schedules : {},
165
+ groups,
166
+ schedules,
165
167
  updatedAt: updated ? toKyivIso(new Date(updated)) : null,
166
168
  fetchedAt: toKyivIso(now),
167
169
  }
@@ -1,4 +1,4 @@
1
- import { compareGroups, makeDay } from '../schedule.js'
1
+ import { compareGroups, makeDay, unpublished } from '../schedule.js'
2
2
  import { addDays, kyivDate, toKyivIso } from '../time.js'
3
3
  import type { DaySchedule, RegionId, RegionSchedule } from '../types.js'
4
4
  import { findUaDate, parseGroupLines } from './ua-text.js'
@@ -53,7 +53,9 @@ export function postsToSchedule(
53
53
  }
54
54
 
55
55
  const dates = [...byDate.keys()].sort()
56
- const groupSet = new Set<string>(dates.length ? (opts.groups ?? []) : [])
56
+ // no post with a schedule: the region's groups (when known) stay listed, unknown for today
57
+ if (!dates.length) return unpublished(region, opts.groups ?? [], now)
58
+ const groupSet = new Set<string>(opts.groups ?? [])
57
59
  for (const d of byDate.values()) for (const g of Object.keys(d.groups)) groupSet.add(g)
58
60
  const groups = [...groupSet].sort(compareGroups)
59
61
 
@@ -62,6 +64,8 @@ export function postsToSchedule(
62
64
  schedules[g] = dates.map((d) => {
63
65
  const slots = byDate.get(d)!.groups[g]
64
66
  if (slots) return makeDay(d, slots)
67
+ // named in the post without times or a "power on" statement: not known
68
+ if (slots === null) return { date: d, status: 'unknown' as const, intervals: [] }
65
69
  // a group missing from that day's post: no outages if the operator lists only the
66
70
  // groups that have them, otherwise we just don't know
67
71
  return opts.omitted === 'none' ? makeDay(d, []) : { date: d, status: 'unknown' as const, intervals: [] }
@@ -2,8 +2,16 @@ import type { Http } from '../http.js'
2
2
  import type { AddressQuery, City, GroupMatch, House, RegionInfo, RegionSchedule, Street } from '../types.js'
3
3
 
4
4
  export interface ProviderContext {
5
+ /** Already bound to `signal`: every request through it stops when the call is cancelled */
5
6
  http: Http
6
7
  now: () => Date
8
+ /**
9
+ * The caller's cancellation, if any. Requests stop by themselves; pass it to `mapLimit` and
10
+ * check it in long loops. Work shared between calls (a session several calls wait for) should
11
+ * run on `http.withSignal(undefined)` and be awaited with `abortable(…, signal)`, so one
12
+ * caller giving up doesn't fail the others.
13
+ */
14
+ signal?: AbortSignal
7
15
  }
8
16
 
9
17
  /**
@@ -230,13 +230,12 @@ export function parseSumySchedules(res: { data?: ScheduleEntry[] }, now: Date):
230
230
  if (at && (!updated || Date.parse(at) > Date.parse(updated))) updated = at
231
231
  }
232
232
 
233
- // every group "no data": nothing is published
234
- const known = Object.keys(schedules).filter((g) => schedules[g]![0]!.status !== 'unknown')
235
- const groups = known.length ? Object.keys(schedules).sort(compareGroups) : []
233
+ // a group marked "no data" stays listed with an `unknown` day, even when every group is: that
234
+ // means nothing is published, which must not read as "no outages"
236
235
  return {
237
236
  region,
238
- groups,
239
- schedules: groups.length ? schedules : {},
237
+ groups: Object.keys(schedules).sort(compareGroups),
238
+ schedules,
240
239
  updatedAt: updated,
241
240
  fetchedAt: toKyivIso(now),
242
241
  }
@@ -1,9 +1,10 @@
1
1
  import { OutageError } from '../errors.js'
2
- import { cellsToIntervals } from '../schedule.js'
2
+ import { cellsToIntervals, unpublished } from '../schedule.js'
3
3
  import { addDays, kyivDate, toKyivIso } from '../time.js'
4
4
  import type { DaySchedule, GroupId, IntervalKind, RegionInfo, RegionSchedule } from '../types.js'
5
5
  import { STANDARD_GROUPS, parseCherg, parsePowerOnTime, powerOnAddressSearch } from './poweron.js'
6
6
  import type { Provider, ProviderContext } from './provider.js'
7
+ import { mapLimit } from '../util.js'
7
8
 
8
9
  /**
9
10
  * Ternopiloblenergo (poweron.toe.com.ua), a PowerOn platform backend.
@@ -110,7 +111,12 @@ export function ternopilProvider(): Provider {
110
111
  async getSchedules(ctx) {
111
112
  const groups = Object.keys(REPRESENTATIVES)
112
113
  // a few at a time: twelve requests per refresh is already more than the site makes
113
- const results = await mapLimit(groups, 3, (g) => fetchWithRetry(ctx, g, ['unknown-address']))
114
+ const results = await mapLimit(
115
+ groups,
116
+ 3,
117
+ (g) => fetchWithRetry(ctx, g, ['unknown-address']),
118
+ ctx.signal,
119
+ )
114
120
  const anyDays = results.some((r) => r.kind === 'days')
115
121
  if (anyDays)
116
122
  for (const [i, r] of results.entries())
@@ -212,7 +218,8 @@ export function buildSchedule(byGroup: Record<GroupId, GroupGraph>, now: Date):
212
218
  (r): r is Extract<GroupGraph, { kind: 'days' }> => r.kind === 'days',
213
219
  )
214
220
  const fetchedAt = toKyivIso(now)
215
- if (!withDays.length) return { region, groups: [], schedules: {}, updatedAt: null, fetchedAt }
221
+ // nothing published for any group: not "no outages", just nothing known yet
222
+ if (!withDays.length) return unpublished(region, Object.keys(byGroup), now)
216
223
 
217
224
  const dates = [...new Set(withDays.flatMap((r) => r.days.map((d) => d.date)))].sort()
218
225
  const groups = STANDARD_GROUPS.filter((g) => g in byGroup)
@@ -260,16 +267,3 @@ function base64Utf8(s: string): string {
260
267
  for (const b of new TextEncoder().encode(s)) bin += String.fromCharCode(b)
261
268
  return btoa(bin)
262
269
  }
263
-
264
- async function mapLimit<T, R>(items: readonly T[], limit: number, fn: (t: T) => Promise<R>): Promise<R[]> {
265
- const out: R[] = new Array(items.length)
266
- let next = 0
267
- const worker = async () => {
268
- while (next < items.length) {
269
- const i = next++
270
- out[i] = await fn(items[i]!)
271
- }
272
- }
273
- await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
274
- return out
275
- }
@@ -79,13 +79,22 @@ export function findTimeRanges(text: string): [number, number][] {
79
79
  return out
80
80
  }
81
81
 
82
+ /** A group line without times that says the power stays on ("Група 2.1. Електроенергія є.") */
83
+ const POWER_ON =
84
+ /електроенергі[яї]\s+є|світло\s+є|без\s+відключень|відключен\S*\s+не\s+(?:передбачен|планує|застосов)/i
85
+
82
86
  /**
83
87
  * Lines like "1.1 00:00-03:00; 08:00-13:30", "1.1: 12:00 – 17:00", "Група 1.1. … з 08:00 до 12:00"
84
- * → group → off slots. A group mentioned with no times (e.g. "Електроенергія є") gets `[]`.
85
- * Lines that name several groups ("Групи 1.1, 1.2: …") apply the times to each.
88
+ * → group → off slots. A group mentioned with no times gets `[]` only when the line says the
89
+ * power stays on ("Електроенергія є"); otherwise ("1.1: графік уточнюється") it gets `null`:
90
+ * named, but its schedule is not known. Lines that name several groups ("Групи 1.1, 1.2: …")
91
+ * apply the times to each.
86
92
  */
87
- export function parseGroupLines(text: string, kind: 'off' | 'maybe' = 'off'): Record<string, MinuteSlot[]> {
88
- const out: Record<string, MinuteSlot[]> = {}
93
+ export function parseGroupLines(
94
+ text: string,
95
+ kind: 'off' | 'maybe' = 'off',
96
+ ): Record<string, MinuteSlot[] | null> {
97
+ const out: Record<string, MinuteSlot[] | null> = {}
89
98
  for (const rawLine of text.split(/\n|<br\s*\/?>|<\/p>|<\/li>|<\/div>/i)) {
90
99
  const line = rawLine
91
100
  .replace(/<[^>]+>/g, ' ')
@@ -101,7 +110,12 @@ export function parseGroupLines(text: string, kind: 'off' | 'maybe' = 'off'): Re
101
110
  .filter((g): g is string => !!g)
102
111
  if (!groups.length) continue
103
112
  const ranges = firstTime === -1 ? [] : findTimeRanges(line.slice(firstTime))
113
+ const known = ranges.length > 0 || POWER_ON.test(line)
104
114
  for (const g of groups) {
115
+ if (!known) {
116
+ if (!(g in out)) out[g] = null
117
+ continue
118
+ }
105
119
  const list = (out[g] ??= [])
106
120
  for (const [start, end] of ranges) list.push({ start, end, kind })
107
121
  }
@@ -1,6 +1,6 @@
1
1
  import { OutageError } from '../errors.js'
2
2
  import { STANDARD_GROUPS } from '../image/table.js'
3
- import { makeDay, type MinuteSlot } from '../schedule.js'
3
+ import { makeDay, unpublished, type MinuteSlot } from '../schedule.js'
4
4
  import { toKyivIso } from '../time.js'
5
5
  import type { DaySchedule, RegionId, RegionInfo, RegionSchedule } from '../types.js'
6
6
  import type { Provider } from './provider.js'
@@ -101,8 +101,9 @@ export function parseUzPage(
101
101
  }
102
102
  }
103
103
 
104
+ /** A page without a table publishes nothing; that is not "no outages" */
104
105
  function empty(region: RegionId, now: Date): RegionSchedule {
105
- return { region, groups: [], schedules: {}, updatedAt: null, fetchedAt: toKyivIso(now) }
106
+ return unpublished(region, STANDARD_GROUPS, now)
106
107
  }
107
108
 
108
109
  const text = (s: string) =>
@@ -1,15 +1,21 @@
1
1
  import { OutageError } from '../errors.js'
2
- import { toKyivIso } from '../time.js'
3
2
  import type { GroupId, RegionInfo } from '../types.js'
4
- import { hydraList, parseCherg, powerOnAddressSearch, type PowerOnBuilding } from './poweron.js'
3
+ import { unpublished } from '../schedule.js'
4
+ import {
5
+ STANDARD_GROUPS,
6
+ hydraList,
7
+ parseCherg,
8
+ powerOnAddressSearch,
9
+ type PowerOnBuilding,
10
+ } from './poweron.js'
5
11
  import type { Provider } from './provider.js'
6
12
 
7
13
  /**
8
14
  * Zakarpattiaoblenergo (outage.zakarpat.energy), a PowerOn platform backend.
9
15
  *
10
16
  * The schedule itself is published only as a picture (`/options?option_key=pw_gpv_image_today`
11
- * points to a PNG). While no picture is published, `getSchedules` returns no groups (no
12
- * outages announced); once one is, it throws `unsupported` with its address, since no sample of
17
+ * points to a PNG). While no picture is published, `getSchedules` lists the twelve groups with
18
+ * today `unknown`: nothing published, which is not "no outages". Once one is, it throws `unsupported` with its address, since no sample of
13
19
  * that picture exists yet to build a reader from. The address lookup works: `pw_address_queues`
14
20
  * returns, per street and group, a comma-separated list of houses.
15
21
  */
@@ -57,7 +63,8 @@ export function zakarpattiaProvider(): Provider {
57
63
  `Zakarpattia: the schedule is published only as an image (${new URL(published, IMAGE_HOST).href}); use findGroup for the group of an address`,
58
64
  { region },
59
65
  )
60
- return { region, groups: [], schedules: {}, updatedAt: null, fetchedAt: toKyivIso(ctx.now()) }
66
+ // nothing published: its twelve groups (1.1–6.2, one PDF each on the site) are unknown
67
+ return unpublished(region, STANDARD_GROUPS, ctx.now())
61
68
  },
62
69
 
63
70
  ...powerOnAddressSearch({
package/src/schedule.ts CHANGED
@@ -1,5 +1,13 @@
1
1
  import { kyivDate, kyivInstant, toKyivIso } from './time.js'
2
- import type { DaySchedule, DayStatus, GroupId, Interval, IntervalKind } from './types.js'
2
+ import type {
3
+ DaySchedule,
4
+ DayStatus,
5
+ GroupId,
6
+ Interval,
7
+ IntervalKind,
8
+ RegionId,
9
+ RegionSchedule,
10
+ } from './types.js'
3
11
 
4
12
  /** A span inside one day, in minutes from Kyiv midnight (end may be 1440). */
5
13
  export interface MinuteSlot {
@@ -58,6 +66,24 @@ export function makeDay(day: string, slots: readonly MinuteSlot[], status?: DayS
58
66
  return { date: day, status: status ?? 'scheduled', intervals }
59
67
  }
60
68
 
69
+ /**
70
+ * A region whose operator publishes nothing right now: each of its groups gets today as
71
+ * `unknown`. Never `groups: []` when the groups are known, and never `no-outages`: nothing
72
+ * published is not the same as "no outages announced".
73
+ */
74
+ export function unpublished(
75
+ region: RegionId,
76
+ groups: readonly GroupId[],
77
+ now: Date,
78
+ updatedAt: string | null = null,
79
+ ): RegionSchedule {
80
+ const today = kyivDate(now)
81
+ const sorted = [...new Set(groups)].sort(compareGroups)
82
+ const schedules: Record<GroupId, DaySchedule[]> = {}
83
+ for (const g of sorted) schedules[g] = [{ date: today, status: 'unknown', intervals: [] }]
84
+ return { region, groups: sorted, schedules, updatedAt, fetchedAt: toKyivIso(now) }
85
+ }
86
+
61
87
  /**
62
88
  * Normalizes how operators name groups: "GPV1.1", "1.1", "Черга 1.1", "1 черга 2 підчерга",
63
89
  * "Група 3.2", "4" → "1.1", "1.1", "1.1", "1.2", "3.2", "4". Returns null if there's no number.
package/src/types.ts CHANGED
@@ -114,9 +114,14 @@ export interface GroupSchedule {
114
114
  fetchedAt: string
115
115
  }
116
116
 
117
+ /**
118
+ * Silence is never "no outages": only `no-outages` days, or `scheduled` days without intervals,
119
+ * mean that. When the operator publishes nothing, its groups are listed with `unknown` days, or,
120
+ * when the SDK can't tell which groups it has, `groups` is empty. Both mean "not published".
121
+ */
117
122
  export interface RegionSchedule {
118
123
  region: RegionId
119
- /** Every group the operator publishes, sorted */
124
+ /** Every group the operator publishes, sorted. Empty: nothing published and groups unknown, not "no outages" */
120
125
  groups: GroupId[]
121
126
  /** group → days */
122
127
  schedules: Record<GroupId, DaySchedule[]>
package/src/util.ts CHANGED
@@ -1,17 +1,32 @@
1
- /** Runs `fn` over `items` with at most `limit` in flight; results keep input order. */
1
+ /**
2
+ * Runs `fn` over `items` with at most `limit` in flight; results keep input order.
3
+ *
4
+ * Fails fast: after the first rejection (or once `signal` aborts) no further items are started,
5
+ * so a site that just refused one request doesn't get the rest of the batch. Calls already in
6
+ * flight are not interrupted here; pass them the same signal for that.
7
+ */
2
8
  export async function mapLimit<T, R>(
3
9
  items: readonly T[],
4
10
  limit: number,
5
11
  fn: (item: T) => Promise<R>,
12
+ signal?: AbortSignal,
6
13
  ): Promise<R[]> {
7
14
  const out: R[] = new Array(items.length)
8
15
  let next = 0
16
+ let failed = false
9
17
  const worker = async () => {
10
- while (next < items.length) {
18
+ while (!failed && next < items.length) {
19
+ signal?.throwIfAborted()
11
20
  const i = next++
12
- out[i] = await fn(items[i]!)
21
+ try {
22
+ out[i] = await fn(items[i]!)
23
+ } catch (e) {
24
+ failed = true
25
+ throw e
26
+ }
13
27
  }
14
28
  }
29
+ signal?.throwIfAborted()
15
30
  await Promise.all(Array.from({ length: Math.min(Math.max(1, limit), items.length) }, worker))
16
31
  return out
17
32
  }
@@ -21,3 +36,67 @@ export function cookieHeader(headers: Headers): string {
21
36
  const list = typeof headers.getSetCookie === 'function' ? headers.getSetCookie() : []
22
37
  return list.map((c) => c.split(';')[0]).join('; ')
23
38
  }
39
+
40
+ /** Settles like `promise`, or rejects with the signal's reason as soon as it aborts. */
41
+ export function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
42
+ if (!signal) return promise
43
+ if (signal.aborted) {
44
+ // nobody waits for it any more; a later rejection must not surface as unhandled
45
+ promise.catch(() => {})
46
+ return Promise.reject(signal.reason)
47
+ }
48
+ return new Promise<T>((resolve, reject) => {
49
+ const onAbort = () => reject(signal.reason)
50
+ signal.addEventListener('abort', onAbort, { once: true })
51
+ promise.then(
52
+ (v) => {
53
+ signal.removeEventListener('abort', onAbort)
54
+ resolve(v)
55
+ },
56
+ (e) => {
57
+ signal.removeEventListener('abort', onAbort)
58
+ reject(e)
59
+ },
60
+ )
61
+ })
62
+ }
63
+
64
+ /**
65
+ * Work several calls wait on (a session, a directory). It runs on its own signal, which aborts
66
+ * only when every caller that joined with a signal has given up; a caller without a signal keeps
67
+ * it running. One caller cancelling never fails the others.
68
+ */
69
+ export class SharedTask<T> {
70
+ private readonly ctrl = new AbortController()
71
+ private waiters = 0
72
+ private pinned = false
73
+ private settled = false
74
+ readonly promise: Promise<T>
75
+
76
+ constructor(run: (signal: AbortSignal) => Promise<T>) {
77
+ this.promise = run(this.ctrl.signal)
78
+ this.promise.then(
79
+ () => (this.settled = true),
80
+ () => (this.settled = true),
81
+ )
82
+ }
83
+
84
+ /** Abandoned by all its callers: start a new one instead of joining */
85
+ get aborted(): boolean {
86
+ return this.ctrl.signal.aborted
87
+ }
88
+
89
+ join(signal?: AbortSignal): Promise<T> {
90
+ if (!signal) {
91
+ this.pinned = true
92
+ return this.promise
93
+ }
94
+ this.waiters++
95
+ // before the task settles a caller can only leave by aborting; after, aborting is moot
96
+ return abortable(this.promise, signal).catch((e) => {
97
+ if (signal.aborted && --this.waiters === 0 && !this.pinned && !this.settled)
98
+ this.ctrl.abort(signal.reason)
99
+ throw e
100
+ })
101
+ }
102
+ }