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
This commit is contained in:
Claude
2026-11-13 14:20:14 +01:00
commit da28d0ac1c
22 changed files with 1844 additions and 0 deletions

123
app/browser/watcher.js Normal file
View File

@@ -0,0 +1,123 @@
// Встраивается в страницы 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) });
})();