Add monitoring TG service

This commit is contained in:
Grendgi
2026-06-04 14:55:41 +03:00
commit f9e072774c
74 changed files with 7232 additions and 0 deletions

View File

@@ -0,0 +1,433 @@
import { api, toast, fmtDate } from "/api/monitoring-tg/static/js/api.js";
import { getVertical, getSection, VERTICAL_META } from "/api/monitoring-tg/static/js/vertical.js";
const V = getVertical();
const section = getSection();
const meta = VERTICAL_META[V];
const state = {
offset: 0,
limit: 50,
channelId: null,
q: "",
realEstate: "",
hrKind: "",
hasPhone: false,
leadsOnly: false,
minConfidence: 0.5,
channels: [],
autorefresh: false,
timer: null,
};
function escape(s) {
if (s == null) return "";
return String(s).replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
}
function highlight(text, q) {
if (!q || !text) return escape(text);
const escaped = escape(text);
const re = new RegExp(escape(q).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
return escaped.replace(re, m => `<mark style="background:#f1c40f33;color:inherit">${m}</mark>`);
}
function channelTitle(id) {
const c = state.channels.find(c => c.id === id);
return c ? (c.title || c.identifier) : `#${id}`;
}
function fmtSize(bytes) {
if (bytes == null) return "";
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}
const REAL_ESTATE_LABELS = { sale: "продажа", rent: "аренда", purchase: "покупка" };
const HR_KIND_LABELS = { vacancy: "вакансия", resume: "резюме", contact: "контакт" };
function senderContacts(m) {
const contacts = [];
if (m && m.post_url) {
contacts.push(`<a class="badge tg-link" href="${escape(m.post_url)}" target="_blank">📬 Открыть в Telegram</a>`);
}
if (m && m.sender_username) {
const u = m.sender_username.startsWith("@") ? m.sender_username : "@" + m.sender_username;
contacts.push(`<a class="badge tg" href="https://t.me/${escape(m.sender_username.replace(/^@/, ""))}" target="_blank">✉️ ${escape(u)}</a>`);
} else if (m && m.sender_name) {
contacts.push(`<span class="badge name">✍️ ${escape(m.sender_name)}</span>`);
}
const handles = (m && m.extracted && m.extracted.tg_handles) || [];
for (const h of handles) {
const bare = h.replace(/^@/, "");
contacts.push(`<a class="badge tg" href="https://t.me/${escape(bare)}" target="_blank">✉️ ${escape(h)}</a>`);
}
return contacts;
}
function renderReLead(lead, m) {
if (!lead || !lead.is_listing) return "";
const tone =
lead.confidence >= 0.7 ? "lead-strong" :
lead.confidence >= 0.4 ? "lead-medium" : "lead-weak";
const bits = [];
if (lead.kind) bits.push(REAL_ESTATE_LABELS[lead.kind] || lead.kind);
if (lead.property_type) bits.push(lead.property_type);
if (lead.rooms) bits.push(lead.rooms);
if (lead.area_m2) bits.push(`${lead.area_m2} м²`);
const priceBit = lead.price_text
|| (lead.price_value != null
? `${lead.price_value.toLocaleString()}${lead.currency ? " " + lead.currency : ""}`
: null);
if (priceBit) bits.push(priceBit);
else if (lead.currency) bits.push(lead.currency);
if (lead.location) bits.push(lead.location);
const facts = bits.length
? `<div class="lead-facts">${escape(bits.join(" · "))}</div>` : "";
const summary = lead.summary
? `<div class="lead-summary">${escape(lead.summary)}</div>` : "";
const contacts = [];
if (lead.contact_phone) {
contacts.push(`<a class="badge phone" href="tel:${escape(lead.contact_phone)}">📞 ${escape(lead.contact_phone)}</a>`);
}
if (lead.contact_name) {
contacts.push(`<span class="badge name">👤 ${escape(lead.contact_name)}</span>`);
}
contacts.push(...senderContacts(m));
return `
<div class="lead-card ${tone}">
<div class="lead-head">
<span class="badge lead">🎯 ЛИД · 🏠</span>
${facts}
<span class="lead-confidence">${(lead.confidence * 100).toFixed(0)}%</span>
</div>
${summary}
${contacts.length ? `<div class="message-tags">${contacts.join(" ")}</div>` : ""}
</div>`;
}
function renderHrLead(lead, m) {
if (!lead || !lead.is_lead) return "";
const tone =
lead.confidence >= 0.7 ? "lead-strong" :
lead.confidence >= 0.4 ? "lead-medium" : "lead-weak";
const bits = [];
if (lead.kind) bits.push(HR_KIND_LABELS[lead.kind] || lead.kind);
if (lead.title) bits.push(lead.title);
if (lead.company) bits.push(lead.company);
if (lead.candidate_name) bits.push(lead.candidate_name);
if (lead.experience_years != null) bits.push(`${lead.experience_years}+ лет опыта`);
if (lead.employment_type) bits.push(lead.employment_type);
if (lead.remote === true) bits.push("удалёнка");
else if (lead.remote === false) bits.push("офис");
if (lead.location) bits.push(lead.location);
const salaryBit = lead.salary_text
|| (lead.salary_value != null
? `${lead.salary_value.toLocaleString()}${lead.currency ? " " + lead.currency : ""}`
: null);
if (salaryBit) bits.push(salaryBit);
else if (lead.currency) bits.push(lead.currency);
const facts = bits.length
? `<div class="lead-facts">${escape(bits.join(" · "))}</div>` : "";
const summary = lead.summary
? `<div class="lead-summary">${escape(lead.summary)}</div>` : "";
const skills = (lead.skills || []).slice(0, 12);
const skillsBlock = skills.length
? `<div class="message-tags">${skills.map(s => `<span class="badge">${escape(s)}</span>`).join(" ")}</div>`
: "";
const contacts = [];
if (lead.contact_phone) {
contacts.push(`<a class="badge phone" href="tel:${escape(lead.contact_phone)}">📞 ${escape(lead.contact_phone)}</a>`);
}
if (lead.contact_name) {
contacts.push(`<span class="badge name">👤 ${escape(lead.contact_name)}</span>`);
}
contacts.push(...senderContacts(m));
return `
<div class="lead-card ${tone}">
<div class="lead-head">
<span class="badge lead">🎯 ЛИД · 👥</span>
${facts}
<span class="lead-confidence">${(lead.confidence * 100).toFixed(0)}%</span>
</div>
${summary}
${skillsBlock}
${contacts.length ? `<div class="message-tags">${contacts.join(" ")}</div>` : ""}
</div>`;
}
function renderExtracted(ex) {
if (!ex) return "";
const parts = [];
const re = ex.real_estate;
const showRegexRE =
V === "real_estate" && re && !(ex.lead && ex.lead.is_listing);
if (showRegexRE) {
const bits = [];
if (re.kind) bits.push(REAL_ESTATE_LABELS[re.kind] || re.kind);
if (re.property_type) bits.push(re.property_type);
if (re.rooms) bits.push(re.rooms);
if (re.area_m2) bits.push(`${re.area_m2} м²`);
if (re.price) bits.push(re.price);
if (bits.length) parts.push(`<span class="badge re">🏠 regex: ${escape(bits.join(" · "))}</span>`);
}
// Phones/names from regex are still useful even when there's a lead — show
// only those that aren't already inside the lead card.
const inLead = new Set();
const activeLead = V === "hr" ? ex.hr_lead : ex.lead;
if (activeLead) {
if (activeLead.contact_phone) inLead.add(activeLead.contact_phone);
if (activeLead.contact_name) inLead.add(activeLead.contact_name);
}
for (const p of ex.phones || []) {
if (inLead.has(p)) continue;
parts.push(`<a class="badge phone" href="tel:${escape(p)}">📞 ${escape(p)}</a>`);
}
for (const n of (ex.names || []).slice(0, 3)) {
if (inLead.has(n)) continue;
parts.push(`<span class="badge name">👤 ${escape(n)}</span>`);
}
if ((ex.names || []).length > 3) {
parts.push(`<span class="badge name muted">+${ex.names.length - 3}</span>`);
}
const leadShown = (V === "hr" && ex.hr_lead && ex.hr_lead.is_lead) ||
(V === "real_estate" && ex.lead && ex.lead.is_listing);
if (!leadShown) {
for (const h of (ex.tg_handles || [])) {
const bare = h.replace(/^@/, "");
parts.push(`<a class="badge tg" href="https://t.me/${escape(bare)}" target="_blank">✉️ ${escape(h)}</a>`);
}
}
const tags = parts.length ? `<div class="message-tags">${parts.join(" ")}</div>` : "";
return tags;
}
function renderMedia(files) {
if (!files || !files.length) return "";
return `<div class="message-media">${files.map(f => {
if (f.skipped) {
const why = f.skipped === "too_large" ? "слишком большой" : f.skipped;
return `<div class="media-item media-skipped"><span class="badge warn">${escape(f.kind)}</span>
<span class="muted">${why}${f.size ? `, ${fmtSize(f.size)}` : ""}</span></div>`;
}
if (!f.url) return "";
if (f.kind === "photo" || f.kind === "sticker") {
return `<a href="${escape(f.url)}" target="_blank" data-action="lightbox" data-url="${escape(f.url)}">
<img class="media-thumb" src="${escape(f.url)}" loading="lazy" alt="" />
</a>`;
}
if (f.kind === "video") {
return `<video class="media-video" src="${escape(f.url)}" controls preload="metadata"></video>`;
}
if (f.kind === "audio") {
return `<audio src="${escape(f.url)}" controls preload="none" style="width:100%"></audio>`;
}
return `<a class="media-doc" href="${escape(f.url)}" target="_blank" download>
<span class="badge">${escape(f.kind)}</span>
<span>${escape(f.mime || "файл")}</span>
<span class="muted">${fmtSize(f.size)}</span>
</a>`;
}).join("")}</div>`;
}
function readUrl() {
const params = new URLSearchParams(location.search);
if (params.has("channel_id")) state.channelId = Number(params.get("channel_id"));
if (params.has("q")) state.q = params.get("q");
if (params.has("real_estate")) state.realEstate = params.get("real_estate");
if (params.has("hr_kind")) state.hrKind = params.get("hr_kind");
if (params.get("has_phone") === "true") state.hasPhone = true;
if (params.get("leads_only") === "true") state.leadsOnly = true;
if (params.has("min_confidence")) state.minConfidence = Number(params.get("min_confidence"));
}
function syncControls() {
document.getElementById("channel-filter").value = state.channelId ?? "";
document.getElementById("search").value = state.q;
const reSel = document.getElementById("real-estate");
if (reSel) reSel.value = state.realEstate;
const hrSel = document.getElementById("hr-kind");
if (hrSel) hrSel.value = state.hrKind;
document.getElementById("has-phone").checked = state.hasPhone;
document.getElementById("leads-only").checked = state.leadsOnly;
document.getElementById("min-confidence").value = String(state.minConfidence);
document.getElementById("limit").value = state.limit;
}
async function loadChannels() {
state.channels = await api.listChannels();
const sel = document.getElementById("channel-filter");
sel.innerHTML = `<option value="">Все каналы (${meta.short})</option>` + state.channels.map(c =>
`<option value="${c.id}">${escape(c.title || c.identifier)}</option>`
).join("");
syncControls();
}
async function loadMessages() {
const list = document.getElementById("list");
list.innerHTML = `<div class="empty">Загрузка...</div>`;
try {
const msgs = await api.listMessages({
channelId: state.channelId,
q: state.q || undefined,
realEstate: state.realEstate || undefined,
hrKind: state.hrKind || undefined,
hasPhone: state.hasPhone || undefined,
leadsOnly: state.leadsOnly || undefined,
minConfidence: state.leadsOnly ? state.minConfidence : undefined,
limit: state.limit,
offset: state.offset,
});
if (!msgs.length) {
list.innerHTML = `<div class="empty">Сообщений нет</div>`;
} else {
list.innerHTML = msgs.map(m => `
<div class="message" data-id="${m.id}">
<div class="message-meta">
<a href="?channel_id=${m.channel_id}">${escape(channelTitle(m.channel_id))}</a>
<span>·</span>
<span>${fmtDate(m.date)}</span>
<span>·</span>
<span class="mono">#${m.tg_message_id}</span>
${m.group_size > 1 ? `<span class="badge">альбом · ${m.group_size}</span>` : (m.has_media ? '<span class="badge">media</span>' : '')}
${m.views != null ? `<span>👁 ${m.views}</span>` : ''}
${m.forwards ? `<span>↗ ${m.forwards}</span>` : ''}
<div class="spacer"></div>
<a href="#" data-action="raw">json</a>
</div>
<div class="message-text">${m.text ? highlight(m.text, state.q) : '<span class="muted">(без текста)</span>'}</div>
${V === "hr"
? renderHrLead(m.extracted && m.extracted.hr_lead, m)
: renderReLead(m.extracted && m.extracted.lead, m)}
${renderExtracted(m.extracted)}
${renderMedia(m.media_files)}
</div>
`).join("");
}
document.getElementById("page-info").textContent =
`${state.offset + 1}${state.offset + msgs.length}`;
document.getElementById("prev").disabled = state.offset === 0;
document.getElementById("next").disabled = msgs.length < state.limit;
} catch (err) {
toast(err.message, "error");
list.innerHTML = `<div class="empty">Ошибка: ${escape(err.message)}</div>`;
}
}
document.getElementById("channel-filter").addEventListener("change", (e) => {
state.channelId = e.target.value ? Number(e.target.value) : null;
state.offset = 0;
loadMessages();
});
let searchTimer;
document.getElementById("search").addEventListener("input", (e) => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
state.q = e.target.value.trim();
state.offset = 0;
loadMessages();
}, 250);
});
document.getElementById("limit").addEventListener("change", (e) => {
state.limit = Number(e.target.value);
state.offset = 0;
loadMessages();
});
const reSelEl = document.getElementById("real-estate");
if (reSelEl) {
reSelEl.addEventListener("change", (e) => {
state.realEstate = e.target.value;
state.offset = 0;
loadMessages();
});
}
const hrSelEl = document.getElementById("hr-kind");
if (hrSelEl) {
hrSelEl.addEventListener("change", (e) => {
state.hrKind = e.target.value;
state.offset = 0;
loadMessages();
});
}
document.getElementById("has-phone").addEventListener("change", (e) => {
state.hasPhone = e.target.checked;
state.offset = 0;
loadMessages();
});
document.getElementById("leads-only").addEventListener("change", (e) => {
state.leadsOnly = e.target.checked;
state.offset = 0;
loadMessages();
});
document.getElementById("min-confidence").addEventListener("change", (e) => {
state.minConfidence = Number(e.target.value);
if (state.leadsOnly) {
state.offset = 0;
loadMessages();
}
});
document.getElementById("refresh").addEventListener("click", loadMessages);
document.getElementById("prev").addEventListener("click", () => {
state.offset = Math.max(0, state.offset - state.limit);
loadMessages();
});
document.getElementById("next").addEventListener("click", () => {
state.offset += state.limit;
loadMessages();
});
document.getElementById("autorefresh").addEventListener("change", (e) => {
state.autorefresh = e.target.checked;
if (state.timer) { clearInterval(state.timer); state.timer = null; }
if (state.autorefresh) state.timer = setInterval(loadMessages, 10000);
});
document.getElementById("list").addEventListener("click", async (e) => {
const lightbox = e.target.closest("[data-action='lightbox']");
if (lightbox) {
e.preventDefault();
openLightbox(lightbox.dataset.url);
return;
}
const a = e.target.closest("[data-action='raw']");
if (!a) return;
e.preventDefault();
const id = Number(a.closest(".message").dataset.id);
try {
const msg = await api.getMessage(id);
document.getElementById("raw-content").textContent = JSON.stringify(msg, null, 2);
document.getElementById("raw-dialog").showModal();
} catch (err) {
toast(err.message, "error");
}
});
function openLightbox(url) {
let lb = document.getElementById("lightbox");
if (!lb) {
lb = document.createElement("div");
lb.id = "lightbox";
lb.addEventListener("click", () => lb.remove());
document.body.appendChild(lb);
}
lb.innerHTML = `<img src="${escape(url)}" alt="" />`;
}
document.getElementById("raw-close").addEventListener("click", () => {
document.getElementById("raw-dialog").close();
});
readUrl();
(async () => {
await loadChannels();
await loadMessages();
})();