Soumik-404 commited on
Commit
ff6b176
·
1 Parent(s): 89157f5

utube transcription

Browse files
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