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:
123
app/browser/watcher.js
Normal file
123
app/browser/watcher.js
Normal 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) });
|
||||
})();
|
||||
Reference in New Issue
Block a user