Initial commit

This commit is contained in:
Alex Levanovich 2026-05-04 08:06:37 +03:00
commit 6244a65b99
4 changed files with 287 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

BIN
IMG_0002.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

10
description.md Normal file
View File

@ -0,0 +1,10 @@
В инфраструктуре предприятия есть сервер exchange. Все пользователи получают и отправляют почту через него. Каждый пользователь авторизуется доменной учетной записью.
Сообщения о неблагоприятных погодных условиях приходят от адресата iaocms@sevmeteo.ru. Сообщения поступают на эл.почту disp5@appm.ru.
Тема сообщения "Предупреждение о НМУ".
Так же к этому же сообщению прикладывается файл (скан). Пример лежит в этой же папке - IMG_0002.jpg. Имена приложенных файлов могут отличаться.
Что необходимо?
1. Надо скачать файл из письма.
2. Распознать текст. Вытащить из шапки дату сообщения, исходящий номер (остальные данные не требуются). Вытащить текст сообщения и тела скана письма.
3. Разослать текст нескольким адресатам по эл.почте. Данные исходящего сервера - 10.0.3.22, порт 25, без авторизации.

277
n8n_workflow.json Normal file
View File

@ -0,0 +1,277 @@
{
"name": "NMU warning mail OCR and forward",
"nodes": [
{
"parameters": {
"content": "Настройте credentials для IMAP/Exchange ящика disp5@appm.ru.\n\nФильтры workflow:\n- From: iaocms@sevmeteo.ru\n- Subject: Предупреждение о НМУ\n\nЛокальная модель:\n- POST http://10.1.1.1:4000/v1/chat/completions\n- model: QWEN3.5-9B\n\nSMTP рассылка:\n- 10.0.3.22:25\n- без авторизации\n\nВ узле Build Forward Email замените список получателей NMU_RECIPIENTS.",
"height": 320,
"width": 420
},
"id": "b3d55a4f-6f6c-4e58-840c-7770e5019111",
"name": "README",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-760,
-280
]
},
{
"parameters": {
"mailbox": "INBOX",
"downloadAttachments": true,
"options": {
"customEmailConfig": "UNSEEN"
}
},
"id": "e73a579a-3a21-4684-96b0-e1d817208b57",
"name": "IMAP Email Trigger",
"type": "n8n-nodes-base.emailReadImap",
"typeVersion": 2,
"position": [
-760,
160
],
"credentials": {
"imap": {
"id": "",
"name": "disp5@appm.ru IMAP"
}
}
},
{
"parameters": {
"jsCode": "const FROM = 'iaocms@sevmeteo.ru';\nconst SUBJECT = 'Предупреждение о НМУ';\n\nreturn $input.all().map((item) => {\n const json = item.json || {};\n const fromText = String(json.from?.value?.[0]?.address || json.from?.text || json.from || '').toLowerCase();\n const subject = String(json.subject || '');\n const binaryKeys = Object.keys(item.binary || {});\n\n const matched = fromText.includes(FROM) && subject.trim() === SUBJECT && binaryKeys.length > 0;\n\n return {\n json: {\n ...json,\n nmu_should_process: matched,\n nmu_filter_reason: matched ? 'matched' : `from=${fromText}; subject=${subject}; attachments=${binaryKeys.length}`,\n nmu_binary_keys: binaryKeys\n },\n binary: item.binary\n };\n});"
},
"id": "6ea5d2c9-ed64-4e35-bab2-492482df028e",
"name": "Filter NMU Message",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-520,
160
]
},
{
"parameters": {
"conditions": {
"boolean": [
{
"value1": "={{ $json.nmu_should_process }}",
"value2": true
}
]
}
},
"id": "86befd17-05e7-46c3-b16d-c5b67b820fe3",
"name": "Should Process?",
"type": "n8n-nodes-base.if",
"typeVersion": 1,
"position": [
-280,
160
]
},
{
"parameters": {
"jsCode": "const item = $input.first();\nconst binary = item.binary || {};\nconst imageKey = Object.keys(binary).find((key) => {\n const mime = String(binary[key].mimeType || '').toLowerCase();\n return mime.startsWith('image/');\n});\n\nif (!imageKey) {\n throw new Error('В письме нет вложения-изображения. Если приходят PDF/TIFF, добавьте конвертацию перед OCR.');\n}\n\nconst attachment = binary[imageKey];\nconst mimeType = attachment.mimeType || 'image/jpeg';\nconst dataUrl = `data:${mimeType};base64,${attachment.data}`;\n\nconst openaiBody = {\n model: 'QWEN3.5-9B',\n temperature: 0,\n messages: [\n {\n role: 'system',\n content: 'Ты извлекаешь данные из скана официального письма. Верни только валидный JSON без markdown, пояснений и комментариев.'\n },\n {\n role: 'user',\n content: [\n {\n type: 'text',\n text: 'Распознай документ на изображении. Нужно извлечь: дату письма рядом с исходящим номером, исходящий номер после символа №, основной текст предупреждения НМУ и полный распознанный текст. Верни только валидный JSON в формате {\"date\":\"ДД.ММ.ГГГГ или null\",\"outgoing_number\":\"строка или null\",\"warning_text\":\"строка\",\"full_text\":\"строка\"}. Если поле не найдено, верни null. Не добавляй markdown.'\n },\n {\n type: 'image_url',\n image_url: {\n url: dataUrl\n }\n }\n ]\n }\n ]\n};\n\nreturn [{\n json: {\n message_id: item.json.messageId || item.json.message_id || null,\n original_subject: item.json.subject || '',\n original_from: item.json.from || '',\n attachment_key: imageKey,\n attachment_file_name: attachment.fileName || imageKey,\n attachment_mime_type: mimeType,\n openai_body: JSON.stringify(openaiBody)\n },\n binary: item.binary\n}];"
},
"id": "753f7228-2e78-4192-b82a-e3f6da3cbe9d",
"name": "Prepare Qwen Vision Request",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-20,
40
]
},
{
"parameters": {
"method": "POST",
"url": "http://10.1.1.1:4000/v1/chat/completions",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"contentType": "raw",
"rawContentType": "application/json",
"body": "={{ $json.openai_body }}",
"options": {
"timeout": 180000
}
},
"id": "c8992117-91e8-4855-9f71-089d2db07d6d",
"name": "OCR via QWEN3.5-9B",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
240,
40
]
},
{
"parameters": {
"jsCode": "function extractJson(text) {\n const clean = String(text || '').trim()\n .replace(/^```json\\s*/i, '')\n .replace(/^```\\s*/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n return JSON.parse(clean);\n } catch (error) {\n const match = clean.match(/\\{[\\s\\S]*\\}/);\n if (match) return JSON.parse(match[0]);\n throw error;\n }\n}\n\nconst response = $json;\nconst content = response.choices?.[0]?.message?.content || response.choices?.[0]?.text || response.content || '';\nconst parsed = extractJson(content);\n\nconst fullText = String(parsed.full_text || '');\nconst dateMatch = fullText.match(/\\b\\d{2}\\.\\d{2}\\.\\d{4}\\b/);\nconst numberMatch = fullText.match(/№\\s*([А-ЯA-Z0-9][А-ЯA-Z0-9\\/-]*)/i);\n\nconst date = parsed.date || dateMatch?.[0] || null;\nconst outgoingNumber = parsed.outgoing_number || numberMatch?.[1] || null;\n\nreturn [{\n json: {\n date,\n outgoing_number: outgoingNumber,\n warning_text: parsed.warning_text || null,\n full_text: parsed.full_text || content,\n raw_model_content: content\n }\n}];"
},
"id": "6f2e9dcc-dfc3-4838-8c42-8988fc433559",
"name": "Parse OCR JSON",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
500,
40
]
},
{
"parameters": {
"jsCode": "const NMU_RECIPIENTS = [\n 'recipient1@appm.ru',\n 'recipient2@appm.ru'\n];\n\nconst date = $json.date || 'дата не распознана';\nconst number = $json.outgoing_number || 'номер не распознан';\nconst warningText = $json.warning_text || $json.full_text || 'Текст предупреждения не распознан.';\n\nconst subject = `Предупреждение о НМУ от ${date} № ${number}`;\nconst text = [\n `Дата письма: ${date}`,\n `Исходящий номер: ${number}`,\n '',\n 'Текст предупреждения:',\n warningText,\n '',\n 'Полный распознанный текст:',\n $json.full_text || ''\n].join('\\n');\n\nreturn [{\n json: {\n to_email: NMU_RECIPIENTS.join(','),\n from_email: 'disp5@appm.ru',\n subject,\n text,\n date: $json.date,\n outgoing_number: $json.outgoing_number\n }\n}];"
},
"id": "af0f0426-8e65-4040-ba56-3450c1d223f0",
"name": "Build Forward Email",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
760,
40
]
},
{
"parameters": {
"fromEmail": "={{ $json.from_email }}",
"toEmail": "={{ $json.to_email }}",
"subject": "={{ $json.subject }}",
"emailFormat": "text",
"text": "={{ $json.text }}",
"options": {}
},
"id": "3ff0b578-b265-4267-944f-8cb95e4a262a",
"name": "Send SMTP Email",
"type": "n8n-nodes-base.emailSend",
"typeVersion": 2.1,
"position": [
1020,
40
],
"credentials": {
"smtp": {
"id": "",
"name": "SMTP 10.0.3.22 no auth"
}
}
},
{
"parameters": {
"content": "Если узел HTTP Request вернет ошибку про image_url/base64, значит локальный OpenAI-compatible сервер не принимает data URL. Тогда нужен промежуточный HTTP-доступ к файлу или отдельный OCR endpoint, который принимает multipart/form-data.",
"height": 180,
"width": 360
},
"id": "f0631818-7c64-43dd-b901-08af5eb3de92",
"name": "Qwen note",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
160,
-220
]
}
],
"pinData": {},
"connections": {
"IMAP Email Trigger": {
"main": [
[
{
"node": "Filter NMU Message",
"type": "main",
"index": 0
}
]
]
},
"Filter NMU Message": {
"main": [
[
{
"node": "Should Process?",
"type": "main",
"index": 0
}
]
]
},
"Should Process?": {
"main": [
[
{
"node": "Prepare Qwen Vision Request",
"type": "main",
"index": 0
}
],
[]
]
},
"Prepare Qwen Vision Request": {
"main": [
[
{
"node": "OCR via QWEN3.5-9B",
"type": "main",
"index": 0
}
]
]
},
"OCR via QWEN3.5-9B": {
"main": [
[
{
"node": "Parse OCR JSON",
"type": "main",
"index": 0
}
]
]
},
"Parse OCR JSON": {
"main": [
[
{
"node": "Build Forward Email",
"type": "main",
"index": 0
}
]
]
},
"Build Forward Email": {
"main": [
[
{
"node": "Send SMTP Email",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1",
"saveManualExecutions": true,
"callerPolicy": "workflowsFromSameOwner"
},
"versionId": "68b03930-1a27-48e3-8a17-2c61433e4800",
"meta": {
"templateCredsSetupCompleted": false
},
"id": "NMU_QWEN_LOCAL_OCR",
"tags": []
}