# -*- coding: utf-8 -*- import json import requests import re import random import xml.etree.ElementTree as ET from config import GEMINI_API_BASE, GEMINI_MODEL import settings_manager def verify_and_clean_urls(text): """ Проверяет все найденные в тексте ссылки. Если ссылка отдает 404/ошибку, она удаляется из текста. Также удаляет Markdown-форматирование ссылок. """ if not text: return text # Удаляем markdown-форматирование ссылок [text](url) -> url import re cleaned_text = re.sub(r'\[([^\]]+)\]\((https?://[^\)]+)\)', r'\2', text) urls = re.findall(r'(https?://[^\s)\]]+)', cleaned_text) if not urls: return cleaned_text for url in urls: try: headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"} r = requests.head(url, headers=headers, timeout=5, allow_redirects=True) if r.status_code >= 400 and r.status_code not in [403, 401]: print(f"[Content Generator] Вилучено неробоче посилання: {url} (HTTP {r.status_code})") cleaned_text = cleaned_text.replace(url, "") else: print(f"[Content Generator] Посилання підтверджено: {url}") except Exception as e: print(f"[Content Generator] Вилучено неробоче посилання: {url} (Помилка: {e})") cleaned_text = cleaned_text.replace(url, "") return cleaned_text.strip() def smart_truncate(text, max_len=490): """ Умное усечение строки до max_len символов, сохраняя целостность ссылки в конце. """ if not text or len(text) <= max_len: return text import re urls = list(re.finditer(r'(https?://[^\s]+)', text)) if not urls: cut_point = max_len - 3 space_idx = text.rfind(' ', max(0, cut_point - 20), cut_point) if space_idx != -1: return text[:space_idx].strip() + "..." return text[:cut_point].strip() + "..." last_url = urls[-1] url_start, url_end = last_url.span() url_text = text[url_start:url_end] suffix = text[url_end:] prefix = text[:url_start] max_prefix_len = max_len - len(url_text) - len(suffix) - 3 if max_prefix_len >= 0: prefix_cut = max_prefix_len space_idx = prefix.rfind(' ', max(0, prefix_cut - 20), prefix_cut) if space_idx != -1: prefix_truncated = prefix[:space_idx].strip() else: prefix_truncated = prefix[:prefix_cut].strip() if prefix_truncated: return prefix_truncated + "..." + url_text + suffix return "..." + url_text + suffix else: cut_point = max_len - 3 space_idx = prefix.rfind(' ', max(0, cut_point - 20), cut_point) if space_idx != -1: return prefix[:space_idx].strip() + "..." return prefix[:cut_point].strip() + "..." def generate_text_via_gemini(prompt): """ Утилитарный метод для вызова нашего локального прокси-сервера Gemini. """ headers = {"Content-Type": "application/json"} payload = { "model": GEMINI_MODEL, "messages": [{"role": "user", "content": prompt}], "temperature": 0.7 } try: response = requests.post(f"{GEMINI_API_BASE}/chat/completions", headers=headers, json=payload, timeout=90) if response.status_code == 200: result = response.json() return result["choices"][0]["message"]["content"].strip() else: print(f"[Content Generator Error] API вернул код {response.status_code}: {response.text}") return None except Exception as e: print(f"[Content Generator Exception] Исключение при обращении к Gemini: {e}") return None def clean_json_response(content): """ Очищает ответ от Markdown-тегов ```json ... ``` и возвращает спарсенный словарь. """ if not content: return None try: content_clean = content.strip() if content_clean.startswith("```"): content_clean = content_clean.split("```")[1] if content_clean.startswith("json"): content_clean = content_clean[4:].strip() content_clean = content_clean.strip("`").strip() return json.loads(content_clean) except Exception as e: print(f"[Content Generator Error] Ошибка парсинга JSON: {e}. Сырой контент: {content}") return None def fetch_latest_news_and_trends(exclude_posts=None): """ Збирає свіжі новини через RSS для гарантії реальних, не вигаданих посилань. """ print("[Content Generator] Поиск реальных новостей через RSS-ленты...") urls = [ ("AI & IT", "https://techcrunch.com/category/artificial-intelligence/feed/"), ("Crypto & Web3", "https://cointelegraph.com/rss"), ("Business & Finance", "https://finance.yahoo.com/news/rss") ] def is_duplicate(title, exclude_list): if not exclude_list: return False title_lower = title.lower() words = re.findall(r'\b[a-zA-Z0-9]{4,}\b', title_lower) stopwords = {"with", "from", "that", "this", "news", "updates", "features", "unveils", "announces", "introduces"} keywords = [w for w in words if w not in stopwords] for post in exclude_list: post_lower = post.lower() match_count = 0 for kw in keywords: if kw in post_lower: match_count += 1 if match_count >= 2 or kw in ["lockdown", "siri", "wwdc", "waymo", "spacex"]: print(f"[Content Generator Filter] Excluded duplicate news: '{title}' (matched keyword '{kw}')") return True return False news_items = [] for category, url in urls: try: r = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=10) if r.status_code == 200: root = ET.fromstring(r.text) items = root.findall(".//item") added_count = 0 for item in items: if added_count >= 2: break title = item.find("title").text if item.find("title") is not None else "" link = item.find("link").text if item.find("link") is not None else "" desc = item.find("description").text if item.find("description") is not None else "" # Очищаем HTML из описания desc_clean = re.sub(r'<[^>]+>', '', desc)[:300] + "..." if title and link: if is_duplicate(title, exclude_posts): continue news_items.append(f"Категория: {category}\nЗаголовок: {title}\nURL: {link}\nОписание: {desc_clean}\n") added_count += 1 except Exception as e: print(f"[Content Generator Error] Ошибка RSS для {category}: {e}") if not news_items: print("[Content Generator Error] Не удалось собрать новости по RSS.") return None return "\\n".join(news_items) def generate_single_post(news_context, exclude_posts=None): """ Генерирует одиночный пост с внедренными навыками (Personas) дизайнера и копирайтера. """ tones = [ "Провокативний стратег (гарячі тейки, злам стереотипів)", "Глибокий аналітик (розкладає по поличках складні речі)", "Інсайдер-візіонер (розповідає про майбутнє як про доконаний факт)" ] import random tone = random.choice(tones) exclude_str = "" if exclude_posts: exclude_items = [] for i, post_text in enumerate(exclude_posts): post_preview = post_text.replace('\n', ' ').strip()[:150] exclude_items.append(f"- {post_preview}...") exclude_list_str = "\n".join(exclude_items) exclude_str = f"\nКАТЕГОРИЧНО ЗАБОРОНЕНО обирати теми або новини, про які вже було опубліковано останні пости:\n{exclude_list_str}\nБудь ласка, обери повністю іншу новину з контексту, яка не має жодного відношення до вищеперелічених тем та новин!" settings = settings_manager.load_settings() prompt_template = settings.get("PROMPT_SINGLE", "") prompt = prompt_template.replace("{news_context}", str(news_context)).replace("{exclude_str}", str(exclude_str)).replace("{tone}", str(tone)) print("[Content Generator] Генерация одиночного поста (Persona Skills)...") raw_res = generate_text_via_gemini(prompt) data = clean_json_response(raw_res) if data and "description" in data: data["description"] = verify_and_clean_urls(data["description"]) return data def generate_carousel_post(news_context, exclude_posts=None): """ Генерирует карусель из 5 слайдов с внедренными навыками. """ tones = [ "Провокативний стратег (гарячі тейки, злам стереотипів)", "Глибокий аналітик (розкладає по поличках складні речі)", "Інсайдер-візіонер (розповідає про майбутнє как про доконаний факт)" ] import random tone = random.choice(tones) exclude_str = "" if exclude_posts: exclude_items = [] for i, post_text in enumerate(exclude_posts): post_preview = post_text.replace('\n', ' ').strip()[:150] exclude_items.append(f"- {post_preview}...") exclude_list_str = "\n".join(exclude_items) exclude_str = f"\nКАТЕГОРИЧНО ЗАБОРОНЕНО обирати теми або новини, про які вже було опубліковано останні пости:\n{exclude_list_str}\nБудь ласка, обери повністю іншу новину з контексту, яка не має жодного відношення до вищеперелічених тем та новин!" settings = settings_manager.load_settings() prompt_template = settings.get("PROMPT_CAROUSEL", "") slide_count = settings.get("CAROUSEL_SLIDE_COUNT", 5) prompt = prompt_template.replace("{news_context}", str(news_context)).replace("{exclude_str}", str(exclude_str)).replace("{tone}", str(tone)).replace("{slide_count}", str(slide_count)) print("[Content Generator] Генерация поста-карусели (Persona Skills)...") raw_res = generate_text_via_gemini(prompt) data = clean_json_response(raw_res) if data and "description" in data: data["description"] = verify_and_clean_urls(data["description"]) return data def predict_post_success(post_text, historical_data=None): """ Оценивает черновик поста на виральность (от 1 до 10). Если балл < 7, возвращает советы по улучшению. """ from config import GEMINI_MODEL, GEMINI_API_BASE import requests import json context = "" if historical_data: history_list = "\n".join([f"- Пост (Лайки: {p.get('likes', 0)}): {p.get('text')[:100]}..." for p in historical_data]) context = f"Для справки, вот наши самые успешные посты в прошлом:\n{history_list}\n" settings = settings_manager.load_settings() prompt_template = settings.get("PROMPT_PREDICTOR", "") prompt = prompt_template.replace("{context}", str(context)).replace("{post_text}", str(post_text)) headers = {"Content-Type": "application/json"} payload = { "model": GEMINI_MODEL, "messages": [{"role": "user", "content": prompt}], "temperature": 0.3 } try: response = requests.post(f"{GEMINI_API_BASE}/chat/completions", headers=headers, json=payload, timeout=30) if response.status_code == 200: result_json = response.json() content = result_json["choices"][0]["message"]["content"].strip() if content.startswith("```"): content = content.split("```")[1] if content.startswith("json"): content = content[4:].strip() content = content.strip("`").strip() parsed = json.loads(content) return parsed else: print(f"[Predictor Error] Ошибка Gemini API ({response.status_code})") return {"score": 7, "criticism": ""} except Exception as e: print(f"[Predictor Exception] Ошибка: {e}") return {"score": 7, "criticism": ""}