Instantly share code, notes, and snippets.
Created
October 13, 2025 20:51
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
Save zmousm/58c3d82572dbf174a06957a446237d9c to your computer and use it in GitHub Desktop.
Google Apps Script to check for and download new bills for DEI (PPC) Shared Areas (κοινόχρηστα) contracts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /*** CONFIG ***/ | |
| const CONFIG = { | |
| accountNumber: '', // <-- your contract account | |
| emailTo: '', // recipient | |
| driveFolderId: '', // optional: Drive folder ID to also save the PDF ('' to skip) | |
| userAgent: 'Mozilla/5.0 (AppsScript)', | |
| baseUrl: 'https://mydei.dei.gr', | |
| loginUrl: 'https://mydei.dei.gr/en/login/', | |
| sharedUrl: 'https://mydei.dei.gr/en/shared/', | |
| // property keys | |
| propKeyLastDate: 'DEI_LAST_BILL_DATE_ISO', | |
| // safety toggle | |
| DRY_RUN: false // set to false to actually email/save when new bill is detected | |
| }; | |
| /*** ENTRY POINTS ***/ | |
| function runJob() { | |
| const result = checkAndMaybeSend(); | |
| Logger.log(JSON.stringify(result, null, 2)); | |
| } | |
| function runDry() { | |
| const R = checkLatestMetadataOnly(); | |
| Logger.log(JSON.stringify(R, null, 2)); | |
| } | |
| function testParseSnippet() { | |
| const jsSnippet = | |
| 'window.historyData = {"types":[{"label":"ALL TRANSACTIONS","value":0},{"label":"BILLS","value":1},{"label":"PAYMENTS","value":2}],"columns":["Date","Type of transaction","Consumption period","Due date","Current amount","Overdue amount","Total amount"],"data":[{"type":2,"row":["01/09/2025","Payment","-","-","-","-","339.66€"],"attachments":[],"fileUrl":"","fileUrlView":"","digitalBillUrl":null},{"type":1,"row":["08/08/2025","Αctual","07/07/2025-04/08/2025","01/09/2025","339.66€","-","339.66€"],"attachments":[],"fileUrl":"/umbraco/surface/accountsurface/communal/getbill?currentpageid=539725&ca=300009085368&dt=20250808&documentId=1445743624","fileUrlView":"/umbraco/surface/accountsurface/communal/getbill?currentpageid=539725&ca=300009085368&dt=20250808&documentId=1445743624","digitalBillUrl":null}]};'; | |
| const history = extractHistoryData(jsSnippet); | |
| if (!history) throw new Error('Parser failed to find historyData'); | |
| const latest = firstBillRow(history); | |
| if (!latest) throw new Error('No bill rows in snippet'); | |
| const issueISO = ddmmyyyyToISO(latest.row[0]); | |
| Logger.log('Parsed OK. Latest issue ISO: ' + issueISO + ' amount=' + latest.row[4]); | |
| } | |
| function resetState() { | |
| PropertiesService.getScriptProperties().deleteProperty(CONFIG.propKeyLastDate); | |
| Logger.log('State cleared.'); | |
| } | |
| /*** REFACTORED COMMON HELPERS ***/ | |
| function fetchHistoryAndLatest() { | |
| const cookieJar = {}; | |
| // GET /en/login | |
| const loginResp = fetchWithCookies(CONFIG.loginUrl, { method: 'get' }, cookieJar); | |
| assert200(loginResp, 'GET login'); | |
| // find correct form (communalEntitiesForm / ContractAccount) | |
| const formInfo = findSharedForm(loginResp.getContentText()); | |
| if (!formInfo) throw new Error('Shared Areas form not found.'); | |
| // POST form | |
| const payload = Object.assign({}, formInfo.hiddenInputs); | |
| payload[formInfo.textFieldName] = CONFIG.accountNumber; | |
| const postUrl = formInfo.action ? absolutize(CONFIG.baseUrl, formInfo.action) : CONFIG.loginUrl; | |
| Logger.log('POSTing to: %s', postUrl); | |
| Logger.log('Payload keys: %s', Object.keys(payload).join(',')); | |
| Logger.log('Payload sample: %s=%s', formInfo.textFieldName, String(payload[formInfo.textFieldName]).slice(0, 8) + '…'); | |
| let resp = fetchWithCookies(postUrl, { method: 'post', payload: payload }, cookieJar); | |
| resp = followRedirects(resp, cookieJar, 5); | |
| // Ensure we have HTML with historyData | |
| let html = resp.getContentText(); | |
| if (!/window\.historyData\s*=/.test(html)) { | |
| let sharedResp = fetchWithCookies(CONFIG.sharedUrl, { method: 'get' }, cookieJar); | |
| sharedResp = followRedirects(sharedResp, cookieJar, 5); | |
| assert200(sharedResp, 'GET shared (after redirects)'); | |
| html = sharedResp.getContentText(); | |
| } | |
| const history = extractHistoryData(html); | |
| if (!history) throw new Error('historyData not found in page.'); | |
| const latest = firstBillRow(history); | |
| return { cookieJar, history, latest }; // latest may be null | |
| } | |
| function buildLatestMeta(latest) { | |
| // latest.row: [Date, Type, Consumption period, Due date, Current amount, Overdue amount, Total amount] | |
| return { | |
| issueISO: ddmmyyyyToISO(latest.row[0]), | |
| dueISO: safeISO(latest.row[3]), | |
| amount: latest.row[4], | |
| consumptionPeriod: latest.row[2], | |
| overdueAmount: latest.row[5], | |
| totalAmount: latest.row[6] | |
| }; | |
| } | |
| function buildSummary(meta, status, extra) { | |
| return Object.assign({ status }, meta, (extra || {})); | |
| } | |
| // Try to download (or just probe) the latest bill. | |
| // opts.probeOnly === true → do NOT read bytes; only check availability/HTTP status. | |
| function tryDownloadLatest(cookieJar, latest, meta, opts) { | |
| const rel = latest.fileUrl || latest.fileUrlView || ''; | |
| if (!rel) { | |
| return { ok: false, status: 'new-no-file' }; // link not published yet | |
| } | |
| const absUrl = absolutize(CONFIG.baseUrl, rel); | |
| const resp = fetchWithCookies(absUrl, { method: 'get' }, cookieJar); | |
| const code = resp.getResponseCode(); | |
| if (code !== 200) { | |
| return { ok: false, status: 'new-file-error', http: code }; // link present but not ready/blocked | |
| } | |
| const contentType = resp.getHeaders()['Content-Type'] || 'application/pdf'; | |
| const filename = `ΔΕΗ_${CONFIG.accountNumber}_${meta.issueISO.replace(/-/g, '')}.pdf`; | |
| if (opts && opts.probeOnly) { | |
| // Don’t read body; just report availability. | |
| return { ok: true, filename, contentType }; | |
| } | |
| const bytes = resp.getBlob().getBytes(); | |
| return { ok: true, filename, contentType, bytes }; | |
| } | |
| function checkAndMaybeSend() { | |
| const { cookieJar, latest } = fetchHistoryAndLatest(); | |
| if (!latest) return buildSummary({}, 'no-bill-rows'); | |
| const meta = buildLatestMeta(latest); | |
| // Freshness check (do NOT update state unless we actually send/save) | |
| const props = PropertiesService.getScriptProperties(); | |
| const lastISO = props.getProperty(CONFIG.propKeyLastDate); | |
| if (lastISO && lastISO >= meta.issueISO) { | |
| return buildSummary({ lastISO, current: meta.issueISO }, 'not-new'); | |
| } | |
| // New bill found — try to download if available | |
| const dl = tryDownloadLatest(cookieJar, latest, meta); | |
| // If file not ready yet (no link or bad HTTP), report and skip send/save/state update | |
| if (!dl.ok) { | |
| // statuses: 'new-no-file' (no link yet), 'new-file-error' (HTTP not 200) | |
| return buildSummary( | |
| meta, | |
| dl.status, | |
| dl.http ? { http: dl.http } : {} | |
| ); | |
| } | |
| // Build success summary (and honor DRY_RUN) | |
| const summary = buildSummary(meta, (CONFIG.DRY_RUN ? 'would-send' : 'sent'), { | |
| filename: dl.filename, | |
| contentType: dl.contentType | |
| }); | |
| if (CONFIG.DRY_RUN) return summary; | |
| // Optional Drive save | |
| if (CONFIG.driveFolderId) { | |
| const folder = DriveApp.getFolderById(CONFIG.driveFolderId); | |
| folder.createFile(Utilities.newBlob(dl.bytes, dl.contentType, dl.filename)); | |
| } | |
| // Email via Gmail | |
| GmailApp.sendEmail( | |
| CONFIG.emailTo, | |
| `Λογαριασμός ΔΕΗ κοινοχρήστων (${CONFIG.accountNumber}) ${meta.issueISO}`, | |
| [ | |
| `Λογαριασμός συμβολαίου: ${CONFIG.accountNumber}`, | |
| `Ημερομηνία έκδοσης: ${meta.issueISO}`, | |
| `Περίοδος κατανάλωσης: ${meta.consumptionPeriod}`, | |
| `Ποσό οφειλής: ${meta.amount}`, | |
| `Ποσό προηγούμενης οφειλής: ${meta.overdueAmount}`, | |
| `Ποσό πληρωμής: ${meta.totalAmount}`, | |
| `Ημερομηνία λήξης: ${meta.dueISO || '-'}` | |
| ].join('\n'), | |
| { attachments: [Utilities.newBlob(dl.bytes, dl.contentType, dl.filename)] } | |
| ); | |
| // Only now mark as processed | |
| props.setProperty(CONFIG.propKeyLastDate, meta.issueISO); | |
| return summary; | |
| } | |
| // Non-sending variant for quick testing (metadata only) | |
| function checkLatestMetadataOnly() { | |
| const { cookieJar, latest } = fetchHistoryAndLatest(); | |
| if (!latest) return buildSummary({}, 'no-bill-rows'); | |
| const meta = buildLatestMeta(latest); | |
| // Freshness check against stored date (read-only here) | |
| const props = PropertiesService.getScriptProperties(); | |
| const lastISO = props.getProperty(CONFIG.propKeyLastDate); | |
| if (lastISO && lastISO >= meta.issueISO) { | |
| return buildSummary({ lastISO, current: meta.issueISO }, 'not-new'); | |
| } | |
| // Probe link availability without downloading bytes | |
| const dl = tryDownloadLatest(cookieJar, latest, meta, { probeOnly: true }); | |
| if (!dl.ok) { | |
| // 'new-no-file' or 'new-file-error' | |
| return buildSummary( | |
| meta, | |
| dl.status, | |
| dl.http ? { http: dl.http, hasDownload: (dl.status !== 'new-no-file') } : { hasDownload: false } | |
| ); | |
| } | |
| // Link is live; metadata-only call reports availability | |
| return buildSummary(meta, 'new-file-available', { hasDownload: true }); | |
| } | |
| /*** Robust extraction: historyData (brace matching, string-aware) ***/ | |
| function extractHistoryData(html) { | |
| const marker = 'window.historyData'; | |
| let i = html.indexOf(marker); | |
| if (i < 0) return null; | |
| i = html.indexOf('=', i); | |
| if (i < 0) return null; | |
| while (i < html.length && html[i] !== '{') i++; | |
| if (i >= html.length) return null; | |
| let depth = 0, inStr = false, strQ = '', esc = false; | |
| const start = i; | |
| for (let j = i; j < html.length; j++) { | |
| const ch = html[j]; | |
| if (inStr) { | |
| if (esc) { esc = false; } | |
| else if (ch === '\\') { esc = true; } | |
| else if (ch === strQ) { inStr = false; } | |
| continue; | |
| } | |
| if (ch === '"' || ch === "'") { inStr = true; strQ = ch; continue; } | |
| if (ch === '{') depth++; | |
| else if (ch === '}') { | |
| depth--; | |
| if (depth === 0) { | |
| const jsonText = html.substring(start, j + 1); | |
| return JSON.parse(jsonText); | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| /*** Simplified, targeted form parsing ***/ | |
| function findSharedForm(html) { | |
| const formRe = /<form\b[^>]*>([\s\S]*?)<\/form>/gi; | |
| let m, chosen = null, reason = ''; | |
| const forms = []; | |
| while ((m = formRe.exec(html))) { | |
| const fullStart = m.index; | |
| const formHtml = m[1]; | |
| const openSlice = html.slice(fullStart, fullStart + 800); | |
| const openTagMatch = /<form\b[^>]*>/i.exec(openSlice); | |
| const formOpenTag = openTagMatch ? openTagMatch[0] : '<form>'; | |
| const attrs = getFormAttributes(formOpenTag); | |
| const inputs = [...formHtml.matchAll(/<input\b[^>]*>/gi)].map(x => x[0]); | |
| if ((attrs.name || '').toLowerCase() === 'communalentitiesform') { | |
| chosen = buildFormInfo(formOpenTag, inputs); | |
| reason = 'name=communalEntitiesForm'; | |
| break; | |
| } | |
| const hasContractAccount = inputs.some(tag => { | |
| const id = (getAttr(tag, 'id') || '').toLowerCase(); | |
| const name = (getAttr(tag, 'name') || '').toLowerCase(); | |
| return id === 'contractaccount' || name === 'contractaccount'; | |
| }); | |
| forms.push({ formOpenTag, inputs, hasContractAccount }); | |
| } | |
| if (!chosen) { | |
| const candidate = forms.find(f => f.hasContractAccount); | |
| if (candidate) { | |
| chosen = buildFormInfo(candidate.formOpenTag, candidate.inputs); | |
| reason = 'contains ContractAccount input'; | |
| } | |
| } | |
| if (chosen) { | |
| Logger.log('Form chosen (%s). action=%s textField=%s hiddenKeys=%s', | |
| reason, | |
| chosen.action || '(empty)', | |
| chosen.textFieldName, | |
| Object.keys(chosen.hiddenInputs).join(',') | |
| ); | |
| return chosen; | |
| } | |
| Logger.log('No matching form found (name=communalEntitiesForm or input id/name=ContractAccount).'); | |
| return null; | |
| } | |
| function buildFormInfo(formOpenTag, inputs) { | |
| const hiddenInputs = {}; | |
| for (const tag of inputs) { | |
| const type = (getAttr(tag, 'type') || '').toLowerCase(); | |
| const name = getAttr(tag, 'name') || ''; | |
| if (!name) continue; | |
| if (type === 'hidden' || type === 'submit') { | |
| hiddenInputs[name] = getAttr(tag, 'value') || ''; | |
| } | |
| } | |
| let textFieldName = | |
| getTextFieldName(inputs, 'ContractAccount') || | |
| getTextFieldName(inputs, 'contractAccount') || | |
| 'contractAccount'; | |
| const action = getAttr(formOpenTag, 'action') || ''; | |
| return { action, hiddenInputs, textFieldName }; | |
| } | |
| function getTextFieldName(inputs, preferred) { | |
| preferred = (preferred || '').toLowerCase(); | |
| for (const tag of inputs) { | |
| const id = (getAttr(tag, 'id') || '').toLowerCase(); | |
| const name = (getAttr(tag, 'name') || '').toLowerCase(); | |
| const type = (getAttr(tag, 'type') || 'text').toLowerCase(); | |
| if ((id === preferred || name === preferred) && (type === 'text' || type === '' || type === 'search')) { | |
| return name || id || null; | |
| } | |
| } | |
| for (const tag of inputs) { | |
| const type = (getAttr(tag, 'type') || 'text').toLowerCase(); | |
| if (type === 'text' || type === '' || type === 'search') { | |
| return getAttr(tag, 'name') || getAttr(tag, 'id') || null; | |
| } | |
| } | |
| return null; | |
| } | |
| function getFormAttributes(openTag) { | |
| return { | |
| name: getAttr(openTag, 'name'), | |
| id: getAttr(openTag, 'id'), | |
| action: getAttr(openTag, 'action'), | |
| method: (getAttr(openTag, 'method') || '').toUpperCase() | |
| }; | |
| } | |
| function getAttr(tag, attr) { | |
| const re = new RegExp(`\\b${attr}\\s*=\\s*("([^"]*)"|'([^']*)')`, 'i'); | |
| const m = re.exec(tag); | |
| if (!m) return ''; | |
| return m[2] !== undefined ? m[2] : (m[3] || ''); | |
| } | |
| /*** Networking helpers ***/ | |
| function fetchWithCookies(url, options, jar) { | |
| options = options || {}; | |
| const headers = options.headers || {}; | |
| headers['User-Agent'] = CONFIG.userAgent; | |
| headers['Accept'] = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'; | |
| headers['Accept-Language'] = 'en'; | |
| const cookieHeader = buildCookieHeader(jar); | |
| if (cookieHeader) headers['Cookie'] = cookieHeader; | |
| const resp = UrlFetchApp.fetch(url, { | |
| method: (options.method || 'get').toUpperCase(), | |
| payload: options.payload || undefined, | |
| headers: headers, | |
| followRedirects: false, | |
| muteHttpExceptions: true | |
| }); | |
| const allHeaders = resp.getAllHeaders(); | |
| const setCookies = [].concat(allHeaders['Set-Cookie'] || []); | |
| setCookies.forEach(sc => mergeCookie(jar, sc)); | |
| return resp; | |
| } | |
| function followRedirects(resp, jar, maxHops) { | |
| let hops = 0, r = resp; | |
| while (hops < (maxHops || 5)) { | |
| const code = r.getResponseCode(); | |
| if (![301,302,303,307,308].includes(code)) break; | |
| const loc = r.getHeaders()['Location'] || r.getAllHeaders()['Location']; | |
| if (!loc) break; | |
| const nextUrl = absolutize(CONFIG.baseUrl, loc); | |
| Logger.log('Redirect (' + code + ') → ' + nextUrl); | |
| r = fetchWithCookies(nextUrl, { method: 'get' }, jar); | |
| hops++; | |
| } | |
| return r; | |
| } | |
| function mergeCookie(jar, setCookieHeader) { | |
| const part = String(setCookieHeader).split(';')[0]; | |
| const eq = part.indexOf('='); | |
| if (eq < 0) return; | |
| const name = part.slice(0, eq).trim(); | |
| const val = part.slice(eq + 1).trim(); | |
| jar[name] = val; | |
| } | |
| function buildCookieHeader(jar) { | |
| const pairs = []; | |
| for (const k in jar) pairs.push(k + '=' + jar[k]); | |
| return pairs.length ? pairs.join('; ') : ''; | |
| } | |
| function absolutize(base, maybeRel) { | |
| if (/^https?:\/\//i.test(maybeRel)) return maybeRel; | |
| if (!maybeRel.startsWith('/')) return base.replace(/\/+$/, '/') + maybeRel; | |
| return base.replace(/\/+$/, '') + maybeRel; | |
| } | |
| /*** Small utils ***/ | |
| function ddmmyyyyToISO(dstr) { | |
| if (!dstr || dstr === '-') return null; | |
| const m = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(dstr); | |
| if (!m) return null; | |
| return `${m[3]}-${m[2]}-${m[1]}`; | |
| } | |
| function safeISO(dstr) { try { return ddmmyyyyToISO(dstr); } catch(e) { return null; } } | |
| function firstBillRow(history) { | |
| if (!history || !Array.isArray(history.data)) return null; | |
| for (const row of history.data) if (row.type === 1) return row; | |
| return null; | |
| } | |
| function assert200(resp, label) { | |
| const code = resp.getResponseCode(); | |
| if (code !== 200) throw new Error(`${label} failed: HTTP ${code}`); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment