call-to-text/imap/preprocessor.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

102 lines
3.2 KiB
Python

import os
import shutil
import subprocess
from typing import Optional
def _find_ffmpeg() -> str:
path = shutil.which("ffmpeg")
if path:
return path
winget_path = os.path.join(
os.environ.get("LOCALAPPDATA", ""),
"Microsoft", "WinGet", "Links", "ffmpeg.exe"
)
if os.path.exists(winget_path):
return winget_path
alt_paths = [
r"C:\ffmpeg\bin\ffmpeg.exe",
r"C:\Program Files\ffmpeg\bin\ffmpeg.exe",
]
for p in alt_paths:
if os.path.exists(p):
return p
raise RuntimeError("FFmpeg not found. Install it via: winget install ffmpeg")
def get_audio_channels(input_path: str) -> int:
ffmpeg_path = _find_ffmpeg()
cmd = [
ffmpeg_path, "-i", input_path,
"-f", "null", "-"
]
result = subprocess.run(cmd, capture_output=True, text=True)
stderr = result.stderr
for line in stderr.split("\n"):
if "Audio:" in line:
if "stereo" in line or "2 channels" in line:
return 2
elif "mono" in line or "1 channel" in line:
return 1
import re
m = re.search(r"(\d+)\s+channels?", line)
if m:
return int(m.group(1))
return 2
def get_audio_duration(input_path: str) -> Optional[float]:
ffprobe = _find_ffmpeg().replace("ffmpeg", "ffprobe")
if not os.path.exists(ffprobe):
return None
cmd = [ffprobe, "-v", "error", "-show_entries", "format=duration",
"-of", "csv=p=0", input_path]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0 and result.stdout.strip():
try:
return int(float(result.stdout.strip()))
except ValueError:
return None
return None
def convert_to_wav(input_path: str, output_dir: str, sample_rate: int = 16000) -> tuple[Optional[str], int]:
os.makedirs(output_dir, exist_ok=True)
base = os.path.splitext(os.path.basename(input_path))[0]
output_path = os.path.join(output_dir, f"{base}_16khz.wav")
channels = get_audio_channels(input_path)
ac = "2" if channels == 2 else "1"
ffmpeg_path = _find_ffmpeg()
cmd = [
ffmpeg_path, "-y",
"-i", input_path,
"-acodec", "pcm_s16le",
"-ac", ac,
"-ar", str(sample_rate),
output_path
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"FFmpeg error: {result.stderr}")
return output_path, channels
def split_stereo_channels(wav_path: str, output_dir: str) -> tuple[Optional[str], Optional[str]]:
base = os.path.splitext(os.path.basename(wav_path))[0]
left_path = os.path.join(output_dir, f"{base}_left.wav")
right_path = os.path.join(output_dir, f"{base}_right.wav")
ffmpeg_path = _find_ffmpeg()
cmd = [
ffmpeg_path, "-y",
"-i", wav_path,
"-filter_complex", "[0:a]pan=mono|c0=c0[left];[0:a]pan=mono|c0=c1[right]",
"-map", "[left]", left_path,
"-map", "[right]", right_path,
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"FFmpeg split error: {result.stderr}")
return left_path, right_path