mirror of
https://gitverse.ru/kpa39l/email-assistant.git
synced 2026-09-29 21:25:10 +00:00
8ea022f5c0
- scripts/contacts_caldav_sync.py: push 81 контакт → vCard (Radicale :5232), pull правок/создания/удаления с телефона → contacts.json (ETag сверка, soft-delete, конфликты 412 → приоритет телефону, лог caldav-sync.log) - fix(contacts_extractor): перестройка by_email-индексов после sort() - STATUS.md: раздел DAVx5 + Caddy (Authorization/handle_path/X-Script-Name), Задача 3 (синхронизация с телефоном) = следующая сессия - tasks.md: отмечены 1.x-3.x/V (реализовано и протестировано), добавлен раздел 5 (Caddy + DAVx5 + jtx board) для следующей сессии
597 lines
26 KiB
Python
597 lines
26 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
contacts_extractor.py — извлечение адресной книги из подписей писем.
|
||
|
||
Сканирует INBOX-письма (email.md), вызывает Qwen3:8b через Ollama API
|
||
для парсинга подписи, дедуплицирует и пишет:
|
||
/opt/hermes/email/contacts/contacts.json — машиночитаемая база
|
||
/opt/hermes/email/contacts/index.json — email → contact_id
|
||
/opt/hermes/email/contacts/contacts.vcf — vCard 4.0 для импорта
|
||
/opt/hermes/email/contacts/last_scan.json — трекинг обработанных
|
||
"""
|
||
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
import time
|
||
import urllib.error
|
||
import urllib.request
|
||
from datetime import date, datetime
|
||
from pathlib import Path
|
||
|
||
# ─── Конфиг ───────────────────────────────────────────────────────────────────
|
||
|
||
EMAIL_ROOT = Path("/opt/hermes/email")
|
||
CONTACTS_DIR = EMAIL_ROOT / "contacts"
|
||
STATE_DIR = EMAIL_ROOT / "state"
|
||
DB_PATH = EMAIL_ROOT / "mail_index.db"
|
||
|
||
OLLAMA_URL = "http://localhost:11434/api/generate"
|
||
OLLAMA_MODEL = "qwen3:8b"
|
||
|
||
# Папки, которые сканируем (только входящие)
|
||
SCAN_FOLDERS = ["INBOX", "INBOX/!Scan", "INBOX/!Битрикс", "INBOX/!ВГ Чек листы",
|
||
"INBOX/!Документооборот", "INBOX/!Завки", "INBOX/!Материалы",
|
||
"INBOX/!Отчеты", "INBOX/!Персонал", "INBOX/!Протоколы",
|
||
"INBOX/!Реестр оплаты", "INBOX/!Торик", "INBOX/Бюджет",
|
||
"INBOX/Контрагенты", "INBOX/ЛНД", "INBOX/Организация работы",
|
||
"INBOX/Приемка и стройка", "INBOX/Системы", "INBOX/Эксплуатация"]
|
||
|
||
LLM_TIMEOUT = 30 # секунд на один запрос к Ollama
|
||
MAX_BODY_CHARS = 5000 # обрезаем body для LLM (первые N символов)
|
||
|
||
# ─── Промпт ───────────────────────────────────────────────────────────────────
|
||
|
||
PROMPT_TEMPLATE = """Ты — экстрактор контактных данных из писем. Твоя задача — найти подпись ОТПРАВИТЕЛЯ (автора этого письма) и извлечь структурированные данные.
|
||
|
||
ВАЖНО: Тебе передаётся ТОЛЬКО новое сообщение, без цитируемой переписки. Подпись отправителя — в самом конце этого текста.
|
||
|
||
Правила поиска подписи:
|
||
- Подпись обычно отделена от тела письма: "С уважением,", "С наилучшими пожеланиями,", "Best regards,", "Kind regards,", "С ув.,", "—\\n", "—\\n", "—\\n"
|
||
- Если разделителя нет — последние 5-10 строк это подпись
|
||
- Ignore boilerplate (disclaimers, confidentiality notices)
|
||
|
||
Извлеки из подписи:
|
||
1. full_name — полное имя (ФИО) — только ОТПРАВИТЕЛЯ, не перепутай
|
||
2. email — email адрес (если есть в подписи, иначе null)
|
||
3. phone — основной телефон (в международном или местном формате)
|
||
4. phone_secondary — дополнительный телефон (если есть)
|
||
5. position — должность
|
||
6. company — название компании/организации
|
||
7. address — почтовый/юридический адрес (если есть)
|
||
8. raw_signature — полный текст найденной подписи (для отладки)
|
||
|
||
Если никакой подписи не найдено — верни JSON со всеми полями null.
|
||
|
||
Верни ТОЛЬКО JSON, без пояснений:
|
||
{{"full_name": null, "email": null, "phone": null, "phone_secondary": null, "position": null, "company": null, "address": null, "raw_signature": null}}
|
||
|
||
Вот текст письма:
|
||
|
||
{body}"""
|
||
|
||
|
||
# ─── Вспомогательные ──────────────────────────────────────────────────────────
|
||
|
||
def contact_id(email_addr):
|
||
"""MD5-хэш email для id контакта."""
|
||
if not email_addr:
|
||
return None
|
||
return hashlib.md5(email_addr.strip().lower().encode()).hexdigest()[:12]
|
||
|
||
|
||
def load_json(path, default=None):
|
||
"""Загрузить JSON, вернуть default если нет или битый."""
|
||
if default is None:
|
||
default = {}
|
||
try:
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
return json.load(f)
|
||
except (FileNotFoundError, json.JSONDecodeError):
|
||
return default
|
||
|
||
|
||
def save_json(path, data):
|
||
"""Сохранить JSON атомарно."""
|
||
tmp = path.with_suffix(".tmp")
|
||
with open(tmp, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||
tmp.replace(path)
|
||
|
||
|
||
def find_email_md_files(root):
|
||
"""Рекурсивно найти все email.md в папке."""
|
||
return sorted(root.rglob("email.md"))
|
||
|
||
|
||
def parse_email_md(path):
|
||
"""Прочитать email.md, вернуть (headers_dict, body_text)."""
|
||
content = path.read_text(encoding="utf-8", errors="replace")
|
||
# YAML frontmatter: первая строка "---", потом YAML, потом "---"
|
||
match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)", content, re.DOTALL)
|
||
if match:
|
||
yaml_block = match.group(1)
|
||
body = match.group(2).strip()
|
||
# Парсим YAML вручную (без PyYAML)
|
||
headers = parse_simple_yaml(yaml_block)
|
||
else:
|
||
headers = {}
|
||
body = content.strip()
|
||
return headers, body
|
||
|
||
|
||
def parse_simple_yaml(text):
|
||
"""Примитивный парсер YAML frontmatter (ключ-значение, без вложенности)."""
|
||
result = {}
|
||
for line in text.strip().split("\n"):
|
||
m = re.match(r"^(\w[\w_-]*)\s*:\s*(.*)", line)
|
||
if m:
|
||
key = m.group(1)
|
||
val = m.group(2).strip().strip('"').strip("'")
|
||
result[key] = val
|
||
return result
|
||
|
||
|
||
def clean_body(body):
|
||
"""Очистить тело письма от HTML, цитируемой переписки и мусора."""
|
||
# Удаляем <#part ...> блоки
|
||
body = re.sub(r'<#part[^>]*>', '', body)
|
||
body = re.sub(r'<#/part>', '', body)
|
||
# Удаляем HTML-теги
|
||
body = re.sub(r'<[^>]+>', '', body)
|
||
# Удаляем mailto: ссылки
|
||
body = re.sub(r'\(mailto:[^)]+\)', '', body)
|
||
# Заменяем unicode-пробелы на обычные
|
||
body = re.sub(r'[\u00a0\u2000-\u200f\u2028-\u202f\u2060]+', ' ', body)
|
||
# Удаляем трекинг-ссылки
|
||
body = re.sub(r'https?://tn-eoc\.[^\s]+', '', body)
|
||
body = re.sub(r'https?://[^\s]+\?utm_[^\s]+', '', body)
|
||
# Удаляем цитируемую переписку — отрезаем всё от САМОГО РАННЕГО маркера цитирования
|
||
# Маркеры: Outlook (рус/англ headers), forwarded, > lines, андерскор-разделители
|
||
quote_patterns = [
|
||
r'^[\s]*_{4,}\s*$', # _____
|
||
r'От:.*\n[\s]*Отправлено:', # Russian Outlook (в любом месте строки)
|
||
r'^[\s]*From:.*\n[\s]*Sent:', # English Outlook headers
|
||
r'——-.*Forwarded.*——-',
|
||
r'——-.*Пересылаемое.*——-',
|
||
r'——-.*Original Message.*——-',
|
||
r'>.*\bwrote:',
|
||
]
|
||
earliest = None
|
||
earliest_pos = len(body)
|
||
for qp in quote_patterns:
|
||
for m in re.finditer(qp, body, re.MULTILINE):
|
||
if m.start() < earliest_pos:
|
||
earliest_pos = m.start()
|
||
earliest = m
|
||
if earliest:
|
||
body = body[:earliest_pos].strip()
|
||
else:
|
||
# Fallback: ищем любой маркер цитирования в последних 500 символах
|
||
# (От: Стороженко, From: ... — вшитые в строку подписи маркеры)
|
||
tail = body[-500:] if len(body) > 500 else body
|
||
for pattern in [r'От:', r'Отправлено:', r'From:', r'Sent:', r'Кому:', r'To:', r'Тема:', r'Subject:']:
|
||
m2 = re.search(pattern, tail)
|
||
if m2:
|
||
offset = len(body) - len(tail) + m2.start()
|
||
body = body[:offset].strip()
|
||
break
|
||
|
||
# Удаляем строки начинающиеся с > (если остались)
|
||
lines = body.split('\n')
|
||
cleaned = [l for l in lines if not re.match(r'^\s*>', l)]
|
||
body = '\n'.join(cleaned)
|
||
body = re.sub(r'\n{3,}', '\n\n', body)
|
||
return body.strip()
|
||
|
||
|
||
def call_llm(body_text, max_retries=2):
|
||
"""Вызвать Qwen через Ollama API, вернуть JSON."""
|
||
# Очищаем body
|
||
body_text = clean_body(body_text)
|
||
# Обрезаем body
|
||
truncated = body_text[:MAX_BODY_CHARS]
|
||
prompt = PROMPT_TEMPLATE.format(body=truncated)
|
||
|
||
for attempt in range(max_retries + 1):
|
||
if attempt > 0:
|
||
time.sleep(1)
|
||
|
||
payload = json.dumps({
|
||
"model": OLLAMA_MODEL,
|
||
"prompt": prompt,
|
||
"stream": False,
|
||
"options": {
|
||
"temperature": 0.1,
|
||
"num_predict": 1024,
|
||
}
|
||
}).encode("utf-8")
|
||
|
||
req = urllib.request.Request(
|
||
OLLAMA_URL,
|
||
data=payload,
|
||
headers={"Content-Type": "application/json"},
|
||
method="POST",
|
||
)
|
||
|
||
try:
|
||
resp = urllib.request.urlopen(req, timeout=LLM_TIMEOUT)
|
||
data = json.loads(resp.read().decode("utf-8"))
|
||
response_text = data.get("response", "").strip()
|
||
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError) as e:
|
||
if attempt < max_retries:
|
||
continue
|
||
print(f" ⚠ LLM error: {e}", file=sys.stderr)
|
||
return None
|
||
|
||
if not response_text:
|
||
if attempt < max_retries:
|
||
continue
|
||
print(f" ⚠ LLM empty response", file=sys.stderr)
|
||
return None
|
||
|
||
# Пробуем распарсить весь ответ как JSON
|
||
try:
|
||
return json.loads(response_text)
|
||
except json.JSONDecodeError:
|
||
pass
|
||
|
||
# Если не получилось — ищем { ... } внутри
|
||
brace_depth = 0
|
||
json_start = None
|
||
for i, ch in enumerate(response_text):
|
||
if ch == '{':
|
||
if brace_depth == 0:
|
||
json_start = i
|
||
brace_depth += 1
|
||
elif ch == '}':
|
||
brace_depth -= 1
|
||
if brace_depth == 0 and json_start is not None:
|
||
try:
|
||
return json.loads(response_text[json_start:i+1])
|
||
except json.JSONDecodeError:
|
||
pass
|
||
json_start = None
|
||
|
||
if attempt < max_retries:
|
||
continue
|
||
print(f" ⚠ LLM JSON parse error, raw: {response_text[:300]}", file=sys.stderr)
|
||
return None
|
||
|
||
|
||
# ─── Основная логика ──────────────────────────────────────────────────────────
|
||
|
||
def get_uid_from_path(path):
|
||
"""Извлечь UID из пути INBOX/YYYY/MM/UID/email.md."""
|
||
parts = path.parts
|
||
try:
|
||
# Ищем часть, которая является числом (UID)
|
||
for p in parts:
|
||
if p.isdigit():
|
||
return int(p)
|
||
except (ValueError, IndexError):
|
||
pass
|
||
return None
|
||
|
||
|
||
def get_folder_from_path(path, root):
|
||
"""Извлечь имя папки (INBOX/!Протоколы) относительно корня почты."""
|
||
rel = path.relative_to(root)
|
||
parts = rel.parts
|
||
# Формат: <folder>/YYYY/MM/UID/email.md
|
||
# folder может быть "INBOX" или "INBOX/!Протоколы"
|
||
folder_parts = []
|
||
for p in parts:
|
||
if p.isdigit() or re.match(r"^\d{4}$", p):
|
||
break
|
||
folder_parts.append(p)
|
||
return "/".join(folder_parts)
|
||
|
||
|
||
def get_date_from_path(path):
|
||
"""Извлечь дату из пути (по году/месяцу)."""
|
||
parts = path.parts
|
||
year = None
|
||
month = None
|
||
for p in parts:
|
||
if re.match(r"^\d{4}$", p) and 2020 <= int(p) <= 2030:
|
||
year = int(p)
|
||
elif re.match(r"^\d{2}$", p) and 1 <= int(p) <= 12:
|
||
month = int(p)
|
||
if year and month:
|
||
return date(year, month, 1)
|
||
return date.today()
|
||
|
||
|
||
def save_progress(contacts_db, contacts_dir, processed_uids, processed_emails):
|
||
"""Инкрементальное сохранение контактов и last_scan."""
|
||
contacts_db["contacts"].sort(key=lambda c: c.get("email", ""))
|
||
# Перестроить by_email — он хранит ИНДЕКСЫ позиций; после сортировки они сдвигаются
|
||
contacts_db["by_email"] = {c.get("email", ""): i for i, c in enumerate(contacts_db["contacts"])}
|
||
save_json(contacts_dir / "contacts.json", contacts_db)
|
||
save_json(contacts_dir / "index.json", contacts_db.get("by_email", {}))
|
||
generate_vcard(contacts_dir, contacts_db["contacts"])
|
||
|
||
last_scan = {
|
||
"last_processed": datetime.now().isoformat(timespec="seconds"),
|
||
"processed_uids": processed_uids,
|
||
"processed_emails": sorted(processed_emails),
|
||
}
|
||
save_json(contacts_dir / "last_scan.json", last_scan)
|
||
print(f" 💾 Сохранено ({len(contacts_db['contacts'])} контактов)", flush=True)
|
||
|
||
|
||
def scan(limit=0):
|
||
"""Основной цикл сканирования с SQLite-трекингом."""
|
||
import sqlite3
|
||
contacts_dir = CONTACTS_DIR
|
||
contacts_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Загружаем контакты
|
||
contacts_db = load_json(contacts_dir / "contacts.json", {"version": 1, "contacts": [], "by_email": {}})
|
||
today_str = date.today().isoformat()
|
||
|
||
# Открываем SQLite
|
||
if not DB_PATH.exists():
|
||
print("❌ mail_index.db не найден. Сначала запусти: python3 mail_index.py")
|
||
return
|
||
conn = sqlite3.connect(str(DB_PATH))
|
||
conn.row_factory = sqlite3.Row
|
||
|
||
# Берём письма, где контакты ещё не извлечены
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
SELECT rowid, path, uid, folder, from_addr, subject
|
||
FROM emails
|
||
WHERE contacts_extracted = 0 AND contacts_skipped = 0
|
||
ORDER BY folder, uid
|
||
LIMIT ?
|
||
""", (limit if limit > 0 else 999999,))
|
||
pending = cursor.fetchall()
|
||
|
||
if not pending:
|
||
print(" Нет новых писем для обработки")
|
||
conn.close()
|
||
return
|
||
|
||
print(f" Найдено писем к обработке: {len(pending)}", flush=True)
|
||
|
||
total_new = 0
|
||
total_skipped = 0
|
||
processed_count = 0
|
||
save_counter = 0
|
||
|
||
for row in pending:
|
||
rowid = row["rowid"]
|
||
rel_path = row["path"]
|
||
uid = row["uid"]
|
||
folder = row["folder"]
|
||
sender = row["from_addr"]
|
||
subject = row["subject"]
|
||
|
||
file_path = EMAIL_ROOT / rel_path
|
||
if not file_path.exists():
|
||
# Письмо удалено — отмечаем чтоб не дёргать
|
||
cursor.execute("UPDATE emails SET contacts_skipped=1 WHERE rowid=?", (rowid,))
|
||
continue
|
||
|
||
# Извлекаем email отправителя
|
||
from_email = None
|
||
email_match = re.search(r'<([^>]+@[^>]+)>', sender)
|
||
if email_match:
|
||
from_email = email_match.group(1).strip().lower()
|
||
elif "@" in sender:
|
||
from_email = sender.strip().lower()
|
||
|
||
# Пропускаем, если уже обработан (по email)
|
||
if from_email and from_email in contacts_db["by_email"]:
|
||
contact = contacts_db["contacts"][contacts_db["by_email"][from_email]]
|
||
last_seen = contact.get("last_seen", "2000-01-01")
|
||
days_since = (date.today() - date.fromisoformat(last_seen)).days
|
||
if days_since < 30:
|
||
cursor.execute("UPDATE emails SET contacts_extracted=1, last_scanned=? WHERE rowid=?", (datetime.now().isoformat(timespec="seconds"), rowid))
|
||
conn.commit()
|
||
total_skipped += 1
|
||
continue
|
||
|
||
# Читаем тело письма
|
||
headers, body = parse_email_md(file_path)
|
||
|
||
print(f" UID {uid} ({sender[:50]})...", end=" ", flush=True)
|
||
|
||
# Вызываем LLM
|
||
llm_result = call_llm(body)
|
||
|
||
if llm_result is None:
|
||
print("⚠ skip (LLM error)", flush=True)
|
||
cursor.execute("UPDATE emails SET contacts_skipped=1, last_scanned=? WHERE rowid=?", (datetime.now().isoformat(timespec="seconds"), rowid))
|
||
conn.commit()
|
||
total_skipped += 1
|
||
continue
|
||
|
||
# Если контакт не найден
|
||
full_name = (llm_result.get("full_name") or "").strip()
|
||
if not full_name or full_name == "null":
|
||
print("→ нет подписи", flush=True)
|
||
cursor.execute("UPDATE emails SET contacts_skipped=1, last_scanned=? WHERE rowid=?", (datetime.now().isoformat(timespec="seconds"), rowid))
|
||
conn.commit()
|
||
total_skipped += 1
|
||
continue
|
||
|
||
# Извлекаем email из LLM-результата
|
||
contact_email = llm_result.get("email") or from_email
|
||
if contact_email and contact_email.strip().lower() != "null":
|
||
contact_email = contact_email.strip().lower()
|
||
else:
|
||
contact_email = from_email
|
||
|
||
if not contact_email:
|
||
print("→ нет email, skip", flush=True)
|
||
cursor.execute("UPDATE emails SET contacts_skipped=1, last_scanned=? WHERE rowid=?", (datetime.now().isoformat(timespec="seconds"), rowid))
|
||
conn.commit()
|
||
total_skipped += 1
|
||
continue
|
||
|
||
cid = contact_id(contact_email)
|
||
source_path = str(rel_path)
|
||
|
||
# Создаём запись контакта
|
||
new_contact = {
|
||
"id": cid,
|
||
"email": contact_email,
|
||
"full_name": full_name,
|
||
"phone": llm_result.get("phone") or None,
|
||
"phone_secondary": llm_result.get("phone_secondary") or None,
|
||
"position": llm_result.get("position") or None,
|
||
"company": llm_result.get("company") or None,
|
||
"address": llm_result.get("address") or None,
|
||
"first_seen": today_str,
|
||
"last_seen": today_str,
|
||
"source_uids": [source_path],
|
||
"source_folders": [folder],
|
||
}
|
||
|
||
# Дедупликация
|
||
existing_idx = contacts_db["by_email"].get(contact_email)
|
||
if existing_idx is not None:
|
||
existing = contacts_db["contacts"][existing_idx]
|
||
for key in ["full_name", "phone", "phone_secondary", "position", "company", "address"]:
|
||
if new_contact.get(key):
|
||
existing[key] = new_contact[key]
|
||
existing["last_seen"] = today_str
|
||
if source_path not in existing["source_uids"]:
|
||
existing["source_uids"].append(source_path)
|
||
if folder not in existing["source_folders"]:
|
||
existing["source_folders"].append(folder)
|
||
print(f"✓ обновлён: {full_name} <{contact_email}>", flush=True)
|
||
else:
|
||
contacts_db["contacts"].append(new_contact)
|
||
contacts_db["by_email"][contact_email] = len(contacts_db["contacts"]) - 1
|
||
print(f"✓ новый: {full_name} <{contact_email}>", flush=True)
|
||
|
||
# Отмечаем в SQLite
|
||
cursor.execute("UPDATE emails SET contacts_extracted=1, last_scanned=? WHERE rowid=?", (datetime.now().isoformat(timespec="seconds"), rowid))
|
||
conn.commit()
|
||
|
||
total_new += 1
|
||
processed_count += 1
|
||
save_counter += 1
|
||
|
||
if limit > 0 and processed_count >= limit:
|
||
print(f" ⏸ лимит {limit} достигнут", flush=True)
|
||
break
|
||
|
||
# Сохраняемся каждые 5 писем
|
||
if save_counter >= 5:
|
||
save_counter = 0
|
||
contacts_db["contacts"].sort(key=lambda c: c.get("email", ""))
|
||
contacts_db["by_email"] = {c.get("email", ""): i for i, c in enumerate(contacts_db["contacts"])}
|
||
save_json(contacts_dir / "contacts.json", contacts_db)
|
||
save_json(contacts_dir / "index.json", contacts_db.get("by_email", {}))
|
||
generate_vcard(contacts_dir, contacts_db["contacts"])
|
||
print(f" 💾 Сохранено ({len(contacts_db['contacts'])} контактов, uid={uid})", flush=True)
|
||
|
||
# Финальное сохранение
|
||
contacts_db["contacts"].sort(key=lambda c: c.get("email", ""))
|
||
save_json(contacts_dir / "contacts.json", contacts_db)
|
||
save_json(contacts_dir / "index.json", contacts_db.get("by_email", {}))
|
||
generate_vcard(contacts_dir, contacts_db["contacts"])
|
||
|
||
# Статистика
|
||
cursor.execute("SELECT COUNT(*) as cnt FROM emails WHERE contacts_extracted=1")
|
||
extracted_total = cursor.fetchone()["cnt"]
|
||
cursor.execute("SELECT COUNT(*) as cnt FROM emails WHERE contacts_skipped=1")
|
||
skipped_total = cursor.fetchone()["cnt"]
|
||
cursor.execute("SELECT COUNT(*) as cnt FROM emails WHERE contacts_extracted=0 AND contacts_skipped=0")
|
||
remaining = cursor.fetchone()["cnt"]
|
||
|
||
conn.close()
|
||
|
||
print(f"\n{'=' * 50}")
|
||
print(f"Готово. Новых контактов: {total_new}, пропущено: {total_skipped}")
|
||
print(f"Всего в базе: {len(contacts_db['contacts'])} контактов")
|
||
print(f"Индекс: {extracted_total} извлечено, {skipped_total} пропущено, {remaining} осталось")
|
||
|
||
|
||
def generate_vcard(contacts_dir, contacts):
|
||
"""Сгенерировать contacts.vcf (vCard 4.0)."""
|
||
lines = []
|
||
for c in contacts:
|
||
if not c.get("email"):
|
||
continue
|
||
lines.append("BEGIN:VCARD")
|
||
lines.append("VERSION:4.0")
|
||
lines.append(f"FN:{c['full_name']}")
|
||
# N:Фамилия;Имя;Отчество;;
|
||
name_parts = c["full_name"].split(maxsplit=2)
|
||
if len(name_parts) >= 2:
|
||
n_line = f"N:{name_parts[-1]};{name_parts[0]};{' '.join(name_parts[1:-1])};;"
|
||
else:
|
||
n_line = f"N:{c['full_name']};;;;"
|
||
lines.append(n_line)
|
||
lines.append(f"EMAIL;TYPE=WORK:{c['email']}")
|
||
if c.get("phone"):
|
||
lines.append(f"TEL;TYPE=WORK:{c['phone']}")
|
||
if c.get("phone_secondary"):
|
||
lines.append(f"TEL;TYPE=CELL:{c['phone_secondary']}")
|
||
if c.get("position"):
|
||
lines.append(f"TITLE:{c['position']}")
|
||
if c.get("company"):
|
||
lines.append(f"ORG:{c['company']}")
|
||
if c.get("address"):
|
||
lines.append(f"ADR;TYPE=WORK:;;{c['address']};;;")
|
||
# NOTE с источником
|
||
sources = ", ".join(c.get("source_uids", []))
|
||
lines.append(f"NOTE:Извлечено из писем: {sources}")
|
||
lines.append("END:VCARD")
|
||
lines.append("")
|
||
|
||
vcf_path = contacts_dir / "contacts.vcf"
|
||
vcf_path.write_text("\n".join(lines), encoding="utf-8")
|
||
print(f" vCard: {vcf_path} ({len(contacts)} контактов)")
|
||
|
||
|
||
# ─── CLI ──────────────────────────────────────────────────────────────────────
|
||
|
||
def main():
|
||
import argparse
|
||
parser = argparse.ArgumentParser(description="Извлечение контактов из писем")
|
||
parser.add_argument("--dry-run", action="store_true", help="Не вызывать LLM, только показать что будет обработано")
|
||
parser.add_argument("--limit", type=int, default=0, help="Максимум писем для обработки (0 = все)")
|
||
args = parser.parse_args()
|
||
|
||
print("📇 Contacts Extractor")
|
||
print(f" База: {CONTACTS_DIR}")
|
||
print(f" Модель: {OLLAMA_MODEL}")
|
||
if args.dry_run:
|
||
print(" 🔍 DRY RUN — без LLM\n")
|
||
|
||
# В dry-run просто сканируем
|
||
if args.dry_run:
|
||
contacts_dir = CONTACTS_DIR
|
||
contacts_dir.mkdir(parents=True, exist_ok=True)
|
||
last_scan = load_json(contacts_dir / "last_scan.json")
|
||
processed_uids = last_scan.get("processed_uids", {})
|
||
total_to_process = 0
|
||
|
||
for folder in SCAN_FOLDERS:
|
||
folder_path = EMAIL_ROOT / folder
|
||
if not folder_path.is_dir():
|
||
continue
|
||
processed_for_folder = processed_uids.get(folder, 0)
|
||
email_files = find_email_md_files(folder_path)
|
||
new_files = [f for f in email_files if (get_uid_from_path(f) or 0) > processed_for_folder]
|
||
print(f" {folder}: {len(email_files)} total, {len(new_files)} new (after UID {processed_for_folder})")
|
||
total_to_process += len(new_files)
|
||
|
||
print(f"\nВсего новых писем к обработке: {total_to_process}")
|
||
return
|
||
|
||
scan(limit=args.limit)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|