diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4e2d976 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,185 @@ +# CLAUDE.md + +## О проекте + +Кратко опиши проект: что он делает, для кого, какую проблему решает. + +> Пример: веб-приложение для управления задачами команды. REST API на Node.js + React фронтенд. + +--- + +## Стек технологий + +| Слой | Технология | +|------|-----------| +| Язык | TypeScript / Python / Go | +| Фреймворк | Next.js / FastAPI / Gin | +| БД | PostgreSQL + Redis | +| Тесты | Jest / Pytest / Go test | +| CI/CD | GitHub Actions | + +--- + +## Структура проекта + +``` +src/ + api/ — маршруты и контроллеры + services/ — бизнес-логика + models/ — схемы данных + utils/ — вспомогательные функции +tests/ — тесты (зеркалируют src/) +docs/ — документация +``` + +--- + +## Окружение и установка + +```bash +# Установка зависимостей +npm install + +# Переменные окружения (скопировать и заполнить) +cp .env.example .env + +# Запуск БД через Docker +docker compose up -d +``` + +Обязательные переменные окружения: +- `DATABASE_URL` — строка подключения к PostgreSQL +- `REDIS_URL` — строка подключения к Redis +- `JWT_SECRET` — секрет для подписи токенов + +--- + +## Команды разработки + +```bash +# Запуск в режиме разработки +npm run dev + +# Сборка +npm run build + +# Проверка типов +npm run typecheck + +# Линтер +npm run lint + +# Форматирование +npm run format +``` + +--- + +## Тестирование + +```bash +# Запуск всех тестов +npm test + +# Один тест по имени +npm test -- --testNamePattern="название теста" + +# Только unit-тесты (быстро) +npm run test:unit + +# Интеграционные тесты (медленно, нужна БД) +npm run test:integration + +# С покрытием +npm run test:coverage +``` + +**Правила:** +- Перед коммитом запускать `npm run typecheck && npm run lint` +- Не запускать полный suite при каждом изменении — используй фильтр по имени +- Новый код = новые тесты; PR без тестов не принимается + +--- + +## Стиль кода + +- ES-модули (`import/export`), не CommonJS +- `async/await` вместо `.then()/.catch()` +- `const` по умолчанию, `let` только когда нужна переменная +- Именование: `camelCase` для переменных и функций, `PascalCase` для классов и типов +- Не оставлять `console.log` в production-коде — использовать логгер +- Форматирование контролирует Prettier — не прописывай стиль вручную + +--- + +## Рабочие процессы + +### Новая фича +1. Создать ветку от `main`: `git checkout -b feature/название` +2. Реализовать изменения +3. Написать тесты +4. Запустить `npm run typecheck && npm run lint && npm test` +5. Создать PR с описанием что и зачем изменено + +### Исправление бага +1. Создать ветку: `git checkout -b fix/описание-бага` +2. Воспроизвести баг тестом +3. Исправить — убедиться что тест проходит +4. PR с номером issue в заголовке + +### Рефакторинг +- Не смешивать рефакторинг с новой функциональностью в одном PR +- Покрытие тестами должно оставаться на том же уровне или расти + +--- + +## Git и PR + +**Именование веток:** +``` +feature/краткое-описание +fix/что-сломано +chore/что-делается +docs/что-документируется +``` + +**Формат коммитов:** +``` +feat: добавить авторизацию через OAuth +fix: исправить утечку памяти в воркере +chore: обновить зависимости +docs: описать API эндпоинты +``` + +**PR:** +- Заголовок: до 70 символов, описывает суть изменения +- Тело: что изменено и почему, как проверить +- Один PR — одна задача + +--- + +## Архитектурные решения + +- **Сервисный слой** — вся бизнес-логика в `services/`, контроллеры только маршрутизируют +- **Репозиторий-паттерн** — доступ к БД только через репозитории, не напрямую из сервисов +- **Ошибки** — бросать типизированные ошибки (`AppError`), не возвращать `null` +- **Валидация** — только на входе системы (HTTP / очередь), не дублировать внутри + +--- + +## Важные особенности и подводные камни + +- Миграции БД запускаются автоматически при старте — не запускать вручную в production +- Тесты используют отдельную БД (`DATABASE_URL_TEST`) — не трогай основную +- Кэш Redis инвалидируется при деплое — первые запросы после деплоя будут медленнее +- Rate limiting: 100 запросов/мин на IP — учитывай в нагрузочных тестах + +--- + +## Что делать нельзя + +- Коммитить `.env` файлы и секреты +- Делать прямые SQL-запросы в обход ORM без веской причины +- Игнорировать ошибки TypeScript через `// @ts-ignore` без объяснения +- Мерджить PR без прохождения CI +- Деплоить напрямую в `main` — только через PR diff --git a/README.md b/README.md deleted file mode 100644 index afe2aa5..0000000 --- a/README.md +++ /dev/null @@ -1,75 +0,0 @@ -# HN AI News Automation — n8n Workflow - -Автоматический pipeline для сбора, обработки и публикации AI/tech новостей из Hacker News с генерацией контента через LLM. - -## Что делает workflow - -1. **Запуск по расписанию** — cron `0 9,18 * * *` (дважды в день: 9:00 и 18:00 МСК) -2. **Сбор данных** — 8 параллельных запросов к Hacker News Algolia API по ключевым темам: `AI`, `LLM`, `GPT`, `agent`, `automation`, `API`, `startup`, `open source` -3. **Фильтрация** — дедупликация по `objectID`, сортировка по `points`, отбор топ-5 статей -4. **Генерация контента** — GPT-4.1-mini создаёт анонс 150–200 символов на **английском и арабском** языках -5. **Сохранение** — запись в Google Sheets (appendOrUpdate по заголовку, без дублей) -6. **Уведомление** — отправка в Telegram с inline-кнопками для быстрых действий - -## Почему Hacker News - -Hacker News — наиболее релевантный публичный источник для AI/tech тематики: -- Algolia API бесплатный, без авторизации, стабильный -- Поле `points` позволяет фильтровать качественный контент (`points > 10`) -- Поле `num_comments` даёт сигнал о вирусности материала -- Высокая плотность постов по выбранным темам (AI, LLM, agents) — гарантирует результат при каждом запуске - -## Стек - -| Компонент | Решение | -|---|---| -| Автоматизация | n8n (self-hosted) | -| Источник данных | Hacker News Algolia API | -| LLM | OpenAI GPT-4.1-mini | -| Хранение | Google Sheets | -| Уведомления | Telegram Bot API | - -## Структура workflow - -``` -Schedule Trigger / Manual Trigger - → Code: генерация поисковых запросов (8 тем) - → HTTP Request: запрос к HN Algolia API - → Aggregate: сбор всех ответов в массив - → Code: дедупликация + топ-5 по points - → Basic LLM Chain (GPT-4.1-mini): генерация EN + AR текстов - → Code: парсинг JSON из LLM + сборка финального объекта - → Google Sheets: appendOrUpdate - → Telegram: отправка с inline-кнопками -``` - -## Узкие места - -- **LLM latency** — при 5 статьях и batch size 1 суммарное время генерации может достигать 30–60 сек. При увеличении количества статей возможны таймауты -- **Потеря pairedItem через LangChain** — LangChain-ноды обрывают цепочку `pairedItem`, из-за чего данные из предыдущих нод приходится доставать через `$('NodeName').all()[$itemIndex]` -- **Отсутствие дедупликации между запусками** — Google Sheets использует `appendOrUpdate` по полю `title`, но при изменении заголовка возможен дубль -- **Нет обработки пустого `source`** — часть HN-постов не имеет внешней ссылки (например, `Tell HN:`), кнопка «Читать статью» в таких случаях нерабочая - -## Что улучшить при масштабировании - -- **Векторная дедупликация** — сравнивать семантическую близость заголовков (embeddings + pgvector), чтобы не публиковать похожие новости -- **Очередь обработки** — вынести LLM-генерацию в отдельный workflow с очередью (Redis / n8n Queue Mode) для надёжности -- **Мультиязычность** — параметризовать языки через конфиг, чтобы легко добавлять новые направления без правки кода -- **Фильтр по свежести** — добавить фильтр `created_at > now - 24h`, чтобы исключить старые посты при повторных запусках -- **Мониторинг** — добавить ноду уведомления об ошибках (отдельный Telegram-алерт при падении любого шага) -- **A/B тест промптов** — логировать какие формулировки дают больше кликов по кнопкам для итерации промпта - -## Файлы - -- `workflow.json` — экспорт workflow из n8n (credentials заменены на заглушки) - -## Настройка - -1. Импортируй `workflow.json` в n8n -2. Подключи credentials: - - **OpenAI API** → нода `OpenAI Chat Model` - - **Google Sheets OAuth2** → нода `Append or update row in sheet` - - **Telegram API** → нода `Send a text message` -3. Укажи свой Google Sheets Document ID и Chat ID в соответствующих нодах -4. Активируй Schedule Trigger -# - diff --git "a/\321\202\320\265\321\201\321\202\320\276\320\262\320\276\320\265 \320\237\321\200\320\276\321\205\320\276\321\200\320\276\320\262 \320\235 (2).json" "b/\321\202\320\265\321\201\321\202\320\276\320\262\320\276\320\265 \320\237\321\200\320\276\321\205\320\276\321\200\320\276\320\262 \320\235 (2).json" deleted file mode 100644 index 9b81bb5..0000000 --- "a/\321\202\320\265\321\201\321\202\320\276\320\262\320\276\320\265 \320\237\321\200\320\276\321\205\320\276\321\200\320\276\320\262 \320\235 (2).json" +++ /dev/null @@ -1,470 +0,0 @@ -{ - "name": "тестовое Прохоров Н.", - "nodes": [ - { - "parameters": {}, - "type": "n8n-nodes-base.manualTrigger", - "typeVersion": 1, - "position": [ - 0, - -256 - ], - "id": "eba15466-5c44-4438-885c-f561b69f7e0f", - "name": "When clicking ‘Execute workflow’" - }, - { - "parameters": { - "rule": { - "interval": [ - { - "field": "cronExpression", - "expression": "0 9,18 * * *" - } - ] - } - }, - "type": "n8n-nodes-base.scheduleTrigger", - "typeVersion": 1.3, - "position": [ - 0, - 0 - ], - "id": "dd6d5606-a657-4cd1-912d-036597353d15", - "name": "Schedule Trigger" - }, - { - "parameters": { - "url": "=https://hn.algolia.com/api/v1/search_by_date", - "sendQuery": true, - "queryParameters": { - "parameters": [ - { - "name": "query", - "value": "={{ $json.query }}" - }, - { - "name": "tags", - "value": "story" - }, - { - "name": "hitsPerPage", - "value": "50" - }, - { - "name": "numericFilters", - "value": "points>10" - } - ] - }, - "options": { - "response": { - "response": { - "responseFormat": "json" - } - } - } - }, - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.3, - "position": [ - 624, - 0 - ], - "id": "caa6b3fe-8877-4414-90d5-6380175cede7", - "name": "HTTP Request" - }, - { - "parameters": { - "model": { - "__rl": true, - "mode": "list", - "value": "gpt-4.1-mini" - }, - "builtInTools": {}, - "options": { - "maxTokens": 300, - "temperature": 0.7 - } - }, - "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi", - "typeVersion": 1.3, - "position": [ - 1328, - 192 - ], - "id": "76150338-c806-4b63-94c6-93181c5f2966", - "name": "OpenAI Chat Model", - "credentials": { - "openAiApi": { - "id": "ЗАГЛУШКА", - "name": "OpenAi account" - } - } - }, - { - "parameters": { - "promptType": "define", - "text": "=You are a social media content writer.\n\nBased on this news title: \"{{ $json.title }}\"\n\nWrite a short announcement of 150-200 characters in TWO languages.\n\nReturn ONLY this JSON format:\n{\n \"text_en\": \"your english text here\",\n \"text_ar\": \"your arabic text here\"\n}\n\nNo explanations, no markdown, only JSON.\nRespond ONLY with valid JSON, no markdown, no extra text:\n{\"text_en\": \"...\", \"text_ar\": \"...\"}", - "batching": { - "batchSize": 1, - "delayBetweenBatches": 1000 - } - }, - "type": "@n8n/n8n-nodes-langchain.chainLlm", - "typeVersion": 1.8, - "position": [ - 1328, - 0 - ], - "id": "3372303d-62e5-4092-8c7a-772ad2ba9d84", - "name": "Basic LLM Chain" - }, - { - "parameters": { - "mode": "runOnceForEachItem", - "jsCode": "const item = $input.item.json;\nconst orig = $('Code in JavaScript1').all()[$itemIndex]?.json || {};\n\nlet text = item.text || '';\n\nif (typeof text === 'object') {\n text = JSON.stringify(text);\n}\n\ntext = text.replace(/```json/g, '').replace(/```/g, '').trim();\n\nlet text_en = '';\nlet text_ar = '';\n\ntry {\n const parsed = JSON.parse(text);\n text_en = parsed.text_en || '';\n text_ar = parsed.text_ar || '';\n} catch(e) {\n const enMatch = text.match(/\"text_en\":\\s*\"([^\"]+)\"/);\n const arMatch = text.match(/\"text_ar\":\\s*\"([^\"]+)\"/);\n text_en = enMatch ? enMatch[1] : text;\n text_ar = arMatch ? arMatch[1] : '';\n}\n\nconst now = new Date();\nconst datetime = now.toLocaleString('ru-RU', {\n timeZone: 'Europe/Moscow',\n day: '2-digit',\n month: '2-digit',\n year: 'numeric',\n hour: '2-digit',\n minute: '2-digit'\n});\n\nreturn {\n json: {\n title: String(orig.title || ''),\n source: String(orig.url || ''),\n score: Number(orig.score || 0),\n comments: Number(orig.comments || 0),\n objectID: String(orig.objectID || ''),\n text_en: String(text_en),\n text_ar: String(text_ar),\n datetime: String(datetime)\n }\n};" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 1712, - 0 - ], - "id": "eebb7e53-bfd2-4d81-82bd-cedad2ca4daf", - "name": "Code in JavaScript" - }, - { - "parameters": { - "operation": "appendOrUpdate", - "documentId": { - "__rl": true, - "value": "1SffwHPqGtNOlnlvygxThUFZORdakivZWQDoaw7qI2o8", - "mode": "list", - "cachedResultName": "Тестовое Прохоров", - "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1SffwHPqGtNOlnlvygxThUFZORdakivZWQDoaw7qI2o8/edit?usp=drivesdk" - }, - "sheetName": { - "__rl": true, - "value": "gid=0", - "mode": "list", - "cachedResultName": "Лист1", - "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1SffwHPqGtNOlnlvygxThUFZORdakivZWQDoaw7qI2o8/edit#gid=0" - }, - "columns": { - "mappingMode": "defineBelow", - "value": { - "title": "={{ $json.title }}", - "source": "={{ $json.source }}", - "text_en": "={{ $json.text_en }}", - "text_ar": "={{ $json.text_ar }}", - "datetime": "={{ $json.datetime }}", - "score": "={{ $json.score }}", - "comments": "={{ $json.comments }}" - }, - "matchingColumns": [ - "title" - ], - "schema": [ - { - "id": "title", - "displayName": "title", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true, - "removed": false - }, - { - "id": "source", - "displayName": "source", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true - }, - { - "id": "text_en", - "displayName": "text_en", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true - }, - { - "id": "text_ar", - "displayName": "text_ar", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true - }, - { - "id": "datetime", - "displayName": "datetime", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true - }, - { - "id": "score", - "displayName": "score", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true, - "removed": false - }, - { - "id": "comments", - "displayName": "comments", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true, - "removed": false - } - ], - "attemptToConvertTypes": false, - "convertFieldsToString": false - }, - "options": {} - }, - "type": "n8n-nodes-base.googleSheets", - "typeVersion": 4.7, - "position": [ - 1936, - 0 - ], - "id": "dba671a6-c388-4955-8e2d-1bef1a4465d7", - "name": "Append or update row in sheet", - "credentials": { - "googleSheetsOAuth2Api": { - "id": "ЗАГЛУШКА", - "name": "Google Sheets account" - } - } - }, - { - "parameters": { - "jsCode": "const data = $input.first().json.data;\n\nconst allHits = data.flatMap(item => item.hits || []);\n\nconst seen = new Set();\nconst unique = allHits.filter(hit => {\n if (seen.has(hit.objectID)) return false;\n seen.add(hit.objectID);\n return true;\n});\n\nif (unique.length === 0) {\n throw new Error('No articles found');\n}\n\nconst top5 = unique\n .sort((a, b) => (b.points || 0) - (a.points || 0))\n .slice(0, 5);\n\nreturn top5.map(hit => ({\n json: {\n title: hit.title,\n url: hit.url,\n score: hit.points || 0,\n comments: hit.num_comments || 0,\n objectID: hit.objectID, \n created_at: hit.created_at\n }\n}));" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 1072, - 0 - ], - "id": "71b61366-cb4e-4684-beca-c7e6de4f70c7", - "name": "Code in JavaScript1" - }, - { - "parameters": { - "jsCode": "return [\n { json: { query: 'AI' } }, // 27% всех постов\n { json: { query: 'LLM' } }, // 45% рост за год\n { json: { query: 'startup' } }, // 9% всех постов\n { json: { query: 'automation' } }, // low-code/no-code +22%\n { json: { query: 'API' } }, // вечно популярно\n { json: { query: 'open source' } }, // всегда много статей\n { json: { query: 'agent' } }, // AI agents тренд 2026\n { json: { query: 'GPT' } } // стабильно много\n];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 416, - 0 - ], - "id": "bef2bc64-84ef-40b6-bd5a-7f7c72c4ff70", - "name": "Code in JavaScript2" - }, - { - "parameters": { - "aggregate": "aggregateAllItemData", - "options": {} - }, - "type": "n8n-nodes-base.aggregate", - "typeVersion": 1, - "position": [ - 832, - 0 - ], - "id": "29d92148-6f71-46f3-b2e3-1dd500ca5c7d", - "name": "Aggregate" - }, - { - "parameters": { - "chatId": "195622777", - "text": "=📰 {{ $('Code in JavaScript').item.json.title }}\n\n🌐 EN: {{ $('Code in JavaScript').item.json.text_en }}\n🇸🇦 AR: {{ $('Code in JavaScript').item.json.text_ar }}\n\n⭐ Score: {{ $json.score }} | 💬 Comments: {{ $json.comments }}\n🕐 {{ $json.datetime }}", - "replyMarkup": "inlineKeyboard", - "inlineKeyboard": { - "rows": [ - { - "row": { - "buttons": [ - { - "text": "📖 Читать статью", - "additionalFields": { - "callback_data": "cads" - } - }, - { - "text": "💬 Создать Оффер", - "additionalFields": { - "callback_data": "daw" - } - } - ] - } - } - ] - }, - "additionalFields": { - "appendAttribution": false, - "disable_notification": true, - "parse_mode": "HTML" - } - }, - "type": "n8n-nodes-base.telegram", - "typeVersion": 1.2, - "position": [ - 2240, - 0 - ], - "id": "b7dd2e9c-ae0f-4e55-bfec-a1fe337255ab", - "name": "Send a text message", - "webhookId": "757dcab1-1f93-489e-925b-4a4619e3fe8d", - "credentials": { - "telegramApi": { - "id": "ЗАГЛУШКА", - "name": "ЗАГЛУШКА" - } - } - } - ], - "pinData": {}, - "connections": { - "Schedule Trigger": { - "main": [ - [ - { - "node": "Code in JavaScript2", - "type": "main", - "index": 0 - } - ] - ] - }, - "When clicking ‘Execute workflow’": { - "main": [ - [ - { - "node": "Code in JavaScript2", - "type": "main", - "index": 0 - } - ] - ] - }, - "HTTP Request": { - "main": [ - [ - { - "node": "Aggregate", - "type": "main", - "index": 0 - } - ] - ] - }, - "OpenAI Chat Model": { - "ai_languageModel": [ - [ - { - "node": "Basic LLM Chain", - "type": "ai_languageModel", - "index": 0 - } - ] - ] - }, - "Basic LLM Chain": { - "main": [ - [ - { - "node": "Code in JavaScript", - "type": "main", - "index": 0 - } - ] - ] - }, - "Code in JavaScript": { - "main": [ - [ - { - "node": "Append or update row in sheet", - "type": "main", - "index": 0 - } - ] - ] - }, - "Code in JavaScript1": { - "main": [ - [ - { - "node": "Basic LLM Chain", - "type": "main", - "index": 0 - } - ] - ] - }, - "Code in JavaScript2": { - "main": [ - [ - { - "node": "HTTP Request", - "type": "main", - "index": 0 - } - ] - ] - }, - "Aggregate": { - "main": [ - [ - { - "node": "Code in JavaScript1", - "type": "main", - "index": 0 - } - ] - ] - }, - "Append or update row in sheet": { - "main": [ - [ - { - "node": "Send a text message", - "type": "main", - "index": 0 - } - ] - ] - } - }, - "active": false, - "settings": { - "executionOrder": "v1", - "availableInMCP": false - }, - "versionId": "6bc23e5f-a975-4e82-ba7d-3d953cb2e3d5", - "meta": { - "templateCredsSetupCompleted": true, - "instanceId": "aef6f84705ac4a62692720e5c63278baa6530dfeabfb104bdd27dc8f1fb6d88f" - }, - "id": "AkIMEcYw5CHKusMx", - "tags": [] -} \ No newline at end of file