Initial commit: Email Assistant project
This commit is contained in:
@@ -0,0 +1,593 @@
|
||||
#!/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", ""))
|
||||
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", ""))
|
||||
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()
|
||||
Reference in New Issue
Block a user