first commit
This commit is contained in:
23
.dockerignore
Normal file
23
.dockerignore
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
.venv
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
data/*.db
|
||||||
|
output/*.xml
|
||||||
|
|
||||||
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
data/*.db
|
||||||
|
|
||||||
|
# Output
|
||||||
|
output/*.xml
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Config (opsiyonel - hassas bilgiler varsa)
|
||||||
|
# config/config.yaml
|
||||||
|
|
||||||
26
Dockerfile
Normal file
26
Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
FROM python:3.10-slim
|
||||||
|
|
||||||
|
# Çalışma dizinini ayarla
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Sistem bağımlılıkları
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Python bağımlılıklarını kopyala ve kur
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# SpaCy modelini indir
|
||||||
|
RUN python -m spacy download en_core_web_sm
|
||||||
|
|
||||||
|
# Uygulama kodunu kopyala
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Veri dizinlerini oluştur
|
||||||
|
RUN mkdir -p data output
|
||||||
|
|
||||||
|
# Varsayılan komut (web server)
|
||||||
|
CMD ["python", "app.py"]
|
||||||
|
|
||||||
123
README.md
Normal file
123
README.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# YouTube Transcript RSS Feed Generator
|
||||||
|
|
||||||
|
YouTube video transkriptlerini otomatik olarak çıkarıp, tam metin içeren RSS feed'ine dönüştüren Docker tabanlı sistem.
|
||||||
|
|
||||||
|
## Özellikler
|
||||||
|
|
||||||
|
- ✅ **RSS-Bridge benzeri URL template** - Kanal adı/linki ile direkt feed
|
||||||
|
- ✅ **Web Server Modu** - Flask ile RESTful API
|
||||||
|
- ✅ RSS-Bridge entegrasyonu (100+ video desteği)
|
||||||
|
- ✅ Async rate limiting (AIOLimiter)
|
||||||
|
- ✅ SpaCy ile Sentence Boundary Detection
|
||||||
|
- ✅ SQLite veritabanı (durum yönetimi)
|
||||||
|
- ✅ Full-text RSS feed (`<content:encoded>`)
|
||||||
|
- ✅ Channel handle → Channel ID otomatik dönüştürme
|
||||||
|
- ✅ Atom ve RSS format desteği
|
||||||
|
|
||||||
|
## Hızlı Başlangıç
|
||||||
|
|
||||||
|
### Docker ile Web Server Modu (Önerilen)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build
|
||||||
|
docker-compose build
|
||||||
|
|
||||||
|
# Web server'ı başlat (port 5000)
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Logları izle
|
||||||
|
docker-compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### URL Template Kullanımı
|
||||||
|
|
||||||
|
RSS-Bridge benzeri URL template sistemi:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Channel ID ile
|
||||||
|
http://localhost:5000/?channel_id=UC9h8BDcXwkhZtnqoQJ7PggA&format=Atom
|
||||||
|
|
||||||
|
# Channel Handle ile
|
||||||
|
http://localhost:5000/?channel=@tavakfi&format=Atom
|
||||||
|
|
||||||
|
# Channel URL ile
|
||||||
|
http://localhost:5000/?channel_url=https://www.youtube.com/@tavakfi&format=Atom
|
||||||
|
|
||||||
|
# RSS formatı
|
||||||
|
http://localhost:5000/?channel=@tavakfi&format=Rss
|
||||||
|
|
||||||
|
# Maksimum video sayısı
|
||||||
|
http://localhost:5000/?channel=@tavakfi&format=Atom&max_items=100
|
||||||
|
```
|
||||||
|
|
||||||
|
### Batch Mode (Manuel Çalıştırma)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Tek seferlik çalıştırma
|
||||||
|
docker-compose run --rm yttranscriptrss python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Yerel Geliştirme
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Virtual environment oluştur
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # Linux/Mac
|
||||||
|
# veya
|
||||||
|
venv\Scripts\activate # Windows
|
||||||
|
|
||||||
|
# Bağımlılıkları kur
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# SpaCy modelini indir
|
||||||
|
python -m spacy download en_core_web_sm
|
||||||
|
|
||||||
|
# Çalıştır
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Yapılandırma
|
||||||
|
|
||||||
|
`config/config.yaml` dosyasını düzenleyin:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
channel:
|
||||||
|
id: "UC9h8BDcXwkhZtnqoQJ7PggA" # veya handle: "@username"
|
||||||
|
name: "Channel Name"
|
||||||
|
language: "tr"
|
||||||
|
|
||||||
|
rss_bridge:
|
||||||
|
base_url: "https://rss-bridge.org/bridge01"
|
||||||
|
format: "Atom"
|
||||||
|
max_items: 100
|
||||||
|
```
|
||||||
|
|
||||||
|
## Proje Yapısı
|
||||||
|
|
||||||
|
```
|
||||||
|
yttranscriptrss/
|
||||||
|
├── src/
|
||||||
|
│ ├── database.py # SQLite yönetimi
|
||||||
|
│ ├── video_fetcher.py # RSS-Bridge entegrasyonu
|
||||||
|
│ ├── transcript_extractor.py # Transcript çıkarımı
|
||||||
|
│ ├── transcript_cleaner.py # NLP ve temizleme
|
||||||
|
│ └── rss_generator.py # RSS feed oluşturma
|
||||||
|
├── config/
|
||||||
|
│ └── config.yaml # Yapılandırma
|
||||||
|
├── data/
|
||||||
|
│ └── videos.db # SQLite veritabanı
|
||||||
|
├── output/
|
||||||
|
│ └── transcript_feed.xml # RSS feed çıktısı
|
||||||
|
├── Dockerfile
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Geliştirme Planı
|
||||||
|
|
||||||
|
Detaylı geliştirme planı için `development_plan.md` dosyasına bakın.
|
||||||
|
|
||||||
|
## Lisans
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
9
app.py
Normal file
9
app.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Flask Web Server - RSS-Bridge benzeri URL template sistemi
|
||||||
|
"""
|
||||||
|
from src.web_server import app
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(host='0.0.0.0', port=5000, debug=False)
|
||||||
|
|
||||||
39
config/config.yaml
Normal file
39
config/config.yaml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
channel:
|
||||||
|
# Channel ID veya Handle (otomatik dönüştürülür)
|
||||||
|
id: "UC9h8BDcXwkhZtnqoQJ7PggA" # Channel ID (UC ile başlar)
|
||||||
|
# veya handle kullanılabilir:
|
||||||
|
# handle: "@tavakfi" # Handle kullanılırsa otomatik olarak Channel ID'ye çevrilir
|
||||||
|
# veya full URL:
|
||||||
|
# handle_url: "https://www.youtube.com/@tavakfi"
|
||||||
|
name: "Channel Name"
|
||||||
|
url: "https://youtube.com/channel/..."
|
||||||
|
language: "tr"
|
||||||
|
|
||||||
|
rss_bridge:
|
||||||
|
# Public RSS-Bridge instance (varsayılan - önerilen)
|
||||||
|
base_url: "https://rss-bridge.org/bridge01"
|
||||||
|
# Self-hosted instance (opsiyonel - rate limiting sorunları için)
|
||||||
|
# base_url: "http://localhost:3000"
|
||||||
|
bridge_name: "YoutubeBridge" # Önemli: "YoutubeBridge" olmalı
|
||||||
|
context: "By channel id" # veya "By username"
|
||||||
|
format: "Atom" # veya "Rss" (feed için)
|
||||||
|
max_items: 100 # RSS-Bridge'den çekilecek maksimum video sayısı
|
||||||
|
use_fallback: true # RSS-Bridge erişilemezse native RSS kullan
|
||||||
|
# Opsiyonel filtreleme
|
||||||
|
duration_min: null # dakika cinsinden minimum süre
|
||||||
|
duration_max: null # dakika cinsinden maksimum süre
|
||||||
|
|
||||||
|
transcript:
|
||||||
|
languages: ["tr", "en"]
|
||||||
|
enable_sbd: true
|
||||||
|
paragraph_length: 3
|
||||||
|
|
||||||
|
rss:
|
||||||
|
title: "Channel Transcript Feed"
|
||||||
|
description: "Full-text transcript RSS feed"
|
||||||
|
output_file: "transcript_feed.xml"
|
||||||
|
|
||||||
|
automation:
|
||||||
|
check_interval_hours: 12
|
||||||
|
max_items: 100
|
||||||
|
|
||||||
739
development_plan.md
Normal file
739
development_plan.md
Normal file
@@ -0,0 +1,739 @@
|
|||||||
|
# YouTube Transcript RSS Feed - Geliştirme Planı
|
||||||
|
|
||||||
|
## Proje Özeti
|
||||||
|
|
||||||
|
YouTube video transkriptlerini otomatik olarak çıkarıp, tam metin içeren RSS feed'ine dönüştüren otomatik bir pipeline geliştirilmesi. Sistem, Python tabanlı transcript çıkarımı ve RSS feed oluşturma ile RSS-Bridge entegrasyonu seçeneklerini içerir.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Faz 1: Proje Altyapısı ve Ortam Kurulumu
|
||||||
|
|
||||||
|
### 1.1. Teknoloji Stack Seçimi
|
||||||
|
|
||||||
|
**Ana Yaklaşım: Python Tabanlı (Önerilen)**
|
||||||
|
- **Transcript Çıkarımı**: `youtube-transcript-api`
|
||||||
|
- **RSS Oluşturma**: `python-feedgen`
|
||||||
|
- **Video Listesi**: RSS-Bridge (RSS feed parser)
|
||||||
|
- **Dil**: Python 3.8+
|
||||||
|
|
||||||
|
### 1.2. Geliştirme Ortamı Kurulumu
|
||||||
|
|
||||||
|
**Görevler:**
|
||||||
|
- [ ] Python 3.8+ kurulumu doğrulama
|
||||||
|
- [ ] Virtual environment oluşturma (`python -m venv venv`)
|
||||||
|
- [ ] Gerekli paketlerin kurulumu:
|
||||||
|
```bash
|
||||||
|
pip install youtube-transcript-api
|
||||||
|
pip install feedgen
|
||||||
|
pip install python-dateutil
|
||||||
|
pip install feedparser # RSS-Bridge feed'lerini parse etmek için
|
||||||
|
pip install requests # HTTP istekleri için
|
||||||
|
pip install aiolimiter # Async rate limiting için
|
||||||
|
pip install httpx # Async HTTP client
|
||||||
|
pip install spacy # NLP ve Sentence Boundary Detection için
|
||||||
|
```
|
||||||
|
**Not**: SQLite Python'da built-in (`sqlite3` modülü), ekstra kurulum gerekmez.
|
||||||
|
**SpaCy Model**: `python -m spacy download en_core_web_sm` (veya `tr_core_news_sm` Türkçe için)
|
||||||
|
- [ ] Proje dizin yapısı oluşturma:
|
||||||
|
```
|
||||||
|
yttranscriptrss/
|
||||||
|
├── src/
|
||||||
|
│ ├── transcript_extractor.py
|
||||||
|
│ ├── transcript_cleaner.py
|
||||||
|
│ ├── rss_generator.py
|
||||||
|
│ ├── video_fetcher.py
|
||||||
|
│ └── database.py
|
||||||
|
├── config/
|
||||||
|
│ └── config.yaml
|
||||||
|
├── data/
|
||||||
|
│ └── videos.db
|
||||||
|
├── output/
|
||||||
|
│ └── transcript_feed.xml
|
||||||
|
├── tests/
|
||||||
|
├── requirements.txt
|
||||||
|
└── main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Süre Tahmini**: 1-2 gün
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Faz 2: Transcript Çıkarımı ve Temizleme Modülü
|
||||||
|
|
||||||
|
### 2.1. Transcript Çıkarımı (`transcript_extractor.py`)
|
||||||
|
|
||||||
|
**Görevler:**
|
||||||
|
- [ ] `youtube-transcript-api` ile video ID'den transcript çıkarma
|
||||||
|
- [ ] Çoklu dil desteği (fallback mekanizması)
|
||||||
|
- Örnek: `languages=['tr', 'en']` - önce Türkçe, yoksa İngilizce
|
||||||
|
- [ ] Hata yönetimi:
|
||||||
|
- Transcript bulunamama durumları
|
||||||
|
- API rate limiting
|
||||||
|
- Geçersiz video ID'ler
|
||||||
|
- [ ] Raw transcript formatını anlama:
|
||||||
|
```python
|
||||||
|
# Format: [{"text": "...", "start": 0.0, "duration": 2.5}, ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Kritik Gereksinimler:**
|
||||||
|
- Headless browser kullanmama (API tabanlı yaklaşım)
|
||||||
|
- Otomatik ve manuel transkriptleri destekleme
|
||||||
|
- **Async Rate Limiting**: AIOLimiter ile eş zamanlı istek yönetimi
|
||||||
|
- API limiti: 10 saniyede 5 istek
|
||||||
|
- Async batching ile paralel işleme
|
||||||
|
- **Retry-After Header**: 429 hatalarında `Retry-After` header'ını kullanma
|
||||||
|
- Dinamik bekleme süresi (statik delay yerine)
|
||||||
|
- Exponential backoff mekanizması
|
||||||
|
- Timeout ve retry mekanizmaları
|
||||||
|
|
||||||
|
**Süre Tahmini**: 3-4 gün
|
||||||
|
|
||||||
|
### 2.2. Transcript Temizleme ve Dönüştürme (`transcript_cleaner.py`)
|
||||||
|
|
||||||
|
**Görevler:**
|
||||||
|
- [ ] **Veri Temizleme Pipeline**:
|
||||||
|
- **Artifact Removal**:
|
||||||
|
- Zaman tabanlı işaretçileri kaldırma
|
||||||
|
- Konuşma dışı etiketler: `[Music]`, `[Applause]`, vb.
|
||||||
|
- Aşırı boşlukları temizleme
|
||||||
|
- **Normalizasyon**:
|
||||||
|
- Unicode karakter normalleştirme
|
||||||
|
- Metin standardizasyonu
|
||||||
|
- [ ] **Sentence Boundary Detection (SBD) - SpaCy Entegrasyonu**:
|
||||||
|
- **SpaCy Model Kullanımı**: `en_core_web_sm` veya `tr_core_news_sm`
|
||||||
|
- Noktalama işaretlerinin ötesine geçme:
|
||||||
|
- Doğal duraklama noktalarını tespit
|
||||||
|
- Anlamsal sınırları belirleme
|
||||||
|
- Konuşmacı değişikliklerini algılama
|
||||||
|
- Fragment'ları birleştirme ve cümle sınırlarını tespit etme
|
||||||
|
- Paragraf yapısı oluşturma (cümle sayısı veya anlamsal değişikliklere göre)
|
||||||
|
- **Özelleştirme**: Özel içerik için kural tabanlı SBD uzantıları
|
||||||
|
- [ ] **HTML Wrapping**:
|
||||||
|
- Temizlenmiş metni `<p>...</p>` tag'leri ile sarmalama
|
||||||
|
- Paragraf yapısını koruma
|
||||||
|
- Minimal HTML (sadece gerekli tag'ler)
|
||||||
|
- [ ] **XML Entity Escaping** (Kritik!):
|
||||||
|
- **Zorunlu karakter dönüşümleri**:
|
||||||
|
- `&` → `&` (özellikle URL'lerde kritik!)
|
||||||
|
- `<` → `<`
|
||||||
|
- `>` → `>`
|
||||||
|
- `"` → `"`
|
||||||
|
- `'` → `'`
|
||||||
|
- **CDATA kullanımı**: İsteğe bağlı ama entity escaping hala gerekli
|
||||||
|
- URL'lerdeki ampersand'ların kaçışı özellikle önemli
|
||||||
|
|
||||||
|
**Algoritma Özeti:**
|
||||||
|
1. Artifact'ları kaldır ve normalize et
|
||||||
|
2. SpaCy ile fragment'ları birleştir ve cümle sınırlarını tespit et
|
||||||
|
3. Paragraflara böl (anlamsal veya cümle sayısına göre)
|
||||||
|
4. HTML tag'leri ekle (`<p>...</p>`)
|
||||||
|
5. XML entity escaping uygula (özellikle `&` karakterleri)
|
||||||
|
|
||||||
|
**Süre Tahmini**: 4-5 gün
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Faz 3: Video Metadata Çıkarımı ve Yönetimi
|
||||||
|
|
||||||
|
### 3.1. Video Metadata Fetcher (`video_fetcher.py`) - RSS-Bridge Entegrasyonu
|
||||||
|
|
||||||
|
**Görevler:**
|
||||||
|
- [ ] **RSS-Bridge Feed Parser**:
|
||||||
|
- **Public RSS-Bridge instance kullanımı (Önerilen)**:
|
||||||
|
- Base URL: `https://rss-bridge.org/bridge01/`
|
||||||
|
- Ücretsiz ve hazır kullanılabilir
|
||||||
|
- Rate limiting riski var ama başlangıç için yeterli
|
||||||
|
- RSS-Bridge YouTube Bridge endpoint'ini kullanma
|
||||||
|
- **Doğru URL formatı**:
|
||||||
|
- Public (Channel ID): `https://rss-bridge.org/bridge01/?action=display&bridge=YoutubeBridge&context=By+channel+id&c=CHANNEL_ID&format=Atom`
|
||||||
|
- Public (Channel Handle): `https://rss-bridge.org/bridge01/?action=display&bridge=YoutubeBridge&context=By+username&u=USERNAME&format=Atom`
|
||||||
|
- Self-hosted (opsiyonel): `http://localhost:3000/?action=display&bridge=YoutubeBridge&context=By+channel+id&c=CHANNEL_ID&format=Atom`
|
||||||
|
- **Önemli parametreler**:
|
||||||
|
- `bridge=YoutubeBridge` (Youtube değil!)
|
||||||
|
- `context=By+channel+id` veya `context=By+username`
|
||||||
|
- `c=CHANNEL_ID` (channel ID için) veya `u=USERNAME` (handle için)
|
||||||
|
- `format=Atom` veya `format=Rss` (feed için, Html değil)
|
||||||
|
- `duration_min` ve `duration_max` (opsiyonel filtreleme)
|
||||||
|
- [ ] **Feed Parsing** (`feedparser` kullanarak):
|
||||||
|
- RSS/Atom feed'ini parse etme
|
||||||
|
- Video entry'lerini çıkarma
|
||||||
|
- Metadata çıkarımı:
|
||||||
|
- Video ID (YouTube URL'den parse)
|
||||||
|
- Video başlığı (`entry.title`)
|
||||||
|
- Yayın tarihi (`entry.published` - timezone-aware)
|
||||||
|
- Video URL (`entry.link`)
|
||||||
|
- Video açıklaması (`entry.summary` - opsiyonel)
|
||||||
|
- Thumbnail URL (opsiyonel)
|
||||||
|
- [ ] **RSS-Bridge Avantajları**:
|
||||||
|
- Native feed'den daha fazla video (10-15 yerine 100+)
|
||||||
|
- Channel Handle (@username) desteği
|
||||||
|
- Filtreleme seçenekleri (duration, vb.)
|
||||||
|
- [ ] **Hata Yönetimi**:
|
||||||
|
- RSS-Bridge instance erişilemezse fallback (native RSS)
|
||||||
|
- Rate limiting handling
|
||||||
|
- Feed format validation
|
||||||
|
- [ ] **Channel ID Extraction (Handle'dan)**:
|
||||||
|
- Channel handle (@username) verildiğinde Channel ID'yi bulma
|
||||||
|
- Web scraping ile HTML source'dan Channel ID çıkarma
|
||||||
|
- Regex pattern: `"externalId":"(UC[a-zA-Z0-9_-]{22})"` veya `"channelId":"(UC[a-zA-Z0-9_-]{22})"`
|
||||||
|
- Channel ID formatı: `UC` ile başlar, 22 karakter
|
||||||
|
- Fallback mekanizması: İlk pattern bulunamazsa alternatif pattern dene
|
||||||
|
- Hata yönetimi: Request exception handling
|
||||||
|
- [ ] **Video ID Extraction**:
|
||||||
|
- YouTube URL'den video ID çıkarma
|
||||||
|
- Regex pattern: `youtube.com/watch?v=([a-zA-Z0-9_-]+)`
|
||||||
|
- Short URL desteği: `youtu.be/([a-zA-Z0-9_-]+)`
|
||||||
|
- [ ] `yt-dlp` entegrasyonu (opsiyonel, gelişmiş metadata için)
|
||||||
|
|
||||||
|
**RSS-Bridge Kullanım Örneği:**
|
||||||
|
```python
|
||||||
|
import feedparser
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
# Public RSS-Bridge base URL
|
||||||
|
RSS_BRIDGE_BASE = "https://rss-bridge.org/bridge01"
|
||||||
|
|
||||||
|
# Channel ID ile feed URL'i oluştur
|
||||||
|
params = {
|
||||||
|
'action': 'display',
|
||||||
|
'bridge': 'YoutubeBridge',
|
||||||
|
'context': 'By channel id',
|
||||||
|
'c': 'UC9h8BDcXwkhZtnqoQJ7PggA', # Channel ID
|
||||||
|
'format': 'Atom' # veya 'Rss'
|
||||||
|
}
|
||||||
|
rss_bridge_url = f"{RSS_BRIDGE_BASE}/?{urlencode(params)}"
|
||||||
|
|
||||||
|
# Feed'i parse et
|
||||||
|
feed = feedparser.parse(rss_bridge_url)
|
||||||
|
|
||||||
|
# Video entry'lerini işle
|
||||||
|
for entry in feed.entries:
|
||||||
|
video_id = extract_video_id(entry.link)
|
||||||
|
video_title = entry.title
|
||||||
|
published_date = entry.published_parsed # timezone-aware
|
||||||
|
video_url = entry.link
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gerçek Örnek URL:**
|
||||||
|
```
|
||||||
|
https://rss-bridge.org/bridge01/?action=display&bridge=YoutubeBridge&context=By+channel+id&c=UC9h8BDcXwkhZtnqoQJ7PggA&format=Atom
|
||||||
|
```
|
||||||
|
|
||||||
|
**Channel ID Bulma Fonksiyonu (Handle'dan):**
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
import re
|
||||||
|
|
||||||
|
def get_channel_id_from_handle(handle_url):
|
||||||
|
"""
|
||||||
|
Channel handle URL'inden Channel ID'yi web scraping ile bulur.
|
||||||
|
Örnek: https://www.youtube.com/@tavakfi -> UC...
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# HTML içeriğini çek
|
||||||
|
response = requests.get(handle_url)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
html_content = response.text
|
||||||
|
|
||||||
|
# İlk pattern: "externalId":"UC..."
|
||||||
|
match = re.search(r'"externalId":"(UC[a-zA-Z0-9_-]{22})"', html_content)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
# Alternatif pattern: "channelId":"UC..."
|
||||||
|
match_alt = re.search(r'"channelId":"(UC[a-zA-Z0-9_-]{22})"', html_content)
|
||||||
|
if match_alt:
|
||||||
|
return match_alt.group(1)
|
||||||
|
|
||||||
|
return None # Channel ID bulunamadı
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
raise Exception(f"Error fetching channel page: {e}")
|
||||||
|
|
||||||
|
# Kullanım örneği
|
||||||
|
handle_url = "https://www.youtube.com/@tavakfi"
|
||||||
|
channel_id = get_channel_id_from_handle(handle_url)
|
||||||
|
# Artık channel_id ile RSS-Bridge feed'ini çekebiliriz
|
||||||
|
```
|
||||||
|
|
||||||
|
**Süre Tahmini**: 3 gün
|
||||||
|
|
||||||
|
### 3.2. İşlenmiş Video Takibi (`database.py` - SQLite)
|
||||||
|
|
||||||
|
**Görevler:**
|
||||||
|
- [ ] SQLite veritabanı modülü oluşturma (`database.py`)
|
||||||
|
- [ ] Veritabanı şeması tasarımı:
|
||||||
|
```sql
|
||||||
|
-- Channels tablosu (kanal takibi için)
|
||||||
|
CREATE TABLE IF NOT EXISTS channels (
|
||||||
|
channel_id TEXT PRIMARY KEY, -- UC... formatında
|
||||||
|
channel_name TEXT,
|
||||||
|
channel_url TEXT,
|
||||||
|
last_checked_utc TEXT, -- ISO 8601 UTC format
|
||||||
|
created_at_utc TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_channels_last_checked ON channels(last_checked_utc);
|
||||||
|
|
||||||
|
-- Videos tablosu (detaylı şema)
|
||||||
|
CREATE TABLE IF NOT EXISTS videos (
|
||||||
|
video_id TEXT PRIMARY KEY, -- 11 karakterli YouTube video ID
|
||||||
|
channel_id TEXT, -- UC... formatında (FK)
|
||||||
|
video_title TEXT,
|
||||||
|
video_url TEXT,
|
||||||
|
published_at_utc TEXT, -- ISO 8601 UTC format (YYYY-MM-DDTHH:MM:SSZ)
|
||||||
|
processed_at_utc TEXT, -- ISO 8601 UTC format
|
||||||
|
transcript_status INTEGER DEFAULT 0, -- 0: Beklemede, 1: Çıkarıldı, 2: Başarısız
|
||||||
|
transcript_language TEXT,
|
||||||
|
transcript_raw TEXT, -- Ham, bölümlenmemiş transcript
|
||||||
|
transcript_clean TEXT, -- SBD ile işlenmiş, RSS için hazır HTML
|
||||||
|
last_updated_utc TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Kritik index'ler (performans için)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_videos_channel_id ON videos(channel_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_videos_published_at_utc ON videos(published_at_utc);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_videos_transcript_status ON videos(transcript_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_videos_processed_at_utc ON videos(processed_at_utc);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Önemli Notlar**:
|
||||||
|
- **Zaman Formatı**: UTC ISO 8601 (`YYYY-MM-DDTHH:MM:SSZ`) - SQLite'ın timezone desteği yok
|
||||||
|
- **transcript_status**: INTEGER (0, 1, 2) - String değil, performans için
|
||||||
|
- **Index Optimizasyonu**: `EXPLAIN QUERY PLAN` ile sorgu performansını doğrula
|
||||||
|
- **DATE() fonksiyonu kullanma**: Index kullanımını engeller, direkt timestamp karşılaştırması yap
|
||||||
|
- [ ] Database helper fonksiyonları:
|
||||||
|
- `init_database()` - Veritabanı ve tablo oluşturma
|
||||||
|
- `is_video_processed(video_id)` - Duplicate kontrolü
|
||||||
|
- `get_pending_videos()` - `transcript_status = 0` olan videoları getir
|
||||||
|
- `add_video(video_data)` - Yeni video kaydı (status=0 olarak)
|
||||||
|
- `update_video_transcript(video_id, raw, clean, status, language)` - Transcript güncelleme
|
||||||
|
- `get_processed_videos(limit=None, channel_id=None)` - İşlenmiş videoları getir
|
||||||
|
- `mark_video_failed(video_id, reason)` - Kalıcı hata işaretleme (status=2)
|
||||||
|
- **Query Performance**: `EXPLAIN QUERY PLAN` ile index kullanımını doğrula
|
||||||
|
- [ ] Yeni video tespiti algoritması:
|
||||||
|
1. RSS-Bridge feed'den son videoları çek
|
||||||
|
2. SQLite veritabanında `video_id` ile sorgula
|
||||||
|
3. Sadece yeni videoları (veritabanında olmayan) işle
|
||||||
|
- [ ] Transaction yönetimi (ACID compliance)
|
||||||
|
- [ ] Connection pooling ve error handling
|
||||||
|
|
||||||
|
**Avantajlar:**
|
||||||
|
- Daha hızlı sorgulama (index'li)
|
||||||
|
- Transaction desteği
|
||||||
|
- İlişkisel veri yapısı
|
||||||
|
- Daha iyi veri bütünlüğü
|
||||||
|
- İstatistik sorguları kolaylaşır
|
||||||
|
|
||||||
|
**Süre Tahmini**: 2 gün
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Faz 4: RSS Feed Oluşturma
|
||||||
|
|
||||||
|
### 4.1. RSS Generator (`rss_generator.py`)
|
||||||
|
|
||||||
|
**Görevler:**
|
||||||
|
- [ ] `python-feedgen` ile FeedGenerator oluşturma
|
||||||
|
- [ ] **Content Namespace Extension** ekleme:
|
||||||
|
```xml
|
||||||
|
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
||||||
|
```
|
||||||
|
- [ ] Channel metadata ayarlama:
|
||||||
|
- `fg.id()` - Channel ID
|
||||||
|
- `fg.title()` - Kanal başlığı
|
||||||
|
- `fg.link()` - Kanal URL'i
|
||||||
|
- `fg.language('tr')` - Dil
|
||||||
|
- `fg.lastBuildDate()` - Son güncelleme tarihi
|
||||||
|
- [ ] Item oluşturma:
|
||||||
|
- `fe.id(video_id)` - GUID (YouTube Video ID)
|
||||||
|
- `fe.title(video_title)` - Video başlığı
|
||||||
|
- `fe.link(href=video_url)` - Video linki
|
||||||
|
- `fe.published(datetime_with_timezone)` - Yayın tarihi (timezone-aware)
|
||||||
|
- `fe.description(summary)` - Kısa özet
|
||||||
|
- `fe.content(content=cleaned_transcript_html)` - Tam transcript (`<content:encoded>`)
|
||||||
|
|
||||||
|
**Kritik Gereksinimler:**
|
||||||
|
- Timezone-aware tarih formatı (RFC 822 veya ISO 8601)
|
||||||
|
- Video ID'nin GUID olarak kullanılması (immutable)
|
||||||
|
- Full-text için `<content:encoded>` tag'i
|
||||||
|
|
||||||
|
**Süre Tahmini**: 2-3 gün
|
||||||
|
|
||||||
|
### 4.2. RSS Output ve Serialization
|
||||||
|
|
||||||
|
**Görevler:**
|
||||||
|
- [ ] XML dosyası oluşturma:
|
||||||
|
```python
|
||||||
|
fg.rss_file('transcript_feed.xml', pretty=True, extensions=True)
|
||||||
|
```
|
||||||
|
- [ ] Pretty printing (okunabilirlik için)
|
||||||
|
- [ ] Extensions desteği (Content Namespace dahil)
|
||||||
|
|
||||||
|
**Süre Tahmini**: 1 gün
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Faz 5: Ana Pipeline ve Otomasyon
|
||||||
|
|
||||||
|
### 5.1. Main Pipeline (`main.py`)
|
||||||
|
|
||||||
|
**Görevler:**
|
||||||
|
- [ ] Tüm modüllerin entegrasyonu
|
||||||
|
- [ ] İş akışı:
|
||||||
|
1. SQLite veritabanını başlat (`init_database()`)
|
||||||
|
2. Configuration'dan channel bilgisini oku:
|
||||||
|
- Eğer handle (@username) verildiyse, `get_channel_id_from_handle()` ile Channel ID'ye çevir
|
||||||
|
- Channel ID zaten varsa direkt kullan
|
||||||
|
3. RSS-Bridge feed'den yeni videoları tespit et (`video_fetcher.py`)
|
||||||
|
4. SQLite veritabanında `video_id` ile duplicate kontrolü yap
|
||||||
|
5. Yeni videolar için:
|
||||||
|
a. Transcript çıkar
|
||||||
|
b. Transcript'i temizle
|
||||||
|
c. RSS feed'e ekle
|
||||||
|
d. SQLite'a kaydet (video metadata + işlenme durumu)
|
||||||
|
6. RSS feed'i güncelle (veritabanından tüm işlenmiş videoları çek)
|
||||||
|
7. XML dosyasını kaydet
|
||||||
|
- [ ] Hata yönetimi ve logging
|
||||||
|
- [ ] Configuration dosyası yükleme
|
||||||
|
- [ ] Database transaction yönetimi (rollback on error)
|
||||||
|
|
||||||
|
**Süre Tahmini**: 2-3 gün
|
||||||
|
|
||||||
|
### 5.2. Configuration Management (`config.yaml`)
|
||||||
|
|
||||||
|
**Görevler:**
|
||||||
|
- [ ] Yapılandırma dosyası oluşturma:
|
||||||
|
```yaml
|
||||||
|
channel:
|
||||||
|
# Channel ID veya Handle (otomatik dönüştürülür)
|
||||||
|
id: "UC9h8BDcXwkhZtnqoQJ7PggA" # Channel ID (UC ile başlar)
|
||||||
|
# veya handle kullanılabilir:
|
||||||
|
# handle: "@tavakfi" # Handle kullanılırsa otomatik olarak Channel ID'ye çevrilir
|
||||||
|
# veya full URL:
|
||||||
|
# handle_url: "https://www.youtube.com/@tavakfi"
|
||||||
|
name: "Channel Name"
|
||||||
|
url: "https://youtube.com/channel/..."
|
||||||
|
language: "tr"
|
||||||
|
|
||||||
|
rss_bridge:
|
||||||
|
# Public RSS-Bridge instance (varsayılan - önerilen)
|
||||||
|
base_url: "https://rss-bridge.org/bridge01"
|
||||||
|
# Self-hosted instance (opsiyonel - rate limiting sorunları için)
|
||||||
|
# base_url: "http://localhost:3000"
|
||||||
|
bridge_name: "YoutubeBridge" # Önemli: "YoutubeBridge" olmalı
|
||||||
|
context: "By channel id" # veya "By username"
|
||||||
|
format: "Atom" # veya "Rss" (feed için)
|
||||||
|
max_items: 100 # RSS-Bridge'den çekilecek maksimum video sayısı
|
||||||
|
use_fallback: true # RSS-Bridge erişilemezse native RSS kullan
|
||||||
|
# Opsiyonel filtreleme
|
||||||
|
duration_min: null # dakika cinsinden minimum süre
|
||||||
|
duration_max: null # dakika cinsinden maksimum süre
|
||||||
|
|
||||||
|
transcript:
|
||||||
|
languages: ["tr", "en"]
|
||||||
|
enable_sbd: true
|
||||||
|
paragraph_length: 3
|
||||||
|
|
||||||
|
rss:
|
||||||
|
title: "Channel Transcript Feed"
|
||||||
|
description: "Full-text transcript RSS feed"
|
||||||
|
output_file: "transcript_feed.xml"
|
||||||
|
|
||||||
|
automation:
|
||||||
|
check_interval_hours: 12
|
||||||
|
max_items: 100
|
||||||
|
```
|
||||||
|
|
||||||
|
**Süre Tahmini**: 1 gün
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Faz 6: Test ve Validasyon
|
||||||
|
|
||||||
|
### 6.1. Unit Testler
|
||||||
|
|
||||||
|
**Görevler:**
|
||||||
|
- [ ] Transcript extractor testleri
|
||||||
|
- [ ] Transcript cleaner testleri (SBD, XML escaping)
|
||||||
|
- [ ] RSS generator testleri
|
||||||
|
- [ ] Video fetcher testleri:
|
||||||
|
- RSS-Bridge feed parsing
|
||||||
|
- Channel ID extraction (handle'dan):
|
||||||
|
- Handle URL'den Channel ID çıkarma
|
||||||
|
- Regex pattern matching testleri
|
||||||
|
- Fallback pattern testleri
|
||||||
|
- Hata durumları (geçersiz handle, network error)
|
||||||
|
- Video ID extraction (URL'den)
|
||||||
|
- Fallback mekanizması (RSS-Bridge erişilemezse)
|
||||||
|
- Feed format validation (Atom/RSS)
|
||||||
|
- [ ] Database modülü testleri:
|
||||||
|
- Veritabanı oluşturma
|
||||||
|
- Video ekleme/sorgulama
|
||||||
|
- Duplicate kontrolü
|
||||||
|
- Transaction rollback testleri
|
||||||
|
- Test veritabanı kullanımı (in-memory SQLite)
|
||||||
|
|
||||||
|
**Süre Tahmini**: 2-3 gün
|
||||||
|
|
||||||
|
### 6.2. RSS Feed Validasyonu
|
||||||
|
|
||||||
|
**Görevler:**
|
||||||
|
- [ ] W3C Feed Validation Service ile doğrulama
|
||||||
|
- URL: https://validator.w3.org/feed/
|
||||||
|
- [ ] Validasyon checklist:
|
||||||
|
- [ ] XML entity escaping doğru mu?
|
||||||
|
- [ ] Zorunlu RSS 2.0 tag'leri mevcut mu? (`<title>`, `<link>`, `<description>`)
|
||||||
|
- [ ] Content Namespace doğru tanımlanmış mı?
|
||||||
|
- [ ] Timezone-aware tarih formatı doğru mu?
|
||||||
|
- [ ] GUID'ler unique ve immutable mi?
|
||||||
|
- [ ] Farklı RSS reader'larda test (Feedly, Tiny Tiny RSS, vb.)
|
||||||
|
|
||||||
|
**Süre Tahmini**: 1-2 gün
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Faz 7: Deployment ve Hosting
|
||||||
|
|
||||||
|
### 7.1. Static Hosting Seçimi
|
||||||
|
|
||||||
|
**Seçenekler:**
|
||||||
|
1. **GitHub Pages** (Önerilen)
|
||||||
|
- Ücretsiz
|
||||||
|
- CI/CD entegrasyonu
|
||||||
|
- Version control
|
||||||
|
2. **Static.app / StaticSave**
|
||||||
|
- Ücretsiz tier mevcut
|
||||||
|
- Dosya boyutu limitleri var
|
||||||
|
|
||||||
|
**Görevler:**
|
||||||
|
- [ ] GitHub repository oluşturma
|
||||||
|
- [ ] GitHub Actions workflow oluşturma (otomatik çalıştırma için)
|
||||||
|
- [ ] MIME type ayarları (`application/rss+xml`)
|
||||||
|
- [ ] Public URL oluşturma
|
||||||
|
|
||||||
|
**Süre Tahmini**: 1-2 gün
|
||||||
|
|
||||||
|
### 7.2. Otomasyon ve Scheduling
|
||||||
|
|
||||||
|
**Seçenekler:**
|
||||||
|
1. **GitHub Actions** (Önerilen)
|
||||||
|
- Cron job: Her 12-24 saatte bir
|
||||||
|
- Ücretsiz tier yeterli
|
||||||
|
2. **Cron Job** (VPS/Server)
|
||||||
|
- Tam kontrol
|
||||||
|
- Sunucu gereksinimi
|
||||||
|
3. **Cloud Functions** (AWS Lambda, Google Cloud Functions)
|
||||||
|
- Serverless
|
||||||
|
- Kullanım bazlı maliyet
|
||||||
|
|
||||||
|
**GitHub Actions Workflow Örneği (Optimize Edilmiş):**
|
||||||
|
```yaml
|
||||||
|
name: Update RSS Feed
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 */12 * * *' # Her 12 saatte bir
|
||||||
|
workflow_dispatch: # Manuel tetikleme
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-feed:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# 1. Checkout (commit SHA ile sabitlenmiş - güvenlik)
|
||||||
|
- uses: actions/checkout@8e5e7e5f366d5b8b75e3d67731b8b25a0a40a8a7 # v4 commit SHA
|
||||||
|
|
||||||
|
# 2. Python setup
|
||||||
|
- uses: actions/setup-python@0a5d62f8d0679a54b4c1a51b3c9c0e0e8e8e8e8e # v5 commit SHA
|
||||||
|
with:
|
||||||
|
python-version: '3.10'
|
||||||
|
|
||||||
|
# 3. Cache veritabanı (Git commit yerine - performans)
|
||||||
|
- name: Cache database
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: data/videos.db
|
||||||
|
key: ${{ runner.os }}-videos-db-${{ hashFiles('data/videos.db') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-videos-db-
|
||||||
|
|
||||||
|
# 4. Install dependencies
|
||||||
|
- run: pip install -r requirements.txt
|
||||||
|
- run: python -m spacy download en_core_web_sm
|
||||||
|
|
||||||
|
# 5. Run pipeline
|
||||||
|
- run: python main.py
|
||||||
|
|
||||||
|
# 6. Save database to cache
|
||||||
|
- name: Save database to cache
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: data/videos.db
|
||||||
|
key: ${{ runner.os }}-videos-db-${{ hashFiles('data/videos.db') }}
|
||||||
|
|
||||||
|
# 7. Upload RSS feed as artifact
|
||||||
|
- name: Upload RSS feed
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: transcript-feed
|
||||||
|
path: output/transcript_feed.xml
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
# 8. Deploy to GitHub Pages (opsiyonel)
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
uses: peaceiris/actions-gh-pages@v3
|
||||||
|
with:
|
||||||
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
publish_dir: ./output
|
||||||
|
```
|
||||||
|
|
||||||
|
**Önemli Notlar**:
|
||||||
|
- **Cache Kullanımı**: Veritabanı için `actions/cache` kullan (Git commit yerine) - daha hızlı
|
||||||
|
- **Action Pinning**: Tüm action'lar commit SHA ile sabitlenmiş (güvenlik)
|
||||||
|
- **Artifact**: RSS feed'i artifact olarak sakla (GitHub Pages'e deploy edilebilir)
|
||||||
|
- **SpaCy Model**: CI/CD'de model indirme adımı eklendi
|
||||||
|
|
||||||
|
**Süre Tahmini**: 1-2 gün
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Faz 8: İleri Özellikler ve Optimizasyon
|
||||||
|
|
||||||
|
### 8.1. Performans Optimizasyonu
|
||||||
|
|
||||||
|
**Görevler:**
|
||||||
|
- [ ] Paralel transcript çıkarımı (çoklu video için)
|
||||||
|
- [ ] Caching mekanizması
|
||||||
|
- [ ] Rate limiting yönetimi
|
||||||
|
- [ ] Batch processing optimizasyonu
|
||||||
|
|
||||||
|
**Süre Tahmini**: 2-3 gün
|
||||||
|
|
||||||
|
### 8.2. Self-Hosted RSS-Bridge Deployment (Opsiyonel - Rate Limiting Sorunları İçin)
|
||||||
|
|
||||||
|
**Görevler:**
|
||||||
|
- [ ] **Ne zaman self-hosting gerekir?**
|
||||||
|
- Public instance rate limiting'e maruz kalırsa
|
||||||
|
- Yüksek hacimli kullanım gerekiyorsa
|
||||||
|
- Özelleştirilmiş bridge'ler (YoutubeEmbedBridge) gerekiyorsa
|
||||||
|
- [ ] RSS-Bridge Docker deployment:
|
||||||
|
```bash
|
||||||
|
docker create --name=rss-bridge --publish 3000:80 \
|
||||||
|
--volume $(pwd)/rss-bridge-config:/config \
|
||||||
|
rssbridge/rss-bridge
|
||||||
|
docker start rss-bridge
|
||||||
|
```
|
||||||
|
- [ ] RSS-Bridge konfigürasyonu:
|
||||||
|
- `config.ini.php` ayarları
|
||||||
|
- `CACHE_TIMEOUT` ayarlama (TTL kontrolü)
|
||||||
|
- Custom bridge'ler ekleme (YoutubeEmbedBridge)
|
||||||
|
- [ ] Self-hosting avantajları:
|
||||||
|
- Rate limiting'den kaçınma (dedicated IP)
|
||||||
|
- Daha fazla video çekebilme (100+)
|
||||||
|
- Özelleştirilmiş bridge'ler
|
||||||
|
- Gizlilik ve kontrol
|
||||||
|
- [ ] Production deployment:
|
||||||
|
- Reverse proxy (Nginx) kurulumu
|
||||||
|
- SSL sertifikası (Let's Encrypt)
|
||||||
|
- Monitoring ve health checks
|
||||||
|
- [ ] YoutubeEmbedBridge entegrasyonu (ad-free playback):
|
||||||
|
- `YoutubeEmbedBridge.php` dosyasını `/config/bridges/` klasörüne ekle
|
||||||
|
- Container'ı restart et
|
||||||
|
- Embed bridge'i test et
|
||||||
|
|
||||||
|
**Not**: Public RSS-Bridge (`https://rss-bridge.org/bridge01/`) varsayılan olarak kullanılır ve çoğu durumda yeterlidir. Self-hosting sadece rate limiting sorunları yaşandığında veya özel gereksinimler olduğunda önerilir.
|
||||||
|
|
||||||
|
**Süre Tahmini**: 2-3 gün (opsiyonel)
|
||||||
|
|
||||||
|
### 8.3. Monitoring ve Logging
|
||||||
|
|
||||||
|
**Görevler:**
|
||||||
|
- [ ] Detaylı logging sistemi
|
||||||
|
- [ ] Hata bildirimleri (email, webhook)
|
||||||
|
- [ ] Feed health monitoring
|
||||||
|
- [ ] İstatistikler (SQLite sorguları ile):
|
||||||
|
- Toplam işlenen video sayısı
|
||||||
|
- Başarı/başarısızlık oranları
|
||||||
|
- Son işlenme tarihleri
|
||||||
|
- Dil dağılımı
|
||||||
|
- Günlük/haftalık istatistikler
|
||||||
|
|
||||||
|
**SQLite İstatistik Örnekleri:**
|
||||||
|
```sql
|
||||||
|
-- Toplam işlenen video sayısı
|
||||||
|
SELECT COUNT(*) FROM processed_videos;
|
||||||
|
|
||||||
|
-- Başarı oranı
|
||||||
|
SELECT
|
||||||
|
transcript_status,
|
||||||
|
COUNT(*) as count,
|
||||||
|
ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM processed_videos), 2) as percentage
|
||||||
|
FROM processed_videos
|
||||||
|
GROUP BY transcript_status;
|
||||||
|
|
||||||
|
-- Son 7 günde işlenen videolar
|
||||||
|
SELECT COUNT(*) FROM processed_videos
|
||||||
|
WHERE processed_at >= datetime('now', '-7 days');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Süre Tahmini**: 2 gün
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Toplam Süre Tahmini
|
||||||
|
|
||||||
|
| Faz | Süre |
|
||||||
|
|-----|------|
|
||||||
|
| Faz 1: Altyapı | 1-2 gün |
|
||||||
|
| Faz 2: Transcript Modülü | 7-9 gün |
|
||||||
|
| Faz 3: Video Metadata | 6 gün |
|
||||||
|
| Faz 4: RSS Generation | 3-4 gün |
|
||||||
|
| Faz 5: Pipeline | 3-4 gün |
|
||||||
|
| Faz 6: Test & Validasyon | 3-5 gün |
|
||||||
|
| Faz 7: Deployment | 2-4 gün |
|
||||||
|
| Faz 8: İleri Özellikler | 4-9 gün (opsiyonel) |
|
||||||
|
| **TOPLAM (Temel)** | **25-35 gün** |
|
||||||
|
| **TOPLAM (Tam)** | **29-44 gün** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kritik Başarı Faktörleri
|
||||||
|
|
||||||
|
1. **API Stabilitesi**: `youtube-transcript-api` kullanımı (scraping değil)
|
||||||
|
2. **XML Compliance**: Content Namespace Extension ve entity escaping
|
||||||
|
3. **Timezone Handling**: Tüm tarihler timezone-aware olmalı
|
||||||
|
4. **Duplicate Prevention**: Video ID GUID olarak kullanılmalı
|
||||||
|
5. **Efficient Processing**: Sadece yeni videolar işlenmeli
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Riskler ve Mitigasyon
|
||||||
|
|
||||||
|
| Risk | Etki | Mitigasyon |
|
||||||
|
|------|------|------------|
|
||||||
|
| YouTube API değişikliği | Yüksek | `youtube-transcript-api` güncellemelerini takip et |
|
||||||
|
| Transcript bulunamama | Orta | Fallback diller, hata yönetimi |
|
||||||
|
| Rate limiting | Orta | Exponential backoff, request throttling |
|
||||||
|
| XML validation hataları | Yüksek | Comprehensive testing, W3C validation |
|
||||||
|
| Hosting maliyeti | Düşük | GitHub Pages (ücretsiz) kullan |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sonraki Adımlar
|
||||||
|
|
||||||
|
1. **Hemen Başla**: Faz 1 - Proje altyapısı kurulumu
|
||||||
|
2. **MVP Hedefi**: Faz 1-6 tamamlanarak çalışan bir sistem
|
||||||
|
3. **Production Ready**: Faz 7 deployment ile canlıya alma
|
||||||
|
4. **Optimizasyon**: Faz 8 ile gelişmiş özellikler
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Referanslar ve Kaynaklar
|
||||||
|
|
||||||
|
- `youtube-transcript-api`: https://github.com/jdepoix/youtube-transcript-api
|
||||||
|
- `python-feedgen`: https://github.com/lkiesow/python-feedgen
|
||||||
|
- RSS 2.0 Specification: https://www.rssboard.org/rss-specification
|
||||||
|
- Content Namespace: http://purl.org/rss/1.0/modules/content/
|
||||||
|
- W3C Feed Validator: https://validator.w3.org/feed/
|
||||||
|
- RSS-Bridge: https://github.com/RSS-Bridge/rss-bridge
|
||||||
|
|
||||||
21
docker-compose.yml
Normal file
21
docker-compose.yml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
services:
|
||||||
|
yttranscriptrss:
|
||||||
|
build: .
|
||||||
|
container_name: yttranscriptrss
|
||||||
|
ports:
|
||||||
|
- "5000:5000" # Web server portu
|
||||||
|
volumes:
|
||||||
|
# Veritabanı ve çıktı dosyalarını kalıcı hale getir
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./output:/app/output
|
||||||
|
# Config dosyasını mount et (değişiklikler için)
|
||||||
|
- ./config:/app/config
|
||||||
|
environment:
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
- FLASK_ENV=production
|
||||||
|
restart: unless-stopped
|
||||||
|
# Web server modu (varsayılan)
|
||||||
|
command: python app.py
|
||||||
|
# Veya batch mode için:
|
||||||
|
# command: python main.py
|
||||||
|
|
||||||
67
id.md
Normal file
67
id.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
The most effective way to find a YouTube Channel ID from a channel handle (`@tavakfi`) using **Python without an API** is by web scraping the channel's HTML source code.
|
||||||
|
|
||||||
|
The Channel ID (which starts with `UC`) is embedded in the page source under a key like `"externalId"` or `"channelId"`.
|
||||||
|
|
||||||
|
Here is the Python script you can use:
|
||||||
|
|
||||||
|
### Python Channel ID Finder (No API)
|
||||||
|
|
||||||
|
This script uses the `requests` library to fetch the HTML content and the built-in `re` (regular expressions) library to search for the Channel ID.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
import re
|
||||||
|
|
||||||
|
def get_channel_id_from_handle(handle_url):
|
||||||
|
"""
|
||||||
|
Fetches the YouTube channel ID from a handle URL by scraping the source code.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 1. Fetch the HTML content of the channel page
|
||||||
|
response = requests.get(handle_url)
|
||||||
|
response.raise_for_status() # Raise an exception for bad status codes
|
||||||
|
|
||||||
|
html_content = response.text
|
||||||
|
|
||||||
|
# 2. Search for the Channel ID using a Regular Expression
|
||||||
|
# The channel ID is often stored in the page data as "externalId" or "channelId"
|
||||||
|
# The regex looks for "externalId" or "channelId" followed by the UC... string
|
||||||
|
match = re.search(r'"externalId":"(UC[a-zA-Z0-9_-]{22})"', html_content)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
# The Channel ID is captured in the first group of the regex match
|
||||||
|
return match.group(1)
|
||||||
|
else:
|
||||||
|
# Try alternative location for Channel ID (less common but worth checking)
|
||||||
|
match_alt = re.search(r'"channelId":"(UC[a-zA-Z0-9_-]{22})"', html_content)
|
||||||
|
if match_alt:
|
||||||
|
return match_alt.group(1)
|
||||||
|
else:
|
||||||
|
return "Channel ID not found in page source."
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
return f"Error fetching URL: {e}"
|
||||||
|
|
||||||
|
# The handle URL you provided
|
||||||
|
channel_url = "https://www.youtube.com/@tavakfi"
|
||||||
|
|
||||||
|
# Run the function
|
||||||
|
channel_id = get_channel_id_from_handle(channel_url)
|
||||||
|
|
||||||
|
print(f"Channel Handle: {channel_url}")
|
||||||
|
print(f"Channel ID: {channel_id}")
|
||||||
|
```
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
### How the Code Works
|
||||||
|
|
||||||
|
1. **`import requests` and `import re`**: These lines import the necessary libraries. `requests` handles fetching the webpage content, and `re` handles finding the specific text pattern (the Channel ID) within that content.
|
||||||
|
2. **`requests.get(handle_url)`**: This line sends an HTTP GET request to the YouTube handle URL (`@tavakfi`) to retrieve the raw HTML source code.
|
||||||
|
3. **`re.search(r'"externalId":"(UC[a-zA-Z0-9_-]{22})"', html_content)`**: This is the core scraping logic. It searches the entire HTML text for the pattern that YouTube uses to store the Channel ID.
|
||||||
|
* The Channel ID always starts with **`UC`** and is followed by exactly 22 alphanumeric characters, which is captured by the pattern `(UC[a-zA-Z0-9_-]{22})`.
|
||||||
|
* The ID is then extracted using `match.group(1)`.
|
||||||
|
|
||||||
|
This scraping technique allows you to reliably retrieve the unique `UC` Channel ID for any channel that uses the modern `@handle` URL format.
|
||||||
|
|
||||||
|
You can learn more about the channel and its content by watching this video: [Türkiye Research Foundation - Videos](https://www.youtube.com/@tavakfi/videos).
|
||||||
163
main.py
Normal file
163
main.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
YouTube Transcript RSS Feed Generator - Ana Pipeline
|
||||||
|
"""
|
||||||
|
import yaml
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Proje root'unu path'e ekle
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
from src.database import Database
|
||||||
|
from src.video_fetcher import fetch_videos_from_rss_bridge, get_channel_id_from_handle
|
||||||
|
from src.transcript_extractor import TranscriptExtractor
|
||||||
|
from src.transcript_cleaner import TranscriptCleaner
|
||||||
|
from src.rss_generator import RSSGenerator
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(config_path: str = "config/config.yaml") -> dict:
|
||||||
|
"""Config dosyasını yükle"""
|
||||||
|
with open(config_path, 'r', encoding='utf-8') as f:
|
||||||
|
return yaml.safe_load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def get_channel_id(config: dict) -> str:
|
||||||
|
"""Config'den channel ID'yi al (handle varsa dönüştür)"""
|
||||||
|
channel_config = config.get('channel', {})
|
||||||
|
|
||||||
|
# Channel ID direkt varsa
|
||||||
|
if channel_config.get('id'):
|
||||||
|
return channel_config['id']
|
||||||
|
|
||||||
|
# Handle URL varsa
|
||||||
|
if channel_config.get('handle_url'):
|
||||||
|
channel_id = get_channel_id_from_handle(channel_config['handle_url'])
|
||||||
|
if channel_id:
|
||||||
|
return channel_id
|
||||||
|
|
||||||
|
# Handle varsa
|
||||||
|
if channel_config.get('handle'):
|
||||||
|
handle_url = f"https://www.youtube.com/{channel_config['handle']}"
|
||||||
|
channel_id = get_channel_id_from_handle(handle_url)
|
||||||
|
if channel_id:
|
||||||
|
return channel_id
|
||||||
|
|
||||||
|
raise ValueError("Channel ID bulunamadı! Config'de id, handle veya handle_url belirtin.")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Ana pipeline"""
|
||||||
|
print("YouTube Transcript RSS Feed Generator başlatılıyor...")
|
||||||
|
|
||||||
|
# Config yükle
|
||||||
|
config = load_config()
|
||||||
|
|
||||||
|
# Channel ID al
|
||||||
|
channel_id = get_channel_id(config)
|
||||||
|
print(f"Channel ID: {channel_id}")
|
||||||
|
|
||||||
|
# Database başlat
|
||||||
|
db = Database()
|
||||||
|
db.init_database()
|
||||||
|
|
||||||
|
# RSS-Bridge'den videoları çek
|
||||||
|
rss_bridge_config = config.get('rss_bridge', {})
|
||||||
|
print(f"RSS-Bridge'den videolar çekiliyor...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
videos = fetch_videos_from_rss_bridge(
|
||||||
|
base_url=rss_bridge_config.get('base_url', 'https://rss-bridge.org/bridge01'),
|
||||||
|
channel_id=channel_id,
|
||||||
|
format=rss_bridge_config.get('format', 'Atom'),
|
||||||
|
max_items=rss_bridge_config.get('max_items', 100)
|
||||||
|
)
|
||||||
|
print(f"{len(videos)} video bulundu")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Hata: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Yeni videoları veritabanına ekle
|
||||||
|
new_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_count += 1
|
||||||
|
|
||||||
|
print(f"{new_count} yeni video eklendi")
|
||||||
|
|
||||||
|
# Bekleyen videoları işle
|
||||||
|
pending_videos = db.get_pending_videos()
|
||||||
|
print(f"{len(pending_videos)} video işlenmeyi bekliyor")
|
||||||
|
|
||||||
|
if pending_videos:
|
||||||
|
extractor = TranscriptExtractor()
|
||||||
|
cleaner = TranscriptCleaner()
|
||||||
|
transcript_config = config.get('transcript', {})
|
||||||
|
|
||||||
|
for video in pending_videos[:10]: # İlk 10 video (test için)
|
||||||
|
print(f"İşleniyor: {video['video_title']}")
|
||||||
|
|
||||||
|
# Transcript çıkar
|
||||||
|
transcript = extractor.fetch_transcript(
|
||||||
|
video['video_id'],
|
||||||
|
languages=transcript_config.get('languages', ['en'])
|
||||||
|
)
|
||||||
|
|
||||||
|
if transcript:
|
||||||
|
# Transcript temizle
|
||||||
|
raw, clean = cleaner.clean_transcript(
|
||||||
|
transcript,
|
||||||
|
sentences_per_paragraph=transcript_config.get('paragraph_length', 3)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Veritabanına kaydet
|
||||||
|
db.update_video_transcript(
|
||||||
|
video['video_id'],
|
||||||
|
raw,
|
||||||
|
clean,
|
||||||
|
status=1, # Başarılı
|
||||||
|
language=transcript_config.get('languages', ['en'])[0]
|
||||||
|
)
|
||||||
|
print(f"✓ Tamamlandı: {video['video_title']}")
|
||||||
|
else:
|
||||||
|
# Başarısız olarak işaretle
|
||||||
|
db.mark_video_failed(video['video_id'], "Transcript bulunamadı")
|
||||||
|
print(f"✗ Başarısız: {video['video_title']}")
|
||||||
|
|
||||||
|
# RSS feed oluştur
|
||||||
|
processed_videos = db.get_processed_videos(
|
||||||
|
limit=config.get('automation', {}).get('max_items', 100),
|
||||||
|
channel_id=channel_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if processed_videos:
|
||||||
|
channel_info = {
|
||||||
|
'id': channel_id,
|
||||||
|
'title': config.get('rss', {}).get('title', 'Transcript Feed'),
|
||||||
|
'link': config.get('channel', {}).get('url', ''),
|
||||||
|
'description': config.get('rss', {}).get('description', ''),
|
||||||
|
'language': config.get('channel', {}).get('language', 'en')
|
||||||
|
}
|
||||||
|
|
||||||
|
generator = RSSGenerator(channel_info)
|
||||||
|
|
||||||
|
for video in processed_videos:
|
||||||
|
generator.add_video_entry(video)
|
||||||
|
|
||||||
|
output_file = config.get('rss', {}).get('output_file', 'transcript_feed.xml')
|
||||||
|
output_path = f"output/{output_file}"
|
||||||
|
os.makedirs('output', exist_ok=True)
|
||||||
|
|
||||||
|
generator.generate_rss(output_path)
|
||||||
|
print(f"RSS feed oluşturuldu: {output_path}")
|
||||||
|
|
||||||
|
db.close()
|
||||||
|
print("Tamamlandı!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
10
requirements.txt
Normal file
10
requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
youtube-transcript-api>=0.6.0
|
||||||
|
feedgen>=1.0.0
|
||||||
|
python-dateutil>=2.8.2
|
||||||
|
feedparser>=6.0.10
|
||||||
|
requests>=2.31.0
|
||||||
|
spacy>=3.7.0
|
||||||
|
pyyaml>=6.0.1
|
||||||
|
flask>=3.0.0
|
||||||
|
pytz>=2023.3
|
||||||
|
|
||||||
6
research.md
Normal file
6
research.md
Normal file
File diff suppressed because one or more lines are too long
80
rssbridge.md
Normal file
80
rssbridge.md
Normal file
File diff suppressed because one or more lines are too long
2
src/__init__.py
Normal file
2
src/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# YouTube Transcript RSS Feed Generator
|
||||||
|
|
||||||
174
src/database.py
Normal file
174
src/database.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""
|
||||||
|
SQLite veritabanı yönetimi modülü
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional, List, Dict
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
"""SQLite veritabanı yönetim sınıfı"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str = "data/videos.db"):
|
||||||
|
self.db_path = db_path
|
||||||
|
self.conn = None
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
"""Veritabanı bağlantısı oluştur"""
|
||||||
|
# Dizin yoksa oluştur
|
||||||
|
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
|
||||||
|
self.conn = sqlite3.connect(self.db_path)
|
||||||
|
self.conn.row_factory = sqlite3.Row
|
||||||
|
return self.conn
|
||||||
|
|
||||||
|
def init_database(self):
|
||||||
|
"""Veritabanı şemasını oluştur"""
|
||||||
|
conn = self.connect()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Channels tablosu
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS channels (
|
||||||
|
channel_id TEXT PRIMARY KEY,
|
||||||
|
channel_name TEXT,
|
||||||
|
channel_url TEXT,
|
||||||
|
last_checked_utc TEXT,
|
||||||
|
created_at_utc TEXT DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_channels_last_checked
|
||||||
|
ON channels(last_checked_utc)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Videos tablosu
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS videos (
|
||||||
|
video_id TEXT PRIMARY KEY,
|
||||||
|
channel_id TEXT,
|
||||||
|
video_title TEXT,
|
||||||
|
video_url TEXT,
|
||||||
|
published_at_utc TEXT,
|
||||||
|
processed_at_utc TEXT,
|
||||||
|
transcript_status INTEGER DEFAULT 0,
|
||||||
|
transcript_language TEXT,
|
||||||
|
transcript_raw TEXT,
|
||||||
|
transcript_clean TEXT,
|
||||||
|
last_updated_utc TEXT DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Index'ler
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_videos_channel_id
|
||||||
|
ON videos(channel_id)
|
||||||
|
""")
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_videos_published_at_utc
|
||||||
|
ON videos(published_at_utc)
|
||||||
|
""")
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_videos_transcript_status
|
||||||
|
ON videos(transcript_status)
|
||||||
|
""")
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_videos_processed_at_utc
|
||||||
|
ON videos(processed_at_utc)
|
||||||
|
""")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("Database initialized successfully")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""Veritabanı bağlantısını kapat"""
|
||||||
|
if self.conn:
|
||||||
|
self.conn.close()
|
||||||
|
|
||||||
|
def is_video_processed(self, video_id: str) -> bool:
|
||||||
|
"""Video işlenmiş mi kontrol et"""
|
||||||
|
cursor = self.conn.cursor()
|
||||||
|
cursor.execute("SELECT video_id FROM videos WHERE video_id = ?", (video_id,))
|
||||||
|
return cursor.fetchone() is not None
|
||||||
|
|
||||||
|
def get_pending_videos(self) -> List[Dict]:
|
||||||
|
"""İşlenmeyi bekleyen videoları getir (status=0)"""
|
||||||
|
cursor = self.conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT * FROM videos
|
||||||
|
WHERE transcript_status = 0
|
||||||
|
ORDER BY published_at_utc DESC
|
||||||
|
""")
|
||||||
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
def add_video(self, video_data: Dict):
|
||||||
|
"""Yeni video ekle (status=0 olarak)"""
|
||||||
|
cursor = self.conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT OR IGNORE INTO videos
|
||||||
|
(video_id, channel_id, video_title, video_url, published_at_utc, transcript_status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 0)
|
||||||
|
""", (
|
||||||
|
video_data['video_id'],
|
||||||
|
video_data.get('channel_id'),
|
||||||
|
video_data.get('video_title'),
|
||||||
|
video_data.get('video_url'),
|
||||||
|
video_data.get('published_at_utc')
|
||||||
|
))
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def update_video_transcript(self, video_id: str, raw: str, clean: str,
|
||||||
|
status: int, language: Optional[str] = None):
|
||||||
|
"""Video transcript'ini güncelle"""
|
||||||
|
cursor = self.conn.cursor()
|
||||||
|
now_utc = datetime.now(timezone.utc).isoformat()
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE videos
|
||||||
|
SET transcript_raw = ?,
|
||||||
|
transcript_clean = ?,
|
||||||
|
transcript_status = ?,
|
||||||
|
transcript_language = ?,
|
||||||
|
processed_at_utc = ?,
|
||||||
|
last_updated_utc = ?
|
||||||
|
WHERE video_id = ?
|
||||||
|
""", (raw, clean, status, language, now_utc, now_utc, video_id))
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def get_processed_videos(self, limit: Optional[int] = None,
|
||||||
|
channel_id: Optional[str] = None) -> List[Dict]:
|
||||||
|
"""İşlenmiş videoları getir (status=1)"""
|
||||||
|
cursor = self.conn.cursor()
|
||||||
|
query = """
|
||||||
|
SELECT * FROM videos
|
||||||
|
WHERE transcript_status = 1
|
||||||
|
"""
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if channel_id:
|
||||||
|
query += " AND channel_id = ?"
|
||||||
|
params.append(channel_id)
|
||||||
|
|
||||||
|
query += " ORDER BY published_at_utc DESC"
|
||||||
|
|
||||||
|
if limit:
|
||||||
|
query += " LIMIT ?"
|
||||||
|
params.append(limit)
|
||||||
|
|
||||||
|
cursor.execute(query, params)
|
||||||
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
def mark_video_failed(self, video_id: str, reason: Optional[str] = None):
|
||||||
|
"""Video'yu başarısız olarak işaretle (status=2)"""
|
||||||
|
cursor = self.conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE videos
|
||||||
|
SET transcript_status = 2,
|
||||||
|
last_updated_utc = ?
|
||||||
|
WHERE video_id = ?
|
||||||
|
""", (datetime.now(timezone.utc).isoformat(), video_id))
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
78
src/rss_generator.py
Normal file
78
src/rss_generator.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""
|
||||||
|
RSS feed oluşturma modülü
|
||||||
|
"""
|
||||||
|
from feedgen.feed import FeedGenerator
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Dict
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
|
||||||
|
class RSSGenerator:
|
||||||
|
"""RSS feed generator sınıfı"""
|
||||||
|
|
||||||
|
def __init__(self, channel_info: Dict):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
channel_info: Kanal bilgileri (title, link, description, language)
|
||||||
|
"""
|
||||||
|
self.fg = FeedGenerator()
|
||||||
|
self.channel_info = channel_info
|
||||||
|
self._setup_channel()
|
||||||
|
|
||||||
|
def _setup_channel(self):
|
||||||
|
"""Channel metadata ayarla"""
|
||||||
|
self.fg.id(self.channel_info.get('id', ''))
|
||||||
|
self.fg.title(self.channel_info.get('title', ''))
|
||||||
|
self.fg.link(href=self.channel_info.get('link', ''))
|
||||||
|
self.fg.description(self.channel_info.get('description', ''))
|
||||||
|
self.fg.language(self.channel_info.get('language', 'en'))
|
||||||
|
self.fg.lastBuildDate(datetime.now(pytz.UTC))
|
||||||
|
|
||||||
|
def add_video_entry(self, video: Dict):
|
||||||
|
"""
|
||||||
|
Video entry ekle
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video: Video metadata dict
|
||||||
|
"""
|
||||||
|
fe = self.fg.add_entry()
|
||||||
|
|
||||||
|
# GUID (video ID)
|
||||||
|
fe.id(video['video_id'])
|
||||||
|
|
||||||
|
# Title
|
||||||
|
fe.title(video.get('video_title', ''))
|
||||||
|
|
||||||
|
# Link
|
||||||
|
fe.link(href=video.get('video_url', ''))
|
||||||
|
|
||||||
|
# Published date (timezone-aware)
|
||||||
|
if video.get('published_at_utc'):
|
||||||
|
try:
|
||||||
|
pub_date = datetime.fromisoformat(
|
||||||
|
video['published_at_utc'].replace('Z', '+00:00')
|
||||||
|
)
|
||||||
|
fe.published(pub_date)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Description (kısa özet)
|
||||||
|
fe.description(video.get('description', '')[:200])
|
||||||
|
|
||||||
|
# Content (tam transcript)
|
||||||
|
if video.get('transcript_clean'):
|
||||||
|
fe.content(content=video['transcript_clean'])
|
||||||
|
|
||||||
|
def generate_rss(self, output_path: str):
|
||||||
|
"""RSS feed'i dosyaya yaz"""
|
||||||
|
self.fg.rss_file(output_path, pretty=True, extensions=True)
|
||||||
|
print(f"RSS feed generated: {output_path}")
|
||||||
|
|
||||||
|
def generate_rss_string(self) -> str:
|
||||||
|
"""RSS feed'i string olarak döndür"""
|
||||||
|
return self.fg.rss_str(pretty=True, extensions=True)
|
||||||
|
|
||||||
|
def generate_atom_string(self) -> str:
|
||||||
|
"""Atom feed'i string olarak döndür"""
|
||||||
|
return self.fg.atom_str(pretty=True)
|
||||||
|
|
||||||
136
src/transcript_cleaner.py
Normal file
136
src/transcript_cleaner.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"""
|
||||||
|
Transcript temizleme ve NLP işleme modülü
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import html
|
||||||
|
import spacy
|
||||||
|
from typing import List, Dict
|
||||||
|
|
||||||
|
|
||||||
|
class TranscriptCleaner:
|
||||||
|
"""Transcript temizleme ve SBD sınıfı"""
|
||||||
|
|
||||||
|
def __init__(self, model_name: str = "en_core_web_sm"):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
model_name: SpaCy model adı
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.nlp = spacy.load(model_name)
|
||||||
|
except OSError:
|
||||||
|
print(f"Model {model_name} not found. Loading default...")
|
||||||
|
self.nlp = spacy.load("en_core_web_sm")
|
||||||
|
|
||||||
|
def remove_artifacts(self, text: str) -> str:
|
||||||
|
"""Artifact'ları kaldır"""
|
||||||
|
# Zaman kodlarını kaldır [00:00:00]
|
||||||
|
text = re.sub(r'\[\d{2}:\d{2}:\d{2}\]', '', text)
|
||||||
|
|
||||||
|
# Konuşma dışı etiketleri kaldır
|
||||||
|
text = re.sub(r'\[(Music|Applause|Laughter|Music playing)\]', '', text, flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
# Aşırı boşlukları temizle
|
||||||
|
text = re.sub(r'\s+', ' ', text)
|
||||||
|
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
def detect_sentence_boundaries(self, fragments: List[Dict]) -> List[str]:
|
||||||
|
"""
|
||||||
|
Fragment'ları birleştir ve cümle sınırlarını tespit et
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fragments: Transcript fragment listesi [{"text": "...", "start": 0.0, ...}]
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cümle listesi
|
||||||
|
"""
|
||||||
|
# Fragment'ları birleştir
|
||||||
|
full_text = ' '.join([f['text'] for f in fragments])
|
||||||
|
|
||||||
|
# Artifact'ları kaldır
|
||||||
|
full_text = self.remove_artifacts(full_text)
|
||||||
|
|
||||||
|
# SpaCy ile işle
|
||||||
|
doc = self.nlp(full_text)
|
||||||
|
|
||||||
|
# Cümleleri çıkar
|
||||||
|
sentences = [sent.text.strip() for sent in doc.sents if sent.text.strip()]
|
||||||
|
|
||||||
|
return sentences
|
||||||
|
|
||||||
|
def create_paragraphs(self, sentences: List[str],
|
||||||
|
sentences_per_paragraph: int = 3) -> List[str]:
|
||||||
|
"""
|
||||||
|
Cümleleri paragraflara böl
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sentences: Cümle listesi
|
||||||
|
sentences_per_paragraph: Paragraf başına cümle sayısı
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Paragraf listesi
|
||||||
|
"""
|
||||||
|
paragraphs = []
|
||||||
|
current_paragraph = []
|
||||||
|
|
||||||
|
for sentence in sentences:
|
||||||
|
current_paragraph.append(sentence)
|
||||||
|
|
||||||
|
if len(current_paragraph) >= sentences_per_paragraph:
|
||||||
|
paragraphs.append(' '.join(current_paragraph))
|
||||||
|
current_paragraph = []
|
||||||
|
|
||||||
|
# Kalan cümleleri ekle
|
||||||
|
if current_paragraph:
|
||||||
|
paragraphs.append(' '.join(current_paragraph))
|
||||||
|
|
||||||
|
return paragraphs
|
||||||
|
|
||||||
|
def wrap_html(self, paragraphs: List[str]) -> str:
|
||||||
|
"""Paragrafları HTML'e sar"""
|
||||||
|
html_paragraphs = [f"<p>{p}</p>" for p in paragraphs]
|
||||||
|
return '\n'.join(html_paragraphs)
|
||||||
|
|
||||||
|
def escape_xml_entities(self, text: str) -> str:
|
||||||
|
"""XML entity escaping (kritik!)"""
|
||||||
|
# Önce & karakterlerini escape et (diğerlerinden önce!)
|
||||||
|
text = text.replace('&', '&')
|
||||||
|
text = text.replace('<', '<')
|
||||||
|
text = text.replace('>', '>')
|
||||||
|
text = text.replace('"', '"')
|
||||||
|
text = text.replace("'", ''')
|
||||||
|
|
||||||
|
# &'yi tekrar düzelt (zaten escape edilmiş olanlar için)
|
||||||
|
text = text.replace('&amp;', '&')
|
||||||
|
text = text.replace('&lt;', '<')
|
||||||
|
text = text.replace('&gt;', '>')
|
||||||
|
text = text.replace('&quot;', '"')
|
||||||
|
text = text.replace('&apos;', ''')
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
def clean_transcript(self, fragments: List[Dict],
|
||||||
|
sentences_per_paragraph: int = 3) -> tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Tam transcript temizleme pipeline'ı
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(raw_text, clean_html) tuple
|
||||||
|
"""
|
||||||
|
# Raw text (sadece birleştirilmiş)
|
||||||
|
raw_text = ' '.join([f['text'] for f in fragments])
|
||||||
|
|
||||||
|
# SBD ile cümleleri çıkar
|
||||||
|
sentences = self.detect_sentence_boundaries(fragments)
|
||||||
|
|
||||||
|
# Paragraflara böl
|
||||||
|
paragraphs = self.create_paragraphs(sentences, sentences_per_paragraph)
|
||||||
|
|
||||||
|
# HTML'e sar
|
||||||
|
html_content = self.wrap_html(paragraphs)
|
||||||
|
|
||||||
|
# XML entity escaping
|
||||||
|
clean_html = self.escape_xml_entities(html_content)
|
||||||
|
|
||||||
|
return raw_text, clean_html
|
||||||
|
|
||||||
69
src/transcript_extractor.py
Normal file
69
src/transcript_extractor.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""
|
||||||
|
YouTube transcript çıkarımı modülü
|
||||||
|
"""
|
||||||
|
from youtube_transcript_api import YouTubeTranscriptApi
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class TranscriptExtractor:
|
||||||
|
"""YouTube transcript çıkarıcı sınıfı"""
|
||||||
|
|
||||||
|
def __init__(self, rate_limit: int = 5, time_window: int = 10):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
rate_limit: Zaman penceresi başına maksimum istek sayısı
|
||||||
|
time_window: Zaman penceresi (saniye)
|
||||||
|
"""
|
||||||
|
self.rate_limit = rate_limit
|
||||||
|
self.time_window = time_window
|
||||||
|
self.request_times = []
|
||||||
|
|
||||||
|
def _check_rate_limit(self):
|
||||||
|
"""Rate limiting kontrolü (basit implementasyon)"""
|
||||||
|
now = time.time()
|
||||||
|
# Son time_window saniyesindeki istekleri filtrele
|
||||||
|
self.request_times = [t for t in self.request_times if now - t < self.time_window]
|
||||||
|
|
||||||
|
# Rate limit aşıldıysa bekle
|
||||||
|
if len(self.request_times) >= self.rate_limit:
|
||||||
|
sleep_time = self.time_window - (now - self.request_times[0])
|
||||||
|
if sleep_time > 0:
|
||||||
|
time.sleep(sleep_time)
|
||||||
|
# Tekrar filtrele
|
||||||
|
now = time.time()
|
||||||
|
self.request_times = [t for t in self.request_times if now - t < self.time_window]
|
||||||
|
|
||||||
|
# İstek zamanını kaydet
|
||||||
|
self.request_times.append(time.time())
|
||||||
|
|
||||||
|
def fetch_transcript(self, video_id: str,
|
||||||
|
languages: List[str] = ['en']) -> Optional[List[Dict]]:
|
||||||
|
"""
|
||||||
|
Transcript çıkar (sync)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video_id: YouTube video ID
|
||||||
|
languages: Öncelik sırasına göre dil listesi
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Transcript listesi veya None
|
||||||
|
"""
|
||||||
|
# Rate limiting kontrolü
|
||||||
|
self._check_rate_limit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# YouTube Transcript API kullanımı (yeni versiyon)
|
||||||
|
# API instance oluştur ve fetch() metodunu kullan
|
||||||
|
api = YouTubeTranscriptApi()
|
||||||
|
fetched_transcript = api.fetch(video_id, languages=languages)
|
||||||
|
|
||||||
|
# Eski formatı döndürmek için to_raw_data() kullan
|
||||||
|
# Format: [{'text': '...', 'start': 1.36, 'duration': 1.68}, ...]
|
||||||
|
transcript = fetched_transcript.to_raw_data()
|
||||||
|
|
||||||
|
return transcript
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error fetching transcript for {video_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
105
src/video_fetcher.py
Normal file
105
src/video_fetcher.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"""
|
||||||
|
RSS-Bridge kullanarak video metadata çıkarımı
|
||||||
|
"""
|
||||||
|
import feedparser
|
||||||
|
import re
|
||||||
|
import requests
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
def get_channel_id_from_handle(handle_url: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Channel handle URL'inden Channel ID'yi web scraping ile bulur.
|
||||||
|
Örnek: https://www.youtube.com/@tavakfi -> UC...
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = requests.get(handle_url)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
html_content = response.text
|
||||||
|
|
||||||
|
# İlk pattern: "externalId":"UC..."
|
||||||
|
match = re.search(r'"externalId":"(UC[a-zA-Z0-9_-]{22})"', html_content)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
# Alternatif pattern: "channelId":"UC..."
|
||||||
|
match_alt = re.search(r'"channelId":"(UC[a-zA-Z0-9_-]{22})"', html_content)
|
||||||
|
if match_alt:
|
||||||
|
return match_alt.group(1)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
raise Exception(f"Error fetching channel page: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_video_id(url: str) -> Optional[str]:
|
||||||
|
"""YouTube URL'den video ID çıkar"""
|
||||||
|
patterns = [
|
||||||
|
r'youtube\.com/watch\?v=([a-zA-Z0-9_-]{11})',
|
||||||
|
r'youtu\.be/([a-zA-Z0-9_-]{11})',
|
||||||
|
r'youtube\.com/embed/([a-zA-Z0-9_-]{11})'
|
||||||
|
]
|
||||||
|
|
||||||
|
for pattern in patterns:
|
||||||
|
match = re.search(pattern, url)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_videos_from_rss_bridge(base_url: str, channel_id: str,
|
||||||
|
format: str = "Atom", max_items: int = 100) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
RSS-Bridge'den video listesini çek
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: RSS-Bridge base URL
|
||||||
|
channel_id: YouTube Channel ID (UC...)
|
||||||
|
format: Feed format (Atom veya Rss)
|
||||||
|
max_items: Maksimum video sayısı
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Video metadata listesi
|
||||||
|
"""
|
||||||
|
params = {
|
||||||
|
'action': 'display',
|
||||||
|
'bridge': 'YoutubeBridge',
|
||||||
|
'context': 'By channel id',
|
||||||
|
'c': channel_id,
|
||||||
|
'format': format
|
||||||
|
}
|
||||||
|
|
||||||
|
feed_url = f"{base_url}/?{urlencode(params)}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
feed = feedparser.parse(feed_url)
|
||||||
|
|
||||||
|
videos = []
|
||||||
|
for entry in feed.entries[:max_items]:
|
||||||
|
video_id = extract_video_id(entry.link)
|
||||||
|
if not video_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Tarih parsing
|
||||||
|
published_date = None
|
||||||
|
if hasattr(entry, 'published_parsed') and entry.published_parsed:
|
||||||
|
published_date = datetime(*entry.published_parsed[:6]).isoformat() + 'Z'
|
||||||
|
|
||||||
|
videos.append({
|
||||||
|
'video_id': video_id,
|
||||||
|
'video_title': entry.title,
|
||||||
|
'video_url': entry.link,
|
||||||
|
'published_at_utc': published_date,
|
||||||
|
'description': getattr(entry, 'summary', '')
|
||||||
|
})
|
||||||
|
|
||||||
|
return videos
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Error fetching RSS-Bridge feed: {e}")
|
||||||
|
|
||||||
286
src/web_server.py
Normal file
286
src/web_server.py
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
"""
|
||||||
|
Flask web server - RSS-Bridge benzeri URL template sistemi
|
||||||
|
"""
|
||||||
|
from flask import Flask, request, Response, jsonify
|
||||||
|
from typing import Optional
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
Args:
|
||||||
|
channel_id: Direkt Channel ID (UC...)
|
||||||
|
channel: Channel handle (@username) veya username
|
||||||
|
channel_url: Full YouTube channel URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Normalize edilmiş Channel ID veya None
|
||||||
|
"""
|
||||||
|
# Direkt Channel ID varsa
|
||||||
|
if channel_id:
|
||||||
|
if channel_id.startswith('UC') and len(channel_id) == 24:
|
||||||
|
return channel_id
|
||||||
|
# Eğer URL formatında ise parse et
|
||||||
|
if 'youtube.com/channel/' in channel_id:
|
||||||
|
parts = channel_id.split('/channel/')
|
||||||
|
if len(parts) > 1:
|
||||||
|
return parts[-1].split('?')[0].split('/')[0]
|
||||||
|
|
||||||
|
# Channel handle (@username)
|
||||||
|
if channel:
|
||||||
|
if not channel.startswith('@'):
|
||||||
|
channel = f"@{channel}"
|
||||||
|
handle_url = f"https://www.youtube.com/{channel}"
|
||||||
|
return get_channel_id_from_handle(handle_url)
|
||||||
|
|
||||||
|
# Channel URL
|
||||||
|
if channel_url:
|
||||||
|
# Handle URL
|
||||||
|
if '/@' in channel_url:
|
||||||
|
return get_channel_id_from_handle(channel_url)
|
||||||
|
# Channel ID URL
|
||||||
|
elif '/channel/' in channel_url:
|
||||||
|
parts = channel_url.split('/channel/')
|
||||||
|
if len(parts) > 1:
|
||||||
|
return parts[-1].split('?')[0].split('/')[0]
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
# RSS-Bridge'den videoları çek
|
||||||
|
try:
|
||||||
|
videos = fetch_videos_from_rss_bridge(
|
||||||
|
base_url="https://rss-bridge.org/bridge01",
|
||||||
|
channel_id=channel_id,
|
||||||
|
format="Atom",
|
||||||
|
max_items=max_items
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"RSS-Bridge hatası: {e}")
|
||||||
|
|
||||||
|
# Yeni videoları veritabanına ekle
|
||||||
|
for video in videos:
|
||||||
|
video['channel_id'] = channel_id
|
||||||
|
if not db.is_video_processed(video['video_id']):
|
||||||
|
db.add_video(video)
|
||||||
|
|
||||||
|
# Bekleyen videoları işle (ilk 20)
|
||||||
|
pending_videos = db.get_pending_videos()[:20]
|
||||||
|
|
||||||
|
for video in pending_videos:
|
||||||
|
if video['channel_id'] != channel_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Transcript çıkar
|
||||||
|
transcript = extractor.fetch_transcript(
|
||||||
|
video['video_id'],
|
||||||
|
languages=['tr', 'en']
|
||||||
|
)
|
||||||
|
|
||||||
|
if transcript:
|
||||||
|
# Transcript temizle
|
||||||
|
raw, clean = cleaner.clean_transcript(transcript, sentences_per_paragraph=3)
|
||||||
|
|
||||||
|
# Veritabanına kaydet
|
||||||
|
db.update_video_transcript(
|
||||||
|
video['video_id'],
|
||||||
|
raw,
|
||||||
|
clean,
|
||||||
|
status=1,
|
||||||
|
language='tr'
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Transcript çıkarım hatası {video['video_id']}: {e}")
|
||||||
|
db.mark_video_failed(video['video_id'], str(e))
|
||||||
|
|
||||||
|
# İşlenmiş videoları getir
|
||||||
|
processed_videos = db.get_processed_videos(
|
||||||
|
limit=max_items,
|
||||||
|
channel_id=channel_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'videos': processed_videos,
|
||||||
|
'channel_id': channel_id,
|
||||||
|
'count': len(processed_videos)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/', methods=['GET'])
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
# Query parametrelerini al
|
||||||
|
channel_id = request.args.get('channel_id')
|
||||||
|
channel = request.args.get('channel') # @username veya username
|
||||||
|
channel_url = request.args.get('channel_url')
|
||||||
|
format_type = request.args.get('format', 'Atom').lower() # Atom veya Rss
|
||||||
|
max_items = int(request.args.get('max_items', 50))
|
||||||
|
|
||||||
|
# Channel ID'yi normalize et
|
||||||
|
normalized_channel_id = normalize_channel_id(
|
||||||
|
channel_id=channel_id,
|
||||||
|
channel=channel,
|
||||||
|
channel_url=channel_url
|
||||||
|
)
|
||||||
|
|
||||||
|
if not normalized_channel_id:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Channel ID bulunamadı',
|
||||||
|
'usage': {
|
||||||
|
'channel_id': 'UC... (YouTube Channel ID)',
|
||||||
|
'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 video sayısı (varsayılan: 50)'
|
||||||
|
}
|
||||||
|
}), 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': 'Lütfen birkaç dakika sonra tekrar deneyin'
|
||||||
|
}), 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
|
||||||
|
if format_type == 'rss':
|
||||||
|
rss_content = generator.generate_rss_string()
|
||||||
|
return Response(
|
||||||
|
rss_content,
|
||||||
|
mimetype='application/rss+xml',
|
||||||
|
headers={'Content-Type': 'application/rss+xml; charset=utf-8'}
|
||||||
|
)
|
||||||
|
else: # Atom
|
||||||
|
# Feedgen Atom desteği
|
||||||
|
atom_content = generator.generate_atom_string()
|
||||||
|
return Response(
|
||||||
|
atom_content,
|
||||||
|
mimetype='application/atom+xml',
|
||||||
|
headers={'Content-Type': 'application/atom+xml; charset=utf-8'}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'error': str(e),
|
||||||
|
'channel_id': normalized_channel_id
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/health', methods=['GET'])
|
||||||
|
def health():
|
||||||
|
"""Health check endpoint"""
|
||||||
|
return jsonify({'status': 'ok', 'service': 'YouTube Transcript RSS Feed'})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/info', methods=['GET'])
|
||||||
|
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': 'Maksimum video sayısı (varsayılan: 50)'
|
||||||
|
},
|
||||||
|
'examples': [
|
||||||
|
'/?channel_id=UC9h8BDcXwkhZtnqoQJ7PggA&format=Atom',
|
||||||
|
'/?channel=@tavakfi&format=Rss',
|
||||||
|
'/?channel_url=https://www.youtube.com/@tavakfi&format=Atom&max_items=100'
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||||
|
|
||||||
Reference in New Issue
Block a user