├── src ├── styles │ ├── hn.css │ ├── reddit.css │ ├── quora.css │ └── content.css ├── content │ ├── utils │ │ └── markdown.js │ ├── index.js │ ├── parsers │ │ ├── base.js │ │ ├── reddit.js │ │ ├── quora.js │ │ └── hn.js │ ├── platforms │ │ ├── HNSummarizer.js │ │ ├── RedditSummarizer.js │ │ └── QuoraSummarizer.js │ └── base │ │ └── BaseSummarizer.js ├── services │ ├── ai │ │ └── openai.js │ └── storage.js ├── popup │ ├── popup.css │ ├── popup.html │ └── popup.js ├── background │ └── worker.js └── options │ ├── options.html │ └── options.js ├── public ├── icons │ ├── icon128.png │ ├── icon16.png │ ├── icon32.png │ ├── icon48.png │ └── icon.svg └── manifest.json ├── chrome_store └── images │ ├── hn.jpg │ ├── hn.png │ ├── other.jpg │ ├── other.png │ ├── reddit.jpg │ ├── reddit.png │ ├── other.html │ ├── hn.svg │ ├── reddit.svg │ └── other.svg ├── .gitignore ├── package.json ├── LICENSE ├── .github └── workflows │ └── build.yml ├── webpack.config.js ├── README.md └── CONTRIBUTING.md /src/styles/hn.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/i365dev/community-tldr/HEAD/public/icons/icon128.png -------------------------------------------------------------------------------- /public/icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/i365dev/community-tldr/HEAD/public/icons/icon16.png -------------------------------------------------------------------------------- /public/icons/icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/i365dev/community-tldr/HEAD/public/icons/icon32.png -------------------------------------------------------------------------------- /public/icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/i365dev/community-tldr/HEAD/public/icons/icon48.png -------------------------------------------------------------------------------- /chrome_store/images/hn.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/i365dev/community-tldr/HEAD/chrome_store/images/hn.jpg -------------------------------------------------------------------------------- /chrome_store/images/hn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/i365dev/community-tldr/HEAD/chrome_store/images/hn.png -------------------------------------------------------------------------------- /chrome_store/images/other.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/i365dev/community-tldr/HEAD/chrome_store/images/other.jpg -------------------------------------------------------------------------------- /chrome_store/images/other.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/i365dev/community-tldr/HEAD/chrome_store/images/other.png -------------------------------------------------------------------------------- /chrome_store/images/reddit.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/i365dev/community-tldr/HEAD/chrome_store/images/reddit.jpg -------------------------------------------------------------------------------- /chrome_store/images/reddit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/i365dev/community-tldr/HEAD/chrome_store/images/reddit.png -------------------------------------------------------------------------------- /public/icons/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | npm-debug.log 4 | yarn-debug.log 5 | yarn-error.log 6 | 7 | # Build 8 | dist/ 9 | build/ 10 | 11 | # IDE 12 | .idea/ 13 | .vscode/ 14 | *.swp 15 | *.swo 16 | 17 | # OS 18 | .DS_Store 19 | Thumbs.db 20 | 21 | # Environment 22 | .env 23 | .env.local 24 | .env.development.local 25 | .env.test.local 26 | .env.production.local 27 | 28 | # Extension specific 29 | *.crx 30 | *.pem 31 | *.zip -------------------------------------------------------------------------------- /src/content/utils/markdown.js: -------------------------------------------------------------------------------- 1 | // Simple markdown renderer 2 | export const renderMarkdown = (text) => { 3 | return text 4 | // Headers 5 | .replace(/^### (.*$)/gm, '

$1

') 6 | .replace(/^## (.*$)/gm, '

$1

') 7 | .replace(/^# (.*$)/gm, '

$1

