commit 84f79dc5f981643ff98eb414ccb434a5dc751d65 Author: haopengzhan Date: Wed Feb 12 07:25:09 2025 +0000 AI generated initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7005809 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Virtual Environment +venv/ +.env + +# Build artifacts +*.egg-info/ +build/ +dist/ + +# Test artifacts +.pytest_cache/ +__pycache__/ +*.pyc +run/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d7beeb6 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/makefile b/makefile new file mode 100644 index 0000000..623bf56 --- /dev/null +++ b/makefile @@ -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 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..88cb9ea --- /dev/null +++ b/pyproject.toml @@ -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"] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1f68129 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..cf068d0 --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1,5 @@ +-r requirements.txt +pytest>=7.0 +black>=23.0 +isort>=5.0 +pip-chill>=1.0 \ No newline at end of file diff --git a/src/modules/__init__.py b/src/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/html_extractor/__init__.py b/src/modules/html_extractor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/html_extractor/extract.py b/src/modules/html_extractor/extract.py new file mode 100644 index 0000000..9b8935e --- /dev/null +++ b/src/modules/html_extractor/extract.py @@ -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") \ No newline at end of file diff --git a/src/modules/summarizer/__init__.py b/src/modules/summarizer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/summarizer/llm_script.py b/src/modules/summarizer/llm_script.py new file mode 100644 index 0000000..27c9b8f --- /dev/null +++ b/src/modules/summarizer/llm_script.py @@ -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 = """ + + +

人工智能的未来发展

+

近年来,AI技术取得了突破性进展...

+ + + + """ + + # 使用OpenAI转换 + openai_converter = PodcastConverter(provider="openai") + result = openai_converter.convert(sample_html) + + if result: + print("转换结果示例:") + print(result[:500] + "...") # 打印前500字符 + else: + print("转换失败") \ No newline at end of file diff --git a/src/server/__init__.py b/src/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/server/app.py b/src/server/app.py new file mode 100644 index 0000000..fe63868 --- /dev/null +++ b/src/server/app.py @@ -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) diff --git a/src/server/static/css/main.css b/src/server/static/css/main.css new file mode 100644 index 0000000..0d7fb04 --- /dev/null +++ b/src/server/static/css/main.css @@ -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); +} \ No newline at end of file diff --git a/src/server/static/icons/back.png b/src/server/static/icons/back.png new file mode 100644 index 0000000..6c7b2bc Binary files /dev/null and b/src/server/static/icons/back.png differ diff --git a/src/server/static/icons/clock.png b/src/server/static/icons/clock.png new file mode 100644 index 0000000..61e206c Binary files /dev/null and b/src/server/static/icons/clock.png differ diff --git a/src/server/static/icons/fast.png b/src/server/static/icons/fast.png new file mode 100644 index 0000000..ec5ef3e Binary files /dev/null and b/src/server/static/icons/fast.png differ diff --git a/src/server/static/icons/music.png b/src/server/static/icons/music.png new file mode 100644 index 0000000..b7dfc73 Binary files /dev/null and b/src/server/static/icons/music.png differ diff --git a/src/server/static/icons/pause.png b/src/server/static/icons/pause.png new file mode 100644 index 0000000..91bb0af Binary files /dev/null and b/src/server/static/icons/pause.png differ diff --git a/src/server/static/icons/people.png b/src/server/static/icons/people.png new file mode 100644 index 0000000..4dffed9 Binary files /dev/null and b/src/server/static/icons/people.png differ diff --git a/src/server/static/icons/play-pause.png b/src/server/static/icons/play-pause.png new file mode 100644 index 0000000..1e60b24 Binary files /dev/null and b/src/server/static/icons/play-pause.png differ diff --git a/src/server/static/icons/play.png b/src/server/static/icons/play.png new file mode 100644 index 0000000..7f099f9 Binary files /dev/null and b/src/server/static/icons/play.png differ diff --git a/src/server/static/js/history.js b/src/server/static/js/history.js new file mode 100644 index 0000000..261c65b --- /dev/null +++ b/src/server/static/js/history.js @@ -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 => ` +
+ +

${item.title}

+
+ `).join(''); + } +}; \ No newline at end of file diff --git a/src/server/static/js/main.js b/src/server/static/js/main.js new file mode 100644 index 0000000..9e67e9a --- /dev/null +++ b/src/server/static/js/main.js @@ -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(); + } + }); + } +}); \ No newline at end of file diff --git a/src/server/static/js/search.js b/src/server/static/js/search.js new file mode 100644 index 0000000..e69de29 diff --git a/src/server/static/logo.png b/src/server/static/logo.png new file mode 100644 index 0000000..ab22df9 Binary files /dev/null and b/src/server/static/logo.png differ diff --git a/src/server/static/test.mp3 b/src/server/static/test.mp3 new file mode 100644 index 0000000..e7abf12 Binary files /dev/null and b/src/server/static/test.mp3 differ diff --git a/src/server/templates/error.html b/src/server/templates/error.html new file mode 100644 index 0000000..6a7daad --- /dev/null +++ b/src/server/templates/error.html @@ -0,0 +1,69 @@ + + + + + + + + Error - Be my Anchor + + + + + +
+ + + + +
+ + Be my Anchor +
+ + + +
+ + +
+ + +
+
+ 10:30 + 文章标题示例 1 +
+ +
+ 11:45 + 文章标题示例 20 +
+
+
+
+
+

Oops! Something went wrong

+

{{ error_message }}

+ Return Home +
+
+ + + \ No newline at end of file diff --git a/src/server/templates/index.html b/src/server/templates/index.html new file mode 100644 index 0000000..2ec6d4c --- /dev/null +++ b/src/server/templates/index.html @@ -0,0 +1,108 @@ + + + + + + + + + Be my Anchor + + + + + + +
+ + +
+ + Be my Anchor +
+ + +
+ + +
+
+

Convert Content to Podcast

+
+ +
+ +
+ + +
+
+ + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+
+
+
+ + + +
+
+ Processing... +
+ + + \ No newline at end of file diff --git a/src/server/templates/login.html b/src/server/templates/login.html new file mode 100644 index 0000000..c73a9ed --- /dev/null +++ b/src/server/templates/login.html @@ -0,0 +1,65 @@ + + + + + + + Login - Be my Anchor + + + + +
+ + +
+ + Be my Anchor +
+ + +
+ +
+
+

Welcome Back

+ +
+
+ + +
+ + +
+ or continue with +
+ + +
+ + +
+
+ + + \ No newline at end of file diff --git a/src/server/templates/page.html b/src/server/templates/page.html new file mode 100644 index 0000000..3f042c1 --- /dev/null +++ b/src/server/templates/page.html @@ -0,0 +1,102 @@ + + + + + + + + Be my Anchor - {{ url }} + + + + + + + +
+ + + + +
+ + Be my Anchor +
+ + + +
+ + +
+ + +
+
+ 10:30 + 文章标题示例 1 +
+ +
+ 11:45 + 文章标题示例 20 +
+
+
+ +
+
+ Cover +
+ + +
+
{{url}}
+
{{summary}}
+
+
+ + + + + + + + \ No newline at end of file diff --git a/src/server/templates/register.html b/src/server/templates/register.html new file mode 100644 index 0000000..4a8c17b --- /dev/null +++ b/src/server/templates/register.html @@ -0,0 +1,61 @@ + + + + + + + + Register - Be my Anchor + + + + + +
+ + +
+ + Be my Anchor +
+ + +
+ +
+
+

Create Account

+ +
+
+ + + + +
+ + + + +
+
+
+ + + + \ No newline at end of file