linkpress 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +184 -0
  3. package/dist/ai.d.ts +31 -0
  4. package/dist/ai.d.ts.map +1 -0
  5. package/dist/ai.js +428 -0
  6. package/dist/ai.js.map +1 -0
  7. package/dist/commands/add.d.ts +3 -0
  8. package/dist/commands/add.d.ts.map +1 -0
  9. package/dist/commands/add.js +38 -0
  10. package/dist/commands/add.js.map +1 -0
  11. package/dist/commands/clear.d.ts +3 -0
  12. package/dist/commands/clear.d.ts.map +1 -0
  13. package/dist/commands/clear.js +25 -0
  14. package/dist/commands/clear.js.map +1 -0
  15. package/dist/commands/generate.d.ts +3 -0
  16. package/dist/commands/generate.d.ts.map +1 -0
  17. package/dist/commands/generate.js +38 -0
  18. package/dist/commands/generate.js.map +1 -0
  19. package/dist/commands/index.d.ts +9 -0
  20. package/dist/commands/index.d.ts.map +1 -0
  21. package/dist/commands/index.js +9 -0
  22. package/dist/commands/index.js.map +1 -0
  23. package/dist/commands/init.d.ts +3 -0
  24. package/dist/commands/init.d.ts.map +1 -0
  25. package/dist/commands/init.js +129 -0
  26. package/dist/commands/init.js.map +1 -0
  27. package/dist/commands/list.d.ts +3 -0
  28. package/dist/commands/list.d.ts.map +1 -0
  29. package/dist/commands/list.js +33 -0
  30. package/dist/commands/list.js.map +1 -0
  31. package/dist/commands/serve.d.ts +3 -0
  32. package/dist/commands/serve.d.ts.map +1 -0
  33. package/dist/commands/serve.js +122 -0
  34. package/dist/commands/serve.js.map +1 -0
  35. package/dist/commands/source.d.ts +3 -0
  36. package/dist/commands/source.d.ts.map +1 -0
  37. package/dist/commands/source.js +48 -0
  38. package/dist/commands/source.js.map +1 -0
  39. package/dist/commands/sync.d.ts +3 -0
  40. package/dist/commands/sync.d.ts.map +1 -0
  41. package/dist/commands/sync.js +24 -0
  42. package/dist/commands/sync.js.map +1 -0
  43. package/dist/config.d.ts +8 -0
  44. package/dist/config.d.ts.map +1 -0
  45. package/dist/config.js +47 -0
  46. package/dist/config.js.map +1 -0
  47. package/dist/db.d.ts +14 -0
  48. package/dist/db.d.ts.map +1 -0
  49. package/dist/db.js +140 -0
  50. package/dist/db.js.map +1 -0
  51. package/dist/i18n.d.ts +4 -0
  52. package/dist/i18n.d.ts.map +1 -0
  53. package/dist/i18n.js +139 -0
  54. package/dist/i18n.js.map +1 -0
  55. package/dist/index.d.ts +3 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +17 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/magazine.d.ts +6 -0
  60. package/dist/magazine.d.ts.map +1 -0
  61. package/dist/magazine.js +1751 -0
  62. package/dist/magazine.js.map +1 -0
  63. package/dist/process.d.ts +16 -0
  64. package/dist/process.d.ts.map +1 -0
  65. package/dist/process.js +77 -0
  66. package/dist/process.js.map +1 -0
  67. package/dist/scraper.d.ts +11 -0
  68. package/dist/scraper.d.ts.map +1 -0
  69. package/dist/scraper.js +159 -0
  70. package/dist/scraper.js.map +1 -0
  71. package/dist/slack/auth.d.ts +6 -0
  72. package/dist/slack/auth.d.ts.map +1 -0
  73. package/dist/slack/auth.js +373 -0
  74. package/dist/slack/auth.js.map +1 -0
  75. package/dist/slack/browser-auth.d.ts +6 -0
  76. package/dist/slack/browser-auth.d.ts.map +1 -0
  77. package/dist/slack/browser-auth.js +236 -0
  78. package/dist/slack/browser-auth.js.map +1 -0
  79. package/dist/slack/client.d.ts +45 -0
  80. package/dist/slack/client.d.ts.map +1 -0
  81. package/dist/slack/client.js +98 -0
  82. package/dist/slack/client.js.map +1 -0
  83. package/dist/slack/index.d.ts +6 -0
  84. package/dist/slack/index.d.ts.map +1 -0
  85. package/dist/slack/index.js +4 -0
  86. package/dist/slack/index.js.map +1 -0
  87. package/dist/slack/sync.d.ts +12 -0
  88. package/dist/slack/sync.d.ts.map +1 -0
  89. package/dist/slack/sync.js +182 -0
  90. package/dist/slack/sync.js.map +1 -0
  91. package/dist/types.d.ts +64 -0
  92. package/dist/types.d.ts.map +1 -0
  93. package/dist/types.js +2 -0
  94. package/dist/types.js.map +1 -0
  95. package/dist/utils.d.ts +3 -0
  96. package/dist/utils.d.ts.map +1 -0
  97. package/dist/utils.js +15 -0
  98. package/dist/utils.js.map +1 -0
  99. package/package.json +71 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Changmin (Chris) Kang (https://github.com/mindori)
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
13
+ all 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
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,184 @@
1
+ <p align="center">
2
+ <img src="assets/FullLogo_Transparent.png" alt="LinkPress" width="400">
3
+ </p>
4
+
5
+ <p align="center">
6
+ <strong>Turn your Slack links into a personal tech magazine</strong>
7
+ </p>
8
+
9
+ <p align="center">
10
+ <img src="assets/serve.gif" alt="LinkPress Demo" width="800">
11
+ </p>
12
+
13
+ ---
14
+
15
+ ## Why LinkPress?
16
+
17
+ Great tech articles get shared in Slack every day. But they pile up, get buried, and you never read them.
18
+
19
+ **LinkPress fixes this.** It collects links from your Slack channels, uses AI to summarize them, and generates a beautiful magazine you'll actually want to read.
20
+
21
+ - 🤖 **AI-Powered Summaries** — Get the gist before you click (Claude, GPT, or Gemini)
22
+ - 📰 **Magazine-Style UI** — Not a boring list, but a curated reading experience
23
+ - 🔒 **100% Local** — Your data stays on your machine
24
+ - ⚡ **5 Minutes Setup** — Install, connect Slack, done
25
+
26
+
27
+ ## Requirements
28
+
29
+ - Node.js 18+
30
+ - AI API key (Anthropic, OpenAI, or Google)
31
+ - Slack account
32
+
33
+
34
+ ## Quick Start
35
+
36
+ ```bash
37
+ # Install globally
38
+ npm install -g linkpress
39
+
40
+ # Initialize (set up AI provider)
41
+ linkpress init
42
+
43
+ # Connect your Slack workspace
44
+ linkpress source add slack
45
+
46
+ # Sync, generate, and view!
47
+ linkpress sync
48
+ linkpress generate
49
+ linkpress serve
50
+ ```
51
+
52
+
53
+ ## Step 1: Connect Slack
54
+
55
+ Connect your Slack workspace with automatic token extraction. No OAuth app needed — just log in.
56
+
57
+ ```bash
58
+ linkpress source add slack
59
+ ```
60
+
61
+ <p align="center">
62
+ <img src="assets/add.gif" alt="Connect Slack" width="800">
63
+ </p>
64
+
65
+ Select the channels you want to watch. LinkPress will collect all shared links from these channels.
66
+
67
+
68
+ ## Step 2: Sync Links
69
+
70
+ Fetch links from your connected Slack channels. AI automatically filters out noise (internal docs, videos, etc.) and keeps only valuable tech content.
71
+
72
+ ```bash
73
+ linkpress sync
74
+ ```
75
+
76
+ <p align="center">
77
+ <img src="assets/sync.gif" alt="Sync Links" width="800">
78
+ </p>
79
+
80
+
81
+ ## Step 3: Generate Magazine
82
+
83
+ Process articles with AI and generate your personal magazine. Each article gets:
84
+ - Catchy headline
85
+ - TL;DR summary
86
+ - Key points
87
+ - Difficulty level
88
+ - Reading time
89
+
90
+ ```bash
91
+ linkpress generate
92
+ linkpress serve
93
+ ```
94
+
95
+ <p align="center">
96
+ <img src="assets/generate.gif" alt="Generate Magazine" width="800">
97
+ </p>
98
+
99
+
100
+ ## Features
101
+
102
+ ### 🤖 Multi-Provider AI
103
+ Choose your preferred AI provider:
104
+ - **Anthropic** (Claude)
105
+ - **OpenAI** (GPT)
106
+ - **Google** (Gemini)
107
+
108
+ ### 📊 Smart Classification
109
+ Articles are automatically tagged and classified by:
110
+ - Topic (Frontend, Backend, DevOps, AI/ML, etc.)
111
+ - Difficulty (Beginner, Intermediate, Advanced)
112
+ - Reading time
113
+
114
+ ### 🌙 Light & Dark Theme
115
+ Toggle between light and dark mode. Your preference is saved.
116
+
117
+ ### ✅ Read/Unread Tracking
118
+ Keep track of what you've read. Mark articles as read with a single click.
119
+
120
+ ### 👀 Watch Mode
121
+ Real-time monitoring — new articles appear automatically as they're shared.
122
+
123
+ ```bash
124
+ linkpress serve --watch
125
+ ```
126
+
127
+ ### 🌍 Multilingual
128
+ AI summaries in your preferred language (English, 한국어, 日本語, 中文, etc.)
129
+
130
+
131
+ ## Commands
132
+
133
+ | Command | Description |
134
+ |---------|-------------|
135
+ | `linkpress init` | Set up AI provider and preferences |
136
+ | `linkpress source add slack` | Connect a Slack workspace |
137
+ | `linkpress source list` | List connected sources |
138
+ | `linkpress source remove slack` | Remove a workspace |
139
+ | `linkpress sync` | Fetch links from Slack |
140
+ | `linkpress add <url>` | Manually add a URL |
141
+ | `linkpress list` | Show saved articles |
142
+ | `linkpress generate` | Process articles and create magazine |
143
+ | `linkpress generate --skip-process` | Regenerate without AI processing |
144
+ | `linkpress serve` | Start local server (localhost:3000) |
145
+ | `linkpress serve --watch` | Start with real-time monitoring |
146
+ | `linkpress clear` | Delete all articles |
147
+
148
+
149
+ ## Configuration
150
+
151
+ Configuration is stored in `~/.linkpress/config.yaml`:
152
+
153
+ ```yaml
154
+ ai:
155
+ provider: anthropic # anthropic, openai, or gemini
156
+ model: claude-sonnet-4-5-20250929
157
+ apiKey: sk-ant-...
158
+ language: English # Summary language
159
+
160
+ sources:
161
+ slack:
162
+ - workspace: MyWorkspace
163
+ channels:
164
+ - id: C01234567
165
+ name: tech-links
166
+
167
+ output:
168
+ directory: ~/.linkpress/output
169
+ format: html
170
+ ```
171
+
172
+
173
+ ## Contributing
174
+
175
+ Contributions are welcome! Feel free to:
176
+
177
+ - 🐛 Report bugs
178
+ - 💡 Suggest features
179
+ - 🔧 Submit pull requests
180
+
181
+
182
+ ## Author
183
+
184
+ [Changmin (Chris) Kang](https://github.com/mindori)
package/dist/ai.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ import type { AIProvider } from './types.js';
2
+ export interface ArticleSummary {
3
+ headline: string;
4
+ tldr: string;
5
+ keyPoints: string[];
6
+ whyItMatters: string;
7
+ keyQuote?: string;
8
+ tags: string[];
9
+ difficulty: 'beginner' | 'intermediate' | 'advanced';
10
+ }
11
+ export type ContentType = 'article' | 'announcement' | 'discussion' | 'reference' | 'social' | 'media' | 'internal' | 'other';
12
+ export type TechnicalDepth = 'none' | 'shallow' | 'moderate' | 'deep' | 'expert' | 'unknown';
13
+ export type Actionability = 'none' | 'awareness' | 'applicable' | 'reference';
14
+ export interface ContentClassification {
15
+ contentType: ContentType;
16
+ technicalDepth: TechnicalDepth;
17
+ actionability: Actionability;
18
+ shouldCollect: boolean;
19
+ reasoning: string;
20
+ }
21
+ export interface ModelInfo {
22
+ id: string;
23
+ name: string;
24
+ }
25
+ export declare const FALLBACK_MODELS: Record<AIProvider, ModelInfo[]>;
26
+ export declare function fetchModels(provider: AIProvider, apiKey: string): Promise<ModelInfo[]>;
27
+ export declare function serializeSummary(summary: ArticleSummary): string;
28
+ export declare function parseSummary(summaryStr: string | undefined): ArticleSummary | null;
29
+ export declare function summarizeArticle(title: string, content: string, url: string): Promise<ArticleSummary>;
30
+ export declare function classifyContent(messageText: string, url: string, title: string, description: string): Promise<ContentClassification>;
31
+ //# sourceMappingURL=ai.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ai.d.ts","sourceRoot":"","sources":["../src/ai.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,UAAU,EAAE,UAAU,GAAG,cAAc,GAAG,UAAU,CAAC;CACtD;AAED,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG,cAAc,GAAG,YAAY,GAAG,WAAW,GAAG,QAAQ,GAAG,OAAO,GAAG,UAAU,GAAG,OAAO,CAAC;AAC9H,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,SAAS,GAAG,UAAU,GAAG,MAAM,GAAG,QAAQ,GAAG,SAAS,CAAC;AAC7F,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,WAAW,GAAG,YAAY,GAAG,WAAW,CAAC;AAE9E,MAAM,WAAW,qBAAqB;IACpC,WAAW,EAAE,WAAW,CAAC;IACzB,cAAc,EAAE,cAAc,CAAC;IAC/B,aAAa,EAAE,aAAa,CAAC;IAC7B,aAAa,EAAE,OAAO,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,SAAS;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;CACd;AAED,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,UAAU,EAAE,SAAS,EAAE,CAmB3D,CAAC;AAEF,wBAAsB,WAAW,CAAC,QAAQ,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC,CAgB5F;AAiED,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,cAAc,GAAG,MAAM,CAEhE;AAED,wBAAgB,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,cAAc,GAAG,IAAI,CAyBlF;AA0ED,wBAAsB,gBAAgB,CACpC,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,EACf,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,cAAc,CAAC,CAmDzB;AAkED,wBAAsB,eAAe,CACnC,WAAW,EAAE,MAAM,EACnB,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,qBAAqB,CAAC,CA8ChC"}
package/dist/ai.js ADDED
@@ -0,0 +1,428 @@
1
+ import Anthropic from '@anthropic-ai/sdk';
2
+ import { GoogleGenerativeAI } from '@google/generative-ai';
3
+ import OpenAI from 'openai';
4
+ import { loadConfig } from './config.js';
5
+ export const FALLBACK_MODELS = {
6
+ anthropic: [
7
+ { id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' },
8
+ { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5' },
9
+ { id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' },
10
+ ],
11
+ openai: [
12
+ { id: 'gpt-4.1-mini', name: 'GPT-4.1 Mini' },
13
+ { id: 'gpt-4.1-nano', name: 'GPT-4.1 Nano' },
14
+ { id: 'gpt-4.1', name: 'GPT-4.1' },
15
+ { id: 'gpt-4o-mini', name: 'GPT-4o Mini' },
16
+ { id: 'gpt-4o', name: 'GPT-4o' },
17
+ ],
18
+ gemini: [
19
+ { id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash' },
20
+ { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro' },
21
+ { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' },
22
+ { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro' },
23
+ ],
24
+ };
25
+ export async function fetchModels(provider, apiKey) {
26
+ try {
27
+ switch (provider) {
28
+ case 'anthropic':
29
+ return await fetchAnthropicModels(apiKey);
30
+ case 'openai':
31
+ return await fetchOpenAIModels(apiKey);
32
+ case 'gemini':
33
+ return await fetchGeminiModels(apiKey);
34
+ default:
35
+ return [];
36
+ }
37
+ }
38
+ catch (error) {
39
+ console.error('Failed to fetch models:', error instanceof Error ? error.message : error);
40
+ return [];
41
+ }
42
+ }
43
+ async function fetchAnthropicModels(apiKey) {
44
+ const response = await fetch('https://api.anthropic.com/v1/models', {
45
+ headers: {
46
+ 'x-api-key': apiKey,
47
+ 'anthropic-version': '2023-06-01',
48
+ },
49
+ });
50
+ if (!response.ok)
51
+ throw new Error(`HTTP ${response.status}`);
52
+ const data = await response.json();
53
+ return data.data
54
+ .filter(m => m.id.includes('claude') && !m.id.includes('instant'))
55
+ .map(m => ({ id: m.id, name: m.display_name || m.id }));
56
+ }
57
+ async function fetchOpenAIModels(apiKey) {
58
+ const response = await fetch('https://api.openai.com/v1/models', {
59
+ headers: {
60
+ 'Authorization': `Bearer ${apiKey}`,
61
+ },
62
+ });
63
+ if (!response.ok)
64
+ throw new Error(`HTTP ${response.status}`);
65
+ const data = await response.json();
66
+ const validPrefixes = ['gpt-4', 'gpt-5', 'o1', 'o3', 'o4'];
67
+ const excludePatterns = ['realtime', 'audio', 'vision', 'instruct', 'turbo', 'preview'];
68
+ return data.data
69
+ .filter(m => {
70
+ const id = m.id.toLowerCase();
71
+ const hasValidPrefix = validPrefixes.some(p => id.startsWith(p));
72
+ const hasExcluded = excludePatterns.some(p => id.includes(p));
73
+ return hasValidPrefix && !hasExcluded;
74
+ })
75
+ .map(m => ({ id: m.id, name: m.id }))
76
+ .sort((a, b) => b.id.localeCompare(a.id));
77
+ }
78
+ async function fetchGeminiModels(apiKey) {
79
+ const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`);
80
+ if (!response.ok)
81
+ throw new Error(`HTTP ${response.status}`);
82
+ const data = await response.json();
83
+ return data.models
84
+ .filter(m => m.supportedGenerationMethods?.includes('generateContent'))
85
+ .filter(m => m.name.includes('gemini'))
86
+ .map(m => ({
87
+ id: m.name.replace('models/', ''),
88
+ name: m.displayName || m.name.replace('models/', ''),
89
+ }))
90
+ .sort((a, b) => {
91
+ const aVersion = a.id.match(/\d+(\.\d+)?/)?.[0] || '0';
92
+ const bVersion = b.id.match(/\d+(\.\d+)?/)?.[0] || '0';
93
+ return parseFloat(bVersion) - parseFloat(aVersion);
94
+ });
95
+ }
96
+ export function serializeSummary(summary) {
97
+ return JSON.stringify(summary);
98
+ }
99
+ export function parseSummary(summaryStr) {
100
+ if (!summaryStr)
101
+ return null;
102
+ try {
103
+ const parsed = JSON.parse(summaryStr);
104
+ if (parsed.headline && parsed.tldr) {
105
+ return parsed;
106
+ }
107
+ return {
108
+ headline: parsed.hook || summaryStr,
109
+ tldr: parsed.summary || summaryStr,
110
+ keyPoints: [],
111
+ whyItMatters: '',
112
+ tags: parsed.tags || [],
113
+ difficulty: parsed.difficulty || 'intermediate',
114
+ };
115
+ }
116
+ catch {
117
+ return {
118
+ headline: summaryStr,
119
+ tldr: summaryStr,
120
+ keyPoints: [],
121
+ whyItMatters: '',
122
+ tags: [],
123
+ difficulty: 'intermediate',
124
+ };
125
+ }
126
+ }
127
+ function buildPrompt(title, content, url, language) {
128
+ const koreanRule = language === '한국어'
129
+ ? '\n6. KOREAN ONLY: Use formal polite speech (존댓말/합쇼체) consistently. End sentences with -습니다, -입니다, -됩니다. NEVER use casual speech (반말).'
130
+ : '';
131
+ return `You are a SENIOR TECH JOURNALIST at a prestigious developer magazine.
132
+ Your job is to create compelling, newspaper-style briefings that developers actually want to read.
133
+
134
+ ---
135
+
136
+ INPUT:
137
+ - Title: ${title}
138
+ - URL: ${url}
139
+ - Content: ${content.substring(0, 6000)}
140
+
141
+ ---
142
+
143
+ TASK: Create a briefing in JSON format.
144
+
145
+ {
146
+ "headline": "Catchy, newspaper-style headline (max 15 words)",
147
+ "tldr": "One-sentence summary for busy readers",
148
+ "keyPoints": [
149
+ "First key point (one sentence)",
150
+ "Second key point (one sentence)",
151
+ "Third key point (one sentence)"
152
+ ],
153
+ "whyItMatters": "Why this matters to developers/readers (1-2 sentences)",
154
+ "keyQuote": "Most impactful quote from the article (if any, otherwise empty string)",
155
+ "tags": ["tag1", "tag2", "tag3"],
156
+ "difficulty": "beginner|intermediate|advanced"
157
+ }
158
+
159
+ ---
160
+
161
+ CRITICAL RULES:
162
+ 1. WRITE EVERYTHING IN ${language}. This is NOT optional. The output MUST be in ${language}.
163
+ 2. Headline should be ATTENTION-GRABBING but accurate—no clickbait lies.
164
+ 3. Key points should be ACTIONABLE insights, not just descriptions.
165
+ 4. Tags: use technical topics (frontend, backend, ai, devops, database, security, career, etc.)
166
+ 5. Difficulty: beginner (anyone can understand), intermediate (some experience needed), advanced (experts only)${koreanRule}
167
+
168
+ OUTPUT: JSON only, no explanation outside JSON.`;
169
+ }
170
+ async function callAnthropic(apiKey, model, prompt) {
171
+ const client = new Anthropic({ apiKey });
172
+ const response = await client.messages.create({
173
+ model,
174
+ max_tokens: 800,
175
+ messages: [{ role: 'user', content: prompt }],
176
+ });
177
+ return response.content[0].type === 'text' ? response.content[0].text : '';
178
+ }
179
+ async function callOpenAI(apiKey, model, prompt) {
180
+ const client = new OpenAI({ apiKey });
181
+ const response = await client.chat.completions.create({
182
+ model,
183
+ max_tokens: 800,
184
+ messages: [{ role: 'user', content: prompt }],
185
+ });
186
+ return response.choices[0]?.message?.content || '';
187
+ }
188
+ async function callGemini(apiKey, model, prompt) {
189
+ const genAI = new GoogleGenerativeAI(apiKey);
190
+ const geminiModel = genAI.getGenerativeModel({ model });
191
+ const result = await geminiModel.generateContent(prompt);
192
+ return result.response.text();
193
+ }
194
+ export async function summarizeArticle(title, content, url) {
195
+ const config = loadConfig();
196
+ if (!config.ai.apiKey) {
197
+ return getDefaultSummary(title, url);
198
+ }
199
+ const provider = config.ai.provider;
200
+ const model = config.ai.model;
201
+ const language = config.ai.language || 'English';
202
+ const prompt = buildPrompt(title, content, url, language);
203
+ try {
204
+ let text = '';
205
+ switch (provider) {
206
+ case 'anthropic':
207
+ text = await callAnthropic(config.ai.apiKey, model, prompt);
208
+ break;
209
+ case 'openai':
210
+ text = await callOpenAI(config.ai.apiKey, model, prompt);
211
+ break;
212
+ case 'gemini':
213
+ text = await callGemini(config.ai.apiKey, model, prompt);
214
+ break;
215
+ default:
216
+ return getDefaultSummary(title, url);
217
+ }
218
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
219
+ if (!jsonMatch) {
220
+ return getDefaultSummary(title, url);
221
+ }
222
+ const parsed = JSON.parse(jsonMatch[0]);
223
+ return {
224
+ headline: parsed.headline || title,
225
+ tldr: parsed.tldr || '',
226
+ keyPoints: Array.isArray(parsed.keyPoints) ? parsed.keyPoints.slice(0, 3) : [],
227
+ whyItMatters: parsed.whyItMatters || '',
228
+ keyQuote: parsed.keyQuote || '',
229
+ tags: Array.isArray(parsed.tags) ? parsed.tags.slice(0, 5) : [],
230
+ difficulty: ['beginner', 'intermediate', 'advanced'].includes(parsed.difficulty)
231
+ ? parsed.difficulty
232
+ : 'intermediate',
233
+ };
234
+ }
235
+ catch (error) {
236
+ console.error('AI summarization failed:', error instanceof Error ? error.message : String(error));
237
+ return getDefaultSummary(title, url);
238
+ }
239
+ }
240
+ function buildClassificationPrompt(messageText, url, title, description) {
241
+ return `You filter links for a tech newsletter. DEFAULT ACTION: COLLECT.
242
+
243
+ INPUT:
244
+ - URL: ${url}
245
+ - Context: ${messageText || '(none)'}
246
+ - Title: ${title || '(none)'}
247
+ - Description: ${description || '(none)'}
248
+
249
+ ---
250
+
251
+ EXCLUDE ONLY these specific categories:
252
+
253
+ 1. INTERNAL TOOLS (workspace/productivity apps, not public content):
254
+ - Google Docs/Sheets/Slides/Drive (docs.google.com, drive.google.com, share.google)
255
+ - Notion workspace pages (notion.so with private content)
256
+ - Figma files (figma.com)
257
+ - Jira/Confluence (atlassian.net)
258
+ - Canva designs (canva.com/design)
259
+ - Slack permalinks
260
+
261
+ 2. VIDEO/AUDIO (reading-focused newsletter):
262
+ - YouTube (youtube.com, youtu.be)
263
+ - Vimeo, Twitch, podcasts
264
+
265
+ 3. TWITTER/X ONLY (not scrapable):
266
+ - x.com, twitter.com
267
+ - NOTE: LinkedIn is NOT excluded. LinkedIn posts ARE scrapable.
268
+
269
+ 4. AUTH/TRANSACTIONAL pages:
270
+ - Login pages, confirmation tokens, password resets
271
+ - URLs with "confirm", "token=", "verify", "unsubscribe"
272
+
273
+ 5. OBVIOUS NON-CONTENT:
274
+ - Image files (.png, .jpg, .gif direct links)
275
+ - File downloads (.zip, .pdf direct links)
276
+
277
+ ---
278
+
279
+ ALWAYS COLLECT (even without metadata):
280
+
281
+ - GitHub repos/gists (github.com, gist.github.com) - developers share code there
282
+ - LinkedIn posts (linkedin.com) - professionals share knowledge, IS scrapable
283
+ - Blog platforms (medium.com, dev.to, substack.com, brunch.co.kr, velog.io, tistory.com)
284
+ - Tech news (news.hada.io, news.ycombinator.com, techcrunch.com)
285
+ - Any unknown domain - might be interesting, we'll scrape and find out
286
+ - Product/tool pages - developers share useful tools
287
+
288
+ ---
289
+
290
+ CRITICAL: Missing metadata (no title/description) is NOT a reason to exclude.
291
+ We will scrape the content later. If someone shared it, it's probably worth checking.
292
+
293
+ OUTPUT (JSON only):
294
+ {
295
+ "content_type": "article|social|reference|internal|media|other",
296
+ "technical_depth": "shallow|moderate|deep|unknown",
297
+ "should_collect": true|false,
298
+ "reasoning": "Brief reason"
299
+ }
300
+
301
+ When uncertain, set should_collect: true.`;
302
+ }
303
+ export async function classifyContent(messageText, url, title, description) {
304
+ const config = loadConfig();
305
+ if (!config.ai.apiKey) {
306
+ return getDefaultClassification(url);
307
+ }
308
+ const provider = config.ai.provider;
309
+ const model = config.ai.model;
310
+ const prompt = buildClassificationPrompt(messageText, url, title, description);
311
+ try {
312
+ let text = '';
313
+ switch (provider) {
314
+ case 'anthropic':
315
+ text = await callAnthropic(config.ai.apiKey, model, prompt);
316
+ break;
317
+ case 'openai':
318
+ text = await callOpenAI(config.ai.apiKey, model, prompt);
319
+ break;
320
+ case 'gemini':
321
+ text = await callGemini(config.ai.apiKey, model, prompt);
322
+ break;
323
+ default:
324
+ return getDefaultClassification(url);
325
+ }
326
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
327
+ if (!jsonMatch) {
328
+ return getDefaultClassification(url);
329
+ }
330
+ const parsed = JSON.parse(jsonMatch[0]);
331
+ return {
332
+ contentType: parsed.content_type,
333
+ technicalDepth: parsed.technical_depth,
334
+ actionability: parsed.actionability || 'awareness',
335
+ shouldCollect: parsed.should_collect === true,
336
+ reasoning: parsed.reasoning || '',
337
+ };
338
+ }
339
+ catch (error) {
340
+ console.error('AI classification failed:', error instanceof Error ? error.message : String(error));
341
+ return getDefaultClassification(url);
342
+ }
343
+ }
344
+ function getDefaultClassification(url) {
345
+ const urlLower = url.toLowerCase();
346
+ const internalPatterns = [
347
+ 'docs.google.com', 'drive.google.com', 'share.google',
348
+ 'sheets.google.com', 'slides.google.com',
349
+ 'notion.so', 'figma.com', 'canva.com/design',
350
+ 'atlassian.net', 'jira', 'confluence',
351
+ 'slack.com/archives',
352
+ ];
353
+ if (internalPatterns.some(p => urlLower.includes(p))) {
354
+ return {
355
+ contentType: 'internal',
356
+ technicalDepth: 'none',
357
+ actionability: 'none',
358
+ shouldCollect: false,
359
+ reasoning: 'Internal workspace tool',
360
+ };
361
+ }
362
+ if (urlLower.includes('youtube.com') || urlLower.includes('youtu.be') || urlLower.includes('vimeo.com')) {
363
+ return {
364
+ contentType: 'media',
365
+ technicalDepth: 'moderate',
366
+ actionability: 'awareness',
367
+ shouldCollect: false,
368
+ reasoning: 'Video content excluded',
369
+ };
370
+ }
371
+ if (urlLower.includes('x.com') || urlLower.includes('twitter.com')) {
372
+ return {
373
+ contentType: 'social',
374
+ technicalDepth: 'unknown',
375
+ actionability: 'awareness',
376
+ shouldCollect: false,
377
+ reasoning: 'Twitter/X excluded - not scrapable',
378
+ };
379
+ }
380
+ if (urlLower.includes('/confirm') || urlLower.includes('token=') || urlLower.includes('/verify') || urlLower.includes('/unsubscribe')) {
381
+ return {
382
+ contentType: 'other',
383
+ technicalDepth: 'none',
384
+ actionability: 'none',
385
+ shouldCollect: false,
386
+ reasoning: 'Auth/transactional page',
387
+ };
388
+ }
389
+ return {
390
+ contentType: 'article',
391
+ technicalDepth: 'shallow',
392
+ actionability: 'awareness',
393
+ shouldCollect: true,
394
+ reasoning: 'Default: collect and scrape',
395
+ };
396
+ }
397
+ function getDefaultSummary(title, url) {
398
+ let hostname = '';
399
+ try {
400
+ hostname = new URL(url).hostname.replace('www.', '');
401
+ }
402
+ catch {
403
+ hostname = 'unknown';
404
+ }
405
+ const tags = [];
406
+ const urlLower = url.toLowerCase();
407
+ if (urlLower.includes('github.com'))
408
+ tags.push('github');
409
+ if (urlLower.includes('medium.com'))
410
+ tags.push('blog');
411
+ if (urlLower.includes('dev.to'))
412
+ tags.push('blog');
413
+ if (urlLower.includes('youtube.com') || urlLower.includes('youtu.be'))
414
+ tags.push('video');
415
+ if (urlLower.includes('linkedin.com'))
416
+ tags.push('linkedin');
417
+ if (urlLower.includes('news.hada.io'))
418
+ tags.push('news');
419
+ return {
420
+ headline: title || `Article from ${hostname}`,
421
+ tldr: title || `Content from ${hostname}`,
422
+ keyPoints: [],
423
+ whyItMatters: '',
424
+ tags,
425
+ difficulty: 'intermediate',
426
+ };
427
+ }
428
+ //# sourceMappingURL=ai.js.map