init
This commit is contained in:
commit
8d36a62ca2
2 files changed
+401
No files matched your search
@@ -0,0 +1,371 @@
|
|||||||
|
import warnings
|
||||||
|
# Полностью отключаем вывод любых предупреждений в консоль
|
||||||
|
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
||||||
|
warnings.filterwarnings("ignore")
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
# Перенаправляем поток ошибок в пустоту, чтобы предупреждения не перехватывали stdio
|
||||||
|
sys.stderr = open(os.devnull, 'w')
|
||||||
|
|
||||||
|
|
||||||
|
import nntplib
|
||||||
|
import email
|
||||||
|
from email.policy import default
|
||||||
|
import re
|
||||||
|
import datetime
|
||||||
|
from email.header import decode_header
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
|
from mcp.server.mcpserver import MCPServer
|
||||||
|
|
||||||
|
# Инициализируем сервер
|
||||||
|
mcp = MCPServer("Bionic-NNTP-Search")
|
||||||
|
|
||||||
|
NNTP_SERVER = "news.skysamara.ru"
|
||||||
|
PORT = 119
|
||||||
|
|
||||||
|
def parse_nntp_date(date_str: str) -> datetime.datetime:
|
||||||
|
"""
|
||||||
|
Конвертирует строку даты из заголовка NNTP в объект datetime с поддержкой UTC таймзоны.
|
||||||
|
"""
|
||||||
|
if not date_str:
|
||||||
|
return datetime.datetime.min.replace(tzinfo=datetime.timezone.utc)
|
||||||
|
try:
|
||||||
|
dt = parsedate_to_datetime(date_str)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=datetime.timezone.utc)
|
||||||
|
return dt
|
||||||
|
except Exception:
|
||||||
|
return datetime.datetime.min.replace(tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
def clean_body_from_quotes(body_text: str) -> str:
|
||||||
|
"""
|
||||||
|
Принимает сырой текст письма и удаляет из него все строки цитирования (>),
|
||||||
|
а также вводные маркеры цитат.
|
||||||
|
"""
|
||||||
|
if not body_text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
lines = body_text.splitlines()
|
||||||
|
clean_lines = []
|
||||||
|
|
||||||
|
intro_patterns = [
|
||||||
|
r"on\s+.*?\s+wrote\s*:",
|
||||||
|
r"в\s+письме\s+от\s+.*?\s+вы\s+писали\s*:",
|
||||||
|
r"писал\s*\(а\)\s*:",
|
||||||
|
r"---.*?original\s+message.*?---"
|
||||||
|
]
|
||||||
|
intro_regex = re.compile("|".join(intro_patterns), re.IGNORECASE)
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
stripped_line = line.strip()
|
||||||
|
|
||||||
|
if stripped_line.startswith(">"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if intro_regex.search(stripped_line):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not stripped_line:
|
||||||
|
if clean_lines and clean_lines[-1] == "":
|
||||||
|
continue
|
||||||
|
|
||||||
|
clean_lines.append(line)
|
||||||
|
|
||||||
|
return "\n".join(clean_lines).strip()
|
||||||
|
|
||||||
|
def decode_mime_header(header_value: str) -> str:
|
||||||
|
"""Декодирует строку заголовка формата =?UTF-8?B?...?="""
|
||||||
|
if not header_value:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
decoded_fragments = decode_header(header_value)
|
||||||
|
parts = []
|
||||||
|
for text, encoding in decoded_fragments:
|
||||||
|
if isinstance(text, bytes):
|
||||||
|
enc = encoding if encoding else 'utf-8'
|
||||||
|
parts.append(text.decode(enc, errors='ignore'))
|
||||||
|
else:
|
||||||
|
parts.append(str(text))
|
||||||
|
return "".join(parts)
|
||||||
|
except Exception:
|
||||||
|
return header_value
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_skysamara_groups() -> list[str]:
|
||||||
|
"""
|
||||||
|
Получить список доступных групп на сервере news.skysamara.ru.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
groups = []
|
||||||
|
with nntplib.NNTP(NNTP_SERVER, port=PORT, timeout=10) as s:
|
||||||
|
resp, group_list = s.list()
|
||||||
|
for group_info in group_list:
|
||||||
|
if group_info and len(group_info) > 0:
|
||||||
|
groups.append(group_info[0])
|
||||||
|
return groups
|
||||||
|
except Exception as e:
|
||||||
|
return [f"Ошибка NNTP при получении групп: {e}"]
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def search_user_by_headers(user_email: str, group_name: str, depth: int = 1000) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Ищет сообщения конкретного пользователя по автору (From) в указанной группе.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
found_messages = []
|
||||||
|
with nntplib.NNTP(NNTP_SERVER, port=PORT, timeout=10) as s:
|
||||||
|
resp, count, first, last, name = s.group(group_name)
|
||||||
|
|
||||||
|
first_num = int(first)
|
||||||
|
last_num = int(last)
|
||||||
|
|
||||||
|
start_msg = max(first_num, last_num - depth + 1)
|
||||||
|
if start_msg > last_num:
|
||||||
|
return []
|
||||||
|
|
||||||
|
resp, overviews = s.over((str(start_msg), str(last_num)))
|
||||||
|
|
||||||
|
for msg_id, over in overviews:
|
||||||
|
raw_author = over.get('from', '')
|
||||||
|
raw_subject = over.get('subject', '')
|
||||||
|
|
||||||
|
author = decode_mime_header(raw_author)
|
||||||
|
subject = decode_mime_header(raw_subject)
|
||||||
|
|
||||||
|
if user_email.lower() in author.lower():
|
||||||
|
found_messages.append({
|
||||||
|
"msg_num": str(msg_id),
|
||||||
|
"subject": subject,
|
||||||
|
"author": author
|
||||||
|
})
|
||||||
|
|
||||||
|
return found_messages
|
||||||
|
except Exception as e:
|
||||||
|
return [{"error": f"Ошибка NNTP при поиске: {e}"}]
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def fetch_messages_by_date_range(
|
||||||
|
group_name: str,
|
||||||
|
start_date_iso: str,
|
||||||
|
end_date_iso: str,
|
||||||
|
max_count: int = 100
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Извлекает сообщения из указанной группы за заданный временной интервал (YYYY-MM-DD).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
start_dt = datetime.datetime.fromisoformat(start_date_iso).replace(tzinfo=datetime.timezone.utc)
|
||||||
|
end_dt = datetime.datetime.fromisoformat(end_date_iso).replace(tzinfo=datetime.timezone.utc)
|
||||||
|
extracted_messages = []
|
||||||
|
|
||||||
|
with nntplib.NNTP(NNTP_SERVER, port=PORT, timeout=15) as s:
|
||||||
|
resp, count, first, last, name = s.group(group_name)
|
||||||
|
first_num = int(first)
|
||||||
|
last_num = int(last)
|
||||||
|
|
||||||
|
if first_num > last_num:
|
||||||
|
return []
|
||||||
|
|
||||||
|
resp, overviews = s.over((str(first_num), str(last_num)))
|
||||||
|
|
||||||
|
for msg_id, over in reversed(overviews):
|
||||||
|
if len(extracted_messages) >= max_count:
|
||||||
|
break
|
||||||
|
|
||||||
|
raw_date = over.get('date', '')
|
||||||
|
msg_dt = parse_nntp_date(raw_date)
|
||||||
|
|
||||||
|
if msg_dt > end_dt:
|
||||||
|
continue
|
||||||
|
if msg_dt < start_dt:
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, info = s.article(str(msg_id))
|
||||||
|
raw_bytes = b"\n".join(info.lines)
|
||||||
|
msg = email.message_from_bytes(raw_bytes, policy=default)
|
||||||
|
|
||||||
|
body_text = ""
|
||||||
|
if msg.is_multipart():
|
||||||
|
for part in msg.walk():
|
||||||
|
if part.get_content_type() == "text/plain":
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
if payload:
|
||||||
|
body_text += payload.decode(part.get_content_charset() or 'utf-8', errors='ignore')
|
||||||
|
else:
|
||||||
|
payload = msg.get_payload(decode=True)
|
||||||
|
if payload:
|
||||||
|
body_text = payload.decode(msg.get_content_charset() or 'utf-8', errors='ignore')
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
extracted_messages.append({
|
||||||
|
"msg_num": str(msg_id),
|
||||||
|
"date": msg_dt.isoformat(),
|
||||||
|
"from": decode_mime_header(over.get('from', '[Неизвестно]')),
|
||||||
|
"subject": decode_mime_header(over.get('subject', '[Без темы]')),
|
||||||
|
"body": clean_body_from_quotes(body_text)
|
||||||
|
})
|
||||||
|
|
||||||
|
return extracted_messages
|
||||||
|
except Exception as e:
|
||||||
|
return [{"error": f"Ошибка NNTP при фильтрации по датам: {e}"}]
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def fetch_message_by_id(msg_num_or_id: str, group_name: str = None) -> dict:
|
||||||
|
"""
|
||||||
|
Получает и полностью декодирует содержимое конкретного сообщения.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with nntplib.NNTP(NNTP_SERVER, port=PORT, timeout=10) as s:
|
||||||
|
is_message_id = str(msg_num_or_id).startswith("<") and str(msg_num_or_id).endswith(">")
|
||||||
|
|
||||||
|
if not is_message_id:
|
||||||
|
if not group_name:
|
||||||
|
return {"error": "Для чтения сообщения по его номеру необходимо указать group_name"}
|
||||||
|
s.group(group_name)
|
||||||
|
|
||||||
|
resp, info = s.article(msg_num_or_id)
|
||||||
|
raw_bytes = b"\n".join(info.lines)
|
||||||
|
msg = email.message_from_bytes(raw_bytes, policy=default)
|
||||||
|
|
||||||
|
subject = decode_mime_header(msg.get("Subject", "[Без темы]"))
|
||||||
|
author = decode_mime_header(msg.get("From", "[Неизвестно]"))
|
||||||
|
date = msg.get("Date", "")
|
||||||
|
|
||||||
|
body_text = ""
|
||||||
|
if msg.is_multipart():
|
||||||
|
for part in msg.walk():
|
||||||
|
content_type = part.get_content_type()
|
||||||
|
content_disposition = str(part.get("Content-Disposition"))
|
||||||
|
|
||||||
|
if content_type == "text/plain" and "attachment" not in content_disposition:
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
if payload:
|
||||||
|
body_text += payload.decode(part.get_content_charset() or 'utf-8', errors='ignore')
|
||||||
|
else:
|
||||||
|
payload = msg.get_payload(decode=True)
|
||||||
|
if payload:
|
||||||
|
body_text = payload.decode(msg.get_content_charset() or 'utf-8', errors='ignore')
|
||||||
|
|
||||||
|
if not body_text.strip():
|
||||||
|
for part in msg.walk():
|
||||||
|
if part.get_content_type() == "text/html":
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
if payload:
|
||||||
|
body_text = "[HTML Content]\n" + payload.decode(part.get_content_charset() or 'utf-8', errors='ignore')
|
||||||
|
break
|
||||||
|
|
||||||
|
if not body_text.strip():
|
||||||
|
body_text = "[Тело сообщения пустое или не содержит читаемого текста]"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"msg_id": msg_num_or_id,
|
||||||
|
"from": author,
|
||||||
|
"subject": subject,
|
||||||
|
"date": date,
|
||||||
|
"body": body_text
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Не удалось прочитать сообщение {msg_num_or_id}: {e}"}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_user_pure_messages(group_name: str, user_email: str, depth: int = 50) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Находит последние сообщения конкретного пользователя в группе и извлекает только его текст.
|
||||||
|
"""
|
||||||
|
pure_messages = []
|
||||||
|
user_email_lower = user_email.lower()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with nntplib.NNTP(NNTP_SERVER, port=PORT, timeout=15) as s:
|
||||||
|
resp, count, first, last, name = s.group(group_name)
|
||||||
|
first_num = int(first)
|
||||||
|
last_num = int(last)
|
||||||
|
|
||||||
|
start_msg = max(first_num, last_num - depth + 1)
|
||||||
|
if start_msg > last_num:
|
||||||
|
return []
|
||||||
|
|
||||||
|
resp, overviews = s.over((str(start_msg), str(last_num)))
|
||||||
|
|
||||||
|
for msg_num, over in overviews:
|
||||||
|
author = decode_mime_header(over.get('from', ''))
|
||||||
|
|
||||||
|
if user_email_lower not in author.lower():
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, info = s.article(str(msg_num))
|
||||||
|
raw_bytes = b"\n".join(info.lines)
|
||||||
|
msg = email.message_from_bytes(raw_bytes, policy=default)
|
||||||
|
subject = decode_mime_header(msg.get("Subject", "[Без темы]"))
|
||||||
|
|
||||||
|
body_text = ""
|
||||||
|
if msg.is_multipart():
|
||||||
|
for part in msg.walk():
|
||||||
|
if part.get_content_type() == "text/plain":
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
if payload:
|
||||||
|
body_text += payload.decode(part.get_content_charset() or 'utf-8', errors='ignore')
|
||||||
|
else:
|
||||||
|
payload = msg.get_payload(decode=True)
|
||||||
|
if payload:
|
||||||
|
body_text = payload.decode(msg.get_content_charset() or 'utf-8', errors='ignore')
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
pure_text = clean_body_from_quotes(body_text)
|
||||||
|
if pure_text:
|
||||||
|
pure_messages.append({
|
||||||
|
"msg_num": str(msg_num),
|
||||||
|
"subject": subject,
|
||||||
|
"author": author,
|
||||||
|
"pure_text": pure_text
|
||||||
|
})
|
||||||
|
|
||||||
|
return pure_messages
|
||||||
|
except Exception as e:
|
||||||
|
return [{"error": f"Ошибка при извлечении сообщений пользователя: {e}"}]
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def save_text_to_file(filename: str, content: str) -> str:
|
||||||
|
"""
|
||||||
|
Save text content into a local file. Supports absolute or relative paths.
|
||||||
|
|
||||||
|
:param filename: Path or name of the file (e.g., 'C:/Users/ysv/Documents/bionic-project/chat-kraft/ghjtrnf/test.txt')
|
||||||
|
:param content: Text to write into the file
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Если передан просто test.txt, сохраняем в папку скрипта
|
||||||
|
# Если передан путь (абсолютный или относительный), работаем с ним
|
||||||
|
target_path = os.path.abspath(filename)
|
||||||
|
|
||||||
|
# Автоматически создаем все промежуточные папки (например, ghjtrnf), если их еще нет на диске
|
||||||
|
target_dir = os.path.dirname(target_path)
|
||||||
|
if target_dir and not os.path.exists(target_dir):
|
||||||
|
os.makedirs(target_dir, exist_ok=True)
|
||||||
|
|
||||||
|
with open(target_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
return f"SUCCESS: File saved at {target_path}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"ERROR: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Для современного пакета MCPServer (MCP v2.x)
|
||||||
|
# этот параметр автоматически развернет изолированный stdio-транспорт
|
||||||
|
# без конфликтов с внутренним вводом-выводом Bionic.
|
||||||
|
mcp.run(transport="stdio")
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
name: "Kraft.Участники"
|
||||||
|
description: "Анализ данных сообщений из заданной группы в определенный временной период с целью подсчета общего количества сообщений от каждого уникального участника."
|
||||||
|
---
|
||||||
|
|
||||||
|
## Входные данные (Inputs)
|
||||||
|
1. **`group_name`**: Название группы на сервере новостей (например, `kraft.talks`).
|
||||||
|
2. **`start_date`**: Начальная дата анализа в формате YYYY-MM-DD.
|
||||||
|
3. **`end_date`**: Конечная дата анализа в формате YYYY-MM-DD.
|
||||||
|
|
||||||
|
## Логика работы (Workflow)
|
||||||
|
1. **Получение данных:** Используй функцию `fetch_messages_by_date_range(group_name, start_date, end_date)` для получения всех сообщений за указанный период и группу.
|
||||||
|
2. **Обработка:** Проанализируй полученный список сообщений (сообщения должны содержать поле `from` в формате "Имя <email@address>").
|
||||||
|
3. **Агрегация:** Создай словарь/таблицу, где ключом будет уникальный отправитель (используй полный email как основной идентификатор). Значением будет счетчик.
|
||||||
|
4. **Подсчет:** Для каждого сообщения из списка:
|
||||||
|
* Извлеки имя и email из поля `from`.
|
||||||
|
* Используй email для однозначной привязки к участнику.
|
||||||
|
* Увеличивай счетчик сообщений для этого уникального пользователя.
|
||||||
|
5. **Форматирование вывода:** Сформируй финальный отчет в виде таблицы со следующим заголовком и структурой: `"Имя | электронный адрес | количество сообщений"`.
|
||||||
|
|
||||||
|
## Пример реализации (Mental Walkthrough)
|
||||||
|
Если получены N сообщений, нужно пройтись по ним один раз. Для каждого сообщения `msg`:
|
||||||
|
* Извлеки отправителя из `msg.from` (например, "Иван <ivan@mail.com>").
|
||||||
|
* Сохрани или обнови запись в словаре: `"Ivan | ivan@mail.com": 1`.
|
||||||
|
|
||||||
|
**Важно:** При выводе результатов используй уникальный email-адрес как основной идентификатор для предотвращения ошибок при одинаковых именах у разных пользователей.
|
||||||
|
|
||||||
|
## Ожидаемый вывод
|
||||||
|
Четкий, легко читаемый и структурированный отчет в формате markdown table:
|
||||||
|
|
||||||
Reference in new issue
Block a user