@@ -19,7 +19,8 @@ import {
1919 ZHYTOMYR_JSON_URL ,
2020 YASNO_KYIV_URL ,
2121 YASNO_DNIPRO_DNEM_URL ,
22- YASNO_DNIPRO_CEK_URL
22+ YASNO_DNIPRO_CEK_URL ,
23+ CHERNIVTSI_URL
2324} from "./constants.js"
2425
2526// --- КОНФІГУРАЦІЯ РЕГІОНІВ (ДТЕК - ОБЛАСТІ) ---
@@ -105,8 +106,8 @@ async function getDtekRegionInfo(browser, config) {
105106 // 🔥 ШУКАЄМО ТЕКСТ У МОДАЛКАХ (для Одеси та інших)
106107 const modals = document . querySelectorAll ( '.modal-content, .popup-content, [role="dialog"], .modal-body' ) ;
107108 modals . forEach ( m => {
108- // Беремо текст, якщо елемент існує і хоч трохи схожий на видимий
109- if ( m . innerText ) fullText += " " + m . innerText ;
109+ // Беремо текст, якщо елемент існує і хоч трохи схожий на видимий
110+ if ( m . innerText ) fullText += " " + m . innerText ;
110111 } ) ;
111112
112113 const text = fullText . toLowerCase ( ) ;
@@ -150,11 +151,11 @@ async function getDtekRegionInfo(browser, config) {
150151 // Спроба закрити модалку, щоб вона не блокувала отримання токенів (опціонально)
151152 try {
152153 await page . evaluate ( ( ) => {
153- const closeBtn = document . querySelector ( '.modal .close, [data-dismiss="modal"], .btn-close' ) ;
154- if ( closeBtn ) closeBtn . click ( ) ;
154+ const closeBtn = document . querySelector ( '.modal .close, [data-dismiss="modal"], .btn-close' ) ;
155+ if ( closeBtn ) closeBtn . click ( ) ;
155156 } ) ;
156157 await sleep ( 1000 ) ;
157- } catch ( e ) { }
158+ } catch ( e ) { }
158159
159160 // Чекаємо на CSRF токен
160161 const csrfTokenTag = await page . waitForSelector ( 'meta[name="csrf-token"]' , { state : "attached" , timeout : 15000 } ) ;
@@ -226,6 +227,75 @@ async function getYasnoData(url, label) {
226227 }
227228}
228229
230+ // 5. ЧЕРНІВЦІ (Playwright)
231+ async function getChernivtsiData ( browser ) {
232+ const MAX_RETRIES = 3 ;
233+ for ( let attempt = 1 ; attempt <= MAX_RETRIES ; attempt ++ ) {
234+ const context = await browser . newContext ( {
235+ userAgent : 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' ,
236+ } ) ;
237+ const page = await context . newPage ( ) ;
238+ try {
239+ console . log ( `🌍 Visiting Chernivtsi (Attempt ${ attempt } )...` ) ;
240+ await page . goto ( CHERNIVTSI_URL , { waitUntil : 'domcontentloaded' , timeout : 60000 } ) ;
241+ await sleep ( 5000 ) ;
242+
243+ const schedule = await page . evaluate ( ( ) => {
244+ const dateEl = document . querySelector ( '#gsv_t b' ) ;
245+ if ( ! dateEl ) return null ;
246+
247+ // Format: 30.01.2026
248+ const dateParts = dateEl . innerText . trim ( ) . split ( '.' ) ;
249+ if ( dateParts . length !== 3 ) return null ;
250+ const dateStr = `${ dateParts [ 2 ] } -${ dateParts [ 1 ] } -${ dateParts [ 0 ] } ` ; // 2026-01-30
251+
252+ const rows = document . querySelectorAll ( '#gsv .scrollable div[id^="inf"]' ) ;
253+ const map = { } ;
254+
255+ rows . forEach ( row => {
256+ const queueId = row . getAttribute ( 'data-id' ) ;
257+ if ( ! queueId ) return ;
258+
259+ // Checking for the active container inside the row
260+ const cellContainer = row . querySelector ( 'o.active' ) ;
261+ if ( ! cellContainer ) return ;
262+
263+ const cells = Array . from ( cellContainer . children ) ;
264+ if ( cells . length < 48 ) return ; // Expecting at least 48 slots
265+
266+ const dailySchedule = { } ;
267+ cells . forEach ( ( cell , i ) => {
268+ if ( i >= 48 ) return ;
269+ const hour = Math . floor ( i / 2 ) ;
270+ const min = ( i % 2 === 0 ) ? "00" : "30" ;
271+ const timeKey = `${ String ( hour ) . padStart ( 2 , '0' ) } :${ min } ` ;
272+
273+ const txt = cell . innerText . trim ( ) ;
274+ // В = Відключено (2), МЗ = Можливо заживлено -> Відключено (2), З = Заживлено (1)
275+ let status = 1 ;
276+ if ( txt === 'В' || txt === 'МЗ' ) status = 2 ;
277+
278+ dailySchedule [ timeKey ] = status ;
279+ } ) ;
280+
281+ if ( ! map [ queueId ] ) map [ queueId ] = { } ;
282+ map [ queueId ] [ dateStr ] = dailySchedule ;
283+ } ) ;
284+ return map ;
285+ } ) ;
286+
287+ await context . close ( ) ;
288+ return schedule ;
289+
290+ } catch ( e ) {
291+ console . warn ( `⚠️ Error scraping Chernivtsi: ${ e . message } ` ) ;
292+ await context . close ( ) ;
293+ if ( attempt === MAX_RETRIES ) return null ;
294+ await sleep ( 3000 ) ;
295+ }
296+ }
297+ }
298+
229299// --- ТРАНСФОРМАЦІЇ ---
230300
231301// 🔥 ОНОВЛЕНА ЛОГІКА ДЛЯ ПОЛТАВИ ТА ІНШИХ JSON 🔥
@@ -262,44 +332,44 @@ function transformToSvitloFormat(dtekRaw) {
262332 let val30 = 1 ; // 1 = Є світло
263333
264334 switch ( status ) {
265- case "yes" :
266- val00 = 1 ; val30 = 1 ;
335+ case "yes" :
336+ val00 = 1 ; val30 = 1 ;
267337 break ;
268-
269- case "no" :
338+
339+ case "no" :
270340 val00 = 2 ; val30 = 2 ; // 2 = Немає світла
271341 break ;
272-
342+
273343 // --- Точні відключення (без "m") - це точно НЕМАЄ ---
274344 case "first" : // Немає 00-30
275- val00 = 2 ; val30 = 1 ;
345+ val00 = 2 ; val30 = 1 ;
276346 break ;
277-
347+
278348 case "second" : // Немає 30-60
279- val00 = 1 ; val30 = 2 ;
349+ val00 = 1 ; val30 = 2 ;
280350 break ;
281351
282352 // --- Сірі зони (з "m") - вважаємо, що світло Є (1) ---
283-
284- case "mfirst" :
353+
354+ case "mfirst" :
285355 // "Можливе 1-ша половина". Вважаємо як Є (1).
286356 // Навіть якщо до цього було "no", mfirst означає початок слота зі світлом.
287- val00 = 1 ; val30 = 1 ;
357+ val00 = 1 ; val30 = 1 ;
288358 break ;
289359
290360 case "msecond" :
291361 // "Можливе 2-га половина".
292362 // Друга половина (30-60) - це сіра зона, тому вважаємо Є (1).
293- val30 = 1 ;
294-
363+ val30 = 1 ;
364+
295365 // Перша половина (00-30) залежить від попередньої години:
296366 if ( prevStatus === "no" ) {
297- // Якщо минула година була "чорна", то перші 30 хв поточної -
298- // це гарантоване продовження відключення.
299- val00 = 2 ;
367+ // Якщо минула година була "чорна", то перші 30 хв поточної -
368+ // це гарантоване продовження відключення.
369+ val00 = 2 ;
300370 } else {
301- // Інакше все ок, світло є.
302- val00 = 1 ;
371+ // Інакше все ок, світло є.
372+ val00 = 1 ;
303373 }
304374 break ;
305375
@@ -398,7 +468,7 @@ async function run() {
398468 name_ua : config . name_ua ,
399469 name_ru : config . name_ru ,
400470 name_en : config . name_en ,
401- schedule : cleanSchedule ,
471+ schedule : cleanSchedule ,
402472 emergency : rawInfo . emergency || false
403473 } ) ;
404474 } else {
@@ -584,6 +654,21 @@ async function run() {
584654 }
585655 }
586656
657+ // 7. ЧЕРНІВЦІ
658+ const chernivtsiSchedule = await getChernivtsiData ( browser ) ;
659+ if ( chernivtsiSchedule && Object . keys ( chernivtsiSchedule ) . length > 0 ) {
660+ console . log ( `✅ Success Chernivtsi` ) ;
661+ updateGlobalDates ( chernivtsiSchedule , globalDates ) ;
662+ processedRegions . push ( {
663+ cpu : "chernivetska-oblast" ,
664+ name_ua : "Чернівецька" ,
665+ name_ru : "Черновицкая" ,
666+ name_en : "Chernivtsi" ,
667+ schedule : chernivtsiSchedule ,
668+ emergency : false
669+ } ) ;
670+ }
671+
587672 // ВІДПРАВКА
588673 if ( processedRegions . length === 0 ) {
589674 console . error ( "❌ No data collected." ) ;
0 commit comments