@מסובך-מאוד אני מעלה את זה וזה לא עולה.
מעלה את הקוד, מי שיודע איך להעלות את זה בקובץ, מוזמן.
<!doctype html>
<html lang="he" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ספר מספרים - ימות המשיח</title>
<style>
:root{--bg:#f3f5f4;--surface:#fff;--ink:#14201c;--muted:#5d6b66;--line:#d9e0dd;--accent:#0f7a5c;--accent-soft:#e1f2ec;--warn:#b45309;--bad:#b42318;color-scheme:light}
@media (prefers-color-scheme:dark){:root{--bg:#0e1513;--surface:#16201d;--ink:#e6eeeb;--muted:#93a39d;--line:#2a3733;--accent:#3fc596;--accent-soft:#183a2f;--warn:#f0a454;--bad:#f2786d;color-scheme:dark}}
*{box-sizing:border-box}[hidden]{display:none!important}
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.55 "Assistant",system-ui,-apple-system,"Segoe UI",Arial,sans-serif;padding:28px 16px 60px}
.wrap{max-width:980px;margin-inline:auto;display:grid;gap:18px}
h1{margin:0;font-size:clamp(1.5rem,4vw,2.1rem)}h2{margin:0;font-size:1.15rem}
.lede,.muted{color:var(--muted)}.small{font-size:.86rem}
.card{background:var(--surface);border:1px solid var(--line);border-radius:14px;padding:20px;display:grid;gap:14px;min-width:0}
.row{display:flex;flex-wrap:wrap;gap:12px;align-items:end}
.field{display:grid;gap:4px;flex:1 1 180px;min-width:0}
label{font-size:.85rem;font-weight:600;color:var(--muted)}
input,select{font:inherit;color:var(--ink);background:var(--bg);border:1px solid var(--line);border-radius:9px;padding:9px 11px;width:100%}
input.ltr{direction:ltr;text-align:left}
button{font:inherit;font-weight:700;border-radius:9px;padding:9px 16px;cursor:pointer;border:1px solid var(--accent);background:var(--accent);color:var(--surface)}
button.ghost{background:transparent;color:var(--accent)}button:disabled{opacity:.5;cursor:default}
.seg{display:inline-flex;border:1px solid var(--line);border-radius:10px;padding:3px;gap:3px;background:var(--bg)}
.seg button{border:0;background:transparent;color:var(--muted);padding:6px 14px;border-radius:7px}
.seg button[aria-pressed="true"]{background:var(--surface);color:var(--ink)}
.status{font-size:.92rem;padding:9px 12px;border-radius:9px;background:var(--accent-soft)}
.status.err{border-inline-start:3px solid var(--bad)}.status.warn{border-inline-start:3px solid var(--warn)}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}
.kpi{border:1px solid var(--line);border-radius:12px;padding:12px 14px;display:grid;gap:2px}
.kpi .v{font:700 1.8rem/1.1 ui-monospace,Consolas,monospace;direction:ltr;text-align:start}
.kpi .k{font-size:.85rem;color:var(--muted);font-weight:600}
.scroll{overflow-x:auto}
table{border-collapse:collapse;width:100%;font-size:.9rem}
th,td{text-align:start;padding:6px 10px;border-bottom:1px solid var(--line);white-space:nowrap}
th{font-size:.8rem;color:var(--muted)}
td.num{font-family:ui-monospace,Consolas,monospace;direction:ltr;text-align:end}
progress{width:100%;height:10px}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>ספר מספרים</h1>
<p class="lede">שולף את כל לוגי הכניסות של המערכת, מאחד לפי מספר טלפון, ומייצא לאקסל: מי התקשר, מאיזה אזור, באילו שעות, ומאז מתי.</p>
</header>
<section class="card">
<div class="row" style="justify-content:space-between">
<h2>חיבור למערכת</h2>
<div class="seg" role="group" aria-label="שיטת התחברות">
<button type="button" id="m-pass" aria-pressed="true">מספר מערכת + סיסמה</button>
<button type="button" id="m-token" aria-pressed="false">טוקן / API KEY</button>
</div>
</div>
<form id="conn" class="row" autocomplete="off">
<div class="field" id="f-num"><label for="num">מספר מערכת</label><input id="num" class="ltr" inputmode="numeric" placeholder="0771234567"></div>
<div class="field" id="f-pass"><label for="pass">סיסמת ניהול</label><input id="pass" class="ltr" type="password"></div>
<div class="field" id="f-token" hidden><label for="token">טוקן או מפתח API</label><input id="token" class="ltr" type="password"></div>
<button type="submit" id="btn-conn">התחבר</button>
</form>
<div id="conn-status" class="status" hidden></div>
<p class="small muted" style="margin:0">הפרטים נשלחים ישירות מהדפדפן לשרת של ימות המשיח (call2all.co.il) ולא נשמרים בשום מקום.</p>
</section>
<section class="card">
<h2>שליפה</h2>
<div class="row">
<div class="field"><label for="from">מחודש (ריק = הכי ישן שיש)</label><input id="from" type="month"></div>
<div class="field"><label for="to">עד חודש (ריק = הכי חדש)</label><input id="to" type="month"></div>
<button type="button" id="btn-pull" disabled>משוך את כל ההיסטוריה</button>
</div>
<progress id="prog" value="0" max="1" hidden></progress>
<div id="pull-status" class="status" hidden></div>
<details>
<summary style="cursor:pointer;font-weight:700">אופציונלי: קובץ התאמה של טלפון לעיר ולשם</summary>
<p class="small muted">קובץ CSV עם כותרות: <b>טלפון</b>, <b>עיר</b>, ואפשר גם <b>שם</b> (או phone, city, name). המספרים שנמצאים בו יקבלו עיר ושם בעמודות נפרדות באקסל. בלי קובץ כזה העמודה "אזור" מבוססת על הקידומת בלבד.</p>
<input id="mapfile" type="file" accept=".csv,.txt">
<div id="map-status" class="small muted"></div>
</details>
<details>
<summary style="cursor:pointer;font-weight:700">אין גישה דרך ה-API? העלה קבצי לוג ידנית</summary>
<p class="small muted">אפשר לבחור כמה קבצי LogFolderEnterExit (CSV או ymgr) יחד. שמות הקבצים צריכים להכיל את החודש כדי שהסדר יהיה נכון.</p>
<input id="logfiles" type="file" accept=".csv,.ymgr,.txt" multiple>
</details>
</section>
<section class="card" id="res" hidden>
<div class="row" style="justify-content:space-between">
<h2>תוצאה</h2>
<div class="row" style="gap:8px">
<button type="button" id="btn-xlsx">הורד Excel</button>
<button type="button" class="ghost" id="btn-csv">הורד CSV</button>
</div>
</div>
<div id="xlsx-warn" class="status warn" hidden></div>
<div class="kpis" id="kpis"></div>
<div class="scroll"><table><thead><tr><th>טלפון</th><th>אזור</th><th>עיר</th><th>ראשון</th><th>אחרון</th><th>ותק (ימים)</th><th>שיחות</th><th>חלק יום עיקרי</th><th>סטטוס</th></tr></thead><tbody id="prev"></tbody></table></div>
<p class="small muted" style="margin:0">מוצגים 100 המספרים עם הכי הרבה שיחות. הקובץ המלא כולל את כולם.</p>
</section>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/exceljs/4.4.0/exceljs.min.js" async></script>
<script>
/* ===== ליבה: פענוח לוג, צבירה לפי מספר, בניית חוברת אקסל ===== */
const T = (() => {
const ANON = new Set(['', '0', 'unknown', 'anonymous', 'חסוי', 'private', 'restricted', 'blocked']);
const isAnon = p => ANON.has(String(p || '').trim().toLowerCase());
const DAY = 86400000;
function parseYmgrLine(line) {
const o = {};
for (const part of line.split('%')) { const i = part.indexOf('#'); if (i > 0) o[part.slice(0, i).trim()] = part.slice(i + 1).trim(); }
return o;
}
function parseCSV(text) {
const rows = []; let row = [], cur = '', q = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (q) { if (c === '"') { if (text[i + 1] === '"') { cur += '"'; i++; } else q = false; } else cur += c; }
else if (c === '"') q = true;
else if (c === ',') { row.push(cur); cur = ''; }
else if (c === '\n' || c === '\r') { if (c === '\r' && text[i + 1] === '\n') i++; row.push(cur); cur = ''; if (row.some(x => x.trim() !== '')) rows.push(row); row = []; }
else cur += c;
}
row.push(cur); if (row.some(x => x.trim() !== '')) rows.push(row);
return rows;
}
const HEAD_MAP = [
['Phone', /^(phone|טלפון|מספר טלפון|מספר הטלפון|טלפון המתקשר|מספר טלפון של המתקשר)$/i],
['Folder', /^(folder|תיקיה|תיקייה|שלוחה|תיקיה - שלוחה במערכת)$/i],
['EnterDate', /^(enterdate|תאריך|תאריך לועזי|תאריך כניסה)$/i],
['EnterTime', /^(entertime|שעת כניסה|שעת הכניסה|שעה)$/i],
['ExitTime', /^(exittime|שעת יציאה|שעת היציאה)$/i],
['TimeTotal', /^(timetotal|סך הכל זמן האזנה|סה"כ זמן האזנה|זמן האזנה|משך)$/i],
];
function parseLogText(text) {
text = String(text).replace(/^\uFEFF/, '');
const lines = text.split(/\r?\n/).filter(l => l.trim());
if (!lines.length) return [];
const ymgrLike = lines.slice(0, 20).filter(l => /[A-Za-z]+#/.test(l) && l.includes('%')).length;
if (ymgrLike >= Math.min(3, lines.length)) return lines.map(parseYmgrLine);
const rows = parseCSV(text);
if (rows.length < 2) return [];
const head = rows[0].map(h => h.trim()), idx = {};
head.forEach((h, i) => { for (const [k, re] of HEAD_MAP) if (idx[k] === undefined && re.test(h)) idx[k] = i; });
return rows.slice(1).map(r => { const o = {}; for (const k in idx) o[k] = (r[idx[k]] || '').trim(); return o; });
}
function parseDate(s) {
if (!s) return null; s = String(s).trim();
let y, m, d, mm;
if ((mm = s.match(/^(\d{4})[-\/.](\d{1,2})[-\/.](\d{1,2})/))) { y = +mm[1]; m = +mm[2]; d = +mm[3]; }
else if ((mm = s.match(/^(\d{1,2})[-\/.](\d{1,2})[-\/.](\d{2,4})/))) { d = +mm[1]; m = +mm[2]; y = +mm[3]; if (y < 100) y += 2000; }
else return null;
if (m < 1 || m > 12 || d < 1 || d > 31) return null;
return [y, m, d];
}
function parseTime(s) {
if (!s) return null; s = String(s).trim();
if (s.includes(' ')) s = s.slice(s.lastIndexOf(' ') + 1);
const m = s.match(/^(\d{1,2})[:\-.](\d{2})(?:[:\-.](\d{2}))?$/);
if (!m) return null;
const h = +m[1], mi = +m[2], se = +(m[3] || 0);
if (h > 23 || mi > 59 || se > 59) return null;
return h * 3600 + mi * 60 + se;
}
function parseDur(s) {
if (s === undefined || s === null) return null; s = String(s).trim(); if (s === '') return null;
if (/^\d+(\.\d+)?$/.test(s)) return Math.round(+s);
const m = s.match(/^(\d+):(\d{2})(?::(\d{2}))?$/);
if (m) return m[3] !== undefined ? (+m[1]) * 3600 + (+m[2]) * 60 + (+m[3]) : (+m[1]) * 60 + (+m[2]);
return null;
}
const at = (d, sec) => new Date(d[0], d[1] - 1, d[2], Math.floor(sec / 3600), Math.floor(sec / 60) % 60, sec % 60).getTime();
/* טלפון -> צורה אחידה: 0501234567 / 021234567 / +<מספר חו"ל> / '' אם חסוי */
function normPhone(raw) {
if (isAnon(raw)) return '';
let d = String(raw).replace(/\D/g, '');
if (!d) return '';
if (d.startsWith('00972')) d = '0' + d.slice(5);
else if (d.startsWith('972') && d.length >= 11) d = '0' + d.slice(3);
else if (d.startsWith('00')) return '+' + d.slice(2);
else if (d.length === 9 && d[0] === '5') d = '0' + d;
return d;
}
const LAND = { '02': 'ירושלים והסביבה', '03': 'גוש דן / מרכז', '04': 'צפון וחיפה', '08': 'שפלה ודרום', '09': 'השרון' };
function phoneInfo(p) {
if (p[0] === '+') return { region: 'חו"ל', type: 'חו"ל' };
if (/^05\d{8}$/.test(p)) return { region: 'נייד (אין אזור)', type: 'נייד' };
if (/^0[23489]\d{7}$/.test(p)) return { region: LAND[p.slice(0, 2)], type: 'קווי' };
if (/^07\d{8}$/.test(p)) return { region: 'קו ארצי / VoIP (07x)', type: 'קווי ארצי' };
return { region: 'לא מזוהה', type: 'לא מזוהה' };
}
function toIntervals(records) {
const out = [], diag = { rows: records.length, ok: 0, bad: 0 };
for (const r of records) {
const d = parseDate(r.EnterDate), t0 = parseTime(r.EnterTime);
if (!d || t0 === null) { diag.bad++; continue; }
const start = at(d, t0), t1 = parseTime(r.ExitTime), dur = parseDur(r.TimeTotal);
let end = null;
if (t1 !== null && t1 >= t0) end = at(d, t1);
else if (t1 !== null) {
if (dur !== null) end = start + dur * 1000;
else if (t0 - t1 >= 12 * 3600) end = at(d, t1) + DAY;
else end = start;
} else if (dur !== null) end = start + dur * 1000;
if (end === null) { diag.bad++; continue; }
end = Math.max(end, start + 1000);
out.push({ phone: normPhone(r.Phone), folder: String(r.Folder || '').trim(), start, end }); diag.ok++;
}
return { intervals: out, diag };
}
function mergeCalls(intervals, gapMs = 3000) {
const byPhone = new Map(), calls = [];
for (const iv of intervals) {
if (isAnon(iv.phone)) { calls.push({ ...iv, folders: [iv.folder] }); continue; }
if (!byPhone.has(iv.phone)) byPhone.set(iv.phone, []);
byPhone.get(iv.phone).push(iv);
}
for (const [, list] of byPhone) {
list.sort((a, b) => a.start - b.start);
let cur = { ...list[0], folders: [list[0].folder] };
for (let i = 1; i < list.length; i++) {
const n = list[i];
if (n.start <= cur.end + gapMs) { cur.end = Math.max(cur.end, n.end); if (!cur.folders.includes(n.folder)) cur.folders.push(n.folder); }
else { calls.push(cur); cur = { ...n, folders: [n.folder] }; }
}
calls.push(cur);
}
return calls;
}
/* ----- צבירה ----- */
const BUCKETS = ['לילה (00-06)', 'בוקר (06-12)', 'צהריים ואחה"צ (12-18)', 'ערב (18-24)'];
const bucketOf = h => h < 6 ? 0 : h < 12 ? 1 : h < 18 ? 2 : 3;
const mkey = t => { const d = new Date(t); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); };
const dnum = t => { const d = new Date(t); return Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / DAY); };
function newAgg() {
return { phones: new Map(), months: new Map(), heat: Array.from({ length: 7 }, () => new Array(24).fill(0)), anonCalls: 0, rows: 0, bad: 0, files: 0 };
}
function addRecords(agg, records) {
const { intervals, diag } = toIntervals(records);
agg.rows += diag.rows; agg.bad += diag.bad; agg.files++;
for (const c of mergeCalls(intervals)) {
const dt = new Date(c.start), h = dt.getHours();
const mk = mkey(c.start);
let m = agg.months.get(mk); if (!m) { m = { calls: 0, phones: new Set() }; agg.months.set(mk, m); }
m.calls++;
agg.heat[dt.getDay()][h]++;
if (isAnon(c.phone)) { agg.anonCalls++; continue; }
m.phones.add(c.phone);
let r = agg.phones.get(c.phone);
if (!r) { r = { phone: c.phone, first: c.start, last: c.start, calls: 0, totalMs: 0, b: [0, 0, 0, 0], hours: new Uint32Array(24), days: new Set(), folders: new Map() }; agg.phones.set(c.phone, r); }
if (c.start < r.first) r.first = c.start;
if (c.start > r.last) r.last = c.start;
r.calls++; r.totalMs += c.end - c.start; r.b[bucketOf(h)]++; r.hours[h]++; r.days.add(dnum(c.start));
for (const f of c.folders) r.folders.set(f, (r.folders.get(f) || 0) + 1);
}
}
const argmax = a => { let bi = 0; for (let i = 1; i < a.length; i++) if (a[i] > a[bi]) bi = i; return bi; };
function finalize(agg, nowMs, cityMap) {
cityMap = cityMap || new Map();
const rows = [];
for (const r of agg.phones.values()) {
const info = phoneInfo(r.phone), cm = cityMap.get(r.phone) || {};
const sinceLast = Math.max(0, Math.floor((nowMs - r.last) / DAY));
let mainF = '', mx = 0; for (const [f, n] of r.folders) if (n > mx) { mx = n; mainF = f; }
rows.push({
phone: r.phone, region: info.region, type: info.type, city: cm.city || '', name: cm.name || '',
first: r.first, last: r.last,
tenureDays: Math.max(0, Math.floor((nowMs - r.first) / DAY)), sinceLast,
spanDays: Math.floor((r.last - r.first) / DAY),
activeDays: r.days.size, calls: r.calls, totalHours: r.totalMs / 3600000, avgMin: r.totalMs / 60000 / r.calls,
b: r.b, mainBucket: BUCKETS[argmax(r.b)], peakHour: argmax(r.hours),
status: sinceLast <= 30 ? 'פעיל (עד 30 יום)' : sinceLast <= 90 ? 'מתקרר (30-90 יום)' : 'רדום (מעל 90 יום)',
folder: mainF
});
}
rows.sort((a, b) => b.calls - a.calls);
const firstByMonth = new Map();
for (const r of rows) { const k = mkey(r.first); firstByMonth.set(k, (firstByMonth.get(k) || 0) + 1); }
const months = [...new Set([...agg.months.keys(), ...firstByMonth.keys()])].sort().map(k => ({
month: k, calls: agg.months.get(k) ? agg.months.get(k).calls : 0, active: agg.months.get(k) ? agg.months.get(k).phones.size : 0, fresh: firstByMonth.get(k) || 0
}));
const regions = new Map();
for (const r of rows) {
let g = regions.get(r.region); if (!g) { g = { region: r.region, numbers: 0, calls: 0, hours: 0, tenure: 0, active: 0 }; regions.set(r.region, g); }
g.numbers++; g.calls += r.calls; g.hours += r.totalHours; g.tenure += r.tenureDays; if (r.sinceLast <= 30) g.active++;
}
const regionList = [...regions.values()].sort((a, b) => b.numbers - a.numbers);
const bucketList = BUCKETS.map((name, i) => ({ name, calls: rows.reduce((s, r) => s + r.b[i], 0), mainFor: rows.filter(r => r.mainBucket === name).length }));
return {
rows, months, regions: regionList, buckets: bucketList, heat: agg.heat,
summary: {
numbers: rows.length, calls: rows.reduce((s, r) => s + r.calls, 0), anonCalls: agg.anonCalls, logRows: agg.rows, badRows: agg.bad, files: agg.files,
first: rows.length ? Math.min(...rows.map(r => r.first)) : null, last: rows.length ? Math.max(...rows.map(r => r.last)) : null,
active30: rows.filter(r => r.sinceLast <= 30).length, mapped: rows.filter(r => r.city).length,
onceOnly: rows.filter(r => r.calls === 1).length, now: nowMs
}
};
}
function parseCityMap(text) {
const rows = parseCSV(String(text).replace(/^\uFEFF/, ''));
if (rows.length < 2) return new Map();
const head = rows[0].map(h => h.trim().toLowerCase());
const ip = head.findIndex(h => /^(phone|טלפון|מספר|מספר טלפון)$/.test(h));
const ic = head.findIndex(h => /^(city|עיר|יישוב|ישוב)$/.test(h));
const inm = head.findIndex(h => /^(name|שם)$/.test(h));
const m = new Map();
if (ip < 0) return m;
for (const r of rows.slice(1)) { const p = normPhone(r[ip]); if (p) m.set(p, { city: ic >= 0 ? (r[ic] || '').trim() : '', name: inm >= 0 ? (r[inm] || '').trim() : '' }); }
return m;
}
/* ----- ייצוא ----- */
const xd = ms => new Date(ms - new Date(ms).getTimezoneOffset() * 60000); // ExcelJS כותב UTC, אז מזיזים כדי שיוצג שעון מקומי
const DOW = ['ראשון', 'שני', 'שלישי', 'רביעי', 'חמישי', 'שישי', 'שבת'];
const MAIN_COLS = [
['טלפון', 16, '@'], ['אזור (לפי קידומת)', 20], ['סוג קו', 11], ['עיר (מקובץ התאמה)', 18], ['שם (מקובץ התאמה)', 18],
['הופעה ראשונה', 17, 'dd/mm/yyyy hh:mm'], ['הופעה אחרונה', 17, 'dd/mm/yyyy hh:mm'],
['ותק (ימים מההופעה הראשונה)', 15, '#,##0'], ['ימים מאז האחרונה', 12, '#,##0'], ['ימים בין ראשונה לאחרונה', 13, '#,##0'], ['ימים פעילים', 10, '#,##0'], ['סטטוס', 18],
['שיחות', 9, '#,##0'], ['סה"כ האזנה (שעות)', 12, '#,##0.0'], ['ממוצע לשיחה (דקות)', 12, '#,##0.0'],
['שיחות בלילה (00-06)', 11, '#,##0'], ['שיחות בבוקר (06-12)', 11, '#,##0'], ['שיחות בצהריים (12-18)', 11, '#,##0'], ['שיחות בערב (18-24)', 11, '#,##0'],
['חלק יום עיקרי', 20], ['שעה נפוצה', 9, '0'], ['שלוחה עיקרית', 14, '@']
];
const mainRow = r => [r.phone, r.region, r.type, r.city, r.name, xd(r.first), xd(r.last), r.tenureDays, r.sinceLast, r.spanDays, r.activeDays, r.status,
r.calls, +r.totalHours.toFixed(2), +r.avgMin.toFixed(2), r.b[0], r.b[1], r.b[2], r.b[3], r.mainBucket, r.peakHour, r.folder];
function buildWorkbook(ExcelJS, res) {
const wb = new ExcelJS.Workbook(); wb.creator = 'ספר מספרים'; wb.created = new Date();
const hdr = ws => { const r = ws.getRow(1); r.font = { bold: true, color: { argb: 'FFFFFFFF' } }; r.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF0F7A5C' } }; r.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }; r.height = 34; };
const s = res.summary, fd = ms => ms ? xd(ms) : '';
const w1 = wb.addWorksheet('סיכום', { views: [{ rightToLeft: true }] });
w1.columns = [{ width: 38 }, { width: 24 }];
const kv = [
['נוצר בתאריך', xd(s.now)], ['מספרי טלפון שונים', s.numbers], ['שיחות (אחרי איחוד מעברי שלוחה)', s.calls], ['שיחות ממספר חסוי (לא בטבלת המספרים)', s.anonCalls],
['מספרים שהתקשרו פעם אחת בלבד', s.onceOnly], ['מספרים פעילים ב-30 הימים האחרונים', s.active30], ['מספרים עם עיר מקובץ ההתאמה', s.mapped],
['ההופעה הראשונה בלוגים', fd(s.first)], ['ההופעה האחרונה בלוגים', fd(s.last)], ['קבצי לוג שנקראו', s.files], ['שורות לוג', s.logRows], ['שורות שלא נקראו', s.badRows]
];
kv.forEach(([k, v]) => { const r = w1.addRow([k, v]); r.getCell(1).font = { bold: true }; if (v instanceof Date) r.getCell(2).numFmt = 'dd/mm/yyyy hh:mm'; else if (typeof v === 'number') r.getCell(2).numFmt = '#,##0'; r.getCell(2).alignment = { horizontal: 'right' }; });
w1.addRow([]);
const note = w1.addRow(['הערות']); note.font = { bold: true };
['* "ותק" = כמה זמן עבר מההופעה הראשונה של המספר בלוגים ועד יום יצירת הקובץ. לא יודעים מתי נרשם בפועל, רק מתי נראה לראשונה בלוג שנשמר במערכת.',
'* "אזור" נקבע לפי הקידומת בלבד. מספר נייד לא נושא מידע על עיר, ומספר קווי נושא רק אזור כללי.',
'* חלק היום נקבע לפי שעת תחילת השיחה.'].forEach(t => { const r = w1.addRow([t]); r.alignment = { wrapText: true }; w1.mergeCells(r.number, 1, r.number, 2); r.height = 44; });
const w2 = wb.addWorksheet('מספרים', { views: [{ rightToLeft: true, state: 'frozen', xSplit: 1, ySplit: 1 }] });
w2.columns = MAIN_COLS.map(c => ({ header: c[0], width: c[1], style: c[2] ? { numFmt: c[2] } : {} }));
hdr(w2);
w2.addRows(res.rows.map(mainRow));
w2.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: MAIN_COLS.length } };
const w3 = wb.addWorksheet('לפי אזור', { views: [{ rightToLeft: true, state: 'frozen', ySplit: 1 }] });
w3.columns = [['אזור', 26], ['מספרים', 12], ['שיחות', 12], ['שעות האזנה', 14], ['ותק ממוצע (ימים)', 16], ['פעילים ב-30 יום', 14], ['% מכלל המספרים', 14]].map(c => ({ header: c[0], width: c[1] }));
hdr(w3);
res.regions.forEach(g => w3.addRow([g.region, g.numbers, g.calls, +g.hours.toFixed(1), Math.round(g.tenure / g.numbers), g.active, g.numbers / Math.max(1, s.numbers)]));
w3.getColumn(7).numFmt = '0.0%';
const w4 = wb.addWorksheet('שעות ביום', { views: [{ rightToLeft: true, state: 'frozen', xSplit: 1, ySplit: 1 }] });
w4.addRow(['יום \\ שעה', ...Array.from({ length: 24 }, (_, h) => String(h).padStart(2, '0'))]); hdr(w4);
w4.getColumn(1).width = 12; for (let c = 2; c <= 25; c++) w4.getColumn(c).width = 7;
res.heat.forEach((row, d) => w4.addRow([DOW[d], ...row]));
w4.addConditionalFormatting({ ref: 'B2:Y8', rules: [{ type: 'colorScale', cfvo: [{ type: 'min' }, { type: 'max' }], color: [{ argb: 'FFFFFFFF' }, { argb: 'FF0F7A5C' }] }] });
w4.addRow([]);
const hh = w4.addRow(['חלק יום', 'שיחות', 'מספרים שזה החלק העיקרי שלהם']); hh.font = { bold: true };
res.buckets.forEach(b => w4.addRow([b.name, b.calls, b.mainFor]));
const w5 = wb.addWorksheet('לפי חודש', { views: [{ rightToLeft: true, state: 'frozen', ySplit: 1 }] });
w5.columns = [['חודש', 12], ['שיחות', 12], ['מספרים פעילים', 14], ['מספרים חדשים (הופיעו לראשונה)', 22]].map(c => ({ header: c[0], width: c[1] }));
hdr(w5);
res.months.forEach(m => w5.addRow([m.month, m.calls, m.active, m.fresh]));
return wb;
}
function toCSV(res) {
const esc = v => { v = v instanceof Date ? v.toISOString().slice(0, 16).replace('T', ' ') : String(v); return /[",\n]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v; };
const lines = [MAIN_COLS.map(c => esc(c[0])).join(',')];
for (const r of res.rows) { const row = mainRow(r); row[0] = '\t' + row[0]; lines.push(row.map(esc).join(',')); }
return '\uFEFF' + lines.join('\r\n');
}
return { isAnon, normPhone, phoneInfo, parseLogText, parseCityMap, newAgg, addRecords, finalize, buildWorkbook, toCSV, mkey, BUCKETS };
})();
if (typeof module !== 'undefined') module.exports = T;
</script>
<script>
(() => {
if (typeof document === 'undefined') return;
const $ = id => document.getElementById(id);
const BASE = 'https://www.call2all.co.il/ym/api/';
let mode = 'pass', token = null, result = null, cityMap = new Map();
const pad = n => String(n).padStart(2, '0');
const fmtNum = n => Number(n).toLocaleString('he-IL');
const fmtD = t => { const d = new Date(t); return pad(d.getDate()) + '/' + pad(d.getMonth() + 1) + '/' + d.getFullYear(); };
function show(el, msg, kind) { el.hidden = !msg; el.className = 'status' + (kind ? ' ' + kind : ''); el.textContent = msg || ''; }
const tick = () => new Promise(r => setTimeout(r, 0));
function netError(e) { return e instanceof TypeError ? new Error('הדפדפן לא הצליח לפנות לשרת של ימות המשיח (אין אינטרנט או שהבקשה נחסמה). אפשר להעלות קבצי לוג ידנית.') : e; }
async function api(method, params = {}) {
let res; try { res = await fetch(BASE + method + '?' + new URLSearchParams({ token, ...params })); } catch (e) { throw netError(e); }
const txt = await res.text(); let j; try { j = JSON.parse(txt); } catch { throw new Error('תשובה לא צפויה מהשרת (' + res.status + ').'); }
if (j.responseStatus && j.responseStatus !== 'OK') throw new Error(j.message || ('השרת החזיר: ' + j.responseStatus));
return j;
}
function decode(buf) {
try { return new TextDecoder('utf-8', { fatal: true }).decode(buf); }
catch { try { return new TextDecoder('windows-1255').decode(buf); } catch { return new TextDecoder().decode(buf); } }
}
async function apiText(method, params = {}) {
let res; try { res = await fetch(BASE + method + '?' + new URLSearchParams({ token, ...params })); } catch (e) { throw netError(e); }
if (res.status === 404) throw new Error('הקובץ לא נמצא במערכת.');
const txt = decode(await res.arrayBuffer());
if (/^\s*\{/.test(txt)) { try { const j = JSON.parse(txt); if (j.responseStatus && j.responseStatus !== 'OK') throw new Error(j.message || j.responseStatus); } catch (e) { if (!(e instanceof SyntaxError)) throw e; } }
return txt;
}
function setMode(m) { mode = m; $('m-pass').setAttribute('aria-pressed', m === 'pass'); $('m-token').setAttribute('aria-pressed', m === 'token'); $('f-num').hidden = $('f-pass').hidden = m !== 'pass'; $('f-token').hidden = m !== 'token'; }
$('m-pass').onclick = () => setMode('pass'); $('m-token').onclick = () => setMode('token');
$('conn').addEventListener('submit', async e => {
e.preventDefault(); const st = $('conn-status');
if (mode === 'pass') { const n = $('num').value.trim(), p = $('pass').value.trim(); if (!n || !p) return show(st, 'הקלד מספר מערכת וסיסמה.', 'err'); token = n + ':' + p; }
else { const t = $('token').value.trim(); if (!t) return show(st, 'הדבק טוקן או מפתח API.', 'err'); token = t; }
$('btn-conn').disabled = true; show(st, 'מתחבר…');
try { const s = await api('GetSession'); show(st, 'מחובר' + (s.username ? ' למערכת ' + s.username : '') + (s.name ? ' · ' + s.name : '')); $('btn-pull').disabled = false; }
catch (err) { token = null; $('btn-pull').disabled = true; show(st, 'ההתחברות נכשלה: ' + err.message, 'err'); }
finally { $('btn-conn').disabled = false; }
});
$('mapfile').onchange = async e => {
const f = e.target.files[0]; if (!f) return;
cityMap = T.parseCityMap(decode(await f.arrayBuffer()));
$('map-status').textContent = cityMap.size ? 'נטענו ' + fmtNum(cityMap.size) + ' מספרים מקובץ ההתאמה.' : 'לא זוהו שורות. ודא שיש עמודת "טלפון" ועמודת "עיר".';
if (result) { /* ייושם בשליפה הבאה */ $('map-status').textContent += ' יחול בשליפה הבאה.'; }
};
function finish(agg) {
result = T.finalize(agg, Date.now(), cityMap);
const s = result.summary;
const k = [['מספרים שונים', s.numbers], ['שיחות', s.calls], ['פעילים ב-30 יום', s.active30], ['התקשרו פעם אחת', s.onceOnly], ['שיחות חסויות', s.anonCalls]];
$('kpis').innerHTML = ''; k.forEach(([a, b]) => { const d = document.createElement('div'); d.className = 'kpi'; d.innerHTML = '<span class="k"></span><span class="v"></span>'; d.firstChild.textContent = a; d.lastChild.textContent = fmtNum(b); $('kpis').appendChild(d); });
const tb = $('prev'); tb.innerHTML = '';
for (const r of result.rows.slice(0, 100)) {
const tr = document.createElement('tr');
[[r.phone, 'num'], [r.region], [r.city], [fmtD(r.first), 'num'], [fmtD(r.last), 'num'], [fmtNum(r.tenureDays), 'num'], [fmtNum(r.calls), 'num'], [r.mainBucket], [r.status]].forEach(([t, c]) => { const td = document.createElement('td'); td.textContent = t; if (c) td.className = c; tr.appendChild(td); });
tb.appendChild(tr);
}
$('res').hidden = false;
}
$('btn-pull').onclick = async () => {
const st = $('pull-status'), pg = $('prog'); $('btn-pull').disabled = true; pg.hidden = false; pg.value = 0;
try {
show(st, 'טוען את רשימת הקבצים בתיקיית Log…');
const d = await api('GetIVR2Dir', { path: 'ivr2:/Log' });
let months = (d.files || []).map(f => f.name || f.fileName || '').map(n => (n.match(/^LogFolderEnterExit-(\d{4}-\d{2})/) || [])[1]).filter(Boolean);
months = [...new Set(months)].sort();
const a = $('from').value, b = $('to').value;
if (a) months = months.filter(m => m >= a); if (b) months = months.filter(m => m <= b);
if (!months.length) throw new Error('לא נמצאו קבצי LogFolderEnterExit בתיקיית Log (בטווח שנבחר).');
const agg = T.newAgg(), failed = [];
pg.max = months.length;
for (let i = 0; i < months.length; i++) {
show(st, 'מוריד ומעבד ' + months[i] + ' (' + (i + 1) + ' מתוך ' + months.length + ') · עד עכשיו ' + fmtNum(agg.phones.size) + ' מספרים');
try { T.addRecords(agg, T.parseLogText(await apiText('DownloadFile', { path: 'ivr2:/Log/LogFolderEnterExit-' + months[i] + '.ymgr' }))); }
catch (err) { failed.push(months[i] + ' (' + err.message + ')'); }
pg.value = i + 1; await tick();
}
if (!agg.phones.size) throw new Error('לא נקראו נתונים. ' + (failed.length ? 'כשלו: ' + failed.join('; ') : ''));
finish(agg);
show(st, 'הסתיים: ' + fmtNum(result.summary.numbers) + ' מספרים מתוך ' + months.length + ' חודשים.' + (failed.length ? ' לא נטענו: ' + failed.join('; ') : ''), failed.length ? 'warn' : '');
} catch (err) { show(st, err.message, 'err'); }
finally { $('btn-pull').disabled = !token; pg.hidden = true; }
};
$('logfiles').onchange = async e => {
const st = $('pull-status'), files = [...e.target.files].sort((a, b) => a.name.localeCompare(b.name));
if (!files.length) return;
const agg = T.newAgg();
for (const f of files) { show(st, 'מעבד ' + f.name + '…'); T.addRecords(agg, T.parseLogText(decode(await f.arrayBuffer()))); await tick(); }
if (!agg.phones.size) return show(st, 'לא זוהו שורות לוג בקבצים.', 'err');
finish(agg); show(st, 'הסתיים: ' + fmtNum(result.summary.numbers) + ' מספרים מ-' + files.length + ' קבצים.');
};
function save(blob, name) { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 4000); }
const stamp = () => { const d = new Date(); return d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate()); };
$('btn-xlsx').onclick = async () => {
if (!result) return;
if (typeof ExcelJS === 'undefined') return show($('xlsx-warn'), 'ספריית האקסל לא נטענה (כנראה חסומה בסינון או שאין אינטרנט). אפשר להוריד CSV, שנפתח באקסל.', 'warn');
$('btn-xlsx').disabled = true; show($('xlsx-warn'), 'בונה קובץ אקסל…');
try { await tick(); const buf = await T.buildWorkbook(ExcelJS, result).xlsx.writeBuffer(); save(new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), 'phone-book-' + stamp() + '.xlsx'); show($('xlsx-warn'), ''); }
catch (err) { show($('xlsx-warn'), 'בניית האקסל נכשלה: ' + err.message + '. נסה CSV.', 'err'); }
finally { $('btn-xlsx').disabled = false; }
};
$('btn-csv').onclick = () => { if (result) save(new Blob([T.toCSV(result)], { type: 'text/csv;charset=utf-8' }), 'phone-book-' + stamp() + '.csv'); };
})();
</script>
</body>
</html>






