Files
uni-notify/app/browser/watcher.js
Claude da28d0ac1c uni-notify: Outlook + Teams → Telegram с LLM-фильтром
- Playwright держит открытыми веб-версии Outlook и Teams (постоянный профиль, Xvfb)
- watcher.js перехватывает уведомления клиента и изменения карточек в реальном времени
- pipeline: дедуп, baseline, VIP/MUTE, оценка важности через Haiku (OpenRouter)
- aiogram-бот: уведомления, дайджест, вопросы по журналу, /login с 2FA-кодом, ручное управление браузером
- Docker/compose, systemd-юнит, e2e-тест на поддельных страницах

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194buWJjpDjU52JPX7PjsMs
2026-11-13 14:20:14 +01:00

124 lines
5.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Встраивается в страницы Outlook/Teams до загрузки их скриптов (add_init_script).
// Ловит события двумя путями:
// 1) уведомления самого клиента (window.Notification и ServiceWorkerRegistration.showNotification);
// 2) изменения карточек в списке писем/чатов (MutationObserver), как страховка.
// Всё отправляется в Python через window.__uniEmit(json).
// Конфиг (селекторы) подставляется в window.__UNI_CFG перед этим скриптом.
(() => {
'use strict';
if (window.top !== window) return; // только верхний фрейм
if (window.__uniWatcher) return;
const CFG = window.__UNI_CFG || {};
const SRC = (CFG.sources || []).find((s) => location.host.includes(s.host));
if (!SRC) return; // логин-страницы и прочее — пропускаем
window.__uniWatcher = true;
const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim();
const emit = (ev) => {
ev.source = SRC.name;
ev.url = location.href;
ev.ts = Date.now();
try {
if (typeof window.__uniEmit === 'function') window.__uniEmit(JSON.stringify(ev));
} catch (e) { /* binding недоступен — игнор */ }
};
// ---------- 1. Уведомления ----------
class UniNotification extends EventTarget {
constructor(title, opts) {
super();
opts = opts || {};
this.title = String(title || '');
this.body = String(opts.body || '');
this.tag = String(opts.tag || '');
this.data = opts.data;
this.icon = opts.icon || '';
this.onclick = this.onclose = this.onshow = this.onerror = null;
emit({ kind: 'notification', title: norm(this.title), text: norm(this.body), tag: this.tag });
setTimeout(() => {
try { if (this.onshow) this.onshow(new Event('show')); this.dispatchEvent(new Event('show')); } catch (e) {}
}, 0);
}
close() { try { if (this.onclose) this.onclose(new Event('close')); } catch (e) {} }
static get permission() { return 'granted'; }
static requestPermission(cb) { if (typeof cb === 'function') cb('granted'); return Promise.resolve('granted'); }
static get maxActions() { return 2; }
}
try {
Object.defineProperty(window, 'Notification', { value: UniNotification, writable: true, configurable: true });
} catch (e) { window.Notification = UniNotification; }
if (window.ServiceWorkerRegistration && ServiceWorkerRegistration.prototype.showNotification) {
ServiceWorkerRegistration.prototype.showNotification = function (title, opts) {
opts = opts || {};
emit({ kind: 'notification', title: norm(title), text: norm(opts.body), tag: String(opts.tag || '') });
return Promise.resolve();
};
}
// Клиенты часто не показывают уведомления, если вкладка в фокусе.
if (CFG.fakeBlur !== false) {
try { Document.prototype.hasFocus = function () { return false; }; } catch (e) {}
}
// ---------- 2. Карточки в списках ----------
const seen = new Set();
function leafMatches(sel) {
const nodes = Array.from(document.querySelectorAll(sel));
// оставляем самые «внутренние» совпадения: группа-папка с чатами внутри не нужна
return nodes.filter((n) => !nodes.some((m) => m !== n && n.contains(m)));
}
function readRows() {
if (!SRC.rowSelector || !document.body) return [];
const nodes = leafMatches(SRC.rowSelector).slice(0, SRC.maxRows || 25);
return nodes.map((n) => {
let id = '';
for (const a of SRC.idAttrs || []) { const v = n.getAttribute(a); if (v) { id = v; break; } }
const lines = String(n.innerText || n.textContent || '').split('\n').map(norm).filter(Boolean);
return { id, lines: lines.slice(0, 10), aria: norm(n.getAttribute('aria-label')).slice(0, 600) };
}).filter((r) => r.lines.length || r.aria);
}
// Строки вида «12:45», «Yesterday», «5 мин» меняются сами по себе — не считаем их изменением.
const TIME_RE = /^(\d{1,2}[:.]\d{2}(\s?[AP]M)?|\d{1,2}[./]\d{1,2}([./]\d{2,4})?|\d+\s?\S{0,5}|now|teraz|сейчас)$/i;
const INLINE_TIME_RE = /\b\d{1,2}[:.]\d{2}(\s?[AP]M)?\b/gi;
const sigOf = (r) => r.id + '|' + r.lines.filter((l) => !TIME_RE.test(l)).map((l) => l.replace(INLINE_TIME_RE, '')).join('|');
function scan(force) {
let rows;
try { rows = readRows(); } catch (e) { return 0; }
let n = 0;
for (const r of rows) {
const sig = sigOf(r);
if (!force && seen.has(sig)) continue;
seen.add(sig);
n++;
emit({ kind: 'row', id: r.id, lines: r.lines, aria: r.aria });
}
if (seen.size > 3000) seen.clear();
return n;
}
let timer = null;
let lastRun = 0;
const schedule = () => {
if (timer) return;
const wait = Math.max(1200, 2500 - (Date.now() - lastRun));
timer = setTimeout(() => { timer = null; lastRun = Date.now(); scan(false); }, wait);
};
const start = () => {
new MutationObserver(schedule).observe(document.body, { childList: true, subtree: true, characterData: true });
schedule();
};
if (document.body) start();
else document.addEventListener('DOMContentLoaded', start, { once: true });
// Для Python: принудительный пересмотр и отладка
window.__uniScan = (force) => scan(!!force);
window.__uniDebug = () => ({ source: SRC.name, title: document.title, url: location.href, rows: readRows().slice(0, 5) });
})();