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:
0
app/browser/__init__.py
Normal file
0
app/browser/__init__.py
Normal file
178
app/browser/login.py
Normal file
178
app/browser/login.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""Автоматический вход в Microsoft 365 своим логином/паролем с 2FA-кодом, который спрашивается в Telegram.
|
||||
|
||||
Ничего не обходит: заполняет обычную форму входа так же, как это сделал бы человек.
|
||||
Для нестандартных SSO-страниц университета присылает скриншот — дальше можно
|
||||
управлять браузером командами /click, /type, /key.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Awaitable, Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from playwright.async_api import Page
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
MS_LOGIN_HOSTS = ("login.microsoftonline.com", "login.live.com", "login.microsoft.com", "account.live.com")
|
||||
|
||||
Say = Callable[..., Awaitable[None]]
|
||||
Ask = Callable[..., Awaitable[str | None]]
|
||||
|
||||
|
||||
def is_login_url(url: str) -> bool:
|
||||
host = urlparse(url).hostname or ""
|
||||
return any(host.endswith(h) for h in MS_LOGIN_HOSTS)
|
||||
|
||||
|
||||
async def _visible(page: Page, sel: str) -> bool:
|
||||
try:
|
||||
loc = page.locator(sel).first
|
||||
return await loc.count() > 0 and await loc.is_visible()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def _text(page: Page, sel: str) -> str:
|
||||
try:
|
||||
loc = page.locator(sel).first
|
||||
if await loc.count() and await loc.is_visible():
|
||||
return (await loc.inner_text()).strip()
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
async def _click(page: Page, sel: str) -> None:
|
||||
await page.locator(sel).first.click(timeout=5000)
|
||||
|
||||
|
||||
async def login_flow(page: Page, *, target_url: str, target_host: str, email: str,
|
||||
password: str | None, say: Say, ask: Ask, timeout: float = 420) -> bool:
|
||||
"""Ведёт страницу через вход Microsoft до target_host. True — вошли."""
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + timeout
|
||||
if not is_login_url(page.url) and target_host not in (urlparse(page.url).hostname or ""):
|
||||
await page.goto(target_url, wait_until="domcontentloaded")
|
||||
|
||||
told_number = told_push = told_unknown = False
|
||||
unknown_since: float | None = None
|
||||
otc_tries = 0
|
||||
last_step = ""
|
||||
|
||||
while loop.time() < deadline:
|
||||
await asyncio.sleep(1.5)
|
||||
url = page.url
|
||||
host = urlparse(url).hostname or ""
|
||||
|
||||
# --- успех ---
|
||||
if target_host in host and not is_login_url(url):
|
||||
await asyncio.sleep(3)
|
||||
if target_host in (urlparse(page.url).hostname or ""):
|
||||
return True
|
||||
continue
|
||||
|
||||
# --- нестандартная страница (SSO университета и т.п.) ---
|
||||
if not is_login_url(url):
|
||||
unknown_since = unknown_since or loop.time()
|
||||
if not told_unknown and loop.time() - unknown_since > 12:
|
||||
told_unknown = True
|
||||
shot = await page.screenshot()
|
||||
await say(
|
||||
f"Нестандартная страница входа: {host}\n"
|
||||
"Управляй браузером: /click X Y, /type текст, /key Enter, /screen — "
|
||||
"я продолжу, как только вернёмся на Microsoft или в почту.",
|
||||
photo=shot,
|
||||
)
|
||||
continue
|
||||
unknown_since = None
|
||||
|
||||
# --- ошибки ---
|
||||
err = await _text(page, "#usernameError, #passwordError, #idSpan_SAOTCC_Error_OTC, #service_exception_message")
|
||||
if err and err != last_step:
|
||||
last_step = err
|
||||
await say(f"Microsoft пишет: {err}")
|
||||
if "password" in err.lower() or "пароль" in err.lower() or "hasło" in err.lower():
|
||||
password = None # спросим заново
|
||||
|
||||
# --- «Оставаться в системе?» ---
|
||||
if await _visible(page, "#KmsiCheckboxField") or await _visible(page, "#KmsiDescription"):
|
||||
if await _visible(page, "#KmsiCheckboxField"):
|
||||
try:
|
||||
await page.locator("#KmsiCheckboxField").check(timeout=3000)
|
||||
except Exception:
|
||||
pass
|
||||
await _click(page, "#idSIButton9")
|
||||
continue
|
||||
|
||||
# --- ввод кода из приложения ---
|
||||
if await _visible(page, "input[name='otc']"):
|
||||
if otc_tries >= 3:
|
||||
await say("Три неверных кода подряд — останавливаюсь. /login чтобы начать заново.")
|
||||
return False
|
||||
code = await ask("🔐 Введи 6-значный код из приложения-аутентификатора:", timeout=240)
|
||||
if not code:
|
||||
await say("Код не пришёл — вход отменён.")
|
||||
return False
|
||||
otc_tries += 1
|
||||
await page.locator("input[name='otc']").first.fill(code.strip().replace(" ", ""))
|
||||
if await _visible(page, "#idSubmit_SAOTCC_Continue"):
|
||||
await _click(page, "#idSubmit_SAOTCC_Continue")
|
||||
else:
|
||||
await page.keyboard.press("Enter")
|
||||
try: # ждём, пока поле исчезнет (успех) — иначе это ошибка кода
|
||||
await page.locator("input[name='otc']").first.wait_for(state="hidden", timeout=12000)
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
# --- сопоставление чисел в Authenticator ---
|
||||
number = await _text(page, "#idRichContext_DisplaySign")
|
||||
if number and not told_number:
|
||||
told_number = True
|
||||
await say(f"📱 Открой Authenticator и выбери число <b>{number}</b>")
|
||||
continue
|
||||
|
||||
# --- выбор способа подтверждения: предпочитаем код ---
|
||||
if await _visible(page, "[data-value='PhoneAppOTP']"):
|
||||
await _click(page, "[data-value='PhoneAppOTP']")
|
||||
continue
|
||||
|
||||
# --- push «Approve sign in» без числа ---
|
||||
if await _visible(page, "#idDiv_SAOTCAS_Title") and not told_push:
|
||||
told_push = True
|
||||
await say("📱 Подтверди вход в приложении Authenticator (или /screen, если что-то не так).")
|
||||
continue
|
||||
|
||||
# --- пароль ---
|
||||
if await _visible(page, "input[name='passwd']"):
|
||||
if not password:
|
||||
password = await ask("🔑 Пароль от универского аккаунта (сообщение сразу удалю):", secret=True, timeout=240)
|
||||
if not password:
|
||||
await say("Пароль не пришёл — вход отменён.")
|
||||
return False
|
||||
await page.locator("input[name='passwd']").first.fill(password)
|
||||
await _click(page, "#idSIButton9")
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
|
||||
# --- email ---
|
||||
if await _visible(page, "input[name='loginfmt']"):
|
||||
await page.locator("input[name='loginfmt']").first.fill(email)
|
||||
await _click(page, "#idSIButton9")
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
|
||||
# --- выбор аккаунта ---
|
||||
tile = page.get_by_text(email, exact=False)
|
||||
try:
|
||||
if await tile.count() and await tile.first.is_visible():
|
||||
await tile.first.click(timeout=5000)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
shot = await page.screenshot()
|
||||
await say("⏱ Вход не завершился за отведённое время. Вот что на экране:", photo=shot)
|
||||
return False
|
||||
213
app/browser/session.py
Normal file
213
app/browser/session.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""Постоянно открытые Outlook и Teams в Chromium (Playwright, persistent profile)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Awaitable, Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from playwright.async_api import BrowserContext, Page, async_playwright
|
||||
|
||||
from ..config import Settings
|
||||
from .login import is_login_url, login_flow
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
WATCHER_JS = (Path(__file__).parent / "watcher.js").read_text(encoding="utf-8")
|
||||
|
||||
OnEvent = Callable[[dict], Awaitable[None]]
|
||||
Say = Callable[..., Awaitable[None]]
|
||||
|
||||
|
||||
class BrowserManager:
|
||||
def __init__(self, s: Settings, on_event: OnEvent, say: Say):
|
||||
self.s = s
|
||||
self.on_event = on_event
|
||||
self.say = say
|
||||
self.cfg = json.loads(s.selectors_path.read_text(encoding="utf-8"))
|
||||
self.hosts = {src["name"]: src["host"] for src in self.cfg["sources"]}
|
||||
self.urls = {"mail": s.mail_url, "teams": s.teams_url}
|
||||
self.pw = None
|
||||
self.ctx: BrowserContext | None = None
|
||||
self.pages: dict[str, Page] = {}
|
||||
self.last_event: dict[str, float] = {}
|
||||
self.login_lock = asyncio.Lock()
|
||||
self._state: dict[str, str] = {}
|
||||
self._last_reload = time.time()
|
||||
self.password_cache: str | None = s.uni_password
|
||||
|
||||
# ---------- запуск ----------
|
||||
async def start(self) -> None:
|
||||
self.pw = await async_playwright().start()
|
||||
self.ctx = await self.pw.chromium.launch_persistent_context(
|
||||
user_data_dir=str(self.s.profile_dir),
|
||||
headless=self.s.headless,
|
||||
executable_path=self.s.chromium_path,
|
||||
viewport={"width": 1400, "height": 900},
|
||||
locale="en-US",
|
||||
timezone_id=self.s.tz,
|
||||
permissions=["notifications"],
|
||||
args=["--disable-dev-shm-usage", "--no-first-run", "--no-default-browser-check",
|
||||
"--disable-blink-features=AutomationControlled"],
|
||||
)
|
||||
await self.ctx.expose_binding("__uniEmit", self._binding)
|
||||
init = f"window.__UNI_CFG = {json.dumps(self.cfg)};\n{WATCHER_JS}"
|
||||
await self.ctx.add_init_script(script=init)
|
||||
|
||||
existing = list(self.ctx.pages)
|
||||
for name in ("mail", "teams"):
|
||||
page = existing.pop(0) if existing else await self.ctx.new_page()
|
||||
await self._attach(name, page)
|
||||
for extra in existing:
|
||||
await extra.close()
|
||||
for name in self.pages:
|
||||
await self._goto(name)
|
||||
|
||||
async def _attach(self, name: str, page: Page) -> None:
|
||||
self.pages[name] = page
|
||||
page.on("crash", lambda _p, n=name: asyncio.create_task(self._recreate(n, "crash")))
|
||||
|
||||
async def _recreate(self, name: str, why: str) -> None:
|
||||
log.warning("Пересоздаю вкладку %s (%s)", name, why)
|
||||
try:
|
||||
await self.pages[name].close()
|
||||
except Exception:
|
||||
pass
|
||||
await self._attach(name, await self.ctx.new_page())
|
||||
await self._goto(name)
|
||||
|
||||
async def _goto(self, name: str) -> None:
|
||||
try:
|
||||
await self.pages[name].goto(self.urls[name], wait_until="domcontentloaded", timeout=60000)
|
||||
except Exception as e:
|
||||
log.warning("Не открылась %s: %s", name, e)
|
||||
|
||||
async def _binding(self, source, payload: str) -> None:
|
||||
try:
|
||||
ev = json.loads(payload)
|
||||
except Exception:
|
||||
return
|
||||
self.last_event[ev.get("source", "?")] = time.time()
|
||||
await self.on_event(ev)
|
||||
|
||||
# ---------- состояние ----------
|
||||
def state(self, name: str) -> str:
|
||||
page = self.pages.get(name)
|
||||
if not page or page.is_closed():
|
||||
return "closed"
|
||||
url = page.url
|
||||
if is_login_url(url):
|
||||
return "login"
|
||||
if self.hosts[name] in (urlparse(url).hostname or ""):
|
||||
return "ok"
|
||||
return "other"
|
||||
|
||||
def states(self) -> dict[str, str]:
|
||||
return {n: self.state(n) for n in self.pages}
|
||||
|
||||
async def screenshot(self, name: str) -> bytes:
|
||||
return await self.pages[name].screenshot()
|
||||
|
||||
async def debug(self, name: str) -> dict:
|
||||
try:
|
||||
return await self.pages[name].evaluate("window.__uniDebug ? window.__uniDebug() : null") or {}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
async def reload(self, name: str | None = None) -> None:
|
||||
for n in [name] if name else list(self.pages):
|
||||
try:
|
||||
await self.pages[n].reload(wait_until="domcontentloaded", timeout=60000)
|
||||
except Exception as e:
|
||||
log.warning("reload %s: %s", n, e)
|
||||
await self._recreate(n, "reload failed")
|
||||
|
||||
# ---------- ручное управление (для нестандартного SSO) ----------
|
||||
async def click(self, name: str, x: float, y: float) -> None:
|
||||
await self.pages[name].mouse.click(x, y)
|
||||
|
||||
async def type(self, name: str, text: str) -> None:
|
||||
await self.pages[name].keyboard.type(text, delay=30)
|
||||
|
||||
async def key(self, name: str, key: str) -> None:
|
||||
await self.pages[name].keyboard.press(key)
|
||||
|
||||
async def goto(self, name: str, url: str | None = None) -> None:
|
||||
await self.pages[name].goto(url or self.urls[name], wait_until="domcontentloaded", timeout=60000)
|
||||
|
||||
# ---------- вход ----------
|
||||
async def login(self, ask) -> bool:
|
||||
if self.login_lock.locked():
|
||||
await self.say("Вход уже идёт.")
|
||||
return False
|
||||
async def ask_cached(prompt: str, secret: bool = False, timeout: float = 300):
|
||||
v = await ask(prompt, secret=secret, timeout=timeout)
|
||||
if secret and v:
|
||||
self.password_cache = v # только в памяти, на диск не пишется
|
||||
return v
|
||||
|
||||
async with self.login_lock:
|
||||
ask = ask_cached
|
||||
ok = await login_flow(
|
||||
self.pages["mail"], target_url=self.urls["mail"], target_host=self.hosts["mail"],
|
||||
email=self.s.uni_email, password=self.password_cache, say=self.say, ask=ask,
|
||||
)
|
||||
if not ok:
|
||||
return False
|
||||
await self.say("✅ Почта открыта. Захожу в Teams…")
|
||||
# Teams обычно подхватывает SSO-сессию сам, максимум просит выбрать аккаунт
|
||||
ok_t = await login_flow(
|
||||
self.pages["teams"], target_url=self.urls["teams"], target_host=self.hosts["teams"],
|
||||
email=self.s.uni_email, password=self.password_cache, say=self.say, ask=ask, timeout=180,
|
||||
)
|
||||
await self.say("✅ Teams открыт. Слежу за событиями." if ok_t else "⚠️ Почта работает, а Teams — нет. /screen teams")
|
||||
return ok_t
|
||||
|
||||
# ---------- фоновые циклы ----------
|
||||
async def health_loop(self) -> None:
|
||||
await asyncio.sleep(45)
|
||||
while True:
|
||||
try:
|
||||
for name in list(self.pages):
|
||||
st = self.state(name)
|
||||
prev = self._state.get(name)
|
||||
self._state[name] = st
|
||||
if st == "closed":
|
||||
await self._recreate(name, "closed")
|
||||
elif st == "login" and prev != "login":
|
||||
await self.say(
|
||||
f"⚠️ {'Почта' if name == 'mail' else 'Teams'}: сессия слетела, нужен вход. Жми /login"
|
||||
)
|
||||
elif st == "ok" and prev == "login":
|
||||
await self.say(f"✅ {'Почта' if name == 'mail' else 'Teams'} снова на связи.")
|
||||
if self.s.reload_hours > 0 and time.time() - self._last_reload > self.s.reload_hours * 3600:
|
||||
if not self.login_lock.locked():
|
||||
log.info("Плановая перезагрузка вкладок")
|
||||
self._last_reload = time.time()
|
||||
await self.reload()
|
||||
except Exception:
|
||||
log.exception("health_loop")
|
||||
await asyncio.sleep(60)
|
||||
|
||||
async def scan_loop(self) -> None:
|
||||
"""Страховка: раз в N секунд просим страницу пересмотреть списки (на случай пропущенных мутаций)."""
|
||||
while True:
|
||||
await asyncio.sleep(self.s.scan_interval)
|
||||
for name, page in list(self.pages.items()):
|
||||
if self.state(name) != "ok":
|
||||
continue
|
||||
try:
|
||||
await page.evaluate("window.__uniScan && window.__uniScan(false)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def close(self) -> None:
|
||||
try:
|
||||
if self.ctx:
|
||||
await self.ctx.close()
|
||||
finally:
|
||||
if self.pw:
|
||||
await self.pw.stop()
|
||||
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