mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
feat: add WebPreviews blocks
This commit is contained in:
parent
b1c4ddd0b7
commit
8d3ede1486
11 changed files with 482 additions and 0 deletions
|
|
@ -38,6 +38,7 @@ dependencies = [
|
|||
"stripe>=11.1.1",
|
||||
"python-jose>=3.3.0",
|
||||
"logfire[sqlalchemy]>=3.8.0",
|
||||
"beautifulsoup4>=4.13.4",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from src.routers.ee import cloud_internal, payments
|
|||
from src.routers.install import install
|
||||
from src.services.dev.dev import isDevModeEnabledOrRaise
|
||||
from src.services.install.install import isInstallModeEnabled
|
||||
from src.routers.utils import router as utils_router
|
||||
|
||||
|
||||
v1_router = APIRouter(prefix="/api/v1")
|
||||
|
|
@ -61,3 +62,5 @@ v1_router.include_router(
|
|||
tags=["install"],
|
||||
dependencies=[Depends(isInstallModeEnabled)],
|
||||
)
|
||||
|
||||
v1_router.include_router(utils_router, prefix="/utils", tags=["utils"])
|
||||
|
|
|
|||
12
apps/api/src/routers/utils.py
Normal file
12
apps/api/src/routers/utils.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
from fastapi import APIRouter, HTTPException, Query
|
||||
from src.services.utils.link_preview import fetch_link_preview
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/link-preview")
|
||||
async def link_preview(url: str = Query(..., description="URL to preview")):
|
||||
try:
|
||||
data = await fetch_link_preview(url)
|
||||
return data
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to fetch link preview: {str(e)}")
|
||||
70
apps/api/src/services/utils/link_preview.py
Normal file
70
apps/api/src/services/utils/link_preview.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import httpx
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
from typing import Optional, Dict
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
async def fetch_link_preview(url: str) -> Dict[str, Optional[str]]:
|
||||
async with httpx.AsyncClient(follow_redirects=True, timeout=10) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
html = response.text
|
||||
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
|
||||
def get_meta(property_name: str, attr: str = 'property') -> Optional[str]:
|
||||
tag = soup.find('meta', attrs={attr: property_name})
|
||||
if tag and isinstance(tag, Tag) and tag.has_attr('content'):
|
||||
content = tag['content']
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
return None
|
||||
|
||||
# Title
|
||||
title = soup.title.string.strip() if soup.title and soup.title.string else None
|
||||
# Description
|
||||
description = get_meta('og:description') or get_meta('description', 'name')
|
||||
# OG Image
|
||||
og_image = get_meta('og:image')
|
||||
if og_image and isinstance(og_image, str) and not og_image.startswith('http'):
|
||||
og_image = urljoin(url, og_image)
|
||||
# Favicon (robust)
|
||||
favicon = None
|
||||
icon_rels = [
|
||||
'icon',
|
||||
'shortcut icon',
|
||||
'apple-touch-icon',
|
||||
'apple-touch-icon-precomposed',
|
||||
]
|
||||
for link in soup.find_all('link'):
|
||||
if not isinstance(link, Tag):
|
||||
continue
|
||||
rels = link.get('rel')
|
||||
href = link.get('href')
|
||||
if rels and href:
|
||||
rels_lower = [r.lower() for r in rels]
|
||||
if any(rel in rels_lower for rel in icon_rels):
|
||||
if isinstance(href, str):
|
||||
favicon = href
|
||||
break
|
||||
# Fallback to /favicon.ico if not found
|
||||
if not favicon:
|
||||
parsed = urlparse(url)
|
||||
favicon = f"{parsed.scheme}://{parsed.netloc}/favicon.ico"
|
||||
elif favicon and not favicon.startswith('http'):
|
||||
favicon = urljoin(url, favicon)
|
||||
# OG Title
|
||||
og_title = get_meta('og:title')
|
||||
# OG Type
|
||||
og_type = get_meta('og:type')
|
||||
# OG URL
|
||||
og_url = get_meta('og:url')
|
||||
|
||||
return {
|
||||
'title': og_title or title,
|
||||
'description': description,
|
||||
'og_image': og_image,
|
||||
'favicon': favicon,
|
||||
'og_type': og_type,
|
||||
'og_url': og_url or url,
|
||||
'url': url,
|
||||
}
|
||||
24
apps/api/uv.lock
generated
24
apps/api/uv.lock
generated
|
|
@ -157,6 +157,19 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/76/b9/d51d34e6cd6d887adddb28a8680a1d34235cc45b9d6e238ce39b98199ca0/bcrypt-4.2.1-cp39-abi3-win_amd64.whl", hash = "sha256:e84e0e6f8e40a242b11bce56c313edc2be121cec3e0ec2d76fce01f6af33c07c", size = 153078 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "beautifulsoup4"
|
||||
version = "4.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "soupsieve" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/e4/0c4c39e18fd76d6a628d4dd8da40543d136ce2d1752bd6eeeab0791f4d6b/beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195", size = 621067 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "boto3"
|
||||
version = "1.36.22"
|
||||
|
|
@ -999,6 +1012,7 @@ source = { virtual = "." }
|
|||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
{ name = "alembic-postgresql-enum" },
|
||||
{ name = "beautifulsoup4" },
|
||||
{ name = "boto3" },
|
||||
{ name = "botocore" },
|
||||
{ name = "chromadb" },
|
||||
|
|
@ -1037,6 +1051,7 @@ dependencies = [
|
|||
requires-dist = [
|
||||
{ name = "alembic", specifier = ">=1.13.2" },
|
||||
{ name = "alembic-postgresql-enum", specifier = ">=1.2.0" },
|
||||
{ name = "beautifulsoup4", specifier = ">=4.13.4" },
|
||||
{ name = "boto3", specifier = ">=1.34.79" },
|
||||
{ name = "botocore", specifier = ">=1.34.93" },
|
||||
{ name = "chromadb", specifier = "==0.5.16" },
|
||||
|
|
@ -1909,6 +1924,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soupsieve"
|
||||
version = "2.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3f/f4/4a80cd6ef364b2e8b65b15816a843c0980f7a5a2b4dc701fc574952aa19f/soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a", size = 103418 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.38"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue