Skip to content

Instantly share code, notes, and snippets.

@zmousm
Created October 13, 2025 20:51
Show Gist options
  • Save zmousm/58c3d82572dbf174a06957a446237d9c to your computer and use it in GitHub Desktop.
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
/*** 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