card-news 1.0.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 +0 -0
- package/README.md +399 -0
- package/bin/cli.js +3 -0
- package/dist/__tests__/article.test.js +23 -0
- package/dist/__tests__/path-validator.test.js +44 -0
- package/dist/__tests__/template-engine.test.js +61 -0
- package/dist/cli.js +54 -0
- package/dist/commands/config.js +21 -0
- package/dist/commands/generate.js +46 -0
- package/dist/commands/init.js +42 -0
- package/dist/commands/list.js +22 -0
- package/dist/commands/test.js +30 -0
- package/dist/services/article.js +39 -0
- package/dist/services/cardgen.js +102 -0
- package/dist/services/summary.js +45 -0
- package/dist/services/template-engine.js +24 -0
- package/dist/services/zip-export.js +50 -0
- package/dist/utils/config.js +38 -0
- package/dist/utils/logger.js +16 -0
- package/dist/utils/path-validator.js +33 -0
- package/package.json +60 -0
- package/templates/card.json +0 -0
- package/templates/colorful.json +27 -0
- package/templates/detailed.json +27 -0
- package/templates/minimal.json +27 -0
package/LICENSE
ADDED
|
File without changes
|
package/README.md
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
# 🎨 card-news CLI
|
|
2
|
+
|
|
3
|
+
[](https://github.com/wjs-ship-it/card-news)
|
|
4
|
+
[](https://www.npmjs.com/package/card-news)
|
|
5
|
+
[](https://www.typescriptlang.org)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
|
|
8
|
+
**Transform news articles into beautiful Instagram card news with AI-powered summarization.**
|
|
9
|
+
|
|
10
|
+
> A CLI tool that extracts articles from any URL, generates 5-line summaries using Claude AI, and creates stunning Instagram-ready PNG cards (1080x1350px).
|
|
11
|
+
|
|
12
|
+
## 🌟 Features
|
|
13
|
+
|
|
14
|
+
- 📰 **Article Extraction** - Parse articles from any URL
|
|
15
|
+
- 🤖 **AI Summarization** - Generate concise 5-line summaries using Claude
|
|
16
|
+
- 🎨 **Beautiful Cards** - Create 5 Instagram-ready PNG cards
|
|
17
|
+
- 🎯 **Customizable** - Magazine name, color, hashtags
|
|
18
|
+
- 💾 **Local Storage** - Keep your config safe locally
|
|
19
|
+
- ⚡ **Fast** - Generate cards in 6-15 seconds
|
|
20
|
+
- 🔧 **CLI-JAW Style** - Use your own API keys (zero hosting cost)
|
|
21
|
+
|
|
22
|
+
## 🚀 Quick Start
|
|
23
|
+
|
|
24
|
+
### Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install -g card-news
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### First Time Setup
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
card-news init
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
You'll be prompted for:
|
|
37
|
+
- Claude API Key (from https://console.anthropic.com/)
|
|
38
|
+
- Magazine name
|
|
39
|
+
- Primary color (hex code)
|
|
40
|
+
- Hashtags
|
|
41
|
+
|
|
42
|
+
### Generate Card News
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
card-news generate https://example.com/article
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**Output:**
|
|
49
|
+
```
|
|
50
|
+
📁 card-news-output/
|
|
51
|
+
├── 01_cover.png (Magazine name + Title)
|
|
52
|
+
├── 02_content.png (Main topic)
|
|
53
|
+
├── 03_content.png (Key points)
|
|
54
|
+
├── 04_content.png (Details)
|
|
55
|
+
└── 05_end.png (Call-to-action)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Other Commands
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# View your magazine settings
|
|
62
|
+
card-news config
|
|
63
|
+
|
|
64
|
+
# Test Claude API connection
|
|
65
|
+
card-news test
|
|
66
|
+
|
|
67
|
+
# Show help
|
|
68
|
+
card-news --help
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## 🔧 Configuration
|
|
72
|
+
|
|
73
|
+
Settings are stored in `~/.card-news/config.json`:
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"apiKey": "sk-ant-...",
|
|
78
|
+
"magazineName": "My Magazine",
|
|
79
|
+
"color": "#FF6B6B",
|
|
80
|
+
"hashtags": ["뉴스", "핫이슈"]
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## 📋 Options
|
|
85
|
+
|
|
86
|
+
### generate command
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
card-news generate <url> [options]
|
|
90
|
+
|
|
91
|
+
Options:
|
|
92
|
+
-o, --output <dir> Output directory (default: ./card-news-output)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Example:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
card-news generate https://example.com/article -o ./my-cards
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## 🔐 Getting Your Claude API Key
|
|
102
|
+
|
|
103
|
+
1. Visit https://console.anthropic.com/
|
|
104
|
+
2. Create an account or log in
|
|
105
|
+
3. Navigate to **API Keys**
|
|
106
|
+
4. Click **Create Key**
|
|
107
|
+
5. Copy your API key
|
|
108
|
+
6. Use it in `card-news init`
|
|
109
|
+
|
|
110
|
+
> 💡 **Tip:** You can also set `CLAUDE_API_KEY` as an environment variable:
|
|
111
|
+
> ```bash
|
|
112
|
+
> export CLAUDE_API_KEY=sk-ant-...
|
|
113
|
+
> ```
|
|
114
|
+
|
|
115
|
+
## 📖 How It Works
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
URL → Extract Article → AI Summarization → Generate Cards
|
|
119
|
+
↓ ↓ ↓
|
|
120
|
+
Get title, Create 5 lines Beautiful
|
|
121
|
+
content, of concise text PNG images
|
|
122
|
+
metadata (30 chars) (1080x1350px)
|
|
123
|
+
each
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## 💡 Use Cases
|
|
127
|
+
|
|
128
|
+
- **Content Creators** - Turn articles into visual posts
|
|
129
|
+
- **News Aggregators** - Summarize and showcase articles
|
|
130
|
+
- **Social Media** - Quick Instagram content generation
|
|
131
|
+
- **Marketing** - Transform blog posts into social graphics
|
|
132
|
+
- **Education** - Summarize research and news
|
|
133
|
+
|
|
134
|
+
## ⚙️ Technical Stack
|
|
135
|
+
|
|
136
|
+
| Component | Technology |
|
|
137
|
+
|-----------|-----------|
|
|
138
|
+
| Language | TypeScript 5+ |
|
|
139
|
+
| CLI Framework | Commander.js |
|
|
140
|
+
| HTML Parsing | Cheerio |
|
|
141
|
+
| AI Engine | Claude 3 Opus (Anthropic) |
|
|
142
|
+
| Image Generation | Canvas |
|
|
143
|
+
| HTTP Requests | Axios |
|
|
144
|
+
| CLI Colors | Chalk |
|
|
145
|
+
|
|
146
|
+
## 🎓 System Requirements
|
|
147
|
+
|
|
148
|
+
- **Node.js** 18+
|
|
149
|
+
- **npm** or **yarn**
|
|
150
|
+
- Internet connection
|
|
151
|
+
- Claude API key
|
|
152
|
+
|
|
153
|
+
### Canvas Dependencies
|
|
154
|
+
|
|
155
|
+
#### macOS
|
|
156
|
+
```bash
|
|
157
|
+
brew install pkg-config cairo libpng jpeg giflib
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
#### Ubuntu/Debian
|
|
161
|
+
```bash
|
|
162
|
+
sudo apt-get install build-essential libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
#### CentOS/RHEL
|
|
166
|
+
```bash
|
|
167
|
+
sudo yum install gcc-c++ cairo-devel libjpeg-turbo-devel giflib-devel
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## 📊 Performance
|
|
171
|
+
|
|
172
|
+
| Metric | Time |
|
|
173
|
+
|--------|------|
|
|
174
|
+
| CLI startup | <1s |
|
|
175
|
+
| Article extraction | 2-5s |
|
|
176
|
+
| AI summarization | 3-8s |
|
|
177
|
+
| Card generation | 1-2s |
|
|
178
|
+
| **Total** | **6-15s** |
|
|
179
|
+
|
|
180
|
+
## 🐛 Troubleshooting
|
|
181
|
+
|
|
182
|
+
### "Config not found"
|
|
183
|
+
```bash
|
|
184
|
+
card-news init
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### "API connection failed"
|
|
188
|
+
- Check your Claude API key is valid
|
|
189
|
+
- Ensure you have internet connection
|
|
190
|
+
- Verify you have API credits
|
|
191
|
+
|
|
192
|
+
### "Failed to extract article"
|
|
193
|
+
- Try a different article URL
|
|
194
|
+
- Some websites may block scraping
|
|
195
|
+
- Ensure the URL is accessible
|
|
196
|
+
|
|
197
|
+
### Canvas build errors
|
|
198
|
+
See [Canvas dependencies](#canvas-dependencies) section above.
|
|
199
|
+
|
|
200
|
+
## 🔮 Roadmap
|
|
201
|
+
|
|
202
|
+
### Phase 2 (Coming Soon)
|
|
203
|
+
- [ ] Multiple card templates
|
|
204
|
+
- [ ] ZIP export
|
|
205
|
+
- [ ] Batch processing (multiple articles)
|
|
206
|
+
- [ ] Advanced customization
|
|
207
|
+
- [ ] Preview mode
|
|
208
|
+
|
|
209
|
+
### Phase 3
|
|
210
|
+
- [ ] npm package release
|
|
211
|
+
- [ ] Web UI (optional)
|
|
212
|
+
- [ ] Instagram API integration
|
|
213
|
+
- [ ] Community templates
|
|
214
|
+
|
|
215
|
+
## 🤝 Contributing
|
|
216
|
+
|
|
217
|
+
Contributions are welcome! Please feel free to:
|
|
218
|
+
- Report bugs
|
|
219
|
+
- Suggest features
|
|
220
|
+
- Submit pull requests
|
|
221
|
+
- Share feedback
|
|
222
|
+
|
|
223
|
+
## 📄 License
|
|
224
|
+
|
|
225
|
+
MIT License - see [LICENSE](./LICENSE) file for details.
|
|
226
|
+
|
|
227
|
+
## 🙋 FAQ
|
|
228
|
+
|
|
229
|
+
**Q: Is this free?**
|
|
230
|
+
A: The tool is free, but you need a Claude API account. You use your own API key (your cost, not ours).
|
|
231
|
+
|
|
232
|
+
**Q: Can I use this commercially?**
|
|
233
|
+
A: Yes! You can use this for commercial purposes as long as you comply with Claude's terms of service.
|
|
234
|
+
|
|
235
|
+
**Q: How do I update my settings?**
|
|
236
|
+
A: Run `card-news init` again to change your magazine settings.
|
|
237
|
+
|
|
238
|
+
**Q: Does it work offline?**
|
|
239
|
+
A: No, you need internet to extract articles and call the Claude API.
|
|
240
|
+
|
|
241
|
+
**Q: Can I use a different AI model?**
|
|
242
|
+
A: Currently it uses Claude 3 Opus. We may add support for other models in future versions.
|
|
243
|
+
|
|
244
|
+
## 📞 Support
|
|
245
|
+
|
|
246
|
+
- **Issues & Bugs:** GitHub Issues
|
|
247
|
+
- **Discussions:** GitHub Discussions
|
|
248
|
+
- **Feature Requests:** GitHub Issues (with label: feature)
|
|
249
|
+
|
|
250
|
+
## 🎯 Why card-news?
|
|
251
|
+
|
|
252
|
+
Unlike web-based alternatives that charge per generation or token:
|
|
253
|
+
- ✅ **Zero hosting cost** - You run it locally
|
|
254
|
+
- ✅ **Infinite sustainability** - Your own API key
|
|
255
|
+
- ✅ **Full customization** - It's open source
|
|
256
|
+
- ✅ **Learning value** - High-quality TypeScript code
|
|
257
|
+
- ✅ **Portfolio ready** - Impress potential employers
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
**Made with ❤️ for content creators and developers**
|
|
262
|
+
|
|
263
|
+
[⭐ Star us on GitHub!](https://github.com/wjs-ship-it/card-news)
|
|
264
|
+
|
|
265
|
+
## 🆕 Phase 2 Features (In Progress)
|
|
266
|
+
|
|
267
|
+
### ZIP Export
|
|
268
|
+
Export all generated cards as a ZIP archive with metadata:
|
|
269
|
+
|
|
270
|
+
```bash
|
|
271
|
+
card-news generate https://example.com --zip
|
|
272
|
+
# → card-news-output.zip
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
The ZIP file includes:
|
|
276
|
+
- All 5 PNG cards
|
|
277
|
+
- metadata.json with generation info
|
|
278
|
+
|
|
279
|
+
### Upcoming Features
|
|
280
|
+
- Multiple card templates (minimal, detailed, colorful)
|
|
281
|
+
- Batch processing (multiple articles)
|
|
282
|
+
- Advanced customization (fonts, gradients, logos)
|
|
283
|
+
- Preview mode
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## 🔐 Security
|
|
288
|
+
|
|
289
|
+
We take security seriously:
|
|
290
|
+
- ✅ No hardcoded secrets or API keys
|
|
291
|
+
- ✅ Input validation for URLs and file paths
|
|
292
|
+
- ✅ Protection against directory traversal attacks
|
|
293
|
+
- ✅ Sensitive info never exposed in error messages
|
|
294
|
+
- ✅ User API keys stored locally only (not our servers)
|
|
295
|
+
- ✅ Safe file handling with proper cleanup
|
|
296
|
+
|
|
297
|
+
See [SECURITY_CHECKLIST.md](./SECURITY_CHECKLIST.md) for details.
|
|
298
|
+
|
|
299
|
+
## 🎨 Templates
|
|
300
|
+
|
|
301
|
+
Choose from multiple pre-built templates:
|
|
302
|
+
|
|
303
|
+
```bash
|
|
304
|
+
# List all available templates
|
|
305
|
+
card-news templates
|
|
306
|
+
|
|
307
|
+
# Generate with specific template
|
|
308
|
+
card-news generate https://example.com -t detailed
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
### Available Templates
|
|
312
|
+
|
|
313
|
+
- **minimal** - Clean white design with black text (default)
|
|
314
|
+
- **detailed** - Light gray with more text space, elegant
|
|
315
|
+
- **colorful** - Vibrant red background with white text
|
|
316
|
+
|
|
317
|
+
Each template includes optimized:
|
|
318
|
+
- Colors and contrast
|
|
319
|
+
- Font sizes and spacing
|
|
320
|
+
- Card layout and proportions
|
|
321
|
+
|
|
322
|
+
### Create Custom Templates
|
|
323
|
+
|
|
324
|
+
Templates are JSON files in `templates/` directory. Copy an existing template and modify:
|
|
325
|
+
|
|
326
|
+
```json
|
|
327
|
+
{
|
|
328
|
+
"name": "my-template",
|
|
329
|
+
"description": "My custom design",
|
|
330
|
+
"backgroundColor": "#FFFFFF",
|
|
331
|
+
"textColor": "#000000",
|
|
332
|
+
"accentColor": "#FF6B6B",
|
|
333
|
+
...
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
## 🧪 Testing
|
|
339
|
+
|
|
340
|
+
### Unit Tests
|
|
341
|
+
|
|
342
|
+
Run automated tests with Jest:
|
|
343
|
+
|
|
344
|
+
```bash
|
|
345
|
+
# Run all tests
|
|
346
|
+
npm test
|
|
347
|
+
|
|
348
|
+
# Watch mode (re-run on changes)
|
|
349
|
+
npm run test:watch
|
|
350
|
+
|
|
351
|
+
# Coverage report
|
|
352
|
+
npm run test:coverage
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Tests include:
|
|
356
|
+
- URL validation
|
|
357
|
+
- Path security (traversal prevention)
|
|
358
|
+
- Template loading and configuration
|
|
359
|
+
- Filename sanitization
|
|
360
|
+
|
|
361
|
+
### Manual Testing
|
|
362
|
+
|
|
363
|
+
Test the CLI directly:
|
|
364
|
+
|
|
365
|
+
```bash
|
|
366
|
+
# Set up API key
|
|
367
|
+
export CLAUDE_API_KEY="sk-ant-..."
|
|
368
|
+
|
|
369
|
+
# Initialize
|
|
370
|
+
card-news init
|
|
371
|
+
|
|
372
|
+
# Test API connection
|
|
373
|
+
card-news test
|
|
374
|
+
|
|
375
|
+
# Generate cards
|
|
376
|
+
card-news generate https://www.bbc.com/news
|
|
377
|
+
card-news generate https://techcrunch.com/
|
|
378
|
+
|
|
379
|
+
# Test templates
|
|
380
|
+
card-news generate https://example.com -t colorful
|
|
381
|
+
card-news generate https://example.com -t detailed
|
|
382
|
+
|
|
383
|
+
# Test ZIP export
|
|
384
|
+
card-news generate https://example.com --zip
|
|
385
|
+
|
|
386
|
+
# List templates
|
|
387
|
+
card-news templates
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
### Testing Checklist
|
|
391
|
+
|
|
392
|
+
- [x] Unit tests with Jest
|
|
393
|
+
- [x] URL validation
|
|
394
|
+
- [x] Path security
|
|
395
|
+
- [x] Template system
|
|
396
|
+
- [ ] Integration tests (requires API key)
|
|
397
|
+
- [ ] E2E tests
|
|
398
|
+
- [ ] Performance tests
|
|
399
|
+
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { extractArticle } from '../services/article.js';
|
|
2
|
+
describe('Article Extractor', () => {
|
|
3
|
+
it('should reject invalid URLs', async () => {
|
|
4
|
+
await expect(extractArticle('not a valid url')).rejects.toThrow('Invalid URL format');
|
|
5
|
+
});
|
|
6
|
+
it('should reject non-HTTP protocols', async () => {
|
|
7
|
+
await expect(extractArticle('ftp://example.com')).rejects.toThrow('Invalid URL format');
|
|
8
|
+
});
|
|
9
|
+
// Note: These tests require actual network access
|
|
10
|
+
// Skip them in CI/offline environments
|
|
11
|
+
describe.skip('Real URL extraction', () => {
|
|
12
|
+
it('should extract article from BBC', async () => {
|
|
13
|
+
const article = await extractArticle('https://www.bbc.com/news');
|
|
14
|
+
expect(article.title).toBeTruthy();
|
|
15
|
+
expect(article.body).toBeTruthy();
|
|
16
|
+
expect(article.body.length).toBeGreaterThan(100);
|
|
17
|
+
});
|
|
18
|
+
it('should handle timeout gracefully', async () => {
|
|
19
|
+
// This would require a server that accepts connections but never responds
|
|
20
|
+
// Skip in normal test runs
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { validateUrl, validateOutputPath, sanitizeFilename } from '../utils/path-validator.js';
|
|
2
|
+
describe('Path Validator', () => {
|
|
3
|
+
describe('validateUrl', () => {
|
|
4
|
+
it('should accept valid HTTP URLs', () => {
|
|
5
|
+
expect(validateUrl('http://example.com')).toBe(true);
|
|
6
|
+
expect(validateUrl('https://www.example.com/path')).toBe(true);
|
|
7
|
+
expect(validateUrl('https://example.com/article?id=123')).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
it('should reject invalid URLs', () => {
|
|
10
|
+
expect(validateUrl('not a url')).toBe(false);
|
|
11
|
+
expect(validateUrl('ftp://example.com')).toBe(false);
|
|
12
|
+
expect(validateUrl('')).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
describe('sanitizeFilename', () => {
|
|
16
|
+
it('should remove dangerous characters', () => {
|
|
17
|
+
expect(sanitizeFilename('my-file.txt')).toBe('my-file.txt');
|
|
18
|
+
expect(sanitizeFilename('file<script>.txt')).toBe('file_script_.txt');
|
|
19
|
+
expect(sanitizeFilename('..\\dangerous')).toBe('__dangerous');
|
|
20
|
+
});
|
|
21
|
+
it('should remove leading dots', () => {
|
|
22
|
+
expect(sanitizeFilename('...hidden')).toBe('hidden');
|
|
23
|
+
expect(sanitizeFilename('.gitignore')).toBe('gitignore');
|
|
24
|
+
});
|
|
25
|
+
it('should limit filename length', () => {
|
|
26
|
+
const longName = 'a'.repeat(300);
|
|
27
|
+
expect(sanitizeFilename(longName).length).toBe(255);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
describe('validateOutputPath', () => {
|
|
31
|
+
it('should reject directory traversal attempts', () => {
|
|
32
|
+
expect(validateOutputPath('../../../etc/passwd')).toBe(false);
|
|
33
|
+
expect(validateOutputPath('output/..')).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
it('should reject absolute paths', () => {
|
|
36
|
+
expect(validateOutputPath('/etc/passwd')).toBe(false);
|
|
37
|
+
expect(validateOutputPath('~/secret')).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
it('should accept valid relative paths', () => {
|
|
40
|
+
expect(validateOutputPath('output')).toBe(true);
|
|
41
|
+
expect(validateOutputPath('output/cards')).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { getAvailableTemplates, loadTemplate, getDefaultTemplate } from '../services/template-engine.js';
|
|
2
|
+
describe('Template Engine', () => {
|
|
3
|
+
describe('getAvailableTemplates', () => {
|
|
4
|
+
it('should return a list of available templates', () => {
|
|
5
|
+
const templates = getAvailableTemplates();
|
|
6
|
+
expect(Array.isArray(templates)).toBe(true);
|
|
7
|
+
expect(templates.length).toBeGreaterThan(0);
|
|
8
|
+
});
|
|
9
|
+
it('should include minimal, detailed, and colorful', () => {
|
|
10
|
+
const templates = getAvailableTemplates();
|
|
11
|
+
expect(templates).toContain('minimal');
|
|
12
|
+
expect(templates).toContain('detailed');
|
|
13
|
+
expect(templates).toContain('colorful');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
describe('loadTemplate', () => {
|
|
17
|
+
it('should load minimal template', () => {
|
|
18
|
+
const template = loadTemplate('minimal');
|
|
19
|
+
expect(template.name).toBe('minimal');
|
|
20
|
+
expect(template.cardWidth).toBe(1080);
|
|
21
|
+
expect(template.cardHeight).toBe(1350);
|
|
22
|
+
});
|
|
23
|
+
it('should load detailed template', () => {
|
|
24
|
+
const template = loadTemplate('detailed');
|
|
25
|
+
expect(template.name).toBe('detailed');
|
|
26
|
+
expect(template.backgroundColor).toBe('#F8F9FA');
|
|
27
|
+
});
|
|
28
|
+
it('should load colorful template', () => {
|
|
29
|
+
const template = loadTemplate('colorful');
|
|
30
|
+
expect(template.name).toBe('colorful');
|
|
31
|
+
expect(template.backgroundColor).toBe('#FF6B6B');
|
|
32
|
+
expect(template.textColor).toBe('#FFFFFF');
|
|
33
|
+
});
|
|
34
|
+
it('should throw error for non-existent template', () => {
|
|
35
|
+
expect(() => loadTemplate('non-existent')).toThrow();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
describe('getDefaultTemplate', () => {
|
|
39
|
+
it('should return minimal template as default', () => {
|
|
40
|
+
const template = getDefaultTemplate();
|
|
41
|
+
expect(template.name).toBe('minimal');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe('Template Configuration', () => {
|
|
45
|
+
it('should have valid color values', () => {
|
|
46
|
+
const templates = getAvailableTemplates();
|
|
47
|
+
templates.forEach((name) => {
|
|
48
|
+
const template = loadTemplate(name);
|
|
49
|
+
// Check hex color format
|
|
50
|
+
expect(template.backgroundColor).toMatch(/^#[0-9A-F]{6}$/i);
|
|
51
|
+
expect(template.textColor).toMatch(/^#[0-9A-F]{6}$/i);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
it('should have proper font sizes', () => {
|
|
55
|
+
const template = loadTemplate('minimal');
|
|
56
|
+
expect(template.coverCard.titleFontSize).toBeGreaterThan(0);
|
|
57
|
+
expect(template.contentCard.textFontSize).toBeGreaterThan(0);
|
|
58
|
+
expect(template.endCard.mainTextFontSize).toBeGreaterThan(0);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
});
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { program } from 'commander';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = path.dirname(__filename);
|
|
8
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
9
|
+
program
|
|
10
|
+
.name('card-news')
|
|
11
|
+
.description('🎨 Transform news articles into Instagram card news')
|
|
12
|
+
.version(pkg.version);
|
|
13
|
+
program
|
|
14
|
+
.command('init')
|
|
15
|
+
.description('Initialize magazine settings')
|
|
16
|
+
.action(async () => {
|
|
17
|
+
const { initSetup } = await import('./commands/init.js');
|
|
18
|
+
await initSetup();
|
|
19
|
+
});
|
|
20
|
+
program
|
|
21
|
+
.command('generate <url>')
|
|
22
|
+
.description('Generate card news from article URL')
|
|
23
|
+
.option('-o, --output <dir>', 'output directory', './card-news-output')
|
|
24
|
+
.option('--zip', 'create ZIP archive of all cards')
|
|
25
|
+
.option('-t, --template <name>', 'card template (minimal, detailed, colorful)', 'minimal')
|
|
26
|
+
.action(async (url, options) => {
|
|
27
|
+
const { generateNews } = await import('./commands/generate.js');
|
|
28
|
+
await generateNews(url, options);
|
|
29
|
+
});
|
|
30
|
+
program
|
|
31
|
+
.command('config')
|
|
32
|
+
.description('View magazine configuration')
|
|
33
|
+
.action(async () => {
|
|
34
|
+
const { showConfig } = await import('./commands/config.js');
|
|
35
|
+
await showConfig();
|
|
36
|
+
});
|
|
37
|
+
program
|
|
38
|
+
.command('test')
|
|
39
|
+
.description('Test Claude API connection')
|
|
40
|
+
.action(async () => {
|
|
41
|
+
const { testAPI } = await import('./commands/test.js');
|
|
42
|
+
await testAPI();
|
|
43
|
+
});
|
|
44
|
+
program
|
|
45
|
+
.command('templates')
|
|
46
|
+
.description('List all available card templates')
|
|
47
|
+
.action(async () => {
|
|
48
|
+
const { listTemplates } = await import('./commands/list.js');
|
|
49
|
+
await listTemplates();
|
|
50
|
+
});
|
|
51
|
+
program.parse(process.argv);
|
|
52
|
+
if (!process.argv.slice(2).length) {
|
|
53
|
+
program.outputHelp();
|
|
54
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { getConfig, configExists } from '../utils/config.js';
|
|
2
|
+
import { logError } from '../utils/logger.js';
|
|
3
|
+
export async function showConfig() {
|
|
4
|
+
try {
|
|
5
|
+
if (!configExists()) {
|
|
6
|
+
logError('설정이 없습니다. 먼저 `card-news init`을 실행하세요.');
|
|
7
|
+
process.exit(1);
|
|
8
|
+
}
|
|
9
|
+
const config = getConfig();
|
|
10
|
+
console.log('\n📋 현재 설정:\n');
|
|
11
|
+
console.log(` 매거진명: ${config.magazineName}`);
|
|
12
|
+
console.log(` 주 색상: ${config.color}`);
|
|
13
|
+
console.log(` API 키: ${config.apiKey ? '***' + config.apiKey.slice(-4) : '설정되지 않음'}`);
|
|
14
|
+
console.log(` 해시태그: ${config.hashtags.join(', ')}`);
|
|
15
|
+
console.log('');
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
logError(error.message);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
import { extractArticle } from '../services/article.js';
|
|
3
|
+
import { generateSummary } from '../services/summary.js';
|
|
4
|
+
import { generateCards } from '../services/cardgen.js';
|
|
5
|
+
import { getConfig, getAPIKey } from '../utils/config.js';
|
|
6
|
+
import { logSuccess, logError, logProcessing } from '../utils/logger.js';
|
|
7
|
+
import { createZipArchive } from '../services/zip-export.js';
|
|
8
|
+
import { validateUrl } from '../utils/path-validator.js';
|
|
9
|
+
export async function generateNews(url, options) {
|
|
10
|
+
try {
|
|
11
|
+
// Validate URL
|
|
12
|
+
if (!validateUrl(url)) {
|
|
13
|
+
throw new Error('Invalid URL format. Please provide a valid HTTP/HTTPS URL.');
|
|
14
|
+
}
|
|
15
|
+
// Sanitize output path
|
|
16
|
+
const outputPath = path.resolve(options.output);
|
|
17
|
+
if (outputPath.includes('..')) {
|
|
18
|
+
throw new Error('Output path cannot contain ".." (directory traversal not allowed)');
|
|
19
|
+
}
|
|
20
|
+
const config = getConfig();
|
|
21
|
+
const apiKey = getAPIKey();
|
|
22
|
+
logProcessing('기사 추출 중...');
|
|
23
|
+
const article = await extractArticle(url);
|
|
24
|
+
logProcessing('AI 요약 생성 중...');
|
|
25
|
+
const lines = await generateSummary(article, apiKey);
|
|
26
|
+
logProcessing(`카드 생성 중... (template: ${options.template || 'minimal'})`);
|
|
27
|
+
await generateCards(lines, config.magazineName, config.color, options.output, options.template);
|
|
28
|
+
console.log('\n');
|
|
29
|
+
logSuccess(`카드는 ${options.output}에 저장되었습니다.`);
|
|
30
|
+
console.log('\n생성된 파일:');
|
|
31
|
+
lines.forEach((line, i) => {
|
|
32
|
+
console.log(` 0${i + 1}_*.png - ${line}`);
|
|
33
|
+
});
|
|
34
|
+
if (options.zip) {
|
|
35
|
+
logProcessing('ZIP 아카이브 생성 중...');
|
|
36
|
+
const zipPath = `${options.output}.zip`;
|
|
37
|
+
await createZipArchive(options.output, zipPath, config.magazineName);
|
|
38
|
+
logSuccess(`ZIP 파일: ${zipPath}`);
|
|
39
|
+
}
|
|
40
|
+
console.log('');
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
logError(error.message);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
import { initConfig } from '../utils/config.js';
|
|
3
|
+
import { logSuccess, logInfo } from '../utils/logger.js';
|
|
4
|
+
export async function initSetup() {
|
|
5
|
+
console.log('\n🎨 Card News Magazine 초기 설정\n');
|
|
6
|
+
const answers = await inquirer.prompt([
|
|
7
|
+
{
|
|
8
|
+
type: 'password',
|
|
9
|
+
name: 'apiKey',
|
|
10
|
+
message: 'Claude API 키를 입력하세요 (또는 CLAUDE_API_KEY 환경변수 사용):',
|
|
11
|
+
default: process.env.CLAUDE_API_KEY || ''
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
type: 'input',
|
|
15
|
+
name: 'magazineName',
|
|
16
|
+
message: '매거진 이름을 입력하세요:',
|
|
17
|
+
default: 'My Magazine'
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
type: 'input',
|
|
21
|
+
name: 'color',
|
|
22
|
+
message: '주 색상을 입력하세요 (hex color, 예: #FF6B6B):',
|
|
23
|
+
default: '#FF6B6B'
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
type: 'input',
|
|
27
|
+
name: 'hashtags',
|
|
28
|
+
message: '해시태그를 입력하세요 (쉼표로 구분, 예: 뉴스,핫이슈):',
|
|
29
|
+
default: '뉴스,핫이슈'
|
|
30
|
+
}
|
|
31
|
+
]);
|
|
32
|
+
const config = {
|
|
33
|
+
apiKey: answers.apiKey || process.env.CLAUDE_API_KEY,
|
|
34
|
+
magazineName: answers.magazineName,
|
|
35
|
+
color: answers.color,
|
|
36
|
+
hashtags: answers.hashtags.split(',').map((tag) => tag.trim())
|
|
37
|
+
};
|
|
38
|
+
initConfig(config);
|
|
39
|
+
logSuccess('설정이 저장되었습니다!');
|
|
40
|
+
logInfo(`매거진: ${answers.magazineName}`);
|
|
41
|
+
logInfo(`색상: ${answers.color}`);
|
|
42
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { getAvailableTemplates, loadTemplate } from '../services/template-engine.js';
|
|
2
|
+
import { logInfo, logSuccess } from '../utils/logger.js';
|
|
3
|
+
export async function listTemplates() {
|
|
4
|
+
try {
|
|
5
|
+
const templates = getAvailableTemplates();
|
|
6
|
+
logSuccess(`Available Templates (${templates.length})`);
|
|
7
|
+
console.log('');
|
|
8
|
+
templates.forEach((name) => {
|
|
9
|
+
const template = loadTemplate(name);
|
|
10
|
+
console.log(` 📋 ${name}`);
|
|
11
|
+
console.log(` ${template.description}`);
|
|
12
|
+
console.log(` Colors: BG=${template.backgroundColor} | Text=${template.textColor}`);
|
|
13
|
+
console.log('');
|
|
14
|
+
});
|
|
15
|
+
logInfo('Usage: card-news generate <url> -t <template-name>');
|
|
16
|
+
console.log('');
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
console.error(`Error listing templates: ${error.message}`);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
import { getAPIKey } from '../utils/config.js';
|
|
3
|
+
import { logSuccess, logError, logProcessing } from '../utils/logger.js';
|
|
4
|
+
export async function testAPI() {
|
|
5
|
+
try {
|
|
6
|
+
const apiKey = getAPIKey();
|
|
7
|
+
logProcessing('Claude API 테스트 중...');
|
|
8
|
+
const client = new Anthropic({ apiKey });
|
|
9
|
+
const message = await client.messages.create({
|
|
10
|
+
model: 'claude-opus-4-6',
|
|
11
|
+
max_tokens: 100,
|
|
12
|
+
messages: [
|
|
13
|
+
{
|
|
14
|
+
role: 'user',
|
|
15
|
+
content: 'Say "API connection successful!" in exactly 5 words.'
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
});
|
|
19
|
+
console.log('');
|
|
20
|
+
logSuccess('API 연결 성공!');
|
|
21
|
+
console.log('');
|
|
22
|
+
console.log(message.content[0].type === 'text' ? message.content[0].text : '');
|
|
23
|
+
console.log('');
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
console.log('');
|
|
27
|
+
logError(`API 연결 실패: ${error.message}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import * as cheerio from 'cheerio';
|
|
3
|
+
import { validateUrl } from '../utils/path-validator.js';
|
|
4
|
+
export async function extractArticle(url) {
|
|
5
|
+
// Validate URL format
|
|
6
|
+
if (!validateUrl(url)) {
|
|
7
|
+
throw new Error('Invalid URL format. Please provide a valid HTTP/HTTPS URL.');
|
|
8
|
+
}
|
|
9
|
+
try {
|
|
10
|
+
const { data } = await axios.get(url, {
|
|
11
|
+
timeout: 10000,
|
|
12
|
+
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; card-news/1.0)' }
|
|
13
|
+
});
|
|
14
|
+
const $ = cheerio.load(data);
|
|
15
|
+
// NOTE: This is a generic way to get the title and body.
|
|
16
|
+
// For better results, this selector logic should be customized
|
|
17
|
+
// for specific news website structures (e.g., 'meta[property="og:title"]' or '.article-body').
|
|
18
|
+
const title = $('head > title').text() || $('h1').first().text();
|
|
19
|
+
// A simple heuristic to find the main content.
|
|
20
|
+
// A more robust solution would analyze paragraph lengths and density.
|
|
21
|
+
let body = $('article').text();
|
|
22
|
+
if (body.length < 200) {
|
|
23
|
+
body = $('main').text();
|
|
24
|
+
}
|
|
25
|
+
if (body.length < 200) {
|
|
26
|
+
body = $('body').text();
|
|
27
|
+
}
|
|
28
|
+
// Clean up the text
|
|
29
|
+
const cleanedBody = body.replace(/\s\s+/g, ' ').trim();
|
|
30
|
+
return {
|
|
31
|
+
title,
|
|
32
|
+
body: cleanedBody,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
console.error('Error fetching or parsing the article:', error);
|
|
37
|
+
throw new Error('Failed to extract the article. Please check the URL and your network connection.');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { createCanvas } from 'canvas';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { loadTemplate, getDefaultTemplate } from './template-engine.js';
|
|
5
|
+
function hexToRgb(hex) {
|
|
6
|
+
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
7
|
+
return result
|
|
8
|
+
? {
|
|
9
|
+
r: parseInt(result[1], 16),
|
|
10
|
+
g: parseInt(result[2], 16),
|
|
11
|
+
b: parseInt(result[3], 16)
|
|
12
|
+
}
|
|
13
|
+
: { r: 255, g: 107, b: 107 };
|
|
14
|
+
}
|
|
15
|
+
function wrapText(ctx, text, maxWidth) {
|
|
16
|
+
const words = text.split(' ');
|
|
17
|
+
const lines = [];
|
|
18
|
+
let currentLine = '';
|
|
19
|
+
words.forEach((word) => {
|
|
20
|
+
const testLine = currentLine + (currentLine ? ' ' : '') + word;
|
|
21
|
+
const metrics = ctx.measureText(testLine);
|
|
22
|
+
if (metrics.width > maxWidth && currentLine) {
|
|
23
|
+
lines.push(currentLine);
|
|
24
|
+
currentLine = word;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
currentLine = testLine;
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
if (currentLine)
|
|
31
|
+
lines.push(currentLine);
|
|
32
|
+
return lines;
|
|
33
|
+
}
|
|
34
|
+
export async function generateCards(lines, magazineName, color, outputDir, templateName) {
|
|
35
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
36
|
+
const template = templateName ? loadTemplate(templateName) : getDefaultTemplate();
|
|
37
|
+
await generateCoverCard(lines[0], magazineName, template, outputDir);
|
|
38
|
+
for (let i = 1; i < 4; i++) {
|
|
39
|
+
await generateContentCard(lines[i], i, template, outputDir);
|
|
40
|
+
}
|
|
41
|
+
await generateEndCard(lines[4], magazineName, template, outputDir);
|
|
42
|
+
}
|
|
43
|
+
async function generateCoverCard(title, magazineName, template, outputDir) {
|
|
44
|
+
const canvas = createCanvas(template.cardWidth, template.cardHeight);
|
|
45
|
+
const ctx = canvas.getContext('2d');
|
|
46
|
+
ctx.fillStyle = template.backgroundColor;
|
|
47
|
+
ctx.fillRect(0, 0, template.cardWidth, template.cardHeight);
|
|
48
|
+
const cfg = template.coverCard;
|
|
49
|
+
ctx.fillStyle = `rgba(0, 0, 0, ${cfg.magazineNameOpacity})`;
|
|
50
|
+
ctx.font = `bold ${cfg.magazineNameFontSize}px ${template.fontFamily}`;
|
|
51
|
+
ctx.textAlign = 'center';
|
|
52
|
+
ctx.fillText(magazineName, template.cardWidth / 2, 100);
|
|
53
|
+
ctx.fillStyle = template.textColor;
|
|
54
|
+
ctx.font = `bold ${cfg.titleFontSize}px ${template.fontFamily}`;
|
|
55
|
+
ctx.textAlign = 'center';
|
|
56
|
+
const titleLines = wrapText(ctx, title, template.cardWidth - cfg.padding);
|
|
57
|
+
const startY = template.cardHeight / 2 - (titleLines.length * 80) / 2;
|
|
58
|
+
titleLines.forEach((line, index) => {
|
|
59
|
+
ctx.fillText(line, template.cardWidth / 2, startY + index * 80);
|
|
60
|
+
});
|
|
61
|
+
const buffer = canvas.toBuffer('image/png');
|
|
62
|
+
fs.writeFileSync(path.join(outputDir, '01_cover.png'), buffer);
|
|
63
|
+
}
|
|
64
|
+
async function generateContentCard(content, index, template, outputDir) {
|
|
65
|
+
const canvas = createCanvas(template.cardWidth, template.cardHeight);
|
|
66
|
+
const ctx = canvas.getContext('2d');
|
|
67
|
+
ctx.fillStyle = template.backgroundColor;
|
|
68
|
+
ctx.fillRect(0, 0, template.cardWidth, template.cardHeight);
|
|
69
|
+
const cfg = template.contentCard;
|
|
70
|
+
ctx.fillStyle = `rgba(0, 0, 0, ${cfg.numberOpacity})`;
|
|
71
|
+
ctx.font = `bold ${cfg.numberFontSize}px ${template.fontFamily}`;
|
|
72
|
+
ctx.textAlign = 'right';
|
|
73
|
+
ctx.fillText(index.toString(), template.cardWidth - 50, template.cardHeight - 100);
|
|
74
|
+
ctx.fillStyle = template.textColor;
|
|
75
|
+
ctx.font = `${cfg.textFontSize}px ${template.fontFamily}`;
|
|
76
|
+
ctx.textAlign = 'center';
|
|
77
|
+
const lines = wrapText(ctx, content, template.cardWidth - cfg.padding);
|
|
78
|
+
const startY = template.cardHeight / 2 - (lines.length * 70) / 2;
|
|
79
|
+
lines.forEach((line, i) => {
|
|
80
|
+
ctx.fillText(line, template.cardWidth / 2, startY + i * 70);
|
|
81
|
+
});
|
|
82
|
+
const buffer = canvas.toBuffer('image/png');
|
|
83
|
+
fs.writeFileSync(path.join(outputDir, `0${index + 1}_content.png`), buffer);
|
|
84
|
+
}
|
|
85
|
+
async function generateEndCard(content, magazineName, template, outputDir) {
|
|
86
|
+
const canvas = createCanvas(template.cardWidth, template.cardHeight);
|
|
87
|
+
const ctx = canvas.getContext('2d');
|
|
88
|
+
ctx.fillStyle = template.backgroundColor;
|
|
89
|
+
ctx.fillRect(0, 0, template.cardWidth, template.cardHeight);
|
|
90
|
+
const cfg = template.endCard;
|
|
91
|
+
ctx.fillStyle = template.textColor;
|
|
92
|
+
ctx.font = `bold ${cfg.mainTextFontSize}px ${template.fontFamily}`;
|
|
93
|
+
ctx.textAlign = 'center';
|
|
94
|
+
ctx.fillText('Thanks for reading!', template.cardWidth / 2, template.cardHeight / 2 - 100);
|
|
95
|
+
ctx.font = `${cfg.subtextFontSize}px ${template.fontFamily}`;
|
|
96
|
+
ctx.fillText(content, template.cardWidth / 2, template.cardHeight / 2 + 100);
|
|
97
|
+
ctx.font = `bold ${cfg.magazineNameFontSize}px ${template.fontFamily}`;
|
|
98
|
+
ctx.fillStyle = template.accentColor;
|
|
99
|
+
ctx.fillText(magazineName, template.cardWidth / 2, template.cardHeight - 100);
|
|
100
|
+
const buffer = canvas.toBuffer('image/png');
|
|
101
|
+
fs.writeFileSync(path.join(outputDir, '05_end.png'), buffer);
|
|
102
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
export async function generateSummary(article, apiKey) {
|
|
3
|
+
const client = new Anthropic({ apiKey });
|
|
4
|
+
const prompt = `당신은 인스타그램 카드뉴스 작성 전문가입니다.
|
|
5
|
+
|
|
6
|
+
주어진 기사를 정확히 5개의 카드로 표현해주세요. 각 줄은 30자 이내로 작성하세요.
|
|
7
|
+
|
|
8
|
+
**기사 정보:**
|
|
9
|
+
제목: ${article.title}
|
|
10
|
+
본문: ${article.body.substring(0, 500)}
|
|
11
|
+
|
|
12
|
+
**응답 형식 (중요):**
|
|
13
|
+
각 줄을 다음과 같이 작성하세요:
|
|
14
|
+
1. 첫 번째 카드 (제목/요약)
|
|
15
|
+
2. 두 번째 카드 (주요 내용)
|
|
16
|
+
3. 세 번째 카드 (추가 정보)
|
|
17
|
+
4. 네 번째 카드 (결론/의견)
|
|
18
|
+
5. 다섯 번째 카드 (마무리)
|
|
19
|
+
|
|
20
|
+
**제약사항:**
|
|
21
|
+
- 한국어로 작성
|
|
22
|
+
- 각 줄은 정확히 30자 이내
|
|
23
|
+
- 숫자나 마크다운 형식 제외
|
|
24
|
+
- 명확하고 간결한 문체`;
|
|
25
|
+
const message = await client.messages.create({
|
|
26
|
+
model: 'claude-opus-4-6',
|
|
27
|
+
max_tokens: 300,
|
|
28
|
+
messages: [
|
|
29
|
+
{
|
|
30
|
+
role: 'user',
|
|
31
|
+
content: prompt
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
});
|
|
35
|
+
const text = message.content[0].type === 'text' ? message.content[0].text : '';
|
|
36
|
+
const lines = text
|
|
37
|
+
.split('\n')
|
|
38
|
+
.filter((line) => line.trim().length > 0)
|
|
39
|
+
.map((line) => line.replace(/^\d+\.\s*/, '').trim())
|
|
40
|
+
.slice(0, 5);
|
|
41
|
+
while (lines.length < 5) {
|
|
42
|
+
lines.push('');
|
|
43
|
+
}
|
|
44
|
+
return lines.slice(0, 5);
|
|
45
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
5
|
+
const __dirname = path.dirname(__filename);
|
|
6
|
+
export function getAvailableTemplates() {
|
|
7
|
+
const templatesDir = path.join(__dirname, '..', '..', 'templates');
|
|
8
|
+
const files = fs.readdirSync(templatesDir);
|
|
9
|
+
return files
|
|
10
|
+
.filter((f) => f.endsWith('.json') && f !== 'card.json')
|
|
11
|
+
.map((f) => f.replace('.json', ''));
|
|
12
|
+
}
|
|
13
|
+
export function loadTemplate(templateName) {
|
|
14
|
+
const templatesDir = path.join(__dirname, '..', '..', 'templates');
|
|
15
|
+
const templatePath = path.join(templatesDir, `${templateName}.json`);
|
|
16
|
+
if (!fs.existsSync(templatePath)) {
|
|
17
|
+
throw new Error(`Template not found: ${templateName}. Available: ${getAvailableTemplates().join(', ')}`);
|
|
18
|
+
}
|
|
19
|
+
const content = fs.readFileSync(templatePath, 'utf8');
|
|
20
|
+
return JSON.parse(content);
|
|
21
|
+
}
|
|
22
|
+
export function getDefaultTemplate() {
|
|
23
|
+
return loadTemplate('minimal');
|
|
24
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { createWriteStream } from 'fs';
|
|
4
|
+
const archiver = require('archiver');
|
|
5
|
+
export async function createZipArchive(sourceDir, outputPath, magazineName) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
// Validate input paths
|
|
8
|
+
if (!fs.existsSync(sourceDir)) {
|
|
9
|
+
reject(new Error(`Source directory not found: ${sourceDir}`));
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
// Ensure output directory exists
|
|
13
|
+
const outputDir = path.dirname(outputPath);
|
|
14
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
15
|
+
const output = createWriteStream(outputPath);
|
|
16
|
+
const archive = archiver.default('zip', { zlib: { level: 9 } });
|
|
17
|
+
output.on('close', () => {
|
|
18
|
+
resolve(outputPath);
|
|
19
|
+
});
|
|
20
|
+
output.on('error', (err) => {
|
|
21
|
+
reject(new Error(`Failed to create ZIP: ${err.message}`));
|
|
22
|
+
});
|
|
23
|
+
archive.on('error', (err) => {
|
|
24
|
+
reject(new Error(`Archiver error: ${err.message}`));
|
|
25
|
+
});
|
|
26
|
+
archive.pipe(output);
|
|
27
|
+
// Add all PNG files from source directory
|
|
28
|
+
const files = fs.readdirSync(sourceDir);
|
|
29
|
+
const pngFiles = files.filter((f) => f.endsWith('.png'));
|
|
30
|
+
if (pngFiles.length === 0) {
|
|
31
|
+
reject(new Error('No PNG files found to archive'));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
pngFiles.forEach((file) => {
|
|
35
|
+
const filePath = path.join(sourceDir, file);
|
|
36
|
+
archive.file(filePath, { name: file });
|
|
37
|
+
});
|
|
38
|
+
// Add metadata
|
|
39
|
+
const metadata = {
|
|
40
|
+
magazineName,
|
|
41
|
+
generatedAt: new Date().toISOString(),
|
|
42
|
+
cardCount: pngFiles.length,
|
|
43
|
+
files: pngFiles
|
|
44
|
+
};
|
|
45
|
+
archive.append(JSON.stringify(metadata, null, 2), {
|
|
46
|
+
name: 'metadata.json'
|
|
47
|
+
});
|
|
48
|
+
archive.finalize();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
const CONFIG_DIR = path.join(os.homedir(), '.card-news');
|
|
5
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
6
|
+
export function initConfig(config) {
|
|
7
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
8
|
+
const fullConfig = {
|
|
9
|
+
apiKey: process.env.CLAUDE_API_KEY || config.apiKey || '',
|
|
10
|
+
magazineName: config.magazineName || 'My Magazine',
|
|
11
|
+
color: config.color || '#FF6B6B',
|
|
12
|
+
hashtags: config.hashtags || ['뉴스', '핫이슈']
|
|
13
|
+
};
|
|
14
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(fullConfig, null, 2));
|
|
15
|
+
}
|
|
16
|
+
export function getConfig() {
|
|
17
|
+
if (!fs.existsSync(CONFIG_FILE)) {
|
|
18
|
+
throw new Error('Config not found. Run: card-news init');
|
|
19
|
+
}
|
|
20
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
21
|
+
}
|
|
22
|
+
export function getAPIKey() {
|
|
23
|
+
const apiKey = process.env.CLAUDE_API_KEY;
|
|
24
|
+
if (apiKey)
|
|
25
|
+
return apiKey;
|
|
26
|
+
const config = getConfig();
|
|
27
|
+
if (config.apiKey)
|
|
28
|
+
return config.apiKey;
|
|
29
|
+
throw new Error('Claude API key not found. Set CLAUDE_API_KEY or run: card-news init');
|
|
30
|
+
}
|
|
31
|
+
export function updateConfig(updates) {
|
|
32
|
+
const current = getConfig();
|
|
33
|
+
const updated = { ...current, ...updates };
|
|
34
|
+
initConfig(updated);
|
|
35
|
+
}
|
|
36
|
+
export function configExists() {
|
|
37
|
+
return fs.existsSync(CONFIG_FILE);
|
|
38
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
export function logSuccess(message) {
|
|
3
|
+
console.log(chalk.green(`✅ ${message}`));
|
|
4
|
+
}
|
|
5
|
+
export function logError(message) {
|
|
6
|
+
console.error(chalk.red(`❌ ${message}`));
|
|
7
|
+
}
|
|
8
|
+
export function logInfo(message) {
|
|
9
|
+
console.log(chalk.blue(`ℹ️ ${message}`));
|
|
10
|
+
}
|
|
11
|
+
export function logWarning(message) {
|
|
12
|
+
console.log(chalk.yellow(`⚠️ ${message}`));
|
|
13
|
+
}
|
|
14
|
+
export function logProcessing(message) {
|
|
15
|
+
console.log(chalk.cyan(`⏳ ${message}`));
|
|
16
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
export function validateOutputPath(outputPath) {
|
|
3
|
+
// Prevent directory traversal attacks
|
|
4
|
+
const resolvedPath = path.resolve(outputPath);
|
|
5
|
+
const normalizedPath = path.normalize(resolvedPath);
|
|
6
|
+
// Ensure path doesn't contain suspicious patterns
|
|
7
|
+
if (normalizedPath.includes('..') ||
|
|
8
|
+
normalizedPath.includes('~') ||
|
|
9
|
+
normalizedPath.startsWith('/')) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
// Only allow alphanumeric, hyphen, underscore, and forward slash
|
|
13
|
+
if (!/^[\w\-./]+$/.test(normalizedPath)) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
export function sanitizeFilename(filename) {
|
|
19
|
+
// Remove any dangerous characters from filename
|
|
20
|
+
return filename
|
|
21
|
+
.replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
22
|
+
.replace(/^\.+/, '') // Remove leading dots
|
|
23
|
+
.substring(0, 255); // Limit length
|
|
24
|
+
}
|
|
25
|
+
export function validateUrl(url) {
|
|
26
|
+
try {
|
|
27
|
+
new URL(url);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "card-news",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "🎨 Transform news articles into beautiful Instagram card news with AI-powered summarization",
|
|
5
|
+
"main": "dist/cli.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"card-news": "bin/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node dist/cli.js",
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"dev": "ts-node src/cli.ts",
|
|
14
|
+
"test": "jest",
|
|
15
|
+
"test:watch": "jest --watch",
|
|
16
|
+
"test:coverage": "jest --coverage"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"cli",
|
|
20
|
+
"card-news",
|
|
21
|
+
"ai",
|
|
22
|
+
"typescript",
|
|
23
|
+
"claude",
|
|
24
|
+
"instagram",
|
|
25
|
+
"news",
|
|
26
|
+
"content-creation",
|
|
27
|
+
"ai-powered"
|
|
28
|
+
],
|
|
29
|
+
"author": "Your Name <your-email@example.com>",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "https://github.com/wjs-ship-it/card-news.git"
|
|
33
|
+
},
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/wjs-ship-it/card-news/issues"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/wjs-ship-it/card-news#readme",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@anthropic-ai/sdk": "^0.104.1",
|
|
41
|
+
"anthropic": "^0.0.0",
|
|
42
|
+
"archiver": "^8.0.0",
|
|
43
|
+
"axios": "^1.17.0",
|
|
44
|
+
"canvas": "^3.2.3",
|
|
45
|
+
"chalk": "^5.6.2",
|
|
46
|
+
"cheerio": "^1.2.0",
|
|
47
|
+
"commander": "^15.0.0",
|
|
48
|
+
"dotenv": "^17.4.2"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/archiver": "^8.0.0",
|
|
52
|
+
"@types/inquirer": "^9.0.10",
|
|
53
|
+
"@types/jest": "^30.0.0",
|
|
54
|
+
"@types/node": "^25.9.3",
|
|
55
|
+
"jest": "^30.4.2",
|
|
56
|
+
"ts-jest": "^29.4.11",
|
|
57
|
+
"ts-node": "^10.9.2",
|
|
58
|
+
"typescript": "^6.0.3"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "colorful",
|
|
3
|
+
"description": "Colorful and vibrant design",
|
|
4
|
+
"cardWidth": 1080,
|
|
5
|
+
"cardHeight": 1350,
|
|
6
|
+
"backgroundColor": "#FF6B6B",
|
|
7
|
+
"textColor": "#FFFFFF",
|
|
8
|
+
"accentColor": "#FFE66D",
|
|
9
|
+
"fontFamily": "-apple-system, BlinkMacSystemFont, 'Segoe UI'",
|
|
10
|
+
"coverCard": {
|
|
11
|
+
"titleFontSize": 64,
|
|
12
|
+
"magazineNameFontSize": 32,
|
|
13
|
+
"magazineNameOpacity": 0.9,
|
|
14
|
+
"padding": 40
|
|
15
|
+
},
|
|
16
|
+
"contentCard": {
|
|
17
|
+
"textFontSize": 52,
|
|
18
|
+
"numberFontSize": 250,
|
|
19
|
+
"numberOpacity": 0.1,
|
|
20
|
+
"padding": 60
|
|
21
|
+
},
|
|
22
|
+
"endCard": {
|
|
23
|
+
"mainTextFontSize": 56,
|
|
24
|
+
"subtextFontSize": 36,
|
|
25
|
+
"magazineNameFontSize": 28
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "detailed",
|
|
3
|
+
"description": "Detailed card design with more text space",
|
|
4
|
+
"cardWidth": 1080,
|
|
5
|
+
"cardHeight": 1350,
|
|
6
|
+
"backgroundColor": "#F8F9FA",
|
|
7
|
+
"textColor": "#1A1A1A",
|
|
8
|
+
"accentColor": "#FF6B6B",
|
|
9
|
+
"fontFamily": "-apple-system, BlinkMacSystemFont, 'Segoe UI'",
|
|
10
|
+
"coverCard": {
|
|
11
|
+
"titleFontSize": 56,
|
|
12
|
+
"magazineNameFontSize": 28,
|
|
13
|
+
"magazineNameOpacity": 0.8,
|
|
14
|
+
"padding": 50
|
|
15
|
+
},
|
|
16
|
+
"contentCard": {
|
|
17
|
+
"textFontSize": 48,
|
|
18
|
+
"numberFontSize": 150,
|
|
19
|
+
"numberOpacity": 0.2,
|
|
20
|
+
"padding": 80
|
|
21
|
+
},
|
|
22
|
+
"endCard": {
|
|
23
|
+
"mainTextFontSize": 52,
|
|
24
|
+
"subtextFontSize": 32,
|
|
25
|
+
"magazineNameFontSize": 24
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "minimal",
|
|
3
|
+
"description": "Clean and minimal card design",
|
|
4
|
+
"cardWidth": 1080,
|
|
5
|
+
"cardHeight": 1350,
|
|
6
|
+
"backgroundColor": "#FFFFFF",
|
|
7
|
+
"textColor": "#000000",
|
|
8
|
+
"accentColor": "#FF6B6B",
|
|
9
|
+
"fontFamily": "-apple-system, BlinkMacSystemFont, 'Segoe UI'",
|
|
10
|
+
"coverCard": {
|
|
11
|
+
"titleFontSize": 48,
|
|
12
|
+
"magazineNameFontSize": 24,
|
|
13
|
+
"magazineNameOpacity": 0.6,
|
|
14
|
+
"padding": 40
|
|
15
|
+
},
|
|
16
|
+
"contentCard": {
|
|
17
|
+
"textFontSize": 42,
|
|
18
|
+
"numberFontSize": 200,
|
|
19
|
+
"numberOpacity": 0.15,
|
|
20
|
+
"padding": 60
|
|
21
|
+
},
|
|
22
|
+
"endCard": {
|
|
23
|
+
"mainTextFontSize": 48,
|
|
24
|
+
"subtextFontSize": 28,
|
|
25
|
+
"magazineNameFontSize": 20
|
|
26
|
+
}
|
|
27
|
+
}
|