call-to-text/imap/directus.py
ed0ss 92f1d76ff8 IMAP-ингестор: обработка старых звонков Novofon из почты Yandex
Добавлен полный пайплайн выгрузки записей разговоров из IMAP-почты
Yandex, транскрипции через faster-whisper (turbo, CUDA) и сохранения
в Directus.

Новые файлы:
- imap/email_fetcher.py — подключение к IMAP (imap.yandex.ru:993),
  пагинация по UID (200/страница), автоматическое переподключение
  при IMAP4.abort (3 retry)
- imap/config.py — конфигурация из .env
- imap/ingestor.py — основной цикл: чтение письма, парсинг метаданных,
  сохранение вложения, конвертация в WAV, разделение стереоканалов,
  транскрипция, запись в Directus; поддержка --limit
- imap/parsers/base.py — абстрактный парсер писем
- imap/parsers/novofon.py — парсер писем Novofon/Zadarma:
  темы [АТС Zadarma] и Novofon, тело письма (JSON+HTML),
  имя файла вложения как fallback; маппинг внутренних номеров
  102/201 на виртуальные; _clean_number без +
- imap/preprocessor.py — конвертация в WAV (ffmpeg),
  разделение стереоканалов (левый=callee, правый=caller),
  get_audio_duration() через ffprobe
- imap/stt.py — транскрипция через faster-whisper (turbo, CUDA)
- imap/aligner.py — alignment результатов STT
- imap/directus.py — клиент Directus: check_call (дедуп),
  save_call в calls/calls_74951284933, find_contact
- match_contacts.py — сопоставление всех звонков с контактами
  по номеру телефона, заполнение caller_name/callee_name
- count_remaining.py — утилита подсчёта оставшихся писем в IMAP

Изменения:
- .env.example: добавлена секция IMAP (сервер, логин, пароль, папка)
- requirements.txt: добавлен imap-tools (IMAP-клиент)

Обработано ~3900 звонков из IMAP (3670 в calls + 687 в
calls_74951284933). Все записи с длительностью аудио из файла
(ffprobe). Все звонки без + в номерах телефонов.
2026-06-28 22:44:51 +03:00

66 lines
2.2 KiB
Python

import httpx
from typing import Optional
from .config import cfg
class ImapDirectusClient:
def __init__(self, url: Optional[str] = None, token: Optional[str] = None):
self.url = (url or cfg.DIRECTUS_URL).rstrip("/")
self.token = token or cfg.DIRECTUS_TOKEN
self._client = httpx.Client(
base_url=self.url,
headers={
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
},
timeout=30,
)
def check_call(self, call_id: str) -> bool:
for col in ["calls", "calls_74951284933"]:
try:
resp = self._client.get(
f"/items/{col}",
params={"filter[call_id][_eq]": call_id, "limit": 1, "fields": "id"},
)
if resp.status_code == 200 and resp.json().get("data"):
return True
except Exception:
pass
return False
def _choose_collection(self, meta: dict) -> str:
caller = meta.get("caller_number") or ""
callee = meta.get("callee_number") or ""
for num in [caller, callee]:
cleaned = num.replace("+", "").replace("-", "").replace(" ", "")
if "74951284933" in cleaned:
return "calls_74951284933"
return "calls"
def save_call(self, data: dict) -> Optional[dict]:
collection = self._choose_collection(data)
try:
resp = self._client.post(f"/items/{collection}", json=data)
if resp.status_code in (200, 201):
return resp.json().get("data")
except Exception:
pass
return None
def find_contact(self, phone_number: str) -> Optional[dict]:
try:
resp = self._client.get(
"/items/contacts",
params={"filter[phone_number][_eq]": phone_number, "limit": 1},
)
if resp.status_code == 200:
data = resp.json().get("data", [])
return data[0] if data else None
except Exception:
pass
return None
def close(self):
self._client.close()