call-to-text/old/parsers/novofon.py
ed0ss 7f03949593 Первая версия: транскрипция звонков из Novofon DATA API в Directus
- Клиент 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/
2026-06-26 12:44:50 +03:00

164 lines
5.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()