""" Flask web server - RSS-Bridge benzeri URL template sistemi """ from flask import Flask, request, Response, jsonify, g from typing import Optional from urllib.parse import unquote, urlparse import sys import os import yaml import time import logging import random from pathlib import Path # Logger oluştur logger = logging.getLogger(__name__) sys.path.insert(0, str(Path(__file__).parent.parent)) from src.database import Database from src.video_fetcher import fetch_videos_from_rss_bridge, get_channel_id_from_handle, extract_video_id from src.transcript_extractor import TranscriptExtractor from src.transcript_cleaner import TranscriptCleaner from src.rss_generator import RSSGenerator from src.security import ( init_security, get_security_manager, require_api_key, rate_limit, validate_input ) app = Flask(__name__) # Security config yükle _security_config = None def load_security_config(): """Security config'i yükle""" global _security_config if _security_config is None: config_path = Path(__file__).parent.parent / 'config' / 'security.yaml' if config_path.exists(): with open(config_path, 'r', encoding='utf-8') as f: _security_config = yaml.safe_load(f).get('security', {}) else: _security_config = {} return _security_config # Security manager'ı initialize et def init_app_security(): """Security manager'ı uygulama başlangıcında initialize et""" config = load_security_config() api_keys = config.get('api_keys', {}) default_rate_limit = config.get('default_rate_limit', 60) init_security(api_keys, default_rate_limit) # Security headers ve CORS middleware @app.after_request def add_security_headers(response): """Security header'ları ekle""" config = load_security_config() headers = config.get('security_headers', {}) # RSS feed'ler için Content-Security-Policy'yi daha esnek yap # RSS okuyucular ve tarayıcılar için sorun çıkarmasın is_feed_response = ( 'application/atom+xml' in response.content_type or 'application/rss+xml' in response.content_type or 'application/xml' in response.content_type or 'text/xml' in response.content_type ) for header, value in headers.items(): # RSS feed'ler için CSP'yi atla veya daha esnek yap if header == 'Content-Security-Policy' and is_feed_response: # RSS feed'ler için CSP'yi daha esnek yap response.headers[header] = "default-src 'self' 'unsafe-inline' data: blob: *" else: response.headers[header] = value # CORS headers cors_config = config.get('cors', {}) if cors_config.get('enabled', True): origins = cors_config.get('allowed_origins', ['*']) if '*' in origins: response.headers['Access-Control-Allow-Origin'] = '*' else: origin = request.headers.get('Origin') if origin in origins: response.headers['Access-Control-Allow-Origin'] = origin response.headers['Access-Control-Allow-Methods'] = ', '.join( cors_config.get('allowed_methods', ['GET', 'OPTIONS']) ) response.headers['Access-Control-Allow-Headers'] = ', '.join( cors_config.get('allowed_headers', ['Content-Type', 'X-API-Key']) ) # Rate limit bilgisini header'a ekle if hasattr(g, 'rate_limit_remaining'): response.headers['X-RateLimit-Remaining'] = str(g.rate_limit_remaining) return response # OPTIONS handler for CORS @app.route('/', methods=['OPTIONS']) @app.route('/', methods=['OPTIONS']) def handle_options(path=None): """CORS preflight request handler""" config = load_security_config() cors_config = config.get('cors', {}) response = Response(status=200) if cors_config.get('enabled', True): origins = cors_config.get('allowed_origins', ['*']) if '*' in origins: response.headers['Access-Control-Allow-Origin'] = '*' else: origin = request.headers.get('Origin') if origin in origins: response.headers['Access-Control-Allow-Origin'] = origin response.headers['Access-Control-Allow-Methods'] = ', '.join( cors_config.get('allowed_methods', ['GET', 'OPTIONS']) ) response.headers['Access-Control-Allow-Headers'] = ', '.join( cors_config.get('allowed_headers', ['Content-Type', 'X-API-Key']) ) response.headers['Access-Control-Max-Age'] = '3600' return response # Uygulama başlangıcında security'yi initialize et init_app_security() # Global instances (lazy loading) db = None extractor = None cleaner = None def get_db(): """Database instance'ı al (singleton)""" global db if db is None: db = Database() db.init_database() return db def get_extractor(): """Transcript extractor instance'ı al""" global extractor if extractor is None: extractor = TranscriptExtractor() return extractor def get_cleaner(): """Transcript cleaner instance'ı al""" global cleaner if cleaner is None: cleaner = TranscriptCleaner() return cleaner def normalize_channel_id(channel_id: Optional[str] = None, channel: Optional[str] = None, channel_url: Optional[str] = None) -> Optional[str]: """ Farklı formatlardan channel ID'yi normalize et ve validate et Args: channel_id: Direkt Channel ID (UC...) channel: Channel handle (@username) veya username channel_url: Full YouTube channel URL Returns: Normalize edilmiş ve validate edilmiş Channel ID veya None """ security = get_security_manager() normalized_id = None # Direkt Channel ID varsa if channel_id: if channel_id.startswith('UC') and len(channel_id) == 24: normalized_id = channel_id # Eğer URL formatında ise parse et elif 'youtube.com/channel/' in channel_id: parts = channel_id.split('/channel/') if len(parts) > 1: normalized_id = parts[-1].split('?')[0].split('/')[0] # Channel handle (@username) if not normalized_id and channel: # Channel parametresini normalize et (@ işareti olabilir veya olmayabilir) channel = channel.strip() if not channel.startswith('@'): channel = f"@{channel}" handle_url = f"https://www.youtube.com/{channel}" logger.info(f"[NORMALIZE] Channel handle URL oluşturuldu: {handle_url}") normalized_id = get_channel_id_from_handle(handle_url) # Channel URL if not normalized_id and channel_url: # URL'yi temizle ve normalize et channel_url = channel_url.strip() # Handle URL (@username formatı) if '/@' in channel_url: # URL'den handle'ı çıkar if '/@' in channel_url: # https://www.youtube.com/@username formatı normalized_id = get_channel_id_from_handle(channel_url) else: # Sadece @username formatı handle = channel_url.replace('@', '').strip() if handle: handle_url = f"https://www.youtube.com/@{handle}" normalized_id = get_channel_id_from_handle(handle_url) # Channel ID URL elif '/channel/' in channel_url: parts = channel_url.split('/channel/') if len(parts) > 1: channel_id_part = parts[-1].split('?')[0].split('/')[0].split('&')[0] # Eğer UC ile başlıyorsa ve 24 karakter ise, direkt kullan if channel_id_part.startswith('UC') and len(channel_id_part) == 24: normalized_id = channel_id_part else: # Parse etmeye çalış normalized_id = channel_id_part # Sadece handle (@username) formatı elif channel_url.startswith('@'): handle = channel_url.replace('@', '').strip() if handle: handle_url = f"https://www.youtube.com/@{handle}" normalized_id = get_channel_id_from_handle(handle_url) # Direkt channel ID formatı (UC...) elif channel_url.startswith('UC') and len(channel_url) == 24: normalized_id = channel_url # Validate: Channel ID formatını kontrol et if normalized_id and security.validate_channel_id(normalized_id): return normalized_id # Geçersiz format return None def process_channel(channel_id: str, max_items: int = 50) -> dict: """ Kanal için transcript feed'i oluştur Returns: RSS feed string ve metadata """ db = get_db() extractor = get_extractor() cleaner = get_cleaner() # ÖNCE: Mevcut işlenmiş videoları kontrol et existing_processed = db.get_processed_videos(limit=max_items, channel_id=channel_id) logger.info(f"[PROCESS] Channel {channel_id} için {len(existing_processed)} mevcut işlenmiş video bulundu") # Eğer yeterli sayıda işlenmiş video varsa, onları hemen döndür if len(existing_processed) >= max_items: logger.info(f"[PROCESS] ✅ Yeterli işlenmiş video var ({len(existing_processed)}), yeni işleme başlatılmıyor") return { 'videos': existing_processed[:max_items], 'channel_id': channel_id, 'count': len(existing_processed[:max_items]) } # Eğer mevcut işlenmiş videolar varsa ama yeterli değilse, onları döndür ve yeni işlemeleri başlat # Ancak sadece ilk batch'i işle (hızlı yanıt için) if len(existing_processed) > 0: logger.info(f"[PROCESS] ⚠️ Mevcut işlenmiş video var ama yeterli değil ({len(existing_processed)}/{max_items}), yeni işleme başlatılıyor") # Mevcut videoları döndürmek için sakla videos_to_return = existing_processed.copy() else: videos_to_return = [] # RSS-Bridge'den videoları çek (max_items'ın 2 katı kadar çek, böylece yeterli video olur) # RSS-Bridge'den daha fazla video çekiyoruz çünkü bazıları transcript'siz olabilir rss_bridge_limit = max(max_items * 2, 50) # En az 50 video çek logger.info(f"[PROCESS] Channel {channel_id} için RSS-Bridge'den video listesi çekiliyor (limit: {rss_bridge_limit})") try: videos = fetch_videos_from_rss_bridge( base_url="https://rss-bridge.org/bridge01", channel_id=channel_id, format="Atom", max_items=rss_bridge_limit ) logger.info(f"[PROCESS] RSS-Bridge'den {len(videos)} video alındı") except Exception as e: logger.error(f"[PROCESS] ❌ RSS-Bridge hatası: {type(e).__name__} - {str(e)}") raise Exception(f"RSS-Bridge hatası: {e}") # Yeni videoları veritabanına ekle new_videos_count = 0 for video in videos: video['channel_id'] = channel_id if not db.is_video_processed(video['video_id']): db.add_video(video) new_videos_count += 1 if new_videos_count > 0: logger.info(f"[PROCESS] {new_videos_count} yeni video veritabanına eklendi") else: logger.debug(f"[PROCESS] Tüm videolar zaten veritabanında") # Bekleyen videoları işle (max_items kadar, küçük batch'ler halinde) # YouTube IP blocking'i önlemek için her batch'te sadece 5 video işlenir # max_items: Her istekte kaç video transcript işleneceği (maksimum 100) batch_size = 5 # Her batch'te işlenecek video sayısı (küçük batch = daha az blocking riski) processed_count = 0 # İşlenen transcript sayısı # Tüm bekleyen videoları al (channel_id'ye göre filtrele) all_pending_videos = [v for v in db.get_pending_videos() if v['channel_id'] == channel_id] logger.info(f"[PROCESS] Channel {channel_id} için {len(all_pending_videos)} bekleyen video bulundu (max_items: {max_items})") # Eğer mevcut işlenmiş videolar varsa, sadece eksik kadar işle remaining_needed = max_items - len(videos_to_return) # max_items kadar transcript işlenene kadar batch'ler halinde işle total_batches = (len(all_pending_videos) + batch_size - 1) // batch_size current_batch = 0 # İlk istek için sadece ilk batch'i işle (hızlı yanıt için) # Sonraki isteklerde daha fazla işlenmiş video olacak max_batches_to_process = 1 if len(videos_to_return) == 0 else min(3, total_batches) # İlk istekte 1 batch, sonra 3 batch for batch_start in range(0, len(all_pending_videos), batch_size): if processed_count >= remaining_needed: logger.info(f"[PROCESS] Yeterli transcript işlendi ({processed_count}/{remaining_needed})") break if current_batch >= max_batches_to_process: logger.info(f"[PROCESS] İlk batch'ler işlendi ({current_batch}/{max_batches_to_process}), kalan işlemeler sonraki isteklerde yapılacak") break current_batch += 1 batch_videos = all_pending_videos[batch_start:batch_start + batch_size] logger.info(f"[BATCH] Batch {current_batch}/{total_batches} başlatılıyor ({len(batch_videos)} video, Toplam işlenen: {processed_count}/{max_items})") batch_processed = 0 batch_cached = 0 batch_failed = 0 for video in batch_videos: if processed_count >= max_items: break video_id = video['video_id'] video_title = video.get('video_title', 'N/A')[:50] # Cache kontrolü: 3 gün içinde işlenmiş transcript varsa atla if db.is_transcript_cached(video_id, cache_days=3): logger.debug(f"[CACHE] Video {video_id} ({video_title}) transcript'i cache'de, atlanıyor") batch_cached += 1 continue try: logger.info(f"[VIDEO] Video işleniyor: {video_id} - {video_title}") # Transcript çıkar transcript = extractor.fetch_transcript( video_id, languages=['tr', 'en'] ) if transcript: # Transcript temizle logger.debug(f"[VIDEO] Video {video_id} transcript'i temizleniyor...") raw, clean = cleaner.clean_transcript(transcript, sentences_per_paragraph=3) # Veritabanına kaydet (her batch hemen kaydedilir) db.update_video_transcript( video_id, raw, clean, status=1, language='tr' ) processed_count += 1 batch_processed += 1 logger.info(f"[VIDEO] ✅ Video {video_id} başarıyla işlendi ve kaydedildi ({processed_count}/{max_items})") else: logger.warning(f"[VIDEO] ⚠️ Video {video_id} transcript'i alınamadı (None döndü)") batch_failed += 1 db.mark_video_failed(video_id, "Transcript None döndü") except Exception as e: error_type = type(e).__name__ error_msg = str(e)[:200] logger.error(f"[VIDEO] ❌ Video {video_id} işleme hatası: {error_type} - {error_msg}") db.mark_video_failed(video_id, str(e)) batch_failed += 1 # Batch özeti logger.info(f"[BATCH] Batch {current_batch}/{total_batches} tamamlandı - İşlenen: {batch_processed}, Cache: {batch_cached}, Başarısız: {batch_failed}") # Batch tamamlandı, uzun bekleme (YouTube IP blocking önleme için) # İlk batch'ler için daha kısa bekleme (hızlı yanıt için), sonraki batch'ler için uzun bekleme if processed_count < remaining_needed and batch_start + batch_size < len(all_pending_videos): # İlk batch'ler için kısa bekleme (2-5 saniye), sonraki batch'ler için uzun bekleme (60-90 saniye) if current_batch <= max_batches_to_process: wait_time = 2 + random.uniform(0, 3) # 2-5 saniye (hızlı yanıt için) logger.info(f"[BATCH] Batch'ler arası kısa bekleme: {wait_time:.1f} saniye (hızlı yanıt için)") else: wait_time = 60 + random.uniform(0, 30) # 60-90 saniye random (human-like) logger.info(f"[BATCH] Batch'ler arası uzun bekleme: {wait_time:.1f} saniye ({wait_time/60:.1f} dakika) - YouTube IP blocking önleme") time.sleep(wait_time) # İşlenmiş videoları getir (yeni işlenenler) newly_processed = db.get_processed_videos( limit=max_items, channel_id=channel_id ) # Mevcut videoları ve yeni işlenen videoları birleştir (duplicate kontrolü ile) all_processed_videos = videos_to_return.copy() # Önce mevcut videoları ekle existing_ids = {v['video_id'] for v in all_processed_videos} # Yeni işlenen videoları ekle for video in newly_processed: if video['video_id'] not in existing_ids and len(all_processed_videos) < max_items: all_processed_videos.append(video) # Tarihe göre sırala (en yeni önce) all_processed_videos.sort( key=lambda x: x.get('published_at_utc', '') or '', reverse=True ) logger.info(f"[PROCESS] ✅ Channel {channel_id} işleme tamamlandı - {len(all_processed_videos)} işlenmiş video döndürülüyor (Mevcut: {len(videos_to_return)}, Yeni işlenen: {len(newly_processed)})") return { 'videos': all_processed_videos[:max_items], 'channel_id': channel_id, 'count': len(all_processed_videos[:max_items]) } @app.route('/', methods=['GET']) @require_api_key # API key zorunlu @validate_input # Input validation def generate_feed(): """ RSS-Bridge benzeri URL template: Örnekler: - /?channel_id=UC9h8BDcXwkhZtnqoQJ7PggA&format=Atom - /?channel=@tavakfi&format=Atom - /?channel_url=https://www.youtube.com/@tavakfi&format=Atom """ # User-Agent kontrolü (RSS okuyucu tespiti için) user_agent = request.headers.get('User-Agent', '') is_rss_reader = any(keyword in user_agent.lower() for keyword in [ 'rss', 'feed', 'reader', 'aggregator', 'feedly', 'newsblur', 'inoreader', 'theoldreader', 'netnewswire', 'reeder' ]) # Query parametrelerini al (validate_input decorator zaten sanitize etti) # URL decode işlemi (tarayıcılar URL'leri encode edebilir, özellikle channel_url içinde başka URL varsa) channel_id_raw = request.args.get('channel_id') channel_raw = request.args.get('channel') # @username veya username channel_url_raw = request.args.get('channel_url') # Channel ID'yi decode et channel_id = None if channel_id_raw: channel_id = unquote(channel_id_raw) if '%' in channel_id_raw else channel_id_raw # Channel handle'ı decode et channel = None if channel_raw: channel = unquote(channel_raw) if '%' in channel_raw else channel_raw # @ işaretini temizle ve normalize et channel = channel.strip().lstrip('@') # Channel URL'yi decode et (eğer encode edilmişse) # Flask request.args zaten decode eder ama channel_url içinde başka URL olduğu için double encoding olabilir channel_url = None if channel_url_raw: # Önce raw değeri al (Flask'ın decode ettiği değer) channel_url = channel_url_raw # Eğer hala encode edilmiş görünüyorsa (%, + gibi karakterler varsa), decode et if '%' in channel_url or '+' in channel_url: # Birden fazla kez encode edilmiş olabilir, güvenli decode max_decode_attempts = 3 for _ in range(max_decode_attempts): decoded = unquote(channel_url) if decoded == channel_url: # Artık decode edilecek bir şey yok break channel_url = decoded if '%' not in channel_url: # Tamamen decode edildi break # URL formatını kontrol et ve düzelt if channel_url and not channel_url.startswith(('http://', 'https://')): # Eğer protocol yoksa, https ekle if channel_url.startswith('www.youtube.com') or channel_url.startswith('youtube.com'): channel_url = 'https://' + channel_url elif channel_url.startswith('@'): channel_url = 'https://www.youtube.com/' + channel_url format_type = request.args.get('format', 'Atom').lower() # Atom veya Rss try: max_items = int(request.args.get('max_items', 10)) # Default: 10 transcript # Maksimum 100 transcript (20'şer batch'ler halinde işlenir) max_items = min(max_items, 100) except (ValueError, TypeError): max_items = 10 # Debug logging (tarayıcı istekleri için) logger.info(f"[REQUEST] Tarayıcı isteği - Raw params: channel_id={channel_id_raw}, channel={channel_raw}, channel_url={channel_url_raw[:100] if channel_url_raw else None}") logger.info(f"[REQUEST] Processed params: channel_id={channel_id}, channel={channel}, channel_url={channel_url[:100] if channel_url else None}") logger.info(f"[REQUEST] Full URL: {request.url}") logger.info(f"[REQUEST] Query string: {request.query_string.decode('utf-8') if request.query_string else None}") # RSS okuyucu tespiti için log if is_rss_reader: logger.info(f"[RSS_READER] RSS okuyucu tespit edildi: {user_agent[:100]}") # Channel ID'yi normalize et try: normalized_channel_id = normalize_channel_id( channel_id=channel_id, channel=channel, channel_url=channel_url ) logger.info(f"[REQUEST] Normalized channel_id: {normalized_channel_id}") except Exception as e: logger.error(f"[REQUEST] ❌ Channel ID normalize hatası: {type(e).__name__} - {str(e)}") normalized_channel_id = None if not normalized_channel_id: error_msg = 'Channel ID bulunamadı veya geçersiz format' if channel_url: error_msg += f'. URL: {channel_url}' elif channel: error_msg += f'. Handle: {channel}' elif channel_id: error_msg += f'. Channel ID: {channel_id}' logger.warning(f"[REQUEST] ❌ Channel ID bulunamadı - Raw: channel_id={channel_id_raw}, channel={channel_raw}, channel_url={channel_url_raw}") logger.warning(f"[REQUEST] ❌ Processed: channel_id={channel_id}, channel={channel}, channel_url={channel_url}") # RSS okuyucular için daha açıklayıcı hata mesajı if is_rss_reader: logger.warning(f"[RSS_READER] Channel ID bulunamadı - URL: {channel_url or channel or channel_id}") return jsonify({ 'error': error_msg, 'message': 'RSS okuyucunuzdan feed eklerken lütfen geçerli bir YouTube kanal URL\'si kullanın', 'received_params': { 'channel_id': channel_id_raw, 'channel': channel_raw, 'channel_url': channel_url_raw, 'decoded_channel_url': channel_url }, 'example_url': f'{request.url_root}?channel_url=https://www.youtube.com/@username&api_key=YOUR_API_KEY&format=Atom', 'usage': { 'channel_id': 'UC... (YouTube Channel ID, 24 karakter)', 'channel': '@username veya username', 'channel_url': 'https://www.youtube.com/@username veya https://www.youtube.com/channel/UC...', 'format': 'Atom veya Rss (varsayılan: Atom)', 'max_items': 'Maksimum transcript sayısı (varsayılan: 10, maksimum: 100)', 'api_key': 'API key query parametresi olarak eklenmelidir (RSS okuyucular header gönderemez)' } }), 400 return jsonify({ 'error': error_msg, 'received_params': { 'channel_id': channel_id_raw, 'channel': channel_raw, 'channel_url': channel_url_raw, 'decoded_channel_url': channel_url }, 'message': 'YouTube Channel ID UC ile başlayan 24 karakter olmalı (sadece alfanumerik ve alt çizgi)', 'usage': { 'channel_id': 'UC... (YouTube Channel ID, 24 karakter)', 'channel': '@username veya username', 'channel_url': 'https://www.youtube.com/@username veya https://www.youtube.com/channel/UC...', 'format': 'Atom veya Rss (varsayılan: Atom)', 'max_items': 'Maksimum transcript sayısı (varsayılan: 10, maksimum: 100, 20\'şer batch\'ler halinde işlenir)' } }), 400 try: # Kanalı işle result = process_channel(normalized_channel_id, max_items=max_items) if not result['videos']: return jsonify({ 'error': 'Henüz işlenmiş video yok', 'channel_id': normalized_channel_id, 'message': 'Transcript\'ler arka planda işleniyor. Lütfen birkaç dakika sonra tekrar deneyin.', 'note': 'YouTube IP blocking nedeniyle transcript çıkarımı yavaş olabilir. İlk istekte birkaç dakika bekleyin.' }), 404 # RSS feed oluştur channel_info = { 'id': normalized_channel_id, 'title': f"YouTube Transcript Feed - {normalized_channel_id}", 'link': f"https://www.youtube.com/channel/{normalized_channel_id}", 'description': f'Full-text transcript RSS feed for channel {normalized_channel_id}', 'language': 'en' } generator = RSSGenerator(channel_info) for video in result['videos']: generator.add_video_entry(video) # Format'a göre döndür response_headers = {} if hasattr(g, 'rate_limit_remaining'): response_headers['X-RateLimit-Remaining'] = str(g.rate_limit_remaining) if format_type == 'rss': rss_content = generator.generate_rss_string() response_headers['Content-Type'] = 'application/rss+xml; charset=utf-8' return Response( rss_content, mimetype='application/rss+xml', headers=response_headers ) else: # Atom # Feedgen Atom desteği atom_content = generator.generate_atom_string() response_headers['Content-Type'] = 'application/atom+xml; charset=utf-8' return Response( atom_content, mimetype='application/atom+xml', headers=response_headers ) except Exception as e: return jsonify({ 'error': str(e), 'channel_id': normalized_channel_id }), 500 @app.route('/health', methods=['GET']) @rate_limit(limit_per_minute=120) # Health check için daha yüksek limit def health(): """Health check endpoint""" return jsonify({'status': 'ok', 'service': 'YouTube Transcript RSS Feed'}) @app.route('/info', methods=['GET']) @require_api_key # API key zorunlu def info(): """API bilgileri""" return jsonify({ 'service': 'YouTube Transcript RSS Feed Generator', 'version': '1.0.0', 'endpoints': { '/': 'RSS Feed Generator', '/health': 'Health Check', '/info': 'API Info' }, 'usage': { 'channel_id': 'UC... (YouTube Channel ID)', 'channel': '@username veya username', 'channel_url': 'Full YouTube channel URL', 'format': 'Atom veya Rss (varsayılan: Atom)', 'max_items': 'Her istekte işlenecek maksimum transcript sayısı (varsayılan: 10, maksimum: 100, 20\'şer batch\'ler halinde işlenir)' }, 'examples': [ '/?channel_id=UC9h8BDcXwkhZtnqoQJ7PggA&format=Atom', '/?channel=@tavakfi&format=Rss', '/?channel_url=https://www.youtube.com/@tavakfi&format=Atom&max_items=50' ] }) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=True)