Spaces:
Running
Running
Commit ·
ff6b176
1
Parent(s): 89157f5
utube transcription
Browse files- Dockerfile +2 -0
- app/api/server.py +3 -0
- app/api/verify.py +19 -0
- app/services/converter_service.py +99 -1
- app/services/verify_service.py +80 -0
- pyproject.toml +2 -0
- requirements.txt +1 -0
Dockerfile
CHANGED
|
@@ -20,6 +20,8 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
|
| 20 |
pip install --no-cache-dir -r requirements.txt && \
|
| 21 |
python -m spacy download en_core_web_sm
|
| 22 |
|
|
|
|
|
|
|
| 23 |
COPY --chown=appuser:appuser . .
|
| 24 |
|
| 25 |
RUN mkdir -p /app/logs && \
|
|
|
|
| 20 |
pip install --no-cache-dir -r requirements.txt && \
|
| 21 |
python -m spacy download en_core_web_sm
|
| 22 |
|
| 23 |
+
RUN pip install --no-cache-dir "youtube-transcript-api>=1.2.4"
|
| 24 |
+
|
| 25 |
COPY --chown=appuser:appuser . .
|
| 26 |
|
| 27 |
RUN mkdir -p /app/logs && \
|
app/api/server.py
CHANGED
|
@@ -11,6 +11,7 @@ from app.config import get_settings
|
|
| 11 |
from app.core.database import pool_manager
|
| 12 |
from app.core.logger import get_logger
|
| 13 |
from app.api.v1.router import api_v1_router
|
|
|
|
| 14 |
|
| 15 |
_logger = get_logger(__name__)
|
| 16 |
_settings = get_settings()
|
|
@@ -51,6 +52,7 @@ def create_application() -> FastAPI:
|
|
| 51 |
{"name": "Convert", "description": "Single-file and single-URL conversion"},
|
| 52 |
{"name": "Batch", "description": "Bulk conversion of files and URLs"},
|
| 53 |
{"name": "System", "description": "Health, info, and supported formats"},
|
|
|
|
| 54 |
],
|
| 55 |
lifespan=lifespan,
|
| 56 |
)
|
|
@@ -64,6 +66,7 @@ def create_application() -> FastAPI:
|
|
| 64 |
)
|
| 65 |
|
| 66 |
app.include_router(api_v1_router, prefix="/api/v1")
|
|
|
|
| 67 |
|
| 68 |
@app.get("/", include_in_schema=False)
|
| 69 |
async def root():
|
|
|
|
| 11 |
from app.core.database import pool_manager
|
| 12 |
from app.core.logger import get_logger
|
| 13 |
from app.api.v1.router import api_v1_router
|
| 14 |
+
from app.api.verify import router as verify_router
|
| 15 |
|
| 16 |
_logger = get_logger(__name__)
|
| 17 |
_settings = get_settings()
|
|
|
|
| 52 |
{"name": "Convert", "description": "Single-file and single-URL conversion"},
|
| 53 |
{"name": "Batch", "description": "Bulk conversion of files and URLs"},
|
| 54 |
{"name": "System", "description": "Health, info, and supported formats"},
|
| 55 |
+
{"name": "Verify", "description": "Phone number and identity verification"},
|
| 56 |
],
|
| 57 |
lifespan=lifespan,
|
| 58 |
)
|
|
|
|
| 66 |
)
|
| 67 |
|
| 68 |
app.include_router(api_v1_router, prefix="/api/v1")
|
| 69 |
+
app.include_router(verify_router, prefix="/verify")
|
| 70 |
|
| 71 |
@app.get("/", include_in_schema=False)
|
| 72 |
async def root():
|
app/api/verify.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, HTTPException, Query
|
| 4 |
+
|
| 5 |
+
from app.services.verify_service import VerifyService
|
| 6 |
+
|
| 7 |
+
router = APIRouter(tags=["Verify"])
|
| 8 |
+
_service = VerifyService()
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@router.get("/phone")
|
| 12 |
+
async def verify_phone(
|
| 13 |
+
number: str = Query(..., description="Phone number (E.164 format like +14155552671, or local with country_code)"),
|
| 14 |
+
country_code: str | None = Query(None, description="ISO 3166-1 alpha-2 country code (e.g. US, IN, GB)"),
|
| 15 |
+
):
|
| 16 |
+
result = _service.verify_phone(number, country_code)
|
| 17 |
+
if not result.valid:
|
| 18 |
+
raise HTTPException(status_code=400, detail=result.error)
|
| 19 |
+
return result.dict()
|
app/services/converter_service.py
CHANGED
|
@@ -3,11 +3,13 @@ from __future__ import annotations
|
|
| 3 |
import hashlib
|
| 4 |
import io
|
| 5 |
import mimetypes
|
|
|
|
| 6 |
import time
|
| 7 |
from pathlib import Path
|
| 8 |
from typing import Optional
|
| 9 |
-
from urllib.parse import urlparse
|
| 10 |
|
|
|
|
| 11 |
from markitdown import MarkItDown
|
| 12 |
|
| 13 |
from app.config import get_settings
|
|
@@ -19,6 +21,84 @@ from app.services.ocr_service import ocr_image, ocr_pdf
|
|
| 19 |
_logger = get_logger(__name__)
|
| 20 |
_settings = get_settings()
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
def _is_image(ext: str, mime: str) -> bool:
|
| 24 |
return ext.lower() in IMAGE_EXTENSIONS or any(mime.startswith(p) for p in IMAGE_MIME_PREFIXES)
|
|
@@ -98,6 +178,24 @@ class ConverterService:
|
|
| 98 |
)
|
| 99 |
|
| 100 |
start = time.perf_counter()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
try:
|
| 102 |
url_ext = Path(urlparse(url).path).suffix.lower()
|
| 103 |
if url_ext in IMAGE_EXTENSIONS:
|
|
|
|
| 3 |
import hashlib
|
| 4 |
import io
|
| 5 |
import mimetypes
|
| 6 |
+
import re
|
| 7 |
import time
|
| 8 |
from pathlib import Path
|
| 9 |
from typing import Optional
|
| 10 |
+
from urllib.parse import urlparse, parse_qs
|
| 11 |
|
| 12 |
+
import httpx
|
| 13 |
from markitdown import MarkItDown
|
| 14 |
|
| 15 |
from app.config import get_settings
|
|
|
|
| 21 |
_logger = get_logger(__name__)
|
| 22 |
_settings = get_settings()
|
| 23 |
|
| 24 |
+
_YOUTUBE_URL_PATTERN = re.compile(
|
| 25 |
+
r"(?:https?://)?"
|
| 26 |
+
r"(?:www\.|m\.)?"
|
| 27 |
+
r"(?:youtube\.com/(?:watch\?v=|embed/|v/|shorts/|live/)|youtu\.be/)"
|
| 28 |
+
r"([\w-]{11})"
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
_YOUTUBE_DOMAINS = {"youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be", "www.youtu.be"}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _is_youtube_url(url: str) -> bool:
|
| 35 |
+
return _YOUTUBE_URL_PATTERN.match(url) is not None
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _extract_youtube_video_id(url: str) -> str | None:
|
| 39 |
+
match = _YOUTUBE_URL_PATTERN.match(url)
|
| 40 |
+
if match:
|
| 41 |
+
return match.group(1)
|
| 42 |
+
parsed = urlparse(url)
|
| 43 |
+
if parsed.hostname in _YOUTUBE_DOMAINS:
|
| 44 |
+
qs = parse_qs(parsed.query)
|
| 45 |
+
return qs.get("v", [None])[0]
|
| 46 |
+
return None
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _fetch_youtube_oembed(video_id: str) -> dict | None:
|
| 50 |
+
oembed_url = f"https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v={video_id}&format=json"
|
| 51 |
+
try:
|
| 52 |
+
resp = httpx.get(oembed_url, timeout=10.0)
|
| 53 |
+
resp.raise_for_status()
|
| 54 |
+
return resp.json()
|
| 55 |
+
except Exception as exc:
|
| 56 |
+
_logger.warning("YouTube oEmbed failed for %s: %s", video_id, exc)
|
| 57 |
+
return None
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _fetch_youtube_transcript(video_id: str) -> str | None:
|
| 61 |
+
try:
|
| 62 |
+
from youtube_transcript_api import YouTubeTranscriptApi
|
| 63 |
+
api = YouTubeTranscriptApi()
|
| 64 |
+
transcript = api.fetch(video_id, languages=["en"])
|
| 65 |
+
return " ".join(part.text for part in transcript)
|
| 66 |
+
except Exception as exc:
|
| 67 |
+
_logger.warning("YouTube transcript failed for %s: %s", video_id, exc)
|
| 68 |
+
return None
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _convert_youtube(url: str) -> ConversionResult | None:
|
| 72 |
+
video_id = _extract_youtube_video_id(url)
|
| 73 |
+
if not video_id:
|
| 74 |
+
return None
|
| 75 |
+
|
| 76 |
+
oembed = _fetch_youtube_oembed(video_id)
|
| 77 |
+
transcript = _fetch_youtube_transcript(video_id)
|
| 78 |
+
|
| 79 |
+
lines = ["# YouTube\n"]
|
| 80 |
+
title = (oembed or {}).get("title", "")
|
| 81 |
+
if title:
|
| 82 |
+
lines.append(f"\n## {title}\n")
|
| 83 |
+
|
| 84 |
+
author = (oembed or {}).get("author_name", "")
|
| 85 |
+
if author:
|
| 86 |
+
lines.append(f"\n- **Channel:** {author}\n")
|
| 87 |
+
|
| 88 |
+
desc = (oembed or {}).get("description", "")
|
| 89 |
+
if desc:
|
| 90 |
+
lines.append(f"\n### Description\n{desc}\n")
|
| 91 |
+
|
| 92 |
+
if transcript:
|
| 93 |
+
lines.append(f"\n### Transcript\n{transcript}\n")
|
| 94 |
+
else:
|
| 95 |
+
if not title and not desc:
|
| 96 |
+
return None
|
| 97 |
+
lines.append("\n> No transcript available.\n")
|
| 98 |
+
|
| 99 |
+
markdown = "".join(lines)
|
| 100 |
+
return _build_result(url, markdown, 0, "text/html", 0.0)
|
| 101 |
+
|
| 102 |
|
| 103 |
def _is_image(ext: str, mime: str) -> bool:
|
| 104 |
return ext.lower() in IMAGE_EXTENSIONS or any(mime.startswith(p) for p in IMAGE_MIME_PREFIXES)
|
|
|
|
| 178 |
)
|
| 179 |
|
| 180 |
start = time.perf_counter()
|
| 181 |
+
|
| 182 |
+
if _is_youtube_url(url):
|
| 183 |
+
result = _convert_youtube(url)
|
| 184 |
+
if result is not None:
|
| 185 |
+
result = ConversionResult(
|
| 186 |
+
source=result.source,
|
| 187 |
+
markdown=result.markdown,
|
| 188 |
+
char_count=result.char_count,
|
| 189 |
+
word_count=result.word_count,
|
| 190 |
+
line_count=result.line_count,
|
| 191 |
+
duration_ms=(time.perf_counter() - start) * 1000,
|
| 192 |
+
file_size_bytes=result.file_size_bytes,
|
| 193 |
+
mime_type=result.mime_type,
|
| 194 |
+
content_hash=result.content_hash,
|
| 195 |
+
)
|
| 196 |
+
return result
|
| 197 |
+
_logger.warning("YouTube conversion returned None, falling back to markitdown: %s", url)
|
| 198 |
+
|
| 199 |
try:
|
| 200 |
url_ext = Path(urlparse(url).path).suffix.lower()
|
| 201 |
if url_ext in IMAGE_EXTENSIONS:
|
app/services/verify_service.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import phonenumbers
|
| 4 |
+
from phonenumbers import carrier, geocoder, PhoneNumberType
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class PhoneVerificationResult:
|
| 8 |
+
def __init__(
|
| 9 |
+
self,
|
| 10 |
+
valid: bool,
|
| 11 |
+
international_format: str | None = None,
|
| 12 |
+
national_format: str | None = None,
|
| 13 |
+
country_code: int | None = None,
|
| 14 |
+
location: str | None = None,
|
| 15 |
+
carrier_name: str | None = None,
|
| 16 |
+
line_type: str | None = None,
|
| 17 |
+
error: str | None = None,
|
| 18 |
+
) -> None:
|
| 19 |
+
self.valid = valid
|
| 20 |
+
self.international_format = international_format
|
| 21 |
+
self.national_format = national_format
|
| 22 |
+
self.country_code = country_code
|
| 23 |
+
self.location = location
|
| 24 |
+
self.carrier = carrier_name
|
| 25 |
+
self.line_type = line_type
|
| 26 |
+
self.error = error
|
| 27 |
+
|
| 28 |
+
def dict(self) -> dict:
|
| 29 |
+
return {
|
| 30 |
+
"valid": self.valid,
|
| 31 |
+
"international_format": self.international_format,
|
| 32 |
+
"national_format": self.national_format,
|
| 33 |
+
"country_code": self.country_code,
|
| 34 |
+
"location": self.location,
|
| 35 |
+
"carrier": self.carrier,
|
| 36 |
+
"line_type": self.line_type,
|
| 37 |
+
"error": self.error,
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
_TYPE_NAMES = {
|
| 42 |
+
PhoneNumberType.FIXED_LINE: "fixed_line",
|
| 43 |
+
PhoneNumberType.MOBILE: "mobile",
|
| 44 |
+
PhoneNumberType.FIXED_LINE_OR_MOBILE: "fixed_line_or_mobile",
|
| 45 |
+
PhoneNumberType.TOLL_FREE: "toll_free",
|
| 46 |
+
PhoneNumberType.PREMIUM_RATE: "premium_rate",
|
| 47 |
+
PhoneNumberType.SHARED_COST: "shared_cost",
|
| 48 |
+
PhoneNumberType.VOIP: "voip",
|
| 49 |
+
PhoneNumberType.PERSONAL_NUMBER: "personal_number",
|
| 50 |
+
PhoneNumberType.PAGER: "pager",
|
| 51 |
+
PhoneNumberType.UAN: "uan",
|
| 52 |
+
PhoneNumberType.VOICEMAIL: "voicemail",
|
| 53 |
+
PhoneNumberType.UNKNOWN: "unknown",
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class VerifyService:
|
| 58 |
+
def verify_phone(self, number: str, country_code: str | None = None) -> PhoneVerificationResult:
|
| 59 |
+
try:
|
| 60 |
+
parsed = phonenumbers.parse(number, country_code)
|
| 61 |
+
except phonenumbers.NumberParseException as exc:
|
| 62 |
+
return PhoneVerificationResult(valid=False, error=f"Parsing error: {exc}")
|
| 63 |
+
|
| 64 |
+
if not phonenumbers.is_valid_number(parsed):
|
| 65 |
+
return PhoneVerificationResult(valid=False, error="Invalid phone number")
|
| 66 |
+
|
| 67 |
+
number_type = phonenumbers.number_type(parsed)
|
| 68 |
+
line_type = _TYPE_NAMES.get(number_type, "unknown")
|
| 69 |
+
location = geocoder.description_for_number(parsed, "en") or None
|
| 70 |
+
carrier_name = carrier.name_for_number(parsed, "en") or None
|
| 71 |
+
|
| 72 |
+
return PhoneVerificationResult(
|
| 73 |
+
valid=True,
|
| 74 |
+
international_format=phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.INTERNATIONAL),
|
| 75 |
+
national_format=phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.NATIONAL),
|
| 76 |
+
country_code=parsed.country_code,
|
| 77 |
+
location=location,
|
| 78 |
+
carrier_name=carrier_name,
|
| 79 |
+
line_type=line_type,
|
| 80 |
+
)
|
pyproject.toml
CHANGED
|
@@ -18,6 +18,7 @@ dependencies = [
|
|
| 18 |
"pydantic-settings>=2.0.0",
|
| 19 |
"python-multipart>=0.0.9",
|
| 20 |
"httpx>=0.27",
|
|
|
|
| 21 |
"pyfiglet>=1.0.0",
|
| 22 |
"rich>=13.7",
|
| 23 |
"numpy>=1.26.0",
|
|
@@ -27,6 +28,7 @@ dependencies = [
|
|
| 27 |
"pypdfium2>=4.30.0",
|
| 28 |
"pandas>=2.0.0",
|
| 29 |
"spacy>=3.7.0",
|
|
|
|
| 30 |
]
|
| 31 |
|
| 32 |
[project.optional-dependencies]
|
|
|
|
| 18 |
"pydantic-settings>=2.0.0",
|
| 19 |
"python-multipart>=0.0.9",
|
| 20 |
"httpx>=0.27",
|
| 21 |
+
"youtube-transcript-api>=1.2.4",
|
| 22 |
"pyfiglet>=1.0.0",
|
| 23 |
"rich>=13.7",
|
| 24 |
"numpy>=1.26.0",
|
|
|
|
| 28 |
"pypdfium2>=4.30.0",
|
| 29 |
"pandas>=2.0.0",
|
| 30 |
"spacy>=3.7.0",
|
| 31 |
+
"phonenumbers>=8.13.0",
|
| 32 |
]
|
| 33 |
|
| 34 |
[project.optional-dependencies]
|
requirements.txt
CHANGED
|
@@ -12,6 +12,7 @@ pillow>=10.0.0
|
|
| 12 |
pypdfium2>=4.30.0
|
| 13 |
pandas>=2.0.0
|
| 14 |
spacy>=3.7.0
|
|
|
|
| 15 |
|
| 16 |
# Async database drivers
|
| 17 |
aiomysql>=0.3.2
|
|
|
|
| 12 |
pypdfium2>=4.30.0
|
| 13 |
pandas>=2.0.0
|
| 14 |
spacy>=3.7.0
|
| 15 |
+
phonenumbers>=8.13.0
|
| 16 |
|
| 17 |
# Async database drivers
|
| 18 |
aiomysql>=0.3.2
|