- Клиент Novofon DATA API (JSON-RPC, get.calls_report, чанки по 90 дней) - faster-whisper (модель turbo) на CUDA с разделением стереоканалов - Маркировка спикеров: левый канал = caller, правый = callee - Directus: коллекции calls, calls_74951284933, contacts - CLI: process, process-file, export, daemon (polling 5 мин) - Docker Compose для Directus + PostgreSQL - Архив email-парсинга в old/
164 lines
5.4 KiB
Python
164 lines
5.4 KiB
Python
import re
|
||
from urllib.parse import unquote, urlparse
|
||
from typing import Optional
|
||
|
||
from .base import CallMetadata, BaseEmailParser
|
||
|
||
|
||
NOVOFON_URL_PATTERN = re.compile(
|
||
r"(https://(?:my|app)\.novofon\.ru/system/media/talk/\d+/[a-f0-9]+[^\s<>\"']*)"
|
||
)
|
||
|
||
|
||
class NovofonParser(BaseEmailParser):
|
||
def extract_audio_url(self, body_text: str, body_html: str) -> Optional[str]:
|
||
for source in (body_text, body_html):
|
||
if not source:
|
||
continue
|
||
m = NOVOFON_URL_PATTERN.search(source)
|
||
if m:
|
||
return m.group(1).rstrip("/") + "/"
|
||
return None
|
||
|
||
def parse_url(self, url: str) -> Optional[CallMetadata]:
|
||
meta = CallMetadata(audio_url=url)
|
||
|
||
path = urlparse(url).path
|
||
parts = path.rstrip("/").split("/")
|
||
if len(parts) >= 3:
|
||
meta.call_id = parts[-2]
|
||
|
||
filename = unquote(parts[-1]) if parts else ""
|
||
if filename:
|
||
self._parse_filename(filename, meta)
|
||
|
||
return meta
|
||
|
||
def parse_email(self, subject: str, body_text: str, body_html: str) -> Optional[CallMetadata]:
|
||
audio_url = self.extract_audio_url(body_text, body_html)
|
||
if not audio_url:
|
||
return None
|
||
|
||
meta = self.parse_url(audio_url)
|
||
|
||
# Try to parse metadata from email body
|
||
self._parse_email_body(body_text, meta)
|
||
self._parse_email_body(body_html, meta)
|
||
self._parse_subject(subject, meta)
|
||
|
||
return meta
|
||
|
||
def _parse_subject(self, subject: str, meta: CallMetadata):
|
||
if "исходящ" in subject.lower():
|
||
meta.direction = "outbound"
|
||
elif "входящ" in subject.lower():
|
||
meta.direction = "inbound"
|
||
|
||
def _parse_email_body(self, body: str, meta: CallMetadata):
|
||
if not body:
|
||
return
|
||
|
||
from_number = None
|
||
to_number = None
|
||
employee_name = None
|
||
duration = None
|
||
duration_unit = None
|
||
|
||
m = re.search(r"От:\s*(\+?\d[\d\s\-]*)", body)
|
||
if m:
|
||
from_number = self._clean_number(m.group(1))
|
||
|
||
m = re.search(r"(?:На номер|на номер|на номере)\s*(\+?\d[\d\s\-]*)", body)
|
||
if m:
|
||
to_number = self._clean_number(m.group(1))
|
||
|
||
m = re.search(r"Сотрудник\s*[-–—]\s*(.+?)(?:\s*(?:Продолжительность|Длительность)\s*|$)", body)
|
||
if m:
|
||
employee_name = m.group(1).strip()
|
||
|
||
m = re.search(r"(?:Продолжительность|Длительность)\s*звонка\s*[-–—]\s*(\d+)\s*(сек|с|мин|м)", body)
|
||
if m:
|
||
duration = int(m.group(1))
|
||
duration_unit = m.group(2)
|
||
if duration_unit in ("мин", "м"):
|
||
duration *= 60
|
||
meta.duration_sec = duration
|
||
|
||
if not meta.caller_number:
|
||
is_outbound = "исходящ" in body.lower()
|
||
if is_outbound:
|
||
meta.caller_number = from_number
|
||
meta.callee_number = to_number
|
||
meta.direction = "outbound"
|
||
else:
|
||
meta.caller_number = from_number
|
||
meta.callee_number = to_number
|
||
meta.direction = "inbound"
|
||
|
||
if not meta.employee and employee_name:
|
||
meta.employee = employee_name
|
||
|
||
def parse_attachment(self, subject: str, filename: str) -> Optional[CallMetadata]:
|
||
meta = CallMetadata(audio_filename=filename)
|
||
|
||
m = re.search(r"(\d+)-(\d+)\.\d+-(\d+)", filename)
|
||
if m:
|
||
meta.call_id = m.group(2)
|
||
meta.callee_number = f"{m.group(1)}-{m.group(3)}"
|
||
|
||
m = re.search(r"(\d{4}-\d{2}-\d{2})-(\d{6})", filename)
|
||
if m:
|
||
meta.datetime = f"{m.group(1)}T{m.group(2)[:2]}:{m.group(2)[2:4]}:{m.group(2)[4:]}Z"
|
||
|
||
self._parse_subject(subject, meta)
|
||
|
||
if not meta.direction:
|
||
meta.direction = "inbound"
|
||
|
||
return meta
|
||
|
||
def _parse_filename(self, filename: str, meta: CallMetadata):
|
||
m = re.search(r"От:\s*(\+?\d[\d\s\-]*)", filename)
|
||
if m:
|
||
meta.caller_number = self._clean_number(m.group(1))
|
||
|
||
m = re.search(r"На номер\s*(\+?\d[\d\s\-]*)", filename)
|
||
if m:
|
||
meta.callee_number = self._clean_number(m.group(1))
|
||
|
||
m = re.search(r"Сотрудник\s*-\s*(.+?)(?:Длительность|$)", filename)
|
||
if m:
|
||
meta.employee = m.group(1).strip()
|
||
|
||
m = re.search(r"Длительность звонка\s*-\s*(\d+)\s*сек", filename)
|
||
if m:
|
||
meta.duration_sec = int(m.group(1))
|
||
|
||
m = re.search(r"(\d{4}-\d{2}-\d{2})_(\d{2}-\d{2}-\d{2})", filename)
|
||
if m:
|
||
meta.datetime = f"{m.group(1)}T{m.group(2).replace('-', ':')}Z"
|
||
|
||
if meta.caller_number and meta.callee_number:
|
||
meta.direction = "inbound"
|
||
else:
|
||
m = re.search(r"from_(\d+)_to_(\d+)", filename)
|
||
if m:
|
||
meta.caller_number = self._clean_number(m.group(1))
|
||
meta.callee_number = self._clean_number(m.group(2))
|
||
meta.direction = "inbound"
|
||
|
||
@staticmethod
|
||
def _clean_number(raw: str) -> str:
|
||
return "+" + re.sub(r"\D", "", raw)
|
||
|
||
|
||
PARSER_REGISTRY = {
|
||
"novofon": NovofonParser,
|
||
}
|
||
|
||
|
||
def get_parser(name: str = "novofon") -> BaseEmailParser:
|
||
cls = PARSER_REGISTRY.get(name)
|
||
if not cls:
|
||
raise ValueError(f"Unknown parser: {name}, available: {list(PARSER_REGISTRY.keys())}")
|
||
return cls()
|