AI generated initial commit
@@ -0,0 +1,14 @@
|
|||||||
|
# Virtual Environment
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
*.egg-info/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
.pytest_cache/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
run/
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Be my anchor
|
||||||
|
|
||||||
|
Make you a podcast like audio fomr articles you'd like to read but want to listen instead.
|
||||||
|
|
||||||
|
## How to run it
|
||||||
|
|
||||||
|
```
|
||||||
|
make setup
|
||||||
|
python -m src.server.app
|
||||||
|
```
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
download-webdriver:
|
||||||
|
curl -L https://storage.googleapis.com/chrome-for-testing-public/132.0.6834.110/linux64/chrome-linux64.zip -o chrome-linux64.zip
|
||||||
|
curl -L https://storage.googleapis.com/chrome-for-testing-public/132.0.6834.110/linux64/chromedriver-linux64.zip -o chromedriver-linux64.zip
|
||||||
|
|
||||||
|
prepare-webdriver: download-webdriver
|
||||||
|
mkdir -p run/webdriver/chrome
|
||||||
|
unzip chrome-linux64.zip -d run/webdriver/chrome
|
||||||
|
rm -f chrome-linux64.zip
|
||||||
|
|
||||||
|
mkdir -p run/webdriver/chromedriver
|
||||||
|
unzip -j chromedriver-linux64.zip -d run/webdriver/chromedriver
|
||||||
|
rm -f chromedriver-linux64.zip
|
||||||
|
|
||||||
|
setup: prepare-webdriver
|
||||||
|
python3 -m venv venv
|
||||||
|
pip install -e .
|
||||||
|
|
||||||
|
clean-webdriver:
|
||||||
|
rm -rf run/webdriver
|
||||||
|
|
||||||
|
clean: clean-webdriver
|
||||||
|
rm -rf run
|
||||||
|
rm -rf venv
|
||||||
|
|
||||||
|
all: setup
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=42", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "be-my-anchor"
|
||||||
|
version = "0.1.0"
|
||||||
|
authors = [
|
||||||
|
{name="Pengzhan Hao", email="me@pengzhan.dev"},
|
||||||
|
]
|
||||||
|
description = "Web content to podcast converter"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
classifiers = [
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Operating System :: OS Independent",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.dependencies]
|
||||||
|
annotated-types==0.7.0
|
||||||
|
anyio==4.8.0
|
||||||
|
attrs==25.1.0
|
||||||
|
beautifulsoup4==4.12.3
|
||||||
|
blinker==1.9.0
|
||||||
|
cachetools==5.5.1
|
||||||
|
certifi==2024.12.14
|
||||||
|
chardet==5.2.0
|
||||||
|
charset-normalizer==3.4.1
|
||||||
|
click==8.1.8
|
||||||
|
cssselect==1.2.0
|
||||||
|
distro==1.9.0
|
||||||
|
feedfinder2==0.0.4
|
||||||
|
feedparser==6.0.11
|
||||||
|
filelock==3.17.0
|
||||||
|
Flask==3.1.0
|
||||||
|
google-ai-generativelanguage==0.6.15
|
||||||
|
google-api-core==2.24.0
|
||||||
|
google-api-python-client==2.159.0
|
||||||
|
google-auth==2.38.0
|
||||||
|
google-auth-httplib2==0.2.0
|
||||||
|
google-cloud-texttospeech==2.24.0
|
||||||
|
google-generativeai==0.8.4
|
||||||
|
googleapis-common-protos==1.66.0
|
||||||
|
greenlet==3.1.1
|
||||||
|
grpcio==1.70.0
|
||||||
|
grpcio-status==1.70.0
|
||||||
|
h11==0.14.0
|
||||||
|
html2text==2024.2.26
|
||||||
|
httpcore==1.0.7
|
||||||
|
httplib2==0.22.0
|
||||||
|
httpx==0.28.1
|
||||||
|
idna==3.10
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
jieba3k==0.35.1
|
||||||
|
Jinja2==3.1.5
|
||||||
|
jiter==0.8.2
|
||||||
|
joblib==1.4.2
|
||||||
|
lxml==5.3.0
|
||||||
|
lxml_html_clean==0.4.1
|
||||||
|
MarkupSafe==3.0.2
|
||||||
|
newspaper3k==0.2.8
|
||||||
|
nltk==3.9.1
|
||||||
|
openai==1.60.1
|
||||||
|
outcome==1.3.0.post0
|
||||||
|
pillow==11.1.0
|
||||||
|
pip-chill==1.0.3
|
||||||
|
playwright==1.49.1
|
||||||
|
proto-plus==1.25.0
|
||||||
|
protobuf==5.29.3
|
||||||
|
pyasn1==0.6.1
|
||||||
|
pyasn1_modules==0.4.1
|
||||||
|
pydantic==2.10.6
|
||||||
|
pydantic_core==2.27.2
|
||||||
|
pyee==12.0.0
|
||||||
|
pyfranc==0.2.3
|
||||||
|
pyparsing==3.2.1
|
||||||
|
PySocks==1.7.1
|
||||||
|
python-dateutil==2.9.0.post0
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
PyYAML==6.0.2
|
||||||
|
readability-lxml==0.8.1
|
||||||
|
regex==2024.11.6
|
||||||
|
requests==2.32.3
|
||||||
|
requests-file==2.1.0
|
||||||
|
rsa==4.9
|
||||||
|
selenium==4.28.1
|
||||||
|
sgmllib3k==1.0.0
|
||||||
|
six==1.17.0
|
||||||
|
sniffio==1.3.1
|
||||||
|
sortedcontainers==2.4.0
|
||||||
|
soupsieve==2.6
|
||||||
|
tinysegmenter==0.3
|
||||||
|
tldextract==5.1.3
|
||||||
|
tqdm==4.67.1
|
||||||
|
trio==0.28.0
|
||||||
|
trio-websocket==0.11.1
|
||||||
|
typing_extensions==4.12.2
|
||||||
|
uritemplate==4.1.1
|
||||||
|
urllib3==2.3.0
|
||||||
|
websocket-client==1.8.0
|
||||||
|
Werkzeug==3.1.3
|
||||||
|
wsproto==1.2.0
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = ["pytest", "black"]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
flask
|
||||||
|
google-cloud-texttospeech
|
||||||
|
google-generativeai
|
||||||
|
grpcio-status
|
||||||
|
html2text
|
||||||
|
lxml-html-clean
|
||||||
|
newspaper3k
|
||||||
|
openai
|
||||||
|
pip-chill
|
||||||
|
playwright
|
||||||
|
pyfranc
|
||||||
|
pysocks
|
||||||
|
python-dotenv
|
||||||
|
readability-lxml
|
||||||
|
selenium
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest>=7.0
|
||||||
|
black>=23.0
|
||||||
|
isort>=5.0
|
||||||
|
pip-chill>=1.0
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
"""
|
||||||
|
Web Content Extractor Module
|
||||||
|
|
||||||
|
Provides functionalities to fetch web content using headless Chrome,
|
||||||
|
sanitize HTML, and save results with comprehensive logging and error handling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from lxml.html.clean import Cleaner
|
||||||
|
from selenium import webdriver
|
||||||
|
from selenium.webdriver.chrome.service import Service
|
||||||
|
from selenium.webdriver.chrome.options import Options
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||||
|
handlers=[logging.StreamHandler()]
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Configuration constants
|
||||||
|
DEFAULT_CHROME_PATH = "run/webdriver/chrome"
|
||||||
|
DEFAULT_WEB_DRIVER_PATH = "run/webdriver/chromedriver"
|
||||||
|
DEFAULT_WAIT_TIME = 10
|
||||||
|
DEFAULT_OUTPUT_DIR = "run/tmp"
|
||||||
|
|
||||||
|
CHROME_OPTIONS = [
|
||||||
|
"--headless",
|
||||||
|
"--no-sandbox",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
"--disable-extensions",
|
||||||
|
"--disable-gpu",
|
||||||
|
"window-size=1920,1080"
|
||||||
|
]
|
||||||
|
|
||||||
|
CLEANER_CONFIG = {
|
||||||
|
'page_structure': True,
|
||||||
|
'meta': True,
|
||||||
|
'embedded': True,
|
||||||
|
'links': True,
|
||||||
|
'style': True,
|
||||||
|
'processing_instructions': True,
|
||||||
|
'inline_style': True,
|
||||||
|
'scripts': True,
|
||||||
|
'javascript': True,
|
||||||
|
'comments': True,
|
||||||
|
'frames': True,
|
||||||
|
'forms': True,
|
||||||
|
'annoying_tags': True,
|
||||||
|
'remove_unknown_tags': True,
|
||||||
|
'safe_attrs_only': True,
|
||||||
|
'safe_attrs': frozenset(['src', 'color', 'href', 'title', 'class', 'name', 'id']),
|
||||||
|
'remove_tags': ('span', 'font', 'div')
|
||||||
|
}
|
||||||
|
|
||||||
|
ALLOWED_TAGS = {
|
||||||
|
'img': ['src', 'alt', 'width', 'height'],
|
||||||
|
'a': ['href', 'title'],
|
||||||
|
'video': ['src', 'controls', 'width', 'height'],
|
||||||
|
'source': ['src', 'type']
|
||||||
|
}
|
||||||
|
|
||||||
|
UNWANTED_TAGS = [
|
||||||
|
'script', 'style', 'nav', 'footer', 'header', 'aside',
|
||||||
|
'form', 'iframe', 'noscript', 'svg', 'canvas', 'applet',
|
||||||
|
'object', 'embed'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def configure_chrome_driver() -> webdriver.Chrome:
|
||||||
|
"""Configure and return a Chrome WebDriver instance."""
|
||||||
|
try:
|
||||||
|
chrome_options = Options()
|
||||||
|
for option in CHROME_OPTIONS:
|
||||||
|
chrome_options.add_argument(option)
|
||||||
|
|
||||||
|
chrome_path = os.path.join(
|
||||||
|
DEFAULT_CHROME_PATH, 'chrome-linux64', 'chrome'
|
||||||
|
)
|
||||||
|
chrome_options.binary_location = chrome_path
|
||||||
|
|
||||||
|
driver_path = os.path.join(
|
||||||
|
DEFAULT_WEB_DRIVER_PATH, 'chromedriver'
|
||||||
|
)
|
||||||
|
chrome_service = Service(driver_path)
|
||||||
|
|
||||||
|
logger.info("Initializing Chrome WebDriver")
|
||||||
|
return webdriver.Chrome(
|
||||||
|
service=chrome_service,
|
||||||
|
options=chrome_options
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Chrome driver configuration failed")
|
||||||
|
raise RuntimeError("WebDriver initialization failed") from e
|
||||||
|
|
||||||
|
|
||||||
|
def extract_html_with_chromedriver(
|
||||||
|
url: str,
|
||||||
|
wait_time: int = DEFAULT_WAIT_TIME
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Extract HTML content from a URL using headless Chrome.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Target URL to scrape
|
||||||
|
wait_time: Maximum wait time for page load (seconds)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Raw HTML content or None if extraction fails
|
||||||
|
"""
|
||||||
|
driver = None
|
||||||
|
try:
|
||||||
|
driver = configure_chrome_driver()
|
||||||
|
logger.info(f"Fetching URL: {url}")
|
||||||
|
|
||||||
|
driver.get(url)
|
||||||
|
WebDriverWait(driver, wait_time).until(
|
||||||
|
EC.presence_of_element_located((By.TAG_NAME, 'body'))
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug("Page load completed successfully")
|
||||||
|
return driver.page_source
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to extract HTML: {str(e)}")
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
if driver:
|
||||||
|
logger.debug("Closing WebDriver instance")
|
||||||
|
driver.quit()
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_html(dirty_html: str) -> str:
|
||||||
|
"""
|
||||||
|
Sanitize HTML content using lxml cleaner.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dirty_html: Raw HTML input
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Sanitized HTML output
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info("Sanitizing HTML content")
|
||||||
|
cleaner = Cleaner(**CLEANER_CONFIG)
|
||||||
|
return cleaner.clean_html(dirty_html)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"HTML sanitization failed: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def extract_content(html_content: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Extract and clean main content from HTML.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
html_content: Raw HTML input
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Cleaned HTML content or None if processing fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not html_content:
|
||||||
|
logger.warning("Empty HTML content received")
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.info("Processing HTML content")
|
||||||
|
soup = BeautifulSoup(html_content, "html.parser")
|
||||||
|
|
||||||
|
# Remove unwanted tags
|
||||||
|
for tag in soup.find_all(UNWANTED_TAGS):
|
||||||
|
tag.decompose()
|
||||||
|
|
||||||
|
# Clean tag attributes
|
||||||
|
for tag in soup.find_all():
|
||||||
|
allowed = ALLOWED_TAGS.get(tag.name, [])
|
||||||
|
attrs = list(tag.attrs.keys())
|
||||||
|
for attr in attrs:
|
||||||
|
if attr not in allowed:
|
||||||
|
del tag[attr]
|
||||||
|
|
||||||
|
# Remove empty tags
|
||||||
|
for tag in soup.find_all():
|
||||||
|
if not tag.get_text(strip=True):
|
||||||
|
tag.decompose()
|
||||||
|
|
||||||
|
return str(soup)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Content extraction failed: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def save_html_to_file(
|
||||||
|
html_content: str,
|
||||||
|
output_dir: str = DEFAULT_OUTPUT_DIR
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Save HTML content to timestamped file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
html_content: Content to save
|
||||||
|
output_dir: Target directory path
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Path to saved file or None if save fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
timestamp = time.strftime("%Y%m%d%H%M%S")
|
||||||
|
rand_str = ''.join(random.choices(string.ascii_letters + string.digits, k=8))
|
||||||
|
filename = f"output_{timestamp}_{rand_str}.html"
|
||||||
|
filepath = os.path.join(output_dir, filename)
|
||||||
|
|
||||||
|
logger.info(f"Saving output to: {filepath}")
|
||||||
|
with open(filepath, "w", encoding="utf-8") as f:
|
||||||
|
f.write(html_content)
|
||||||
|
|
||||||
|
return filepath
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"File save failed: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_content_from_url(url: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Main pipeline: Fetch URL -> Extract Content -> Sanitize HTML
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Target URL to process
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Processed HTML content or None if any step fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
raw_html = extract_html_with_chromedriver(url)
|
||||||
|
if not raw_html:
|
||||||
|
return None
|
||||||
|
|
||||||
|
sanitized = sanitize_html(raw_html)
|
||||||
|
return extract_content(sanitized)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Processing pipeline failed: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
target_url = "https://www.cncf.io/blog/2025/01/22/kubernetes-in-2025-are-you-ready-for-these-top-5-trends-and-predictions/"
|
||||||
|
if (result := get_content_from_url(target_url)) is not None:
|
||||||
|
file_path = save_html_to_file(result)
|
||||||
|
if file_path:
|
||||||
|
logger.info(f"Successfully saved output to: {file_path}")
|
||||||
|
else:
|
||||||
|
logger.error("Failed to save output file")
|
||||||
|
else:
|
||||||
|
logger.error("Content extraction failed for target URL")
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""
|
||||||
|
LLM-based Podcast Script Converter
|
||||||
|
|
||||||
|
将HTML内容转换为播客风格的稿件,支持多种LLM模型接口。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Optional, Dict, Literal
|
||||||
|
import html2text
|
||||||
|
|
||||||
|
# 第三方库导入(需要额外安装)
|
||||||
|
try:
|
||||||
|
from openai import OpenAI # OpenAI官方库
|
||||||
|
import google.generativeai as genai # Google Gemini
|
||||||
|
except ImportError:
|
||||||
|
pass # 运行时检查
|
||||||
|
|
||||||
|
# 配置日志
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class LLMConverter(ABC):
|
||||||
|
"""LLM转换器抽象基类"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def convert_to_podcast(self, html_content: str) -> Optional[str]:
|
||||||
|
"""将HTML内容转换为播客稿件"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class OpenAIConverter(LLMConverter):
|
||||||
|
"""ChatGPT转换器实现"""
|
||||||
|
|
||||||
|
def __init__(self, model: str = "gpt-4-turbo", temperature: float = 0.7):
|
||||||
|
self.client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
||||||
|
self.model = model
|
||||||
|
self.temperature = temperature
|
||||||
|
|
||||||
|
def convert_to_podcast(self, html_content: str) -> Optional[str]:
|
||||||
|
try:
|
||||||
|
# 将HTML转换为Markdown格式
|
||||||
|
markdown_content = html2text.html2text(html_content)
|
||||||
|
|
||||||
|
response = self.client.chat.completions.create(
|
||||||
|
model=self.model,
|
||||||
|
temperature=self.temperature,
|
||||||
|
messages=[
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": """你是一位专业播客编剧,需要将技术文章转换为生动有趣的播客稿件。要求:
|
||||||
|
1. 使用口语化表达,避免专业术语
|
||||||
|
2. 结构清晰,包含引言、主体和总结
|
||||||
|
3. 突出文章的核心观点和技术亮点
|
||||||
|
4. 适当添加过渡语和互动提示
|
||||||
|
5. 总长度控制在1500字左右"""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": markdown_content
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return response.choices[0].message.content
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"OpenAI转换失败: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
class GeminiConverter(LLMConverter):
|
||||||
|
"""Google Gemini转换器实现"""
|
||||||
|
|
||||||
|
def __init__(self, model: str = "gemini-pro", temperature: float = 0.7):
|
||||||
|
genai.configure(api_key=os.getenv("GEMINI_API_KEY"))
|
||||||
|
self.model = genai.GenerativeModel(model)
|
||||||
|
self.temperature = temperature
|
||||||
|
|
||||||
|
def convert_to_podcast(self, html_content: str) -> Optional[str]:
|
||||||
|
try:
|
||||||
|
markdown_content = html2text.html2text(html_content)
|
||||||
|
|
||||||
|
response = self.model.generate_content(
|
||||||
|
f"[播客稿件转换要求同OpenAI版本]\n\n{markdown_content}",
|
||||||
|
generation_config=genai.types.GenerationConfig(
|
||||||
|
temperature=self.temperature
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return response.text
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Gemini转换失败: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
class DeepSeekConverter(LLMConverter):
|
||||||
|
"""深度求索转换器实现"""
|
||||||
|
|
||||||
|
def __init__(self, model: str = "deepseek-chat", temperature: float = 0.7):
|
||||||
|
self.client = OpenAI(
|
||||||
|
api_key=os.getenv("DEEPSEEK_API_KEY"),
|
||||||
|
base_url="https://api.deepseek.com/v1"
|
||||||
|
)
|
||||||
|
self.model = model
|
||||||
|
self.temperature = temperature
|
||||||
|
|
||||||
|
def convert_to_podcast(self, html_content: str) -> Optional[str]:
|
||||||
|
try:
|
||||||
|
markdown_content = html2text.html2text(html_content)
|
||||||
|
|
||||||
|
response = self.client.chat.completions.create(
|
||||||
|
model=self.model,
|
||||||
|
temperature=self.temperature,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": "[播客稿件转换要求同OpenAI版本]"},
|
||||||
|
{"role": "user", "content": markdown_content}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return response.choices[0].message.content
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"DeepSeek转换失败: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
class PodcastConverter:
|
||||||
|
"""播客稿件转换管理器"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
provider: Literal["openai", "gemini", "deepseek"] = "openai",
|
||||||
|
model: Optional[str] = None,
|
||||||
|
temperature: float = 0.7
|
||||||
|
):
|
||||||
|
self.provider = provider
|
||||||
|
self.model = model
|
||||||
|
self.temperature = temperature
|
||||||
|
self.converter = self._init_converter()
|
||||||
|
|
||||||
|
def _init_converter(self) -> LLMConverter:
|
||||||
|
"""初始化指定的LLM转换器"""
|
||||||
|
try:
|
||||||
|
if self.provider == "openai":
|
||||||
|
return OpenAIConverter(
|
||||||
|
model=self.model or "gpt-4-turbo",
|
||||||
|
temperature=self.temperature
|
||||||
|
)
|
||||||
|
elif self.provider == "gemini":
|
||||||
|
return GeminiConverter(
|
||||||
|
model=self.model or "gemini-pro",
|
||||||
|
temperature=self.temperature
|
||||||
|
)
|
||||||
|
elif self.provider == "deepseek":
|
||||||
|
return DeepSeekConverter(
|
||||||
|
model=self.model or "deepseek-chat",
|
||||||
|
temperature=self.temperature
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError("不支持的LLM提供商")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"转换器初始化失败: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def convert(self, html_content: str) -> Optional[str]:
|
||||||
|
"""执行转换流程"""
|
||||||
|
if not html_content:
|
||||||
|
logger.warning("输入内容为空")
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.info(f"开始转换,使用模型: {self.provider}/{self.model}")
|
||||||
|
return self.converter.convert_to_podcast(html_content)
|
||||||
|
|
||||||
|
# 使用示例
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 测试用HTML内容(实际应从文件或网络获取)
|
||||||
|
sample_html = """
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h1>人工智能的未来发展</h1>
|
||||||
|
<p>近年来,AI技术取得了突破性进展...</p>
|
||||||
|
<ul>
|
||||||
|
<li>自然语言处理的进步</li>
|
||||||
|
<li>计算机视觉的应用扩展</li>
|
||||||
|
<li>伦理问题的重要性</li>
|
||||||
|
</ul>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 使用OpenAI转换
|
||||||
|
openai_converter = PodcastConverter(provider="openai")
|
||||||
|
result = openai_converter.convert(sample_html)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
print("转换结果示例:")
|
||||||
|
print(result[:500] + "...") # 打印前500字符
|
||||||
|
else:
|
||||||
|
print("转换失败")
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
from flask import Flask, request, render_template, redirect, url_for
|
||||||
|
from ..modules.html_extractor.extract import get_content_from_url
|
||||||
|
from flask_dance.contrib.google import make_google_blueprint, google
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['SECRET_KEY'] = 'your-secret-key-here'
|
||||||
|
app.config['PREFIX'] = '/proxy/5000'
|
||||||
|
|
||||||
|
app.config['GOOGLE_OAUTH_CLIENT_ID'] = 'your-client-id'
|
||||||
|
app.config['GOOGLE_OAUTH_CLIENT_SECRET'] = 'your-client-secret'
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
print("index!")
|
||||||
|
return render_template("index.html", prefix=app.config['PREFIX'],)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/page")
|
||||||
|
def article_page():
|
||||||
|
url = request.args.get("url")
|
||||||
|
if not url:
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = get_content_from_url(url)
|
||||||
|
return render_template('page.html',
|
||||||
|
prefix=app.config['PREFIX'],
|
||||||
|
url=url,
|
||||||
|
summary=content,
|
||||||
|
audio_url="/static/audio/sample.mp3")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.error(f"Error processing {url}: {str(e)}")
|
||||||
|
return render_template('error.html',
|
||||||
|
error_message="Failed to process the URL"), 500
|
||||||
|
|
||||||
|
@app.route("/login", methods=['GET', 'POST'])
|
||||||
|
def login():
|
||||||
|
if request.method == 'POST':
|
||||||
|
# Add authentication logic here
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
return render_template("login.html", prefix=app.config['PREFIX'])
|
||||||
|
|
||||||
|
@app.route("/register", methods=['GET', 'POST'])
|
||||||
|
def register():
|
||||||
|
if request.method == 'POST':
|
||||||
|
# Add registration logic here
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
return render_template("register.html", prefix=app.config['PREFIX'])
|
||||||
|
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def page_not_found(e):
|
||||||
|
return render_template('error.html', prefix=app.config['PREFIX'],
|
||||||
|
error_message="Page not found"), 404
|
||||||
|
|
||||||
|
@app.errorhandler(500)
|
||||||
|
def internal_error(e):
|
||||||
|
return render_template('error.html', prefix=app.config['PREFIX'],
|
||||||
|
error_message="Internal server error"), 500
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
google_bp = make_google_blueprint(
|
||||||
|
scope=["profile", "email"],
|
||||||
|
redirect_to="auth_callback"
|
||||||
|
)
|
||||||
|
app.register_blueprint(google_bp, url_prefix="/auth")
|
||||||
|
|
||||||
|
@app.route("/auth/callback")
|
||||||
|
def auth_callback():
|
||||||
|
if not google.authorized:
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
|
||||||
|
resp = google.get("/oauth2/v2/userinfo")
|
||||||
|
if resp.ok:
|
||||||
|
user_info = resp.json()
|
||||||
|
# Handle user authentication logic here
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
|
||||||
|
@app.route("/auth/google")
|
||||||
|
def google_auth():
|
||||||
|
return redirect(url_for("google.login"))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(port=5000, debug=True)
|
||||||
@@ -0,0 +1,818 @@
|
|||||||
|
:root {
|
||||||
|
--primary: #6366f1;
|
||||||
|
--surface: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr auto;
|
||||||
|
min-height: 100dvh;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 头部样式 */
|
||||||
|
header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branding {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-self: end;
|
||||||
|
/* 右侧菜单保持右对齐 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-button {
|
||||||
|
padding: 8px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-button img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* 菜单系统 */
|
||||||
|
.menu {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-button {
|
||||||
|
padding: 8px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-button img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 100%;
|
||||||
|
z-index: 100;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
display: none;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-dropdown.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
padding: 12px 16px;
|
||||||
|
color: #334155;
|
||||||
|
text-decoration: none;
|
||||||
|
display: block;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主容器设置 */
|
||||||
|
main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 图片容器 */
|
||||||
|
.image-container {
|
||||||
|
position: relative;
|
||||||
|
width: min(60vw, 300px);
|
||||||
|
height: min(60vw, 300px);
|
||||||
|
/* 明确设置高度 */
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 图片绝对居中方案 */
|
||||||
|
.image-container img {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文字内容容器 */
|
||||||
|
.text-content {
|
||||||
|
width: 80%;
|
||||||
|
max-width: 800px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主容器设置 */
|
||||||
|
main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 图片容器 */
|
||||||
|
.image-container {
|
||||||
|
position: relative;
|
||||||
|
width: min(60vw, 300px);
|
||||||
|
height: min(60vw, 300px);
|
||||||
|
/* 明确设置高度 */
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 图片绝对居中方案 */
|
||||||
|
.image-container img {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文字内容容器 */
|
||||||
|
.text-content {
|
||||||
|
width: 80%;
|
||||||
|
max-width: 800px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url {
|
||||||
|
font-size: 1.1em;
|
||||||
|
color: #3b82f6;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #64748b;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
/* 默认显示4行 */
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
--lines: 4;
|
||||||
|
/* 默认显示行数 */
|
||||||
|
--mobile-lines: 3;
|
||||||
|
/* 移动端行数 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式调整 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.text-content {
|
||||||
|
width: 90%;
|
||||||
|
/* 小屏幕更宽 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
/* 减少显示行数 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 播放控制栏 */
|
||||||
|
footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
gap: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
flex-grow: 1;
|
||||||
|
height: 6px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--primary);
|
||||||
|
width: 0%;
|
||||||
|
transition: width 0.1s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button {
|
||||||
|
padding: 8px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button img {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 历史记录面板样式 */
|
||||||
|
.history-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: -100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba(255, 255, 255, 0.98);
|
||||||
|
backdrop-filter: blur(30px);
|
||||||
|
z-index: 2000;
|
||||||
|
transition: top 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 60px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-panel.active {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 关闭按钮位置调整 */
|
||||||
|
.history-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
/* 改为左上角 */
|
||||||
|
padding: 8px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-close img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动容器 */
|
||||||
|
.history-content {
|
||||||
|
height: calc(100vh - 100px);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 历史记录项样式 */
|
||||||
|
.history-item {
|
||||||
|
padding: 12px;
|
||||||
|
margin: 8px 0;
|
||||||
|
background: rgba(0, 0, 0, 0.03);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条美化 */
|
||||||
|
.history-content::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-content::-webkit-scrollbar-track {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-content::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex-grow: 1;
|
||||||
|
border: none;
|
||||||
|
padding: 15px;
|
||||||
|
font-size: 16px;
|
||||||
|
outline: none;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-button {
|
||||||
|
background-color: #007bff;
|
||||||
|
/* Blue button color */
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 15px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-button:hover {
|
||||||
|
background-color: #0069d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optional: Add a magnifying glass icon */
|
||||||
|
.search-input::placeholder {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments (optional) */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.search-form {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add to main.css */
|
||||||
|
/* Auth System */
|
||||||
|
.auth-main {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2rem;
|
||||||
|
min-height: calc(100dvh - 120px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container {
|
||||||
|
background: white;
|
||||||
|
padding: 2.5rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-title {
|
||||||
|
text-align: center;
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-divider::before,
|
||||||
|
.oauth-divider::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-login {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-login:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-login img {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-links {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-links a {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-links a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 统一搜索页面样式 */
|
||||||
|
.search-main {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: calc(100dvh - 120px);
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 720px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
color: #1e293b;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
padding: 16px 24px;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 统一按钮悬停效果 */
|
||||||
|
.primary-button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 历史面板动画优化 */
|
||||||
|
.history-panel {
|
||||||
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
transform: translateY(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-panel.active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 新增CSS样式 */
|
||||||
|
|
||||||
|
.search-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon-button {
|
||||||
|
position: absolute;
|
||||||
|
right: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
background: var(--primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon-button:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon-button img {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-options {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-selection select {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: white;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载动画 */
|
||||||
|
.loading-indicator {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 3px solid #e2e8f0;
|
||||||
|
border-top-color: var(--primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 验证状态提示 */
|
||||||
|
.invalid-input {
|
||||||
|
border-color: #ef4444 !important;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-error {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 4px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 新增/修改的CSS */
|
||||||
|
.search-group {
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 20px;
|
||||||
|
border: none;
|
||||||
|
resize: none;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
min-height: 150px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advanced-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advanced-toggle:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.advanced-toggle img {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon-button {
|
||||||
|
background: var(--primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: white;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon-button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon-button img {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 高级选项面板 */
|
||||||
|
.advanced-options {
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.3s ease-out;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 0 0 16px 16px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.advanced-options.active {
|
||||||
|
max-height: 400px; /* 根据内容调整 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-group label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-group select,
|
||||||
|
.option-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 开关样式 */
|
||||||
|
.switch {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
position: relative;
|
||||||
|
width: 40px;
|
||||||
|
height: 20px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider:before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
left: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .slider {
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .slider:before {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,29 @@
|
|||||||
|
// history.js
|
||||||
|
const HistoryManager = {
|
||||||
|
MAX_ITEMS: 20,
|
||||||
|
|
||||||
|
init: function() {
|
||||||
|
if (!localStorage.history) {
|
||||||
|
localStorage.history = JSON.stringify([]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
add: function(item) {
|
||||||
|
const history = JSON.parse(localStorage.history);
|
||||||
|
history.unshift({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
...item
|
||||||
|
});
|
||||||
|
localStorage.history = JSON.stringify(history.slice(0, this.MAX_ITEMS));
|
||||||
|
},
|
||||||
|
|
||||||
|
render: function(container) {
|
||||||
|
const history = JSON.parse(localStorage.history);
|
||||||
|
container.innerHTML = history.map(item => `
|
||||||
|
<div class="history-item">
|
||||||
|
<time>${new Date(item.timestamp).toLocaleString()}</time>
|
||||||
|
<p>${item.title}</p>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
// static/js/shared.js
|
||||||
|
const App = {
|
||||||
|
init: function () {
|
||||||
|
this.bindMenuToggle();
|
||||||
|
this.bindGlobalClick();
|
||||||
|
this.initHistoryPanel();
|
||||||
|
this.initAdvancedOptions();
|
||||||
|
this.checkLoginStatus();
|
||||||
|
this.loadSettings();
|
||||||
|
},
|
||||||
|
|
||||||
|
// 菜单切换功能
|
||||||
|
bindMenuToggle: function () {
|
||||||
|
document.querySelectorAll('.menu-button').forEach(button => {
|
||||||
|
button.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const menu = e.currentTarget.closest('.menu').querySelector('.menu-dropdown');
|
||||||
|
menu.classList.toggle('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 全局点击处理
|
||||||
|
bindGlobalClick: function () {
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
// 关闭所有菜单
|
||||||
|
document.querySelectorAll('.menu-dropdown.active').forEach(menu => {
|
||||||
|
if (!menu.contains(e.target)) {
|
||||||
|
menu.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 关闭高级选项
|
||||||
|
const advancedPanel = document.getElementById('advancedOptions');
|
||||||
|
if (advancedPanel &&
|
||||||
|
!e.target.closest('.advanced-options') &&
|
||||||
|
!e.target.closest('.advanced-toggle')) {
|
||||||
|
advancedPanel.classList.remove('active');
|
||||||
|
const toggleImg = document.querySelector('.advanced-toggle img');
|
||||||
|
if (toggleImg) toggleImg.style.transform = 'rotate(0deg)';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 历史记录面板
|
||||||
|
initHistoryPanel: function () {
|
||||||
|
const historyButton = document.querySelector('.history-button');
|
||||||
|
const historyPanel = document.getElementById('historyPanel');
|
||||||
|
|
||||||
|
if (historyButton && historyPanel) {
|
||||||
|
historyButton.addEventListener('click', () => {
|
||||||
|
historyPanel.classList.toggle('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 触摸滑动处理
|
||||||
|
let touchStartY = 0;
|
||||||
|
historyPanel.addEventListener('touchstart', e => {
|
||||||
|
touchStartY = e.touches[0].clientY;
|
||||||
|
}, false);
|
||||||
|
|
||||||
|
historyPanel.addEventListener('touchmove', e => {
|
||||||
|
const touchY = e.touches[0].clientY;
|
||||||
|
if (touchY - touchStartY > 50) {
|
||||||
|
historyPanel.classList.remove('active');
|
||||||
|
}
|
||||||
|
}, false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 高级选项功能
|
||||||
|
initAdvancedOptions: function () {
|
||||||
|
const toggleButton = document.querySelector('.advanced-toggle');
|
||||||
|
if (toggleButton) {
|
||||||
|
toggleButton.addEventListener('click', () => {
|
||||||
|
const optionsPanel = document.getElementById('advancedOptions');
|
||||||
|
optionsPanel.classList.toggle('active');
|
||||||
|
const img = toggleButton.querySelector('img');
|
||||||
|
img.style.transform = optionsPanel.classList.contains('active') ?
|
||||||
|
'rotate(180deg)' : 'rotate(0deg)';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 登录状态检查
|
||||||
|
checkLoginStatus: function () {
|
||||||
|
const loginItem = document.getElementById('loginItem');
|
||||||
|
if (loginItem) {
|
||||||
|
// 实际应通过API检查登录状态
|
||||||
|
const isLoggedIn = false;
|
||||||
|
if (isLoggedIn) {
|
||||||
|
loginItem.textContent = 'Profile';
|
||||||
|
loginItem.href = '/profile';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置管理
|
||||||
|
loadSettings: function () {
|
||||||
|
if (document.querySelector('[name="voice"]')) {
|
||||||
|
const settings = JSON.parse(localStorage.getItem('userSettings')) || {};
|
||||||
|
document.querySelector('[name="voice"]').value = settings.voice || 'male';
|
||||||
|
document.querySelector('[name="style"]').value = settings.style || 'news';
|
||||||
|
document.querySelector('[name="experimental"]').checked = settings.experimental || false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
saveSettings: function () {
|
||||||
|
if (document.querySelector('[name="voice"]')) {
|
||||||
|
const settings = {
|
||||||
|
voice: document.querySelector('[name="voice"]').value,
|
||||||
|
style: document.querySelector('[name="style"]').value,
|
||||||
|
experimental: document.querySelector('[name="experimental"]').checked
|
||||||
|
};
|
||||||
|
localStorage.setItem('userSettings', JSON.stringify(settings));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 通用表单验证
|
||||||
|
validateRegistration: function (form) {
|
||||||
|
const password = form.querySelector('#password').value;
|
||||||
|
const confirmPassword = form.querySelector('#confirmPassword').value;
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
alert('Passwords do not match!');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 播放器控制
|
||||||
|
initAudioPlayer: function () {
|
||||||
|
const audio = document.getElementById('audioPlayer');
|
||||||
|
if (!audio) return;
|
||||||
|
|
||||||
|
// 设置有效音频源
|
||||||
|
if (window.audioSource) {
|
||||||
|
audio.src = window.audioSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = document.querySelector('.progress-fill');
|
||||||
|
const playButton = document.getElementById('playButton');
|
||||||
|
|
||||||
|
// 安全更新播放按钮状态
|
||||||
|
const updatePlayButton = () => {
|
||||||
|
if (!audio.paused && !isFinite(audio.duration)) {
|
||||||
|
return; // 防止无效状态
|
||||||
|
}
|
||||||
|
const iconPath = audio.paused ? 'play.png' : 'pause.png';
|
||||||
|
playButton.querySelector('img').src =
|
||||||
|
`${window.prefix}/static/icons/${iconPath}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 音频元数据加载完成后再启用控制
|
||||||
|
audio.addEventListener('loadedmetadata', () => {
|
||||||
|
// 初始化进度条
|
||||||
|
progress.style.width = '0%';
|
||||||
|
|
||||||
|
// 绑定控制事件
|
||||||
|
document.querySelectorAll('#playButton').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
audio.paused ? audio.play().catch(console.error) : audio.pause();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.jump-back').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => this.jumpAudio(-15, audio));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.jump-forward').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => this.jumpAudio(15, audio));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 错误处理
|
||||||
|
audio.addEventListener('error', (e) => {
|
||||||
|
console.error('Audio error:', e.target.error);
|
||||||
|
alert('Error loading audio file');
|
||||||
|
});
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
jumpAudio: function (seconds, audio) {
|
||||||
|
try {
|
||||||
|
if (!isFinite(audio.duration) || audio.readyState < 2) {
|
||||||
|
console.warn('Audio not ready');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTime = Math.max(0, Math.min(
|
||||||
|
audio.currentTime + seconds,
|
||||||
|
audio.duration
|
||||||
|
));
|
||||||
|
|
||||||
|
audio.currentTime = newTime;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Jump error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化应用
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
App.init();
|
||||||
|
App.initAudioPlayer();
|
||||||
|
|
||||||
|
// 注册表单验证
|
||||||
|
const regForm = document.querySelector('.auth-form');
|
||||||
|
if (regForm) {
|
||||||
|
regForm.addEventListener('submit', (e) => {
|
||||||
|
if (!App.validateRegistration(regForm)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
After Width: | Height: | Size: 274 KiB |
@@ -0,0 +1,69 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-zoom=user-scalable=no">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<title>Error - Be my Anchor</title>
|
||||||
|
<link rel="stylesheet" href="{{ prefix }}/{{ url_for('static', filename='css/main.css') }}">
|
||||||
|
<script src="{{ prefix }}/{{ url_for('static', filename='js/main.js') }}"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<!-- 左侧历史按钮 -->
|
||||||
|
<button class="history-button" onclick="toggleHistory()">
|
||||||
|
<img src="/proxy/5000/static/icons/clock.png" alt="History">
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 中间Logo和标题 -->
|
||||||
|
<div class="branding">
|
||||||
|
<img src="/proxy/5000/static/logo.png" alt="Logo" class="brand-logo">
|
||||||
|
<span class="site-name">Be my Anchor</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧菜单组 -->
|
||||||
|
<div class="nav-group">
|
||||||
|
<div class="menu">
|
||||||
|
<button class="menu-button" onclick="toggleMenu(event)">
|
||||||
|
<img src="/proxy/5000/static/icons/people.png" alt="Menu">
|
||||||
|
</button>
|
||||||
|
<!-- 菜单下拉内容 -->
|
||||||
|
<div class="menu-dropdown" id="mainMenu">
|
||||||
|
<a href="/" class="menu-item">返回主页</a>
|
||||||
|
<a href="/report" class="menu-item">报告问题</a>
|
||||||
|
<a href="/login" class="menu-item" id="loginItem">登录</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 历史记录面板结构 -->
|
||||||
|
<div class="history-panel" id="historyPanel">
|
||||||
|
<button class="history-close" onclick="toggleHistory()">
|
||||||
|
<img src="/proxy/5000/static/icons/play-pause.png" alt="Close">
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="history-content">
|
||||||
|
<div class="history-item">
|
||||||
|
<span class="timestamp">10:30</span>
|
||||||
|
<span class="title">文章标题示例 1</span>
|
||||||
|
</div>
|
||||||
|
<!-- 更多历史记录项... -->
|
||||||
|
<div class="history-item">
|
||||||
|
<span class="timestamp">11:45</span>
|
||||||
|
<span class="title">文章标题示例 20</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<main>
|
||||||
|
<div class="error-container">
|
||||||
|
<h2>Oops! Something went wrong</h2>
|
||||||
|
<p class="error-message">{{ error_message }}</p>
|
||||||
|
<a href="/" class="primary-button">Return Home</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<!-- ./src/server/templates/index.html -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-zoom=user-scalable=no">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<title>Be my Anchor</title>
|
||||||
|
<link rel="stylesheet" href="{{ prefix }}/{{ url_for('static', filename='css/main.css') }}">
|
||||||
|
<script src="{{ prefix }}/{{ url_for('static', filename='js/main.js') }}"></script>
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<button class="history-button" onclick="toggleHistory()">
|
||||||
|
<img src="{{ prefix }}/{{ url_for('static', filename='icons/clock.png') }}" alt="History">
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="branding">
|
||||||
|
<img src="{{ prefix }}/{{ url_for('static', filename='logo.png') }}" alt="Logo" class="brand-logo">
|
||||||
|
<span class="site-name">Be my Anchor</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-group">
|
||||||
|
<div class="menu">
|
||||||
|
<button class="menu-button" onclick="toggleMenu(event)">
|
||||||
|
<img src="{{ prefix }}/{{ url_for('static', filename='icons/people.png') }}" alt="Menu">
|
||||||
|
</button>
|
||||||
|
<div class="menu-dropdown" id="mainMenu">
|
||||||
|
<a href="{{ prefix }}/login" class="menu-item">Login</a>
|
||||||
|
<a href="{{ prefix }}/register" class="menu-item">Register</a>
|
||||||
|
<a href="{{ prefix }}/report" class="menu-item">Report Issue</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- index.html 修改后的搜索部分 -->
|
||||||
|
<main class="search-main">
|
||||||
|
<div class="search-container">
|
||||||
|
<h1 class="search-title">Convert Content to Podcast</h1>
|
||||||
|
<form class="search-form" action="{{ prefix }}/page" method="get">
|
||||||
|
<!-- 搜索输入区 -->
|
||||||
|
<div class="search-group">
|
||||||
|
<textarea name="content" class="search-textarea" placeholder="Paste your text or enter URL..."
|
||||||
|
rows="4" required></textarea>
|
||||||
|
<div class="search-actions">
|
||||||
|
<button type="button" class="advanced-toggle" onclick="toggleAdvancedOptions()">
|
||||||
|
Advanced Options
|
||||||
|
<img src="{{ prefix }}/static/icons/chevron-down.svg" alt="▼">
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="search-icon-button">
|
||||||
|
<img src="{{ prefix }}/static/icons/convert.svg" alt="Convert">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 高级选项下拉 -->
|
||||||
|
<div class="advanced-options" id="advancedOptions">
|
||||||
|
<div class="option-grid">
|
||||||
|
<div class="option-group">
|
||||||
|
<label>Voice Gender</label>
|
||||||
|
<select name="voice">
|
||||||
|
<option value="male">Male</option>
|
||||||
|
<option value="female">Female</option>
|
||||||
|
<option value="neutral">Neutral</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option-group">
|
||||||
|
<label>Style</label>
|
||||||
|
<select name="style">
|
||||||
|
<option value="news">News Report</option>
|
||||||
|
<option value="blog">Casual Blog</option>
|
||||||
|
<option value="story">Story Telling</option>
|
||||||
|
<option value="academic">Academic</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option-group">
|
||||||
|
<label>Custom Prompt</label>
|
||||||
|
<input type="text" name="prompt" placeholder="Additional instructions...">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" name="experimental">
|
||||||
|
<span class="slider"></span>
|
||||||
|
<span>Experimental Mode</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- 在body末尾添加 -->
|
||||||
|
<div class="loading-indicator">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<span>Processing...</span>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-zoom=user-scalable=no">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<title>Login - Be my Anchor</title>
|
||||||
|
<link rel="stylesheet" href="{{ prefix }}/{{ url_for('static', filename='css/main.css') }}">
|
||||||
|
<script src="{{ prefix }}/{{ url_for('static', filename='js/main.js') }}"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<button class="history-button" onclick="toggleHistory()">
|
||||||
|
<img src="{{ prefix }}/{{ url_for('static', filename='icons/clock.png') }}" alt="History">
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="branding">
|
||||||
|
<img src="{{ prefix }}/{{ url_for('static', filename='logo.png') }}" alt="Logo" class="brand-logo">
|
||||||
|
<span class="site-name">Be my Anchor</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-group">
|
||||||
|
<div class="menu">
|
||||||
|
<button class="menu-button" onclick="toggleMenu(event)">
|
||||||
|
<img src="{{ prefix }}/{{ url_for('static', filename='icons/people.png') }}" alt="Menu">
|
||||||
|
</button>
|
||||||
|
<div class="menu-dropdown" id="mainMenu">
|
||||||
|
<a href="{{ prefix }}/" class="menu-item">Home</a>
|
||||||
|
<a href="{{ prefix }}/report" class="menu-item">Report Issue</a>
|
||||||
|
<a href="{{ prefix }}/register" class="menu-item">Register</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="auth-main">
|
||||||
|
<div class="auth-container">
|
||||||
|
<h2 class="auth-title">Welcome Back</h2>
|
||||||
|
|
||||||
|
<form class="auth-form" method="POST" action="/login">
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" name="username" placeholder="Username" required>
|
||||||
|
<input type="password" name="password" placeholder="Password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="primary-button">Login</button>
|
||||||
|
|
||||||
|
<div class="oauth-divider">
|
||||||
|
<span>or continue with</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="google-login" onclick="loginWithGoogle()">
|
||||||
|
<img src="{{ prefix }}/{{ url_for('static', filename='icons/google.png') }}" alt="Google">
|
||||||
|
Continue with Google
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="auth-links">
|
||||||
|
<a href="{{ prefix }}/register">Create new account</a>
|
||||||
|
<a href="{{ prefix }}/forgot-password">Forgot password?</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-zoom=user-scalable=no">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<title>Be my Anchor - {{ url }} </title>
|
||||||
|
<link rel="stylesheet" href="{{ prefix }}/{{ url_for('static', filename='css/main.css') }}">
|
||||||
|
<script>
|
||||||
|
// 定义全局路径前缀
|
||||||
|
window.prefix = "{{ prefix }}";
|
||||||
|
// 初始化音频源
|
||||||
|
window.audioSource = "{{ prefix }}/{{ url_for('static', filename='test.mp3') }}";
|
||||||
|
</script>
|
||||||
|
<script src="{{ prefix }}/{{ url_for('static', filename='js/main.js') }}"></script>
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<!-- 左侧历史按钮 -->
|
||||||
|
<button class="history-button" onclick="toggleHistory()">
|
||||||
|
<img src="{{ prefix }}/{{ url_for('static', filename='icons/clock.png') }}" alt="History">
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 中间Logo和标题 -->
|
||||||
|
<div class="branding">
|
||||||
|
<img src="{{ prefix }}/{{ url_for('static', filename='logo.png') }}" alt="Logo" class="brand-logo">
|
||||||
|
<span class="site-name">Be my Anchor</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧菜单组 -->
|
||||||
|
<div class="nav-group">
|
||||||
|
<div class="menu">
|
||||||
|
<button class="menu-button" onclick="toggleMenu(event)">
|
||||||
|
<img src="{{ prefix }}/{{ url_for('static', filename='icons/people.png') }}" alt="Menu">
|
||||||
|
</button>
|
||||||
|
<!-- 菜单下拉内容 -->
|
||||||
|
<div class="menu-dropdown" id="mainMenu">
|
||||||
|
<a href="{{ prefix }}/" class="menu-item">Home</a>
|
||||||
|
<a href="{{ prefix }}/report" class="menu-item">Report Issue</a>
|
||||||
|
<a href="{{ prefix }}/login" class="menu-item" id="loginItem">Login</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 历史记录面板结构 -->
|
||||||
|
<div class="history-panel" id="historyPanel">
|
||||||
|
<button class="history-close" onclick="toggleHistory()">
|
||||||
|
<img src="{{ prefix }}/{{ url_for('static', filename='icons/play-pause.png') }}" alt="Close">
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="history-content">
|
||||||
|
<div class="history-item">
|
||||||
|
<span class="timestamp">10:30</span>
|
||||||
|
<span class="title">文章标题示例 1</span>
|
||||||
|
</div>
|
||||||
|
<!-- 更多历史记录项... -->
|
||||||
|
<div class="history-item">
|
||||||
|
<span class="timestamp">11:45</span>
|
||||||
|
<span class="title">文章标题示例 20</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="image-container">
|
||||||
|
<img src="{{ prefix }}/{{ url_for('static', filename='logo.png') }}" alt="Cover">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 独立文字容器 -->
|
||||||
|
<div class="text-content">
|
||||||
|
<div class="url">{{url}}</div>
|
||||||
|
<div class="summary">{{summary}}</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- page.html 修改后的按钮部分 -->
|
||||||
|
<footer>
|
||||||
|
<button class="control-button jump-back">
|
||||||
|
<img src="{{ prefix }}/static/icons/back.png" alt="-15s">
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="control-button" id="playButton">
|
||||||
|
<img src="{{ prefix }}/static/icons/play.png" alt="Play/Pause">
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="control-button jump-forward">
|
||||||
|
<img src="{{ prefix }}/static/icons/fast.png" alt="+15s">
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<audio id="audioPlayer" src="{{ prefix }}/{{ url_for('static', filename='test.mp3') }}"></audio>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-zoom=user-scalable=no">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<title>Register - Be my Anchor</title>
|
||||||
|
<link rel="stylesheet" href="{{ prefix }}/{{ url_for('static', filename='css/main.css') }}">
|
||||||
|
<script src="{{ prefix }}/{{ url_for('static', filename='js/main.js') }}"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<button class="history-button" onclick="handleHistoryButton()">
|
||||||
|
<img src="{{ prefix }}/{{ url_for('static', filename='icons/clock.png') }}" alt="History">
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="branding">
|
||||||
|
<img src="{{ prefix }}/{{ url_for('static', filename='logo.png') }}" alt="Logo" class="brand-logo">
|
||||||
|
<span class="site-name">Be my Anchor</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-group">
|
||||||
|
<div class="menu">
|
||||||
|
<button class="menu-button" onclick="toggleMenu(event)">
|
||||||
|
<img src="{{ prefix }}/{{ url_for('static', filename='icons/people.png') }}" alt="Menu">
|
||||||
|
</button>
|
||||||
|
<div class="menu-dropdown" id="mainMenu">
|
||||||
|
<a href="{{ prefix }}/" class="menu-item">Home</a>
|
||||||
|
<a href="{{ prefix }}/login" class="menu-item">Login</a>
|
||||||
|
<a href="{{ prefix }}/report" class="menu-item">Report Issue</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="auth-main">
|
||||||
|
<div class="auth-container">
|
||||||
|
<h2 class="auth-title">Create Account</h2>
|
||||||
|
|
||||||
|
<form class="auth-form" onsubmit="return validateRegistration(event)">
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" id="username" placeholder="Username" required>
|
||||||
|
<input type="email" id="email" placeholder="Email" required>
|
||||||
|
<input type="password" id="password" placeholder="Password" required>
|
||||||
|
<input type="password" id="confirmPassword" placeholder="Confirm Password" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="primary-button">Register</button>
|
||||||
|
|
||||||
|
<div class="auth-links">
|
||||||
|
<span>Already have an account? <a href="{{ prefix }}/login">Login here</a></span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||