Покрокова інструкція: від реєстрації DNS до робочого Workbench на analyst.newsomo.com. Приблизно 4-6 годин роботи · одноразово.
Pro+ використовує існуючу інфраструктуру newsomo + один новий субдомен. Без окремих серверів — все на тому ж Hetzner CCX13, Netlify CDN та SQLite.
newsomo.com → DNSCNAME analyst → newsomo.netlify.app (або ваш Netlify subdomain)dig analyst.newsomo.com або nslookup analyst.newsomo.comЯкщо плануєте відправляти повідомлення з analyst@newsomo.com через Resend:
# Add TXT records у Cloudflare DNS TXT @ v=spf1 include:_spf.resend.com ~all TXT resend._domainkey k=rsa; p=MIGfMA0... # від Resend dashboard TXT _dmarc v=DMARC1; p=quarantine; rua=mailto:postmaster@newsomo.com
app.netlify.comnewsomo_project_wizard.htmlnewsomo_analyst_workbench.htmlnewsomo_workbench_grid.htmlnewsomo_workbench_full.htmlnewsomo_ai_capabilities.htmlindex.html (login page — створіть або редирект на wizard)analyst.newsomo.com# Один раз npm install -g netlify-cli netlify login # У папці з HTML netlify init netlify deploy --prod --dir=. # Майбутні оновлення netlify deploy --prod
[build] publish = "." # Redirect index.html → wizard [[redirects]] from = "/" to = "/newsomo_project_wizard.html" status = 200 # API proxy до Hetzner backend [[redirects]] from = "/api/*" to = "https://api.newsomo.com/:splat" status = 200 force = true # Security headers [[headers]] for = "/*" [headers.values] X-Frame-Options = "DENY" X-Content-Type-Options = "nosniff" Referrer-Policy = "strict-origin-when-cross-origin" Strict-Transport-Security = "max-age=63072000; includeSubDomains; preload"
https://analyst.newsomo.com/ у браузері — має редиректнути на wizard. SSL замочок зелений.Бекенд Pro+ — це один blueprint у Flask з 5 routes, що додається до існуючого newsomo. Жодних окремих процесів.
ssh newsomo@157.180.68.160 cd /home/newsomo # Створити новий blueprint файл touch newsomo/api/analyst.py nano newsomo/api/analyst.py
CREATE TABLE projects ( id INTEGER PRIMARY KEY, fingerprint TEXT UNIQUE NOT NULL, -- minfin-a691c2f name TEXT, client TEXT, analyst_email TEXT, start_date DATE, end_date DATE, taxonomy_json TEXT, -- {fixed:[], theses:[], speakers:[]} matrix_json TEXT, -- AI/Auto/Human per field sources_json TEXT, -- selected RSS cats, custom, csv, etc groups_json TEXT, status TEXT DEFAULT 'active', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE project_articles ( id INTEGER PRIMARY KEY, project_id INTEGER REFERENCES projects(id), news_id INTEGER REFERENCES news(id), -- посилання на newsomo news ai_preview_json TEXT, -- {minfin_type, theses, speakers} coding_json TEXT, -- final analyst answers is_completed BOOLEAN DEFAULT 0, completed_at TIMESTAMP ); CREATE TABLE coding_decisions ( id INTEGER PRIMARY KEY, project_id INTEGER, news_id INTEGER, field TEXT, -- minfin/marchenko/thesis_X/speaker ai_value TEXT, human_value TEXT, decision TEXT, -- accept/reject/override/add ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE analyst_users ( id INTEGER PRIMARY KEY, email TEXT UNIQUE, google_sub TEXT, role TEXT DEFAULT 'pending', -- pending/analyst/admin approved_at TIMESTAMP, approved_by TEXT, pro_plus_active BOOLEAN DEFAULT 0, trial_ends_at TIMESTAMP );
from flask import Blueprint, request, jsonify from newsomo.db import get_conn from newsomo.ai import claude_classify, embed_text, find_similar bp = Blueprint('analyst', __name__, url_prefix='/api') @bp.route('/projects', methods=['POST']) def create_project(): payload = request.json conn = get_conn() conn.execute("INSERT INTO projects (...) VALUES (...)", ...) # Trigger background: enroll matching articles, generate AI preview enqueue_project_init(payload['fingerprint']) return jsonify({'ok': True, 'fingerprint': ...}) @bp.route('/projects/<fp>/articles') def list_articles(fp): # Returns 15-300 articles already filtered + AI preview attached ... @bp.route('/codings/<article_id>', methods=['POST']) def save_coding(article_id): payload = request.json # {minfin, marchenko, theses, speakers, ...} save_decisions(article_id, payload) # log into coding_decisions return jsonify({'ok': True}) @bp.route('/projects/<fp>/export') def export_xlsx(fp): # Generate Excel з 113 колонками точно як ТЗ return send_file(generate_xlsx(fp), as_attachment=True)
sudo systemctl restart newsomo sudo systemctl status newsomo tail -f /var/log/newsomo/app.log
api.newsomo.comserver {
listen 443 ssl http2;
server_name api.newsomo.com;
ssl_certificate /etc/letsencrypt/live/newsomo.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/newsomo.com/privkey.pem;
add_header Access-Control-Allow-Origin "https://analyst.newsomo.com";
add_header Access-Control-Allow-Credentials "true";
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
client_max_body_size 50M; # для CSV upload
}
}
Access-Control-Allow-Origin для analyst.newsomo.com — не *, бо auth cookies.console.cloud.google.com → новий проєкт "newsomo-analyst"marikovski@gmail.comnewsomo.comemail, profile, openidhttps://analyst.newsomo.comhttps://api.newsomo.com/auth/google/callbackfrom authlib.integrations.flask_client import OAuth oauth = OAuth(app) oauth.register( name='google', client_id=os.environ['GOOGLE_CLIENT_ID'], client_secret=os.environ['GOOGLE_CLIENT_SECRET'], server_metadata_url='https://accounts.google.com/.well-known/openid-configuration', client_kwargs={'scope': 'openid email profile'}, ) @app.route('/auth/google') def google_login(): redirect_uri = 'https://api.newsomo.com/auth/google/callback' return oauth.google.authorize_redirect(redirect_uri) @app.route('/auth/google/callback') def google_callback(): token = oauth.google.authorize_access_token() user = token['userinfo'] # Upsert у analyst_users conn.execute("INSERT OR REPLACE INTO analyst_users (email, google_sub, role) VALUES (?, ?, 'pending')", (user['email'], user['sub'])) # Set httpOnly cookie з session token response = redirect('https://analyst.newsomo.com/') response.set_cookie('session', generate_jwt(user), httponly=True, secure=True, samesite='Lax') return response
У index.html на analyst.newsomo.com:
<a href="https://api.newsomo.com/auth/google" class="btn-google"> <img src="/google-icon.svg"> Sign in with Google </a>
# SQL — після того як вони увійшли через Google перший раз UPDATE analyst_users SET role = 'analyst', pro_plus_active = 1, trial_ends_at = datetime('now', '+60 days'), approved_at = datetime('now'), approved_by = 'marikovski@gmail.com' WHERE email IN ( 'analyst1@agency.ua', 'analyst2@pr.com', 'analyst3@minfin.gov.ua', -- 5 beta partners );
Окремий route /admin на subdomain analyst.newsomo.com/admin, доступний лише role=admin.
GET /api/admin/users/pending → список нових login’ів POST /api/admin/users/<email>/approve → {role: 'analyst', trial_days: 14} POST /api/admin/users/<email>/reject → ban + notification email GET /api/admin/projects → всі активні проєкти + analyst POST /api/admin/projects/<fp>/disable → заморозити (наприклад quota) GET /api/admin/metrics → MRR, total codings, AI agreement
Простий dashboard на /admin/index.html з:
# /home/newsomo/.env
ANTHROPIC_API_KEY=sk-ant-...
GOOGLE_CLIENT_ID=...apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-...
RESEND_API_KEY=re_...
JWT_SECRET=$(openssl rand -hex 32)
from anthropic import Anthropic client = Anthropic() def classify_communication(text, entity='Мінфін', examples=[]): """Returns 'активна' | 'пасивна' | None.""" few_shot = "\n".join([ f"Приклад: {e['text'][:300]}\nВідповідь: {e['label']}\n" for e in examples[:8] ]) msg = client.messages.create( model="claude-haiku-4-5-20251001", max_tokens=20, messages=[{ "role": "user", "content": f"""Тебе питають: чи комунікація {entity} у тексті 'активна' (сам сказав/зробив) чи 'пасивна' (про нього говорять інші)? {few_shot} Тепер класифікуй: {text[:1500]} Відповідь одним словом: активна, пасивна, або none""" }] ) return msg.content[0].text.strip().lower()
from sentence_transformers import SentenceTransformer import numpy as np model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2') def top_theses(text, theses_with_embeddings, k=5): """Returns top-K most similar theses.""" text_emb = model.encode(text[:2000]) sims = [ (thesis, np.dot(text_emb, emb) / (np.linalg.norm(text_emb) * np.linalg.norm(emb))) for thesis, emb in theses_with_embeddings ] sims.sort(key=lambda x: -x[1]) return [(t, float(s)) for t, s in sims[:k] if s > 0.45]
import spacy from rapidfuzz import fuzz nlp = spacy.load('uk_core_news_md') def detect_speakers(text, known_speakers): """Returns list of matched speakers from DB.""" doc = nlp(text[:3000]) found = [] for ent in doc.ents: if ent.label_ != 'PER': continue for speaker in known_speakers: name = speaker.split(',')[0].strip() if fuzz.ratio(ent.text.lower(), name.lower()) > 80: found.append(speaker) break return list(set(found))
def retrain_thesis_centroids(): """Recompute thesis centroids based on accepted articles.""" for project in active_projects(): for thesis in project.taxonomy.theses: accepted_articles = get_accepted_for(project.id, thesis) if len(accepted_articles) >= 3: embs = [model.encode(a.text[:2000]) for a in accepted_articles] centroid = np.mean(embs, axis=0) save_centroid(project.id, thesis, centroid) log("Retrained thesis centroids")
# crontab -e як newsomo user # Existing (вже працюють): */15 * * * * cd /home/newsomo && .venv/bin/python scripts/rss_collect.py */15 * * * * cd /home/newsomo && .venv/bin/python scripts/translate_pending.py 0 6 * * * cd /home/newsomo && .venv/bin/python scripts/promote_temp_sources.py # Pro+ нові: # Кожні 15 хв: знайти нові статті для активних проєктів і згенерувати AI preview */15 * * * * cd /home/newsomo && .venv/bin/python scripts/project_ai_preview.py >> logs/preview.log 2>&1 # Кожну годину: перерахувати thesis centroids на основі прийнятих рішень 0 * * * * cd /home/newsomo && .venv/bin/python scripts/retrain_centroids.py >> logs/learn.log 2>&1 # Щодня о 09:00: відправити weekly/daily reports клієнтам 0 9 * * * cd /home/newsomo && .venv/bin/python scripts/send_scheduled_reports.py # Щодня о 03:00: backup БД у S3-сумісне сховище 0 3 * * * cd /home/newsomo && .venv/bin/sh scripts/backup_db.sh # Раз на тиждень: deactivate expired trials, send reminders 0 10 * * 1 cd /home/newsomo && .venv/bin/python scripts/trial_lifecycle.py
https://newsomo.com — main site, 5 min intervalhttps://analyst.newsomo.com — Pro+ frontendhttps://api.newsomo.com/health — backend healthcheck endpointhttps://api.newsomo.com/api/projects/healthz — DB connectivity checkAlert channels: Telegram bot + email marikovski@gmail.com
@app.route('/health') def health(): return jsonify({'status': 'ok', 'ts': datetime.utcnow().isoformat()}) @app.route('/api/projects/healthz') def healthz(): try: conn = get_conn() n = conn.execute("SELECT COUNT(*) FROM projects WHERE status='active'").fetchone()[0] return jsonify({'status': 'ok', 'active_projects': n}) except Exception as e: return jsonify({'status': 'error', 'msg': str(e)}), 500
# у Flask error handler @app.errorhandler(500) def server_error(e): requests.post( f"https://api.telegram.org/bot{TG_TOKEN}/sendMessage", data={ 'chat_id': ADMIN_CHAT_ID, 'text': f"🚨 newsomo Pro+ 500 error\n{request.path}\n{str(e)[:200]}", 'parse_mode': 'HTML' } ) return 'Internal error', 500
# /etc/logrotate.d/newsomo
/home/newsomo/logs/*.log {
daily
rotate 30
compress
delaycompress
notifempty
create 0640 newsomo newsomo
}
| Артикл | Опис | Сума |
|---|---|---|
| Google OAuth app verification | Безкоштовно (Anthropic standard) | $0 |
| SSL cert | Let's Encrypt через Netlify | $0 |
| Анти-bot, captcha | Cloudflare Turnstile (free tier 1M/міс) | $0 |
| UptimeRobot | 50 monitors free | $0 |
| Час девелопменту | ~73 години (за PLATFORM_ARCHITECTURE.md) | ~$1,800 (alas, self) |
| Разом | One-time | $0 cash + ваш час |
| Сервіс | План | $/міс |
|---|---|---|
| Hetzner CCX13 | 3 vCPU · 8GB · 80GB SSD (вже є) | $15 |
| Netlify | Free tier (100GB bandwidth) | $0 |
| Cloudflare DNS | Free | $0 |
| Anthropic Claude | ~$0.001/article × 25 users × 200 articles/міс | ~$50 |
| Resend email | 3000 emails free, 0.0001/email далі | ~$3 |
| S3 backups | AWS S3 ~5GB | $1 |
| Stripe processing | 2.9% + $0.30 per tx (25 users × $30) | ~$30 |
| Разом | За місяць | ~$99/міс |
| Артикул | Перевірка |
|---|---|
| ✓ | DNS: dig analyst.newsomo.com резолвиться |
| ✓ | SSL: замочок зелений, валідний до 2026-09-XX |
| ✓ | Frontend: всі 5 HTML файлів завантажуються |
| ✓ | Backend: /health повертає 200 |
| ✓ | DB migrations: всі 4 нові таблиці створені |
| ✓ | Google OAuth: можна залогінитись з тестового gmail |
| ✓ | Admin panel: бачите своїх pending users |
| ✓ | 5 cron tasks додані, запустилися хоча б раз без помилок |
| ✓ | Email send: тестовий welcome email прийшов |
| ✓ | UptimeRobot моніторить 4 endpoints |
| ✓ | Telegram alert bot працює (тестове повідомлення) |
Мінфін-приклад кодування.xlsx"Rollback to previous" → старий фронт працює. Backend має git checkout v41.18 && sudo systemctl restart newsomo — Pro+ blueprint просто не реєструється, основний newsomo продовжує працювати.
Total: ~68 годин · 10 робочих днів · $99/міс recurring
~$1.2K інвестиції часу → $9-30K річний MRR за рік