') 8 | // Lists 9 | .replace(/^\d+\. (.*$)/gm, '
  • $1
  • ') 10 | .replace(/^\* (.*$)/gm, '
  • $1
  • ') 11 | // Paragraphs 12 | .split('\n') 13 | .map(line => line.trim() ? `

    ${line}

    ` : '') 14 | .join('\n'); 15 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "community-tldr", 3 | "version": "1.1.4", 4 | "description": "AI-powered community discussion summarizer", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "webpack --watch --mode=development", 8 | "build": "webpack --mode=production", 9 | "clean": "rimraf dist" 10 | }, 11 | "keywords": ["chrome-extension", "ai", "summarizer"], 12 | "author": "", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@babel/core": "^7.22.9", 16 | "@babel/preset-env": "^7.22.9", 17 | "babel-loader": "^9.1.3", 18 | "clean-webpack-plugin": "^4.0.0", 19 | "copy-webpack-plugin": "^11.0.0", 20 | "css-loader": "^6.8.1", 21 | "html-webpack-plugin": "^5.5.3", 22 | "rimraf": "^5.0.1", 23 | "style-loader": "^3.3.3", 24 | "webpack": "^5.88.1", 25 | "webpack-cli": "^5.1.4" 26 | } 27 | } -------------------------------------------------------------------------------- /src/content/index.js: -------------------------------------------------------------------------------- 1 | import { RedditSummarizer } from './platforms/RedditSummarizer'; 2 | import { HNSummarizer } from './platforms/HNSummarizer'; 3 | import { QuoraSummarizer } from './platforms/QuoraSummarizer'; 4 | 5 | const PLATFORM_HANDLERS = { 6 | 'news.ycombinator.com': HNSummarizer, 7 | 'reddit.com': RedditSummarizer, 8 | 'www.reddit.com': RedditSummarizer, 9 | 'quora.com': QuoraSummarizer, 10 | 'www.quora.com': QuoraSummarizer 11 | }; 12 | 13 | function initializeSummarizer() { 14 | const hostname = window.location.hostname; 15 | const SummarizerClass = Object.entries(PLATFORM_HANDLERS).find(([domain]) => 16 | hostname.includes(domain) 17 | )?.[1]; 18 | 19 | if (!SummarizerClass) { 20 | console.log('Site not supported'); 21 | return; 22 | } 23 | 24 | const summarizer = new SummarizerClass(); 25 | summarizer.init(); 26 | } 27 | 28 | initializeSummarizer(); 29 | -------------------------------------------------------------------------------- /src/styles/reddit.css: -------------------------------------------------------------------------------- 1 | .tldr-summarize-btn { 2 | display: inline-block; 3 | margin-left: 10px; 4 | font-size: 12px; 5 | color: #666; 6 | cursor: pointer; 7 | text-decoration: none; 8 | } 9 | 10 | .tldr-summarize-btn:hover { 11 | color: #1a1a1b; 12 | text-decoration: underline; 13 | } 14 | 15 | .tldr-highlight { 16 | animation: tldrHighlight 3s ease-out; 17 | } 18 | 19 | @keyframes tldrHighlight { 20 | 0% { 21 | background-color: rgba(255, 102, 0, 0.2); 22 | } 23 | 70% { 24 | background-color: rgba(255, 102, 0, 0.2); 25 | } 26 | 100% { 27 | background-color: transparent; 28 | } 29 | } 30 | 31 | .tldr-summarize-btn { 32 | display: inline-block; 33 | margin-left: 10px; 34 | color: #666; 35 | font-size: 12px; 36 | cursor: pointer; 37 | text-decoration: none; 38 | user-select: none; 39 | } 40 | 41 | .tldr-summarize-btn:hover { 42 | color: #ff4500; 43 | text-decoration: none; 44 | } 45 | 46 | .tldr-btn-container { 47 | display: inline-block; 48 | vertical-align: middle; 49 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Community TL;DR Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/services/ai/openai.js: -------------------------------------------------------------------------------- 1 | export class OpenAIService { 2 | constructor(apiKey) { 3 | this.apiKey = apiKey; 4 | } 5 | 6 | async summarize(text) { 7 | try { 8 | const response = await fetch('https://api.openai.com/v1/chat/completions', { 9 | method: 'POST', 10 | headers: { 11 | 'Content-Type': 'application/json', 12 | 'Authorization': `Bearer ${this.apiKey}` 13 | }, 14 | body: JSON.stringify({ 15 | model: 'gpt-3.5-turbo', 16 | messages: [ 17 | { 18 | role: 'system', 19 | content: 'You are a helpful assistant that summarizes HN discussions.' 20 | }, 21 | { 22 | role: 'user', 23 | content: `Please summarize this HN discussion: ${text}` 24 | } 25 | ] 26 | }) 27 | }); 28 | 29 | const data = await response.json(); 30 | return data.choices[0].message.content; 31 | } catch (error) { 32 | console.error('OpenAI API error:', error); 33 | throw error; 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /src/popup/popup.css: -------------------------------------------------------------------------------- 1 | body { 2 | width: 320px; 3 | min-height: 200px; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 6 | } 7 | 8 | .popup-container { 9 | min-height: 200px; 10 | } 11 | 12 | /* Animations */ 13 | @keyframes fadeIn { 14 | from { 15 | opacity: 0; 16 | transform: translateY(-10px); 17 | } 18 | to { 19 | opacity: 1; 20 | transform: translateY(0); 21 | } 22 | } 23 | 24 | .fade-in { 25 | animation: fadeIn 0.3s ease-out; 26 | } 27 | 28 | /* Custom scrollbar */ 29 | ::-webkit-scrollbar { 30 | width: 8px; 31 | } 32 | 33 | ::-webkit-scrollbar-track { 34 | background: #f1f1f1; 35 | border-radius: 4px; 36 | } 37 | 38 | ::-webkit-scrollbar-thumb { 39 | background: #888; 40 | border-radius: 4px; 41 | } 42 | 43 | ::-webkit-scrollbar-thumb:hover { 44 | background: #666; 45 | } 46 | 47 | .line-clamp-2 { 48 | display: -webkit-box; 49 | -webkit-line-clamp: 2; 50 | -webkit-box-orient: vertical; 51 | overflow: hidden; 52 | word-break: break-word; 53 | } 54 | 55 | .break-words { 56 | word-break: break-word; 57 | } -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/build.yml 2 | 3 | name: Build and Release 4 | 5 | on: 6 | push: 7 | branches: [ main ] 8 | tags: [ 'v*' ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Setup Node.js 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: '18' 23 | cache: 'npm' 24 | 25 | - name: Install dependencies 26 | run: npm ci 27 | 28 | - name: Build extension 29 | run: npm run build 30 | 31 | - name: Create ZIP archive 32 | run: | 33 | cd dist 34 | zip -r ../community-tldr.zip . 35 | 36 | - name: Upload build artifact 37 | uses: actions/upload-artifact@v4 38 | with: 39 | name: extension-build 40 | path: community-tldr.zip 41 | 42 | - name: Create Release 43 | if: startsWith(github.ref, 'refs/tags/v') 44 | uses: softprops/action-gh-release@v1 45 | with: 46 | files: community-tldr.zip 47 | generate_release_notes: true 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | -------------------------------------------------------------------------------- /chrome_store/images/other.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 跨语言社区讨论助手 SVG 示例 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 跨语言社区讨论助手 16 | 一键获取多语言讨论要点 17 | 18 | 19 | • 支持中文、英文等多语言总结 20 | • 快速理解英文社区讨论 21 | • 智能多语言转换 22 | 23 | 24 | 突破语言障碍,链接全球社区讨论 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/styles/quora.css: -------------------------------------------------------------------------------- 1 | .tldr-summarize-btn { 2 | display: inline-block; 3 | margin-left: 10px; 4 | font-size: 13px; 5 | color: #636466; 6 | cursor: pointer; 7 | background: none; 8 | border: none; 9 | padding: 4px 8px; 10 | border-radius: 3px; 11 | transition: background-color 0.2s; 12 | } 13 | 14 | .tldr-summarize-btn:hover { 15 | background-color: #f1f1f2; 16 | } 17 | 18 | .tldr-btn-container { 19 | display: inline-block; 20 | margin-left: 10px; 21 | } 22 | 23 | /* Highlighted answer styling */ 24 | .tldr-highlight { 25 | animation: quoraTldrHighlight 3s ease-out; 26 | } 27 | 28 | @keyframes quoraTldrHighlight { 29 | 0% { 30 | background-color: rgba(46, 105, 255, 0.1); 31 | box-shadow: 0 0 0 2px rgba(46, 105, 255, 0.3); 32 | } 33 | 70% { 34 | background-color: rgba(46, 105, 255, 0.1); 35 | box-shadow: 0 0 0 2px rgba(46, 105, 255, 0.3); 36 | } 37 | 100% { 38 | background-color: transparent; 39 | box-shadow: none; 40 | } 41 | } 42 | 43 | /* Sidebar styling specific to Quora */ 44 | .tldr-sidebar .tldr-thread-summary { 45 | border-radius: 4px; 46 | margin-bottom: 12px; 47 | transition: transform 0.2s; 48 | } 49 | 50 | .tldr-sidebar .tldr-thread-summary:hover { 51 | transform: translateY(-2px); 52 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); 53 | } 54 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const CopyPlugin = require('copy-webpack-plugin'); 3 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 4 | 5 | module.exports = { 6 | mode: 'development', 7 | devtool: 'source-map', 8 | entry: { 9 | popup: './src/popup/popup.js', 10 | options: './src/options/options.js', 11 | content: './src/content/index.js', 12 | background: './src/background/worker.js' 13 | }, 14 | output: { 15 | path: path.resolve(__dirname, 'dist'), 16 | filename: '[name].js', 17 | clean: true 18 | }, 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.js$/, 23 | exclude: /node_modules/, 24 | use: { 25 | loader: 'babel-loader', 26 | options: { 27 | presets: ['@babel/preset-env'] 28 | } 29 | } 30 | }, 31 | { 32 | test: /\.css$/, 33 | use: ['style-loader', 'css-loader'] 34 | } 35 | ] 36 | }, 37 | plugins: [ 38 | new CleanWebpackPlugin({ 39 | cleanStaleWebpackAssets: false 40 | }), 41 | new CopyPlugin({ 42 | patterns: [ 43 | { 44 | from: "src/popup/popup.html", 45 | to: "popup.html" 46 | }, 47 | { 48 | from: "src/options/options.html", 49 | to: "options.html" 50 | }, 51 | { 52 | from: "src/styles", 53 | to: "styles" 54 | }, 55 | { 56 | from: "public/manifest.json", 57 | to: "manifest.json" 58 | }, 59 | { 60 | from: "public/icons", 61 | to: "icons", 62 | noErrorOnMissing: true 63 | } 64 | ] 65 | }) 66 | ], 67 | stats: { 68 | errorDetails: true 69 | } 70 | }; -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Community TL;DR", 4 | "version": "1.1.4", 5 | "description": "AI-powered community discussion summarizer", 6 | 7 | "icons": { 8 | "16": "icons/icon16.png", 9 | "48": "icons/icon48.png", 10 | "128": "icons/icon128.png" 11 | }, 12 | 13 | "action": { 14 | "default_popup": "popup.html", 15 | "default_icon": { 16 | "16": "icons/icon16.png", 17 | "48": "icons/icon48.png", 18 | "128": "icons/icon128.png" 19 | } 20 | }, 21 | 22 | "options_page": "options.html", 23 | 24 | "permissions": [ 25 | "storage", 26 | "activeTab" 27 | ], 28 | 29 | "host_permissions": [ 30 | "https://news.ycombinator.com/*", 31 | "*://*.reddit.com/*", 32 | "*://*.quora.com/*", 33 | "https://api.openai.com/*", 34 | "https://api.anthropic.com/*", 35 | "https://*.workers.dev/*" 36 | ], 37 | 38 | "content_scripts": [ 39 | { 40 | "matches": [ 41 | "https://news.ycombinator.com/*", 42 | "https://*.reddit.com/*", 43 | "*://*.quora.com/*" 44 | ], 45 | "js": ["content.js"], 46 | "css": [ 47 | "styles/content.css", 48 | "styles/hn.css", 49 | "styles/reddit.css", 50 | "styles/quora.css" 51 | ] 52 | } 53 | ], 54 | 55 | "background": { 56 | "service_worker": "background.js", 57 | "type": "module" 58 | }, 59 | 60 | "web_accessible_resources": [{ 61 | "resources": ["styles/*", "icons/*"], 62 | "matches": [ 63 | "https://news.ycombinator.com/*", 64 | "https://*.reddit.com/*", 65 | "*://*.quora.com/*" 66 | ] 67 | }] 68 | } 69 | -------------------------------------------------------------------------------- /src/content/parsers/base.js: -------------------------------------------------------------------------------- 1 | export class BaseParser { 2 | constructor() { 3 | if (this.constructor === BaseParser) { 4 | throw new Error('Cannot instantiate abstract BaseParser'); 5 | } 6 | } 7 | 8 | // Check if current page is a discussion 9 | isDiscussionPage() { 10 | throw new Error('Method isDiscussionPage() must be implemented'); 11 | } 12 | 13 | // Get page title 14 | getTitle() { 15 | throw new Error('Method getTitle() must be implemented'); 16 | } 17 | 18 | // Get page content including main post and metadata 19 | getPageContent() { 20 | throw new Error('Method getPageContent() must be implemented'); 21 | } 22 | 23 | // Parse a comment thread starting from the given element 24 | parseCommentThread(commentElement) { 25 | throw new Error('Method parseCommentThread() must be implemented'); 26 | } 27 | 28 | // Get all top-level comments 29 | getTopLevelComments() { 30 | throw new Error('Method getTopLevelComments() must be implemented'); 31 | } 32 | 33 | // Scroll to specific comment 34 | scrollToComment(commentId) { 35 | throw new Error('Method scrollToComment() must be implemented'); 36 | } 37 | 38 | // Highlight specific comment 39 | highlightComment(commentElement) { 40 | if (!commentElement) return; 41 | 42 | // Remove existing highlights 43 | document.querySelectorAll('.comment-highlight').forEach(el => 44 | el.classList.remove('comment-highlight') 45 | ); 46 | 47 | // Add highlight to target comment 48 | commentElement.classList.add('comment-highlight'); 49 | } 50 | 51 | // Get comment indentation level 52 | getCommentIndent(commentElement) { 53 | throw new Error('Method getCommentIndent() must be implemented'); 54 | } 55 | } -------------------------------------------------------------------------------- /src/content/parsers/reddit.js: -------------------------------------------------------------------------------- 1 | import { BaseParser } from './base'; 2 | 3 | export class RedditParser extends BaseParser { 4 | isDiscussionPage() { 5 | return window.location.pathname.includes('/comments/'); 6 | } 7 | 8 | getTitle() { 9 | const titleElement = document.querySelector('h1'); 10 | return titleElement ? titleElement.textContent.trim() : ''; 11 | } 12 | 13 | getTopLevelComments() { 14 | return Array.from(document.querySelectorAll('shreddit-comment')).filter(comment => 15 | comment.getAttribute('depth') === '0' 16 | ); 17 | } 18 | 19 | initializeThreadControls() { 20 | const topLevelComments = Array.from(document.querySelectorAll('shreddit-comment')).filter(comment => 21 | comment.getAttribute('depth') === '0' 22 | ); 23 | 24 | topLevelComments.forEach(comment => { 25 | const timeElement = comment.querySelector('a[rel="nofollow noopener noreferrer"]'); 26 | if (!timeElement || timeElement.nextSibling?.classList?.contains('tldr-summarize-btn')) { 27 | return; 28 | } 29 | 30 | const tldrLink = document.createElement('a'); 31 | tldrLink.href = '#'; 32 | tldrLink.textContent = 'TL;DR'; 33 | tldrLink.className = 'tldr-summarize-btn'; 34 | tldrLink.style.marginLeft = '10px'; 35 | 36 | tldrLink.addEventListener('click', (event) => { 37 | event.preventDefault(); 38 | if (this.contentScript) { 39 | this.contentScript.summarizeThread({ 40 | id: comment.id, 41 | text: comment.innerText 42 | }); 43 | } 44 | }); 45 | 46 | timeElement.parentNode.insertBefore(tldrLink, timeElement.nextSibling); 47 | }); 48 | } 49 | 50 | getPageContent() { 51 | if (!this.isDiscussionPage()) return null; 52 | 53 | const mainPost = { 54 | title: this.getTitle(), 55 | url: window.location.href 56 | }; 57 | 58 | const threads = this.getTopLevelComments().map(comment => ({ 59 | id: comment.id, 60 | root: { 61 | text: comment.innerText 62 | } 63 | })); 64 | 65 | return { mainPost, threads }; 66 | } 67 | 68 | setContentScript(contentScript) { 69 | this.contentScript = contentScript; 70 | } 71 | } -------------------------------------------------------------------------------- /chrome_store/images/hn.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | HN Discussion Summaries 10 | 11 | 12 | 13 | Essential insights from technical discussions 14 | 15 | 16 | 17 | 18 | 19 | 20 | Technical discussion highlights 21 | 22 | 23 | 24 | 25 | 26 | 27 | Quick thread overviews 28 | 29 | 30 | 31 | 32 | 33 | 34 | Seamless HN integration 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | Get to the core of technical discussions faster 69 | 70 | -------------------------------------------------------------------------------- /chrome_store/images/reddit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Reddit Discussions Made Easy 10 | 11 | 12 | 13 | Instant summaries for Reddit threads 14 | 15 | 16 | 17 | 18 | 19 | 20 | One-click thread analysis 21 | 22 | 23 | 24 | 25 | 26 | 27 | Smart comment summarization 28 | 29 | 30 | 31 | 32 | 33 | 34 | Reddit-native experience 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | Never miss key insights in long Reddit threads 70 | 71 | -------------------------------------------------------------------------------- /chrome_store/images/other.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 跨语言社区讨论助手 10 | 11 | 12 | 13 | 一键获取多语言讨论要点 14 | 15 | 16 | 17 | 18 | 19 | 20 | 支持中文、英文等多语言总结 21 | 22 | 23 | 24 | 25 | 26 | 27 | 快速理解英文社区讨论 28 | 29 | 30 | 31 | 32 | 33 | 34 | 智能多语言转换 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 56 | 57 | 58 | 59 | 60 | 61 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | Original 76 | 中文总结 77 | 78 | 79 | 80 | 81 | 突破语言障碍,链接全球社区讨论 82 | 83 | 84 | -------------------------------------------------------------------------------- /src/styles/content.css: -------------------------------------------------------------------------------- 1 | /* styles/content.css */ 2 | 3 | .tldr-sidebar { 4 | position: fixed; 5 | top: 0; 6 | right: 0; 7 | width: 320px; 8 | height: 100vh; 9 | background: white; 10 | box-shadow: -2px 0 5px rgba(0,0,0,0.1); 11 | z-index: 999999; 12 | display: flex; 13 | flex-direction: column; 14 | transform: translateX(100%); 15 | transition: transform 0.3s ease; 16 | } 17 | 18 | .tldr-sidebar.visible { 19 | transform: translateX(0); 20 | } 21 | 22 | .tldr-sidebar-header { 23 | padding: 16px; 24 | background: #f6f6f6; 25 | border-bottom: 1px solid #e5e5e5; 26 | display: flex; 27 | justify-content: space-between; 28 | align-items: center; 29 | } 30 | 31 | .tldr-sidebar-title { 32 | font-size: 16px; 33 | font-weight: 600; 34 | color: #333; 35 | margin: 0; 36 | } 37 | 38 | .tldr-sidebar-close { 39 | background: none; 40 | border: none; 41 | cursor: pointer; 42 | padding: 4px 8px; 43 | font-size: 20px; 44 | color: #666; 45 | border-radius: 4px; 46 | } 47 | 48 | .tldr-sidebar-close:hover { 49 | background: #eee; 50 | color: #333; 51 | } 52 | 53 | .tldr-sidebar-content { 54 | flex: 1; 55 | overflow-y: auto; 56 | padding: 16px; 57 | } 58 | 59 | .tldr-loading { 60 | display: flex; 61 | flex-direction: column; 62 | align-items: center; 63 | justify-content: center; 64 | padding: 20px; 65 | color: #666; 66 | } 67 | 68 | .tldr-loading-spinner { 69 | border: 2px solid #f3f3f3; 70 | border-top: 2px solid #3498db; 71 | border-radius: 50%; 72 | width: 24px; 73 | height: 24px; 74 | animation: spin 1s linear infinite; 75 | margin-bottom: 12px; 76 | } 77 | 78 | @keyframes spin { 79 | 0% { transform: rotate(0deg); } 80 | 100% { transform: rotate(360deg); } 81 | } 82 | 83 | .tldr-summary-card { 84 | background: #fff; 85 | border: 1px solid #e5e5e5; 86 | border-radius: 6px; 87 | padding: 16px; 88 | margin-bottom: 16px; 89 | } 90 | 91 | .tldr-summary-card-header { 92 | margin-bottom: 12px; 93 | } 94 | 95 | .tldr-summary-card-title { 96 | font-size: 14px; 97 | font-weight: 600; 98 | color: #333; 99 | margin: 0 0 4px 0; 100 | } 101 | 102 | .tldr-summary-card-meta { 103 | font-size: 12px; 104 | color: #666; 105 | } 106 | 107 | .tldr-summary-content { 108 | font-size: 14px; 109 | line-height: 1.5; 110 | color: #444; 111 | } 112 | 113 | /* 确保侧边栏在移动设备上也能正常显示 */ 114 | @media (max-width: 768px) { 115 | .tldr-sidebar { 116 | width: 100%; 117 | } 118 | } 119 | 120 | .markdown-body { 121 | padding: 12px; 122 | font-size: 14px; 123 | line-height: 1.6; 124 | } 125 | 126 | .markdown-body h1, 127 | .markdown-body h2, 128 | .markdown-body h3 { 129 | margin-top: 16px; 130 | margin-bottom: 12px; 131 | } 132 | 133 | .markdown-body p { 134 | margin-bottom: 8px; 135 | } 136 | 137 | .markdown-body ul, 138 | .markdown-body ol { 139 | padding-left: 20px; 140 | margin-bottom: 8px; 141 | } 142 | 143 | .markdown-body code { 144 | background-color: rgba(27,31,35,0.05); 145 | padding: 2px 4px; 146 | border-radius: 3px; 147 | } 148 | 149 | .markdown-body pre { 150 | background-color: #f6f8fa; 151 | padding: 12px; 152 | border-radius: 4px; 153 | overflow-x: auto; 154 | } -------------------------------------------------------------------------------- /src/services/storage.js: -------------------------------------------------------------------------------- 1 | const DEFAULT_SETTINGS = { 2 | aiProvider: 'openai', 3 | apiKey: '', 4 | endpoint: '', 5 | model: 'gpt-3.5-turbo', 6 | summaryLength: 'medium', 7 | autoSummarize: true 8 | }; 9 | 10 | const CACHE_PREFIX = 'tldr_cache_'; 11 | const CACHE_EXPIRY = 24 * 60 * 60 * 1000; 12 | 13 | export class StorageService { 14 | static async getSettings() { 15 | try { 16 | const settings = await chrome.storage.sync.get(DEFAULT_SETTINGS); 17 | return { ...DEFAULT_SETTINGS, ...settings }; 18 | } catch (error) { 19 | console.error('Failed to load settings:', error); 20 | return DEFAULT_SETTINGS; 21 | } 22 | } 23 | 24 | static async saveSettings(settings) { 25 | try { 26 | await chrome.storage.sync.set(settings); 27 | return true; 28 | } catch (error) { 29 | console.error('Failed to save settings:', error); 30 | return false; 31 | } 32 | } 33 | 34 | static async getCachedSummary(key) { 35 | const cacheKey = CACHE_PREFIX + key; 36 | try { 37 | const result = await chrome.storage.local.get(cacheKey); 38 | if (result[cacheKey]) { 39 | const { timestamp, data } = result[cacheKey]; 40 | if (Date.now() - timestamp < CACHE_EXPIRY) { 41 | return data; 42 | } else { 43 | await chrome.storage.local.remove(cacheKey); 44 | } 45 | } 46 | return null; 47 | } catch (error) { 48 | console.error('Failed to get cached summary:', error); 49 | return null; 50 | } 51 | } 52 | 53 | static async cacheSummary(key, data) { 54 | const cacheKey = CACHE_PREFIX + key; 55 | try { 56 | await chrome.storage.local.set({ 57 | [cacheKey]: { 58 | timestamp: Date.now(), 59 | data 60 | } 61 | }); 62 | return true; 63 | } catch (error) { 64 | console.error('Failed to cache summary:', error); 65 | return false; 66 | } 67 | } 68 | 69 | static async clearCache(key) { 70 | const cacheKey = CACHE_PREFIX + key; 71 | try { 72 | await chrome.storage.local.remove(cacheKey); 73 | return true; 74 | } catch (error) { 75 | console.error('Failed to clear cache:', error); 76 | return false; 77 | } 78 | } 79 | 80 | static async clearAllCache() { 81 | try { 82 | const all = await chrome.storage.local.get(null); 83 | const cacheKeys = Object.keys(all).filter(key => 84 | key.startsWith(CACHE_PREFIX) 85 | ); 86 | 87 | if (cacheKeys.length > 0) { 88 | await chrome.storage.local.remove(cacheKeys); 89 | } 90 | return true; 91 | } catch (error) { 92 | console.error('Failed to clear all cache:', error); 93 | return false; 94 | } 95 | } 96 | 97 | static async getCacheStats() { 98 | try { 99 | const all = await chrome.storage.local.get(null); 100 | const cacheEntries = Object.entries(all).filter(([key]) => 101 | key.startsWith(CACHE_PREFIX) 102 | ); 103 | 104 | const stats = { 105 | totalEntries: cacheEntries.length, 106 | totalSize: 0, 107 | oldestEntry: Date.now(), 108 | newestEntry: 0 109 | }; 110 | 111 | cacheEntries.forEach(([, value]) => { 112 | stats.totalSize += JSON.stringify(value).length; 113 | stats.oldestEntry = Math.min(stats.oldestEntry, value.timestamp); 114 | stats.newestEntry = Math.max(stats.newestEntry, value.timestamp); 115 | }); 116 | 117 | return stats; 118 | } catch (error) { 119 | console.error('Failed to get cache stats:', error); 120 | return null; 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/content/parsers/quora.js: -------------------------------------------------------------------------------- 1 | import { BaseParser } from './base'; 2 | 3 | export class QuoraParser extends BaseParser { 4 | isDiscussionPage() { 5 | // Check if current page is a question page 6 | return window.location.hostname.includes('quora.com') && 7 | (window.location.pathname.includes('/answer/') || 8 | document.querySelector('.q-box.qu-borderAll') !== null); 9 | } 10 | 11 | getTitle() { 12 | // Get the question title 13 | const titleElement = document.querySelector('div[class*="q-text"][id^="mainContent"] span'); 14 | return titleElement ? titleElement.textContent.trim() : 'Quora Discussion'; 15 | } 16 | 17 | getTopLevelComments() { 18 | // Get all answers (Quora's equivalent of top-level comments) 19 | return Array.from(document.querySelectorAll('div[class*="q-box"][id^="answer_"]')); 20 | } 21 | 22 | parseCommentThread(answerElement) { 23 | if (!answerElement) { 24 | console.warn('Invalid answer element provided'); 25 | return null; 26 | } 27 | 28 | // Extract answer content 29 | const contentElement = answerElement.querySelector('div[class*="q-text"]'); 30 | const authorElement = answerElement.querySelector('a[class*="q-box"][href^="/profile/"]'); 31 | const upvoteElement = answerElement.querySelector('div[class*="q-text"][class*="qu-color--gray"]'); 32 | 33 | const thread = { 34 | id: answerElement.id, 35 | root: { 36 | text: contentElement ? contentElement.textContent.trim() : '', 37 | by: authorElement ? authorElement.textContent.trim() : 'Anonymous', 38 | score: upvoteElement ? upvoteElement.textContent.trim() : '', 39 | element: answerElement, 40 | level: 0 41 | }, 42 | replies: [], 43 | level: 0 44 | }; 45 | 46 | // Extract comments on this answer if available 47 | const commentsContainer = answerElement.querySelector('div[class*="q-box"][class*="qu-mt--medium"] div[class*="q-box"][class*="qu-borderTop"]'); 48 | if (commentsContainer) { 49 | const comments = commentsContainer.querySelectorAll('div[class*="q-box"][class*="qu-pt--medium"]'); 50 | 51 | comments.forEach(comment => { 52 | const commentText = comment.querySelector('div[class*="q-text"]'); 53 | const commentAuthor = comment.querySelector('a[class*="q-box"][href^="/profile/"]'); 54 | 55 | thread.replies.push({ 56 | text: commentText ? commentText.textContent.trim() : '', 57 | by: commentAuthor ? commentAuthor.textContent.trim() : 'Anonymous', 58 | level: 1 59 | }); 60 | }); 61 | } 62 | 63 | return thread; 64 | } 65 | 66 | getPageContent() { 67 | if (!this.isDiscussionPage()) { 68 | return null; 69 | } 70 | 71 | // Get question content 72 | const mainPost = { 73 | title: this.getTitle(), 74 | url: window.location.href, 75 | text: this.getQuestionDetails() 76 | }; 77 | 78 | // Get and process all answers 79 | const threads = []; 80 | const answers = this.getTopLevelComments(); 81 | 82 | answers.forEach(answer => { 83 | const thread = this.parseCommentThread(answer); 84 | if (thread) { 85 | threads.push(thread); 86 | } 87 | }); 88 | 89 | return { 90 | mainPost, 91 | threads 92 | }; 93 | } 94 | 95 | getQuestionDetails() { 96 | // Get additional question details if available 97 | const detailsElement = document.querySelector('div[class*="q-box"][class*="qu-mt--small"] div[class*="q-text"]'); 98 | return detailsElement ? detailsElement.textContent.trim() : ''; 99 | } 100 | 101 | scrollToComment(commentId) { 102 | const comment = document.getElementById(commentId); 103 | if (comment) { 104 | comment.scrollIntoView({ behavior: 'smooth', block: 'center' }); 105 | this.highlightComment(comment); 106 | } 107 | } 108 | 109 | getCommentIndent(commentElement) { 110 | // Quora doesn't have traditional indentation like HN or Reddit 111 | // Return 0 for top-level answers and 1 for comments 112 | return 0; 113 | } 114 | 115 | setContentScript(contentScript) { 116 | this.contentScript = contentScript; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/popup/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Community TL;DR 6 | 7 | 8 | 9 | 10 | 90 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /src/background/worker.js: -------------------------------------------------------------------------------- 1 | // Cache prefix for storage 2 | const CACHE_PREFIX = 'tldr_cache_'; 3 | // Cache expiration time (24 hours) 4 | const CACHE_EXPIRY = 24 * 60 * 60 * 1000; 5 | 6 | // Listen for messages from content script 7 | chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 8 | console.log('Background worker received message:', request); 9 | 10 | if (request.type === 'SUMMARIZE') { 11 | console.log('Processing summarize request...'); 12 | handleSummarize(request.data, sender.tab?.id).then(sendResponse); 13 | return true; // Keep message channel open for async response 14 | } 15 | }); 16 | 17 | // Handle summarization requests 18 | async function handleSummarize(data, tabId) { 19 | try { 20 | // Get AI service configuration 21 | const settings = await chrome.storage.sync.get({ 22 | aiProvider: 'custom', 23 | apiKey: '', 24 | endpoint: '', 25 | model: 'gpt-3.5-turbo', 26 | summaryLength: 'medium', 27 | language: 'chinese' // Default language 28 | }); 29 | 30 | console.log('Using AI settings:', { 31 | provider: settings.aiProvider, 32 | endpoint: settings.endpoint, 33 | language: settings.language 34 | }); 35 | 36 | // Call AI service for summarization 37 | const summary = await callAIService(settings, { 38 | prompt: data.content, 39 | temperature: 0.7, 40 | max_tokens: getSummaryLength(settings.summaryLength) 41 | }); 42 | 43 | return { success: true, data: summary }; 44 | } catch (error) { 45 | console.error('Summarization error:', error); 46 | return { 47 | success: false, 48 | error: error.message || 'Failed to generate summary' 49 | }; 50 | } 51 | } 52 | 53 | // Get system prompt based on language 54 | function getSystemPrompt(language) { 55 | switch (language.toLowerCase()) { 56 | case 'chinese': 57 | return '你是一个专业的讨论帖总结助手。请以中文总结讨论内容,注重提取关键观点、主要论据和讨论结果。总结应该清晰、准确、全面。回复格式:\n1. 主要观点:\n2. 关键见解:\n3. 讨论结果:'; 58 | case 'japanese': 59 | return 'あなたは専門的な議論要約アシスタントです。議論の内容を日本語で要約し、重要なポイント、主な論点、議論の結果に焦点を当ててください。要約は明確で、正確で、包括的である必要があります。'; 60 | case 'korean': 61 | return '당신은 전문적인 토론 요약 도우미입니다. 토론 내용을 한국어로 요약하고, 주요 논점, 주요 논거, 토론 결과를 중심으로 정리해 주세요. 요약은 명확하고 정확하며 포괄적이어야 합니다.'; 62 | case 'quora': 63 | return 'You are a professional Quora answer summarizer. Please create a concise summary that captures the key information, insights, and evidence from the answer. Format your response as:\n1. Main Points:\n2. Key Insights:\n3. Examples or Evidence:'; 64 | default: 65 | return 'You are a professional discussion summarizer. Please summarize the discussion clearly, accurately, and comprehensively, focusing on key points, main arguments, and discussion outcomes. Format your response as:\n1. Main Points:\n2. Key Insights:\n3. Discussion Results:'; 66 | } 67 | } 68 | 69 | // Call AI service with proper configuration 70 | async function callAIService(settings, requestData) { 71 | if (!settings.endpoint) { 72 | throw new Error('AI service endpoint not configured'); 73 | } 74 | 75 | if (!settings.apiKey) { 76 | throw new Error('API key not configured'); 77 | } 78 | 79 | return await callCustomEndpoint(settings, requestData); 80 | } 81 | 82 | // Call custom endpoint with provided settings 83 | async function callCustomEndpoint(settings, requestData) { 84 | try { 85 | console.log('Calling custom endpoint:', settings.endpoint); 86 | 87 | const response = await fetch(settings.endpoint, { 88 | method: 'POST', 89 | headers: { 90 | 'Content-Type': 'application/json', 91 | 'Authorization': `Bearer ${settings.apiKey}` 92 | }, 93 | body: JSON.stringify({ 94 | model: settings.model || 'gpt-3.5-turbo', 95 | messages: [ 96 | { 97 | role: 'system', 98 | content: getSystemPrompt(settings.language) 99 | }, 100 | { 101 | role: 'user', 102 | content: requestData.prompt 103 | } 104 | ], 105 | temperature: 0.7, 106 | max_tokens: requestData.max_tokens || 500 107 | }) 108 | }); 109 | 110 | if (!response.ok) { 111 | const error = await response.json(); 112 | throw new Error(error.error?.message || 'AI service error'); 113 | } 114 | 115 | const result = await response.json(); 116 | console.log('API response:', result); 117 | 118 | // Extract the summary from the response 119 | return result.choices?.[0]?.message?.content || result.response || result.summary; 120 | } catch (error) { 121 | console.error('Custom endpoint error:', error); 122 | throw error; 123 | } 124 | } 125 | 126 | // Get appropriate token length based on summary length setting 127 | function getSummaryLength(length) { 128 | switch (length) { 129 | case 'short': 130 | return 256; 131 | case 'medium': 132 | return 512; 133 | case 'long': 134 | return 1024; 135 | default: 136 | return 300; 137 | } 138 | } 139 | 140 | // Listen for extension installation 141 | chrome.runtime.onInstalled.addListener(async (details) => { 142 | if (details.reason === 'install') { 143 | // Open options page for initial configuration 144 | chrome.runtime.openOptionsPage(); 145 | } 146 | }); 147 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Community TL;DR 2 | 3 | A Chrome extension powered by AI to generate quick summaries of community discussions. Get insights from lengthy threads without reading through everything. 4 | 5 | Currently released on the [Chrome Web Store](https://chromewebstore.google.com/detail/community-tldr/kikhlploiflbfpdliimemhelcpneobfm). 6 | 7 | ![](./chrome_store//images//reddit.png) 8 | ![](./chrome_store/images/hn.png) 9 | ![](./chrome_store//images//other.png) 10 | 11 | ## Features 12 | 13 | - 🤖 AI-powered discussion summarization 14 | - 🌐 Support for major communities (Hacker News, Reddit) 15 | - 🌍 Multiple language support 16 | - English 17 | - Chinese (中文) 18 | - Japanese (日本語) 19 | - Korean (한국어) 20 | - ⚙️ Flexible AI backend 21 | - Custom API endpoint 22 | - OpenAI API 23 | - Anthropic Claude 24 | - Cloudflare AI Worker 25 | - 📱 Thread-level summaries with side-by-side view 26 | - 🎯 Focus on key points and insights 27 | - 📊 Smart content analysis and language translation 28 | 29 | ## Supported Communities 30 | 31 | Currently supported: 32 | 33 | - ✅ Hacker News discussions 34 | - ✅ Reddit threads 35 | - ✅ Quora answers 36 | 37 | Coming soon: 38 | - 🔄 Twitter threads 39 | - 🔄 GitHub Discussions 40 | - More suggestions welcome! 41 | 42 | ## Installation 43 | 44 | ### Chrome Web Store (Recommended) 45 | 46 | 1. Visit the [Chrome Web Store page](https://chromewebstore.google.com/detail/community-tldr/kikhlploiflbfpdliimemhelcpneobfm) 47 | 2. Click "Add to Chrome" 48 | 3. Follow the installation prompts 49 | 50 | ### Manual Installation (Development) 51 | 52 | 1. Clone the repository 53 | 2. Install dependencies: `npm install` 54 | 3. Build: `npm run build` 55 | 4. Load unpacked extension from `dist` folder 56 | 57 | ## Configuration 58 | 59 | 1. Click the extension icon and go to Settings 60 | 2. Configure your AI service: 61 | - Custom Endpoint (recommended) 62 | - OpenAI 63 | - Anthropic (Claude) 64 | - Cloudflare AI Worker 65 | 3. Set your preferred: 66 | - Summary language 67 | - Summary length 68 | - Auto-summarize options 69 | 4. Save settings and start using 70 | 71 | ## Usage 72 | 73 | 1. Visit a supported site (Hacker News or Reddit) 74 | 2. Click the extension icon or use the TL;DR button 75 | 3. Choose summarization options: 76 | - Full discussion summary 77 | - Individual thread summary 78 | 4. View summaries in the sidebar 79 | 5. Click summaries to navigate to original content 80 | 81 | ## Development 82 | 83 | ```bash 84 | # Install dependencies 85 | npm install 86 | 87 | # Development build with watch mode 88 | npm run dev 89 | 90 | # Production build 91 | npm run build 92 | ``` 93 | 94 | ## Contributing 95 | 96 | We welcome contributions! See our [Contributing Guide](CONTRIBUTING.md) for: 97 | - Adding new community support 98 | - Improving summarization 99 | - Bug fixes and feature enhancements 100 | - Documentation improvements 101 | 102 | ### Adding Support for New Communities 103 | 104 | Check our [Contributing Guide](CONTRIBUTING.md) for: 105 | - Architecture overview 106 | - Parser implementation 107 | - Testing requirements 108 | - PR submission process 109 | 110 | ## Roadmap 111 | 112 | ## Phase 1: Core Feature Enhancement 113 | 114 | ### Universal Content Support 115 | - [ ] Generic webpage & article summarization 116 | - Basic article parsing engine 117 | - Support for common article layouts 118 | - Handling of dynamic content 119 | - [ ] Email thread summarization 120 | - Gmail basic integration 121 | - Email thread parser 122 | - Plain text email support 123 | - [ ] Additional community platforms 124 | - Twitter/X threads 125 | - GitHub Discussions 126 | - Stack Overflow Q&A 127 | 128 | ### Self-hosted Web Version 129 | - [ ] Basic web application 130 | - Docker deployment support 131 | - Simple URL input interface 132 | - Summary viewing & sharing 133 | - [ ] Chrome extension sync capability 134 | - Local storage sync 135 | - Settings synchronization 136 | - History sync (optional) 137 | - [ ] Basic API endpoints 138 | - Summary generation 139 | - URL processing 140 | - Health checks 141 | 142 | ## Phase 2: Technical Improvements 143 | 144 | ### Parser Framework Enhancement 145 | - [ ] Modular parser architecture 146 | - Easy addition of new sources 147 | - Parser plugin system 148 | - Common parsing utilities 149 | - [ ] Content extraction improvements 150 | - Better noise removal 151 | - Main content detection 152 | - Comment thread handling 153 | 154 | ### AI Backend Flexibility 155 | - [ ] Enhanced AI provider support 156 | - Improved prompt templates 157 | - Better error handling 158 | - Failover support 159 | - [ ] Local AI model support 160 | - Basic local model integration 161 | - Offline capabilities 162 | - Model selection flexibility 163 | 164 | ## Phase 3: Quality of Life Features 165 | 166 | ### User Experience 167 | - [ ] Improved summary formats 168 | - Bullet point summaries 169 | - Key points extraction 170 | - Source linking 171 | - [ ] Basic customization 172 | - Summary length options 173 | - Language preferences 174 | - Style selection 175 | 176 | ### Developer Experience 177 | - [ ] Documentation improvements 178 | - Setup guides 179 | - API documentation 180 | - Contributing guidelines 181 | - [ ] Development tooling 182 | - Testing frameworks 183 | - Development containers 184 | - Local development setup 185 | 186 | ## Continuous Improvement 187 | 188 | ### Core Functionality 189 | - [ ] Performance optimization 190 | - [ ] Bug fixes 191 | - [ ] Code quality improvements 192 | - [ ] Test coverage expansion 193 | 194 | ### Community 195 | - [ ] Issue template refinement 196 | - [ ] Pull request guidelines 197 | - [ ] Community support channels 198 | - [ ] Regular maintenance updates 199 | 200 | ## Privacy & Security 201 | 202 | - No user data collection 203 | - Local storage for settings only 204 | - Secure API handling 205 | - Privacy-focused design 206 | -------------------------------------------------------------------------------- /src/popup/popup.js: -------------------------------------------------------------------------------- 1 | class PopupManager { 2 | constructor() { 3 | this.currentTab = null; 4 | this.init(); 5 | } 6 | 7 | async init() { 8 | await this.getCurrentTab(); 9 | this.setupEventListeners(); 10 | await this.updateUI(); 11 | } 12 | 13 | async getCurrentTab() { 14 | const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); 15 | this.currentTab = tab; 16 | } 17 | 18 | setupEventListeners() { 19 | // Summary button handler 20 | const summarizeBtn = document.getElementById('summarizeBtn'); 21 | if (summarizeBtn) { 22 | summarizeBtn.addEventListener('click', () => this.handleSummarize()); 23 | } 24 | 25 | // Settings button handler 26 | const settingsBtn = document.getElementById('settingsButton'); 27 | if (settingsBtn) { 28 | settingsBtn.addEventListener('click', () => { 29 | chrome.runtime.openOptionsPage(); 30 | }); 31 | } 32 | 33 | // Sidebar toggle button handler 34 | const toggleSidebarBtn = document.getElementById('toggleSidebarBtn'); 35 | if (toggleSidebarBtn) { 36 | toggleSidebarBtn.addEventListener('click', () => this.handleToggleSidebar()); 37 | } 38 | } 39 | 40 | async updateUI() { 41 | try { 42 | // Check if current page is supported 43 | const isHN = this.currentTab.url.includes('news.ycombinator.com'); 44 | const isReddit = this.currentTab.url.includes('reddit.com'); 45 | const isQuora = this.currentTab.url.includes('quora.com'); 46 | const isSupportedSite = isHN || isReddit || isQuora; 47 | 48 | // Update UI visibility 49 | document.getElementById('notOnCommunity').style.display = 50 | isSupportedSite ? 'none' : 'block'; 51 | 52 | document.getElementById('onCommunity').style.display = 53 | isSupportedSite ? 'block' : 'none'; 54 | 55 | if (isSupportedSite) { 56 | try { 57 | // Get page info from content script 58 | const pageInfo = await this.sendMessageToTab('getPageInfo'); 59 | console.log('Page info:', pageInfo); 60 | 61 | // Update page type and title 62 | const pageTypeElement = document.getElementById('pageType'); 63 | const pageTitleElement = document.getElementById('pageTitle'); 64 | 65 | if (pageTypeElement) { 66 | pageTypeElement.textContent = pageInfo.isDiscussion ? 'Discussion' : 'Listing'; 67 | } 68 | 69 | if (pageTitleElement) { 70 | pageTitleElement.textContent = pageInfo.title || 'Untitled'; 71 | } 72 | 73 | // Update button visibility based on page type 74 | document.getElementById('summarizeBtn').style.display = 75 | pageInfo.isDiscussion ? 'block' : 'none'; 76 | 77 | document.getElementById('toggleSidebarBtn').style.display = 78 | pageInfo.isDiscussion ? 'block' : 'none'; 79 | } catch (error) { 80 | console.error('Error getting page info:', error); 81 | this.showError('Could not get page information. Please refresh the page.'); 82 | } 83 | } 84 | } catch (error) { 85 | console.error('Error updating UI:', error); 86 | this.showError('Failed to update UI'); 87 | } 88 | } 89 | 90 | async handleSummarize() { 91 | try { 92 | this.setStatus('Generating summary...', true); 93 | const response = await this.sendMessageToTab('summarize'); 94 | 95 | if (response.success) { 96 | this.setStatus('Summary generated!'); 97 | setTimeout(() => this.clearStatus(), 2000); 98 | } else { 99 | throw new Error(response.error || 'Failed to generate summary'); 100 | } 101 | } catch (error) { 102 | console.error('Summarization error:', error); 103 | this.showError(error.message); 104 | } 105 | } 106 | 107 | async handleToggleSidebar() { 108 | try { 109 | await this.sendMessageToTab('toggleSidebar'); 110 | } catch (error) { 111 | console.error('Toggle sidebar error:', error); 112 | this.showError('Could not toggle sidebar'); 113 | } 114 | } 115 | 116 | async sendMessageToTab(action, data = {}) { 117 | if (!this.currentTab?.id) { 118 | throw new Error('No active tab found'); 119 | } 120 | 121 | try { 122 | const response = await chrome.tabs.sendMessage(this.currentTab.id, { action, ...data }); 123 | return response; 124 | } catch (error) { 125 | console.error('Message send error:', error); 126 | throw new Error('Could not communicate with the page. Please refresh and try again.'); 127 | } 128 | } 129 | 130 | setStatus(message, loading = false) { 131 | const statusMessage = document.getElementById('statusMessage'); 132 | const statusText = document.getElementById('statusText'); 133 | 134 | if (statusMessage && statusText) { 135 | statusText.textContent = message; 136 | statusMessage.classList.toggle('hidden', !message); 137 | const spinner = statusMessage.querySelector('svg'); 138 | if (spinner) { 139 | spinner.classList.toggle('hidden', !loading); 140 | } 141 | } 142 | } 143 | 144 | clearStatus() { 145 | this.setStatus(''); 146 | } 147 | 148 | showError(message) { 149 | this.setStatus(`Error: ${message}`); 150 | setTimeout(() => this.clearStatus(), 3000); 151 | } 152 | } 153 | 154 | // Initialize popup 155 | document.addEventListener('DOMContentLoaded', () => { 156 | new PopupManager(); 157 | }); 158 | -------------------------------------------------------------------------------- /src/content/parsers/hn.js: -------------------------------------------------------------------------------- 1 | import { BaseParser } from './base'; 2 | 3 | export class HNParser extends BaseParser { 4 | isDiscussionPage() { 5 | return window.location.pathname.startsWith('/item'); 6 | } 7 | 8 | getTitle() { 9 | return document.querySelector('.storylink')?.textContent || 10 | document.querySelector('.titleline a')?.textContent || 11 | 'Hacker News Discussion'; 12 | } 13 | 14 | getTopLevelComments() { 15 | return Array.from(document.querySelectorAll('.comment-tree > tbody > tr.athing.comtr')); 16 | } 17 | 18 | parseCommentThread(rootComment) { 19 | if (!rootComment?.classList?.contains('comtr')) { 20 | log.warn('Invalid comment element provided'); 21 | return null; 22 | } 23 | 24 | const thread = { 25 | id: rootComment.id, 26 | root: this.parseComment(rootComment), 27 | replies: [], 28 | level: this.getCommentIndent(rootComment), 29 | }; 30 | 31 | let currentElement = rootComment.nextElementSibling; 32 | const rootIndent = thread.level; 33 | 34 | while (currentElement) { 35 | const isComment = currentElement.classList.contains('comtr'); 36 | if (!isComment) { 37 | currentElement = currentElement.nextElementSibling; 38 | continue; 39 | } 40 | 41 | const currentIndent = this.getCommentIndent(currentElement); 42 | 43 | if (currentIndent <= rootIndent) { 44 | break; 45 | } 46 | 47 | const reply = this.parseComment(currentElement); 48 | if (reply) { 49 | reply.level = currentIndent - rootIndent; 50 | thread.replies.push(reply); 51 | } 52 | 53 | currentElement = currentElement.nextElementSibling; 54 | } 55 | 56 | return thread; 57 | } 58 | 59 | getPageContent() { 60 | if (!this.isDiscussionPage()) { 61 | return null; 62 | } 63 | 64 | // Get main post content 65 | const mainPost = { 66 | title: this.getTitle(), 67 | url: document.querySelector('.titleline a')?.href, 68 | text: document.querySelector('.toptext')?.textContent, 69 | by: document.querySelector('.hnuser')?.textContent, 70 | time: document.querySelector('.age')?.textContent 71 | }; 72 | 73 | // Get and process all comments in a single pass 74 | const threads = this.processComments(); 75 | 76 | return { 77 | mainPost, 78 | threads 79 | }; 80 | } 81 | 82 | processComments() { 83 | // Process top-level comments and nested threads in a single pass 84 | const comments = []; 85 | const commentElements = Array.from(document.querySelectorAll('.comment-tree > tbody > tr.athing.comtr')); 86 | 87 | let currentThread = null; 88 | commentElements.forEach((commentElement) => { 89 | const currentIndent = this.getCommentIndent(commentElement); 90 | 91 | if (!currentThread || currentIndent === 0) { 92 | // Start a new thread if we are at top-level or after ending a previous thread 93 | currentThread = { 94 | id: commentElement.id, 95 | root: this.parseComment(commentElement), 96 | replies: [], 97 | level: 0 98 | }; 99 | comments.push(currentThread); 100 | } else if (currentThread && currentIndent > currentThread.level) { 101 | // Add nested comments to the current thread based on indentation level 102 | const reply = this.parseComment(commentElement); 103 | if (reply) { 104 | reply.level = currentIndent - currentThread.level; 105 | currentThread.replies.push(reply); 106 | } 107 | } 108 | }); 109 | 110 | return comments; 111 | } 112 | 113 | parseComment(commentElement) { 114 | if (!commentElement) return null; 115 | 116 | const commentText = commentElement.querySelector('.commtext'); 117 | const userElement = commentElement.querySelector('.hnuser'); 118 | const ageElement = commentElement.querySelector('.age'); 119 | const scoreElement = commentElement.querySelector('.score'); 120 | 121 | return { 122 | id: commentElement.id, 123 | text: commentText?.textContent?.trim() || '', 124 | by: userElement?.textContent || '', 125 | time: ageElement?.getAttribute('title') || ageElement?.textContent || '', 126 | score: scoreElement?.textContent || '', 127 | element: commentElement, 128 | level: this.getCommentIndent(commentElement) 129 | }; 130 | } 131 | 132 | getCommentIndent(commentElement) { 133 | const indentElement = commentElement.querySelector('.ind img'); 134 | if (!indentElement) return 0; 135 | 136 | // HN uses width attribute for indentation, each level is 40px 137 | const width = parseInt(indentElement.getAttribute('width') || '0', 10); 138 | return Math.floor(width / 40); 139 | } 140 | 141 | scrollToComment(commentId) { 142 | const comment = document.getElementById(commentId); 143 | if (comment) { 144 | comment.scrollIntoView({ behavior: 'smooth', block: 'center' }); 145 | this.highlightComment(comment); 146 | 147 | // Add specific HN highlight style 148 | comment.style.backgroundColor = '#f6f6ef'; 149 | comment.style.borderLeft = '3px solid #ff6600'; 150 | 151 | // Remove highlight after 3 seconds 152 | setTimeout(() => { 153 | comment.style.backgroundColor = ''; 154 | comment.style.borderLeft = ''; 155 | }, 3000); 156 | } 157 | } 158 | 159 | setContentScript(contentScript) { 160 | this.contentScript = contentScript; 161 | } 162 | 163 | destroy() { 164 | // Remove any added styles or elements 165 | document.getElementById('highlight-styles')?.remove(); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/options/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Community TL;DR Settings 6 | 7 | 8 | 9 |
    10 |
    11 |
    12 |

    Community TL;DR Settings

    13 |
    14 | 15 |
    16 | 17 |
    18 |

    AI Provider Configuration

    19 |
    20 | 23 | 29 |
    30 | 31 | 32 |
    33 | 34 |
    35 |
    36 | 37 | 38 |
    39 |

    Summary Preferences

    40 | 41 |
    42 | 45 | 51 |
    52 | 53 |
    54 | 57 | 62 |
    63 | 64 |
    65 | 66 | 69 |
    70 |
    71 | 72 | 73 |
    74 |

    Customization

    75 | 76 |
    77 | 80 | 85 |
    86 |
    87 | 88 | 89 |
    90 |

    Supported Communities

    91 | 92 |
    93 |
    94 | 95 | 98 |
    99 | 100 |
    101 | 102 | 105 |
    106 | 107 |
    108 | 109 | 112 |
    113 |
    114 |
    115 |
    116 | 117 |
    118 |
    119 | Powered by i365.tech 120 | 123 |
    124 |
    125 |
    126 |
    127 | 128 | 129 | 130 | 131 | -------------------------------------------------------------------------------- /src/content/platforms/HNSummarizer.js: -------------------------------------------------------------------------------- 1 | import { BaseSummarizer } from '../base/BaseSummarizer'; 2 | import { HNParser } from '../parsers/hn'; 3 | import { renderMarkdown } from '../utils/markdown'; 4 | 5 | export class HNSummarizer extends BaseSummarizer { 6 | constructor() { 7 | super(); 8 | this.parser = new HNParser(); 9 | this.parser.setContentScript(this); 10 | } 11 | 12 | initializeUI() { 13 | this.sidebar = this.getOrCreateSidebar(); 14 | 15 | if (this.parser.isDiscussionPage()) { 16 | this.addTLDRLinks(); 17 | } 18 | } 19 | 20 | addTLDRLinks() { 21 | const topLevelComments = this.parser.getTopLevelComments(); 22 | 23 | topLevelComments.forEach(comment => { 24 | const commentHead = comment.querySelector('.comhead'); 25 | if (commentHead && !commentHead.querySelector('.tldr-summarize-btn')) { 26 | const summarizeBtn = document.createElement('a'); 27 | summarizeBtn.href = 'javascript:void(0)'; 28 | summarizeBtn.className = 'tldr-summarize-btn'; 29 | summarizeBtn.textContent = 'TL;DR'; 30 | summarizeBtn.style.marginLeft = '4px'; 31 | summarizeBtn.style.color = '#666'; 32 | summarizeBtn.style.fontSize = '11px'; 33 | 34 | summarizeBtn.addEventListener('click', (e) => { 35 | e.preventDefault(); 36 | this.summarizeThread(comment); 37 | }); 38 | 39 | commentHead.appendChild(summarizeBtn); 40 | } 41 | }); 42 | } 43 | 44 | async summarizeThread(commentElement) { 45 | const threadId = commentElement.id; 46 | const summarizeBtn = commentElement.querySelector('.tldr-summarize-btn'); 47 | 48 | try { 49 | // Update button state to loading 50 | if (summarizeBtn) { 51 | summarizeBtn.textContent = 'Summarizing...'; 52 | summarizeBtn.style.color = '#999'; 53 | } 54 | 55 | // Get thread content with replies 56 | const thread = this.parser.parseCommentThread(commentElement); 57 | 58 | // Prepare content for summarization 59 | const threadContent = ` 60 | Thread started by ${thread.root.by}: 61 | ${thread.root.text} 62 | 63 | Thread discussion (${thread.replies.length} replies): 64 | ${thread.replies.map(reply => `[${reply.by}]: ${reply.text}`).join('\n\n')} 65 | 66 | Please summarize focusing on: 67 | 1. Main points and arguments 68 | 2. Key insights or conclusions 69 | 3. Any consensus or disagreements 70 | `; 71 | 72 | const summary = await this.summarizeContent({ 73 | content: threadContent, 74 | type: 'thread' 75 | }); 76 | 77 | // Store summary data 78 | this.threadSummaries.set(threadId, { 79 | summary, 80 | author: thread.root.by, 81 | replyCount: thread.replies.length, 82 | timestamp: Date.now() 83 | }); 84 | 85 | // Update UI 86 | this.updateThreadSummary(threadId); 87 | 88 | // Show sidebar if hidden 89 | if (!this.sidebarVisible) { 90 | this.handleToggleSidebar(); 91 | } 92 | 93 | // Update button to success state 94 | if (summarizeBtn) { 95 | summarizeBtn.textContent = 'TL;DR ✓'; 96 | summarizeBtn.style.color = '#090'; 97 | } 98 | } catch (error) { 99 | console.error(`Error summarizing thread ${threadId}:`, error); 100 | if (summarizeBtn) { 101 | summarizeBtn.textContent = 'TL;DR (error)'; 102 | summarizeBtn.style.color = '#c00'; 103 | } 104 | } 105 | } 106 | 107 | updateThreadSummary(threadId) { 108 | const threadData = this.threadSummaries.get(threadId); 109 | if (!threadData) return; 110 | 111 | let threadsList = document.getElementById('tldr-threads-list'); 112 | if (!threadsList) { 113 | const content = this.sidebar.querySelector('.tldr-sidebar-content'); 114 | if (!content) return; 115 | 116 | const threadsContainer = document.createElement('div'); 117 | threadsContainer.className = 'tldr-threads-container'; 118 | threadsContainer.innerHTML = ` 119 |

    Thread Summaries

    120 |
    121 | `; 122 | content.appendChild(threadsContainer); 123 | threadsList = threadsContainer.querySelector('#tldr-threads-list'); 124 | } 125 | 126 | let threadElement = threadsList.querySelector(`[data-thread-id="${threadId}"]`); 127 | if (!threadElement) { 128 | threadElement = document.createElement('div'); 129 | threadElement.className = 'tldr-thread-summary'; 130 | threadElement.setAttribute('data-thread-id', threadId); 131 | threadsList.insertBefore(threadElement, threadsList.firstChild); 132 | } 133 | 134 | threadElement.innerHTML = ` 135 |
    136 | ${threadData.author} 137 | ${threadData.replyCount} replies 138 |
    139 |
    140 | ${renderMarkdown(threadData.summary)} 141 |
    142 | `; 143 | 144 | threadElement.addEventListener('click', () => { 145 | this.parser.scrollToComment(threadId); 146 | }); 147 | } 148 | 149 | formatContentForSummary(content) { 150 | let formattedContent = ''; 151 | 152 | if (content.mainPost) { 153 | formattedContent += `Title: ${content.mainPost.title}\n`; 154 | if (content.mainPost.url) formattedContent += `URL: ${content.mainPost.url}\n`; 155 | if (content.mainPost.by) formattedContent += `Author: ${content.mainPost.by}\n`; 156 | if (content.mainPost.text) formattedContent += `\nContent:\n${content.mainPost.text}\n`; 157 | } 158 | 159 | if (content.threads && content.threads.length > 0) { 160 | formattedContent += '\n\nDiscussion:\n'; 161 | content.threads.forEach(thread => { 162 | if (thread.root && thread.root.text) { 163 | formattedContent += `\n[${thread.root.by || 'anonymous'}]: ${thread.root.text}\n`; 164 | 165 | if (thread.replies && thread.replies.length > 0) { 166 | thread.replies.forEach(reply => { 167 | const indent = ' '.repeat(reply.level || 1); 168 | formattedContent += `${indent}↳ [${reply.by || 'anonymous'}]: ${reply.text}\n`; 169 | }); 170 | } 171 | } 172 | }); 173 | } 174 | 175 | return formattedContent; 176 | } 177 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Community TL;DR 2 | 3 | We love your input! We want to make contributing to Community TL;DR as easy and transparent as possible, whether it's: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | - Adding support for new communities 10 | 11 | ## Development Process 12 | 13 | We use GitHub to host code, to track issues and feature requests, as well as accept pull requests. 14 | 15 | 1. Fork the repo and create your branch from `main`. 16 | 2. If you've added code that should be tested, add tests. 17 | 3. If you've changed APIs, update the documentation. 18 | 4. Ensure the test suite passes. 19 | 5. Make sure your code lints. 20 | 6. Issue that pull request! 21 | 22 | ## Adding Support for New Communities 23 | 24 | To add support for a new community (e.g., Twitter), you'll need to create two main components: 25 | 26 | 1. Create a Parser (`src/content/parsers/twitter.js`): 27 | ```javascript 28 | export class TwitterParser extends BaseParser { 29 | isDiscussionPage() { 30 | return window.location.pathname.includes('/status/'); 31 | } 32 | 33 | getTitle() { 34 | // Get thread title or first tweet content 35 | return document.querySelector('[data-testid="tweetText"]')?.textContent || ''; 36 | } 37 | 38 | getTopLevelComments() { 39 | // Get top-level replies only 40 | const comments = document.querySelectorAll('[data-testid="tweet"]'); 41 | return Array.from(comments).filter(comment => { 42 | // Filter for top-level comments only 43 | return true; // Implement your logic 44 | }); 45 | } 46 | } 47 | ``` 48 | 49 | 2. Create a Summarizer (`src/content/platforms/TwitterSummarizer.js`): 50 | ```javascript 51 | import { BaseSummarizer } from '../base/BaseSummarizer'; 52 | import { TwitterParser } from '../parsers/twitter'; 53 | import { renderMarkdown } from '../utils/markdown'; 54 | 55 | export class TwitterSummarizer extends BaseSummarizer { 56 | constructor() { 57 | super(); 58 | this.parser = new TwitterParser(); 59 | this.parser.setContentScript(this); 60 | this.setupCommentObserver(); 61 | } 62 | 63 | setupCommentObserver() { 64 | const observer = new MutationObserver((mutations) => { 65 | for (const mutation of mutations) { 66 | if (mutation.addedNodes.length) { 67 | // Check for new comments and add TLDR buttons 68 | const comments = document.querySelectorAll('[data-testid="tweet"]'); 69 | if (comments.length > 0) { 70 | this.addTLDRLinks(); 71 | } 72 | } 73 | } 74 | }); 75 | 76 | observer.observe(document.body, { 77 | childList: true, 78 | subtree: true 79 | }); 80 | } 81 | 82 | addTLDRLinks() { 83 | const topLevelComments = this.parser.getTopLevelComments(); 84 | 85 | topLevelComments.forEach(comment => { 86 | if (!comment.querySelector('.tldr-summarize-btn')) { 87 | const tldrContainer = document.createElement('span'); 88 | tldrContainer.className = 'tldr-btn-container'; 89 | 90 | const tldrLink = document.createElement('a'); 91 | tldrLink.textContent = 'TL;DR'; 92 | tldrLink.className = 'tldr-summarize-btn'; 93 | tldrLink.style.marginLeft = '10px'; 94 | 95 | tldrLink.addEventListener('mousedown', (event) => { 96 | event.preventDefault(); 97 | event.stopPropagation(); 98 | this.summarizeThread({ 99 | id: comment.id, 100 | text: comment.innerText 101 | }); 102 | }); 103 | 104 | // Add TLDR button to the tweet actions area 105 | const actionsBar = comment.querySelector('[role="group"]'); 106 | if (actionsBar) { 107 | actionsBar.appendChild(tldrContainer); 108 | } 109 | } 110 | }); 111 | } 112 | } 113 | ``` 114 | 115 | 3. Update index.js to include the new platform: 116 | ```javascript 117 | // src/content/index.js 118 | import { RedditSummarizer } from './platforms/RedditSummarizer'; 119 | import { HNSummarizer } from './platforms/HNSummarizer'; 120 | import { TwitterSummarizer } from './platforms/TwitterSummarizer'; 121 | 122 | const PLATFORM_HANDLERS = { 123 | 'news.ycombinator.com': HNSummarizer, 124 | 'reddit.com': RedditSummarizer, 125 | 'www.reddit.com': RedditSummarizer, 126 | 'twitter.com': TwitterSummarizer 127 | }; 128 | 129 | function initializeSummarizer() { 130 | const hostname = window.location.hostname; 131 | const SummarizerClass = Object.entries(PLATFORM_HANDLERS).find(([domain]) => 132 | hostname.includes(domain) 133 | )?.[1]; 134 | 135 | if (!SummarizerClass) { 136 | console.log('Site not supported'); 137 | return; 138 | } 139 | 140 | const summarizer = new SummarizerClass(); 141 | summarizer.init(); 142 | } 143 | 144 | initializeSummarizer(); 145 | ``` 146 | 147 | 4. Update manifest.json: 148 | ```json 149 | { 150 | "host_permissions": [ 151 | "https://news.ycombinator.com/*", 152 | "*://*.reddit.com/*", 153 | "https://twitter.com/*" 154 | ], 155 | "content_scripts": [{ 156 | "matches": [ 157 | "https://news.ycombinator.com/*", 158 | "*://*.reddit.com/*", 159 | "https://twitter.com/*" 160 | ], 161 | "js": ["content.js"], 162 | "css": ["styles/content.css"] 163 | }] 164 | } 165 | ``` 166 | 167 | ### Key Components Requirements 168 | 169 | When implementing a new platform, ensure your components handle: 170 | 171 | 1. Parser: 172 | - Discussion page detection 173 | - Comment extraction 174 | - Thread structure parsing 175 | 176 | 2. Summarizer: 177 | - Dynamic content loading (using MutationObserver if needed) 178 | - TLDR button insertion 179 | - Summary UI updates 180 | - Platform-specific event handling 181 | 182 | 3. Error Handling: 183 | - Graceful degradation if elements aren't found 184 | - Proper error messages 185 | - Recovery from failed summarizations 186 | 187 | ## Code Style Guidelines 188 | 189 | - Use meaningful variable names 190 | - Document platform-specific quirks 191 | - Keep functions small and focused 192 | - Handle dynamic content loading properly 193 | - Test across different states of the platform's UI 194 | 195 | ## Testing New Platforms 196 | 197 | - Test with various thread lengths 198 | - Test with dynamic content loading 199 | - Verify proper event handling 200 | - Check for memory leaks with long-running observers 201 | - Test error cases and recovery 202 | 203 | ## Pull Request Process 204 | 205 | 1. Update the README.md with details of the new platform 206 | 2. Include screenshots of the integration 207 | 3. Document any platform-specific limitations 208 | 4. Update version numbers following SemVer 209 | 210 | ## Questions? 211 | 212 | Feel free to open an issue for any questions about implementing new platforms! 213 | -------------------------------------------------------------------------------- /src/content/platforms/RedditSummarizer.js: -------------------------------------------------------------------------------- 1 | import { BaseSummarizer } from '../base/BaseSummarizer'; 2 | import { RedditParser } from '../parsers/reddit'; 3 | import { renderMarkdown } from '../utils/markdown'; 4 | 5 | export class RedditSummarizer extends BaseSummarizer { 6 | constructor() { 7 | super(); 8 | this.parser = new RedditParser(); 9 | this.parser.setContentScript(this); 10 | this.setupCommentObserver(); 11 | } 12 | 13 | setupCommentObserver() { 14 | const observer = new MutationObserver((mutations) => { 15 | for (const mutation of mutations) { 16 | if (mutation.addedNodes.length) { 17 | const comments = document.querySelectorAll('shreddit-comment'); 18 | // console.log('Found shreddit comments:', comments.length); 19 | if (comments.length > 0) { 20 | // console.log('First comment properties:', { 21 | // postId: comments[0].postId, 22 | // author: comments[0].author, 23 | // hasTimeElement: !!comments[0].querySelector('a[rel="nofollow noopener noreferrer"]') 24 | // }); 25 | this.addTLDRLinks(); 26 | } 27 | } 28 | } 29 | }); 30 | 31 | observer.observe(document.body, { 32 | childList: true, 33 | subtree: true 34 | }); 35 | } 36 | 37 | initializeUI() { 38 | this.sidebar = this.getOrCreateSidebar(); 39 | } 40 | 41 | addTLDRLinks() { 42 | const topLevelComments = this.parser.getTopLevelComments(); 43 | // console.log('Found top level comments:', topLevelComments.length); 44 | 45 | topLevelComments.forEach(comment => { 46 | // console.log('Processing comment:', { 47 | // id: comment.id, 48 | // postId: comment.postId, 49 | // author: comment.author, 50 | // hasExistingTLDR: !!comment.querySelector('.tldr-summarize-btn'), 51 | // hasTimeElement: !!comment.querySelector('a[rel="nofollow noopener noreferrer"]') 52 | // }); 53 | 54 | const timeElement = comment.querySelector('a[rel="nofollow noopener noreferrer"]'); 55 | if (!timeElement) { 56 | console.log('Failed to find time element for comment'); 57 | return; 58 | } 59 | 60 | if (comment.querySelector('.tldr-summarize-btn')) { 61 | console.log('TLDR button already exists for comment'); 62 | return; 63 | } 64 | 65 | try { 66 | const tldrContainer = document.createElement('span'); 67 | tldrContainer.className = 'tldr-btn-container'; 68 | tldrContainer.setAttribute('data-thread-id', comment.postId || comment.id); 69 | 70 | const tldrLink = document.createElement('a'); 71 | tldrLink.href = 'javascript:void(0)'; 72 | tldrLink.textContent = 'TL;DR'; 73 | tldrLink.className = 'tldr-summarize-btn'; 74 | tldrLink.style.marginLeft = '10px'; 75 | tldrLink.style.cursor = 'pointer'; 76 | tldrLink.style.color = '#666'; 77 | 78 | tldrLink.addEventListener('mousedown', (event) => { 79 | event.preventDefault(); 80 | event.stopPropagation(); 81 | 82 | const button = event.currentTarget; 83 | button.textContent = 'Summarizing...'; 84 | button.style.color = '#999'; 85 | 86 | this.summarizeThread({ 87 | id: comment.postId || comment.id, 88 | author: comment.author || 'anonymous', 89 | replyCount: this.countReplies(comment), 90 | text: comment.innerText, 91 | button: button 92 | }); 93 | }, true); 94 | 95 | tldrLink.addEventListener('click', (event) => { 96 | event.preventDefault(); 97 | event.stopPropagation(); 98 | }, true); 99 | 100 | tldrContainer.appendChild(tldrLink); 101 | 102 | // console.log('Attempting to insert TLDR button'); 103 | timeElement.parentNode.insertBefore(tldrContainer, timeElement.nextSibling); 104 | // console.log('Successfully inserted TLDR button'); 105 | } catch (error) { 106 | console.error('Error adding TLDR link:', error); 107 | } 108 | }); 109 | } 110 | 111 | async summarizeThread({ id, author, replyCount, text, button }) { 112 | console.log('Starting to summarize thread:', id); 113 | 114 | try { 115 | const threadContent = ` 116 | Thread content: 117 | ${text} 118 | 119 | Please summarize focusing on: 120 | 1. Main points and arguments 121 | 2. Key insights or conclusions 122 | 3. Any consensus or disagreements 123 | `; 124 | 125 | const summary = await this.summarizeContent({ 126 | content: threadContent, 127 | type: 'thread' 128 | }); 129 | 130 | this.threadSummaries.set(id, { 131 | summary, 132 | author, 133 | replyCount, 134 | timestamp: Date.now() 135 | }); 136 | 137 | this.updateThreadSummary(id); 138 | 139 | if (!this.sidebarVisible) { 140 | this.handleToggleSidebar(); 141 | } 142 | 143 | // Update button with checkmark 144 | if (button) { 145 | button.textContent = 'TL;DR ✓'; 146 | button.style.color = '#090'; 147 | } 148 | 149 | } catch (error) { 150 | console.error('Thread summarization error:', error); 151 | if (button) { 152 | button.textContent = 'TL;DR (error)'; 153 | button.style.color = '#c00'; 154 | } 155 | } 156 | } 157 | 158 | countReplies(commentElement) { 159 | // Count all nested replies 160 | const allReplies = commentElement.querySelectorAll('shreddit-comment'); 161 | return allReplies.length; 162 | } 163 | 164 | updateThreadSummary(threadId) { 165 | const threadData = this.threadSummaries.get(threadId); 166 | if (!threadData) return; 167 | 168 | let threadsList = this.sidebar.querySelector('#tldr-threads-list'); 169 | if (!threadsList) { 170 | const content = this.sidebar.querySelector('.tldr-sidebar-content'); 171 | if (!content) return; 172 | 173 | const threadsContainer = document.createElement('div'); 174 | threadsContainer.className = 'tldr-threads-container'; 175 | threadsContainer.innerHTML = ` 176 |

    Thread Summaries

    177 |
    178 | `; 179 | content.appendChild(threadsContainer); 180 | threadsList = threadsContainer.querySelector('#tldr-threads-list'); 181 | } 182 | 183 | // Create new thread summary element (always add to top) 184 | let threadElement = document.createElement('div'); 185 | threadElement.className = 'tldr-thread-summary'; 186 | threadElement.setAttribute('data-thread-id', threadId); 187 | 188 | threadElement.innerHTML = ` 189 |
    190 | ${threadData.author} 191 | ${threadData.replyCount} replies 192 |
    193 |
    194 | ${renderMarkdown(threadData.summary)} 195 |
    196 | `; 197 | 198 | // Style the summary header 199 | const header = threadElement.querySelector('.tldr-thread-header'); 200 | if (header) { 201 | header.style.marginBottom = '8px'; 202 | header.style.color = '#666'; 203 | header.style.fontSize = '12px'; 204 | } 205 | 206 | // Add click handler to scroll to comment 207 | threadElement.addEventListener('click', () => { 208 | const commentElement = document.getElementById(threadId); 209 | if (commentElement) { 210 | commentElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); 211 | } 212 | }); 213 | 214 | // Insert at the top of the list 215 | threadsList.insertBefore(threadElement, threadsList.firstChild); 216 | } 217 | } -------------------------------------------------------------------------------- /src/content/base/BaseSummarizer.js: -------------------------------------------------------------------------------- 1 | import { renderMarkdown } from '../utils/markdown'; 2 | 3 | export class BaseSummarizer { 4 | constructor() { 5 | this.sidebarVisible = false; 6 | this.threadSummaries = new Map(); 7 | this.settings = null; 8 | this.parser = null; 9 | } 10 | 11 | async init() { 12 | this.settings = await this.loadSettings(); 13 | this.setupMessageListener(); 14 | this.initializeUI(); 15 | console.log('Community TL;DR: Content script loaded'); 16 | } 17 | 18 | async loadSettings() { 19 | try { 20 | return await chrome.storage.sync.get({ 21 | aiProvider: 'custom', 22 | apiKey: '', 23 | endpoint: '', 24 | model: 'gpt-3.5-turbo', 25 | summaryLength: 'medium', 26 | language: 'chinese', 27 | autoSummarize: false 28 | }); 29 | } catch (error) { 30 | console.error('Failed to load settings:', error); 31 | return {}; 32 | } 33 | } 34 | 35 | setupMessageListener() { 36 | chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 37 | console.log('Content script received message:', request); 38 | 39 | const action = typeof request === 'string' ? request : request.action; 40 | 41 | switch (action) { 42 | case 'summarize': 43 | (async () => { 44 | try { 45 | const result = await this.handleSummarize(); 46 | sendResponse(result); 47 | } catch (error) { 48 | console.error('Summarization error:', error); 49 | sendResponse({ 50 | success: false, 51 | error: error.message || 'Summarization failed' 52 | }); 53 | } 54 | })(); 55 | return true; 56 | 57 | case 'toggleSidebar': 58 | try { 59 | this.handleToggleSidebar(); 60 | sendResponse({ success: true }); 61 | } catch (error) { 62 | console.error('Toggle sidebar error:', error); 63 | sendResponse({ 64 | success: false, 65 | error: error.message || 'Toggle sidebar failed' 66 | }); 67 | } 68 | return true; 69 | 70 | case 'getPageInfo': 71 | try { 72 | const pageInfo = this.getPageInfo(); 73 | sendResponse(pageInfo); 74 | } catch (error) { 75 | console.error('Get page info error:', error); 76 | sendResponse({ 77 | success: false, 78 | error: error.message || 'Failed to get page info' 79 | }); 80 | } 81 | return true; 82 | 83 | default: 84 | console.warn('Unknown message action:', action); 85 | sendResponse({ 86 | success: false, 87 | error: 'Unknown action' 88 | }); 89 | return true; 90 | } 91 | }); 92 | } 93 | 94 | getPageInfo() { 95 | return { 96 | isDiscussion: this.parser.isDiscussionPage(), 97 | title: this.parser.getTitle(), 98 | url: window.location.href 99 | }; 100 | } 101 | 102 | async handleSummarize() { 103 | try { 104 | const sidebar = this.getOrCreateSidebar(); 105 | this.updateSidebarContent({ loading: true, message: 'Analyzing discussion...' }); 106 | sidebar.classList.add('visible'); 107 | this.sidebarVisible = true; 108 | 109 | const content = this.parser.getPageContent(); 110 | if (!content) { 111 | throw new Error('Could not extract page content'); 112 | } 113 | 114 | const summary = await this.summarizeContent({ 115 | content: this.formatContentForSummary(content), 116 | type: 'discussion' 117 | }); 118 | 119 | this.updateSidebarContent({ 120 | type: 'main_post', 121 | title: content.mainPost?.title || 'Discussion', 122 | summary: summary, 123 | threadCount: content.threads?.length || 0 124 | }); 125 | 126 | return { success: true }; 127 | } catch (error) { 128 | console.error('Summarization error:', error); 129 | this.updateSidebarContent({ 130 | error: true, 131 | message: error.message || 'Failed to generate summary' 132 | }); 133 | throw error; 134 | } 135 | } 136 | 137 | async summarizeContent(data) { 138 | try { 139 | const response = await chrome.runtime.sendMessage({ 140 | type: 'SUMMARIZE', 141 | data: { 142 | content: data.content, 143 | type: data.type, 144 | language: this.settings?.language || 'chinese' 145 | } 146 | }); 147 | 148 | if (!response.success) { 149 | throw new Error(response.error || 'Failed to generate summary'); 150 | } 151 | 152 | return response.data; 153 | } catch (error) { 154 | console.error('Summarization error:', error); 155 | throw error; 156 | } 157 | } 158 | 159 | formatContentForSummary(content) { 160 | let formattedContent = ''; 161 | 162 | if (content.mainPost) { 163 | formattedContent += `Title: ${content.mainPost.title}\n`; 164 | if (content.mainPost.url) formattedContent += `URL: ${content.mainPost.url}\n`; 165 | } 166 | 167 | if (content.threads && content.threads.length > 0) { 168 | formattedContent += '\n\nDiscussion:\n'; 169 | content.threads.forEach(thread => { 170 | if (thread.root && thread.root.text) { 171 | formattedContent += `\n${thread.root.text}\n`; 172 | } 173 | }); 174 | } 175 | 176 | return formattedContent; 177 | } 178 | 179 | handleToggleSidebar() { 180 | const sidebar = this.getOrCreateSidebar(); 181 | this.sidebarVisible = !sidebar.classList.contains('visible'); 182 | sidebar.classList.toggle('visible'); 183 | } 184 | 185 | getOrCreateSidebar() { 186 | let sidebar = document.querySelector('.tldr-sidebar'); 187 | 188 | if (!sidebar) { 189 | sidebar = document.createElement('div'); 190 | sidebar.className = 'tldr-sidebar'; 191 | sidebar.innerHTML = ` 192 |
    193 |

    Discussion Summary

    194 | 195 |
    196 |
    197 | `; 198 | 199 | document.body.appendChild(sidebar); 200 | 201 | const closeBtn = sidebar.querySelector('.tldr-sidebar-close'); 202 | closeBtn.addEventListener('click', () => { 203 | sidebar.classList.remove('visible'); 204 | this.sidebarVisible = false; 205 | }); 206 | } 207 | 208 | return sidebar; 209 | } 210 | 211 | updateSidebarContent(data) { 212 | const content = this.sidebar.querySelector('.tldr-sidebar-content'); 213 | if (!content) return; 214 | 215 | if (data.loading) { 216 | content.innerHTML = ` 217 |
    218 |
    219 |

    ${data.message || 'Loading...'}

    220 |
    221 | `; 222 | return; 223 | } 224 | 225 | if (data.error) { 226 | content.innerHTML = ` 227 |
    228 |

    ${data.message || 'An error occurred'}

    229 |
    230 | `; 231 | return; 232 | } 233 | 234 | if (data.type === 'main_post' && !content.querySelector('.tldr-main-content')) { 235 | content.innerHTML = ` 236 |
    237 |
    238 |
    239 |

    ${data.title}

    240 |
    241 | ${data.threadCount} comments in total 242 |
    243 |
    244 |
    245 | ${renderMarkdown(data.summary)} 246 |
    247 |
    248 |
    249 |
    250 |

    Thread Summaries

    251 |
    252 |
    253 | `; 254 | } 255 | } 256 | 257 | // Abstract methods to be implemented by platform-specific classes 258 | initializeUI() { 259 | throw new Error('initializeUI must be implemented by platform-specific class'); 260 | } 261 | } -------------------------------------------------------------------------------- /src/content/platforms/QuoraSummarizer.js: -------------------------------------------------------------------------------- 1 | import { BaseSummarizer } from '../base/BaseSummarizer'; 2 | import { QuoraParser } from '../parsers/quora'; 3 | import { renderMarkdown } from '../utils/markdown'; 4 | 5 | export class QuoraSummarizer extends BaseSummarizer { 6 | constructor() { 7 | super(); 8 | this.parser = new QuoraParser(); 9 | this.parser.setContentScript(this); 10 | this.setupAnswerObserver(); 11 | } 12 | 13 | setupAnswerObserver() { 14 | // Set up a mutation observer to detect when new answers are loaded 15 | const observer = new MutationObserver((mutations) => { 16 | for (const mutation of mutations) { 17 | if (mutation.addedNodes.length) { 18 | // Check if new answers were added 19 | if (document.querySelectorAll('div[id^="answer_"]').length > 0) { 20 | this.addTLDRLinks(); 21 | } 22 | } 23 | } 24 | }); 25 | 26 | observer.observe(document.body, { 27 | childList: true, 28 | subtree: true 29 | }); 30 | } 31 | 32 | initializeUI() { 33 | this.sidebar = this.getOrCreateSidebar(); 34 | 35 | if (this.parser.isDiscussionPage()) { 36 | this.addTLDRLinks(); 37 | } 38 | } 39 | 40 | addTLDRLinks() { 41 | const answers = this.parser.getTopLevelComments(); 42 | 43 | answers.forEach(answer => { 44 | // Skip if TLDR button already exists 45 | if (answer.querySelector('.tldr-summarize-btn')) { 46 | return; 47 | } 48 | 49 | // Find the upvote/actions area to place our button 50 | const actionArea = answer.querySelector('div[class*="q-flex"][class*="qu-justifyContent--space-between"]'); 51 | if (!actionArea) return; 52 | 53 | try { 54 | // Create container for our button 55 | const tldrContainer = document.createElement('div'); 56 | tldrContainer.className = 'tldr-btn-container'; 57 | tldrContainer.style.display = 'inline-block'; 58 | tldrContainer.style.marginLeft = '10px'; 59 | 60 | // Create the TLDR button 61 | const tldrButton = document.createElement('button'); 62 | tldrButton.textContent = 'TL;DR'; 63 | tldrButton.className = 'tldr-summarize-btn'; 64 | tldrButton.style.fontSize = '13px'; 65 | tldrButton.style.color = '#636466'; 66 | tldrButton.style.cursor = 'pointer'; 67 | tldrButton.style.background = 'none'; 68 | tldrButton.style.border = 'none'; 69 | tldrButton.style.padding = '4px 8px'; 70 | tldrButton.style.borderRadius = '3px'; 71 | 72 | // Add hover effect 73 | tldrButton.addEventListener('mouseover', () => { 74 | tldrButton.style.backgroundColor = '#f1f1f2'; 75 | }); 76 | 77 | tldrButton.addEventListener('mouseout', () => { 78 | tldrButton.style.backgroundColor = 'transparent'; 79 | }); 80 | 81 | // Add click handler 82 | tldrButton.addEventListener('click', (event) => { 83 | event.preventDefault(); 84 | event.stopPropagation(); 85 | 86 | tldrButton.textContent = 'Summarizing...'; 87 | tldrButton.style.color = '#939598'; 88 | 89 | this.summarizeAnswer(answer, tldrButton); 90 | }); 91 | 92 | tldrContainer.appendChild(tldrButton); 93 | actionArea.appendChild(tldrContainer); 94 | } catch (error) { 95 | console.error('Error adding TLDR link:', error); 96 | } 97 | }); 98 | } 99 | 100 | async summarizeAnswer(answerElement, button) { 101 | try { 102 | // Get answer ID 103 | const answerId = answerElement.id; 104 | 105 | // Extract author name 106 | const authorElement = answerElement.querySelector('a[class*="q-box"][href^="/profile/"]'); 107 | const author = authorElement ? authorElement.textContent.trim() : 'Anonymous'; 108 | 109 | // Extract answer content 110 | const contentElement = answerElement.querySelector('div[class*="q-text"]'); 111 | const content = contentElement ? contentElement.textContent.trim() : ''; 112 | 113 | // Count comments 114 | const commentsContainer = answerElement.querySelector('div[class*="q-box"][class*="qu-mt--medium"] div[class*="q-box"][class*="qu-borderTop"]'); 115 | const commentCount = commentsContainer ? commentsContainer.querySelectorAll('div[class*="q-box"][class*="qu-pt--medium"]').length : 0; 116 | 117 | // Format content for summarization 118 | const answerContent = ` 119 | Question: ${this.parser.getTitle()} 120 | 121 | Answer by ${author}: 122 | ${content} 123 | 124 | ${commentCount > 0 ? `This answer has ${commentCount} comments.` : ''} 125 | 126 | Please summarize focusing on: 127 | 1. Main points and arguments 128 | 2. Key insights 129 | 3. Evidence or examples provided 130 | `; 131 | 132 | const summary = await this.summarizeContent({ 133 | content: answerContent, 134 | type: 'answer' 135 | }); 136 | 137 | // Store summary data 138 | this.threadSummaries.set(answerId, { 139 | summary, 140 | author, 141 | replyCount: commentCount, 142 | timestamp: Date.now() 143 | }); 144 | 145 | // Update UI 146 | this.updateThreadSummary(answerId); 147 | 148 | // Show sidebar if hidden 149 | if (!this.sidebarVisible) { 150 | this.handleToggleSidebar(); 151 | } 152 | 153 | // Update button state 154 | if (button) { 155 | button.textContent = 'TL;DR ✓'; 156 | button.style.color = '#2e69ff'; 157 | } 158 | 159 | } catch (error) { 160 | console.error('Answer summarization error:', error); 161 | if (button) { 162 | button.textContent = 'TL;DR (error)'; 163 | button.style.color = '#b92b27'; 164 | } 165 | } 166 | } 167 | 168 | updateThreadSummary(answerId) { 169 | const threadData = this.threadSummaries.get(answerId); 170 | if (!threadData) return; 171 | 172 | let threadsList = this.sidebar.querySelector('#tldr-threads-list'); 173 | if (!threadsList) { 174 | const content = this.sidebar.querySelector('.tldr-sidebar-content'); 175 | if (!content) return; 176 | 177 | const threadsContainer = document.createElement('div'); 178 | threadsContainer.className = 'tldr-threads-container'; 179 | threadsContainer.innerHTML = ` 180 |

    Answer Summaries

    181 |
    182 | `; 183 | content.appendChild(threadsContainer); 184 | threadsList = threadsContainer.querySelector('#tldr-threads-list'); 185 | } 186 | 187 | // Create new summary element 188 | let threadElement = document.createElement('div'); 189 | threadElement.className = 'tldr-thread-summary'; 190 | threadElement.setAttribute('data-thread-id', answerId); 191 | threadElement.style.marginBottom = '16px'; 192 | threadElement.style.padding = '12px'; 193 | threadElement.style.backgroundColor = '#f7f7f8'; 194 | threadElement.style.borderRadius = '4px'; 195 | threadElement.style.cursor = 'pointer'; 196 | 197 | threadElement.innerHTML = ` 198 |
    199 | ${threadData.author} 200 | ${threadData.replyCount > 0 ? 201 | `${threadData.replyCount} comments` : 202 | ''} 203 |
    204 |
    205 | ${renderMarkdown(threadData.summary)} 206 |
    207 | `; 208 | 209 | // Add click handler to scroll to answer 210 | threadElement.addEventListener('click', () => { 211 | this.parser.scrollToComment(answerId); 212 | }); 213 | 214 | // Insert at the top of the list 215 | threadsList.insertBefore(threadElement, threadsList.firstChild); 216 | } 217 | 218 | formatContentForSummary(content) { 219 | let formattedContent = ''; 220 | 221 | if (content.mainPost) { 222 | formattedContent += `Question: ${content.mainPost.title}\n`; 223 | if (content.mainPost.url) formattedContent += `URL: ${content.mainPost.url}\n`; 224 | if (content.mainPost.text) formattedContent += `\nDetails: ${content.mainPost.text}\n`; 225 | } 226 | 227 | if (content.threads && content.threads.length > 0) { 228 | formattedContent += '\n\nAnswers:\n'; 229 | content.threads.forEach((thread, index) => { 230 | if (thread.root && thread.root.text) { 231 | formattedContent += `\n[Answer ${index + 1} by ${thread.root.by || 'anonymous'}]:\n${thread.root.text}\n`; 232 | 233 | if (thread.replies && thread.replies.length > 0) { 234 | formattedContent += `\nComments on this answer:\n`; 235 | thread.replies.forEach(reply => { 236 | formattedContent += `- [${reply.by || 'anonymous'}]: ${reply.text}\n`; 237 | }); 238 | } 239 | } 240 | }); 241 | } 242 | 243 | return formattedContent; 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /src/options/options.js: -------------------------------------------------------------------------------- 1 | class OptionsManager { 2 | constructor() { 3 | // Default settings 4 | this.defaultSettings = { 5 | aiProvider: 'custom', 6 | apiKey: '', 7 | endpoint: '', 8 | model: 'gpt-3.5-turbo', 9 | summaryLength: 'medium', 10 | language: 'chinese', 11 | autoSummarize: false, 12 | promptTemplate: '', 13 | enableHN: true, 14 | enableReddit: false 15 | }; 16 | 17 | // Provider configurations 18 | this.providerConfigs = { 19 | custom: { 20 | name: 'Custom Endpoint', 21 | fields: [ 22 | { 23 | id: 'endpoint', 24 | label: 'API Endpoint URL', 25 | type: 'url', 26 | required: true, 27 | placeholder: 'https://your-api-endpoint.com' 28 | }, 29 | { 30 | id: 'apiKey', 31 | label: 'API Key', 32 | type: 'password', 33 | required: true, 34 | placeholder: 'Your API key' 35 | }, 36 | { 37 | id: 'model', 38 | label: 'Model (Optional)', 39 | type: 'text', 40 | required: false, 41 | placeholder: 'Model identifier if required by your API' 42 | } 43 | ] 44 | }, 45 | openai: { 46 | name: 'OpenAI', 47 | fields: [ 48 | { 49 | id: 'apiKey', 50 | label: 'API Key', 51 | type: 'password', 52 | required: true 53 | }, 54 | { 55 | id: 'model', 56 | label: 'Model', 57 | type: 'select', 58 | options: [ 59 | { value: 'gpt-4', label: 'GPT-4' }, 60 | { value: 'gpt-3.5-turbo', label: 'GPT-3.5 Turbo' } 61 | ], 62 | required: true 63 | } 64 | ] 65 | }, 66 | anthropic: { 67 | name: 'Anthropic (Claude)', 68 | fields: [ 69 | { 70 | id: 'apiKey', 71 | label: 'API Key', 72 | type: 'password', 73 | required: true 74 | }, 75 | { 76 | id: 'model', 77 | label: 'Model', 78 | type: 'select', 79 | options: [ 80 | { value: 'claude-3-opus-20240229', label: 'Claude 3 Opus' }, 81 | { value: 'claude-3-sonnet-20240229', label: 'Claude 3 Sonnet' } 82 | ], 83 | required: true 84 | } 85 | ] 86 | }, 87 | cloudflare: { 88 | name: 'Cloudflare AI Worker', 89 | fields: [ 90 | { 91 | id: 'endpoint', 92 | label: 'Worker URL', 93 | type: 'url', 94 | required: true 95 | }, 96 | { 97 | id: 'apiKey', 98 | label: 'API Key', 99 | type: 'password', 100 | required: true 101 | } 102 | ] 103 | } 104 | }; 105 | 106 | this.init(); 107 | } 108 | 109 | async init() { 110 | await this.loadSettings(); 111 | this.setupEventListeners(); 112 | this.updateProviderSettings(); 113 | } 114 | 115 | async loadSettings() { 116 | try { 117 | // Load saved settings 118 | const settings = await chrome.storage.sync.get(this.defaultSettings); 119 | this.populateFields(settings); 120 | } catch (error) { 121 | console.error('Failed to load settings:', error); 122 | this.showStatus('Error loading settings', true); 123 | } 124 | } 125 | 126 | setupEventListeners() { 127 | // AI Provider change handler 128 | document.getElementById('aiProvider').addEventListener('change', (e) => { 129 | this.updateProviderSettings(); 130 | }); 131 | 132 | // Save button handler 133 | document.getElementById('saveSettings').addEventListener('click', () => { 134 | this.saveSettings(); 135 | }); 136 | 137 | // Clear status on form changes 138 | document.querySelectorAll('input, select, textarea').forEach(element => { 139 | element.addEventListener('change', () => { 140 | this.clearStatus(); 141 | }); 142 | }); 143 | } 144 | 145 | updateProviderSettings() { 146 | const container = document.getElementById('providerSettings'); 147 | const provider = document.getElementById('aiProvider').value; 148 | const config = this.providerConfigs[provider]; 149 | 150 | if (!config) { 151 | container.innerHTML = '

    Invalid provider configuration

    '; 152 | return; 153 | } 154 | 155 | // Create provider-specific fields 156 | const fields = config.fields.map(field => this.createField(field)).join(''); 157 | container.innerHTML = fields; 158 | 159 | // Populate field values 160 | this.populateProviderFields(provider); 161 | } 162 | 163 | createField(field) { 164 | const commonClasses = 'w-full border border-gray-300 rounded-md shadow-sm px-3 py-2 mt-1'; 165 | 166 | let input; 167 | switch (field.type) { 168 | case 'select': 169 | input = ` 170 | 175 | `; 176 | break; 177 | 178 | case 'textarea': 179 | input = ` 180 | 186 | `; 187 | break; 188 | 189 | default: 190 | input = ` 191 | 198 | `; 199 | } 200 | 201 | return ` 202 |
    203 | 207 | ${input} 208 |
    209 | `; 210 | } 211 | 212 | populateFields(settings) { 213 | // Populate base settings 214 | document.getElementById('aiProvider').value = settings.aiProvider; 215 | document.getElementById('language').value = settings.language; 216 | document.getElementById('summaryLength').value = settings.summaryLength; 217 | document.getElementById('autoSummarize').checked = settings.autoSummarize; 218 | 219 | if (settings.promptTemplate) { 220 | document.getElementById('promptTemplate').value = settings.promptTemplate; 221 | } 222 | 223 | // Populate provider fields 224 | this.populateProviderFields(settings.aiProvider); 225 | } 226 | 227 | populateProviderFields(provider) { 228 | const config = this.providerConfigs[provider]; 229 | if (!config) return; 230 | 231 | chrome.storage.sync.get(this.defaultSettings, (settings) => { 232 | config.fields.forEach(field => { 233 | const element = document.getElementById(field.id); 234 | if (element && settings[field.id]) { 235 | element.value = settings[field.id]; 236 | } 237 | }); 238 | }); 239 | } 240 | 241 | async saveSettings() { 242 | const saveButton = document.getElementById('saveSettings'); 243 | saveButton.disabled = true; 244 | saveButton.textContent = 'Saving...'; 245 | 246 | try { 247 | const settings = await this.collectSettings(); 248 | 249 | // Validate required fields 250 | const provider = this.providerConfigs[settings.aiProvider]; 251 | const requiredFields = provider.fields.filter(f => f.required); 252 | 253 | for (const field of requiredFields) { 254 | if (!settings[field.id]) { 255 | throw new Error(`${field.label} is required`); 256 | } 257 | } 258 | 259 | // Save settings to storage 260 | await chrome.storage.sync.set(settings); 261 | this.showStatus('Settings saved successfully!'); 262 | 263 | } catch (error) { 264 | console.error('Save error:', error); 265 | this.showStatus(error.message, true); 266 | } finally { 267 | saveButton.disabled = false; 268 | saveButton.textContent = 'Save Settings'; 269 | } 270 | } 271 | 272 | async collectSettings() { 273 | const settings = { 274 | aiProvider: document.getElementById('aiProvider').value, 275 | language: document.getElementById('language').value, 276 | summaryLength: document.getElementById('summaryLength').value, 277 | autoSummarize: document.getElementById('autoSummarize').checked, 278 | promptTemplate: document.getElementById('promptTemplate').value 279 | }; 280 | 281 | // Collect provider-specific settings 282 | const provider = this.providerConfigs[settings.aiProvider]; 283 | if (provider) { 284 | provider.fields.forEach(field => { 285 | const element = document.getElementById(field.id); 286 | if (element) { 287 | settings[field.id] = element.value; 288 | } 289 | }); 290 | } 291 | 292 | console.log('Saving settings:', settings); 293 | return settings; 294 | } 295 | 296 | showStatus(message, isError = false) { 297 | const statusElement = document.getElementById('saveStatus'); 298 | statusElement.textContent = message; 299 | statusElement.className = `text-sm ${isError ? 'text-red-500' : 'text-green-500'}`; 300 | 301 | if (!isError) { 302 | setTimeout(() => this.clearStatus(), 3000); 303 | } 304 | } 305 | 306 | clearStatus() { 307 | const statusElement = document.getElementById('saveStatus'); 308 | statusElement.textContent = ''; 309 | statusElement.className = 'text-sm text-gray-500'; 310 | } 311 | } 312 | 313 | // Initialize options manager 314 | document.addEventListener('DOMContentLoaded', () => { 315 | new OptionsManager(); 316 | }); 317 | --------------------------------------------------------------------------------