Add monitoring TG service
This commit is contained in:
433
src/parser_bot/web/static/js/messages.js
Normal file
433
src/parser_bot/web/static/js/messages.js
Normal 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 => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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();
|
||||
})();
|
||||
Reference in New Issue
Block a user