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 +50 -12
- 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 +6 -2
- 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 +3 -2
- package/src/providers/zakarpattia.ts +12 -5
- package/src/schedule.ts +27 -1
- package/src/types.ts +6 -1
- package/src/util.ts +82 -3
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 | ✅ |
|
|
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
|
|
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`
|
|
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
|
|
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
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> => {
|
package/src/providers/lviv.ts
CHANGED
|
@@ -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
|
|
164
|
-
schedules
|
|
165
|
+
groups,
|
|
166
|
+
schedules,
|
|
165
167
|
updatedAt: updated ? toKyivIso(new Date(updated)) : null,
|
|
166
168
|
fetchedAt: toKyivIso(now),
|
|
167
169
|
}
|
package/src/providers/posts.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/src/providers/sumy.ts
CHANGED
|
@@ -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
|
-
//
|
|
234
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
}
|
package/src/providers/ua-text.ts
CHANGED
|
@@ -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
|
|
85
|
-
*
|
|
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(
|
|
88
|
-
|
|
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
|
}
|
package/src/providers/uz.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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`
|
|
12
|
-
* outages
|
|
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
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
+
}
|