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.
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/dist/ai.d.ts +31 -0
- package/dist/ai.d.ts.map +1 -0
- package/dist/ai.js +428 -0
- package/dist/ai.js.map +1 -0
- package/dist/commands/add.d.ts +3 -0
- package/dist/commands/add.d.ts.map +1 -0
- package/dist/commands/add.js +38 -0
- package/dist/commands/add.js.map +1 -0
- package/dist/commands/clear.d.ts +3 -0
- package/dist/commands/clear.d.ts.map +1 -0
- package/dist/commands/clear.js +25 -0
- package/dist/commands/clear.js.map +1 -0
- package/dist/commands/generate.d.ts +3 -0
- package/dist/commands/generate.d.ts.map +1 -0
- package/dist/commands/generate.js +38 -0
- package/dist/commands/generate.js.map +1 -0
- package/dist/commands/index.d.ts +9 -0
- package/dist/commands/index.d.ts.map +1 -0
- package/dist/commands/index.js +9 -0
- package/dist/commands/index.js.map +1 -0
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +129 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/list.d.ts +3 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/list.js +33 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/serve.d.ts +3 -0
- package/dist/commands/serve.d.ts.map +1 -0
- package/dist/commands/serve.js +122 -0
- package/dist/commands/serve.js.map +1 -0
- package/dist/commands/source.d.ts +3 -0
- package/dist/commands/source.d.ts.map +1 -0
- package/dist/commands/source.js +48 -0
- package/dist/commands/source.js.map +1 -0
- package/dist/commands/sync.d.ts +3 -0
- package/dist/commands/sync.d.ts.map +1 -0
- package/dist/commands/sync.js +24 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +47 -0
- package/dist/config.js.map +1 -0
- package/dist/db.d.ts +14 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +140 -0
- package/dist/db.js.map +1 -0
- package/dist/i18n.d.ts +4 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +139 -0
- package/dist/i18n.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/magazine.d.ts +6 -0
- package/dist/magazine.d.ts.map +1 -0
- package/dist/magazine.js +1751 -0
- package/dist/magazine.js.map +1 -0
- package/dist/process.d.ts +16 -0
- package/dist/process.d.ts.map +1 -0
- package/dist/process.js +77 -0
- package/dist/process.js.map +1 -0
- package/dist/scraper.d.ts +11 -0
- package/dist/scraper.d.ts.map +1 -0
- package/dist/scraper.js +159 -0
- package/dist/scraper.js.map +1 -0
- package/dist/slack/auth.d.ts +6 -0
- package/dist/slack/auth.d.ts.map +1 -0
- package/dist/slack/auth.js +373 -0
- package/dist/slack/auth.js.map +1 -0
- package/dist/slack/browser-auth.d.ts +6 -0
- package/dist/slack/browser-auth.d.ts.map +1 -0
- package/dist/slack/browser-auth.js +236 -0
- package/dist/slack/browser-auth.js.map +1 -0
- package/dist/slack/client.d.ts +45 -0
- package/dist/slack/client.d.ts.map +1 -0
- package/dist/slack/client.js +98 -0
- package/dist/slack/client.js.map +1 -0
- package/dist/slack/index.d.ts +6 -0
- package/dist/slack/index.d.ts.map +1 -0
- package/dist/slack/index.js +4 -0
- package/dist/slack/index.js.map +1 -0
- package/dist/slack/sync.d.ts +12 -0
- package/dist/slack/sync.d.ts.map +1 -0
- package/dist/slack/sync.js +182 -0
- package/dist/slack/sync.js.map +1 -0
- package/dist/types.d.ts +64 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +3 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +15 -0
- package/dist/utils.js.map +1 -0
- 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
|
package/dist/ai.d.ts.map
ADDED
|
@@ -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
|