Compare commits
No commits in common. "29c8bb9c9ea7ae86ae222b956e22625c43192d48" and "c67792497217efa2b54a511a6b2cb4015f306d29" have entirely different histories.
29c8bb9c9e
...
c677924972
|
|
@ -0,0 +1,199 @@
|
|||
# Posimai Events / Maps 監査と完成ロードマップ
|
||||
|
||||
**目的**: 単体ミニマルアプリとしての現状整理・改善点の優先度付け・Maps/Events どちらを先に「完全体」にするかの方針。
|
||||
**前提**: 機能を積み増ししすぎず、シンプル&スタイリッシュを維持。既存アプリ(Feed / Brain / Reader)との整合性・Lucide アイコン統一を重視する。
|
||||
|
||||
---
|
||||
|
||||
## 1. デザインシステム共通チェック(全アプリ)
|
||||
|
||||
| 項目 | Feed | Brain | Reader | Maps | Events |
|
||||
|------|------|-------|--------|------|--------|
|
||||
| ヘッダー高さ 52px | ○ | ○ (--topbar-h) | - | 要確認 | ○ |
|
||||
| Inter フォント | ○ | ○ | - | 要確認 | ○ |
|
||||
| :root / [data-theme="light"] | ○ | ○ | - | ○ | ○ |
|
||||
| --bg, --surface, --text, --accent, --radius: 8px | ○ | ○ | - | ○ | ○ |
|
||||
| Lucide のみ(絵文字・他アイコンなし) | ○ | ○ | - | ○ | **要修正** |
|
||||
| PWA manifest + theme-color | ○ | ○ | - | ○ | ○ |
|
||||
| テーマ切替(sun/moon アイコン) | ○ | ○ | - | ○ | ○ |
|
||||
|
||||
**結論**: Events の「絞り込み」シート内の一部アイコン名が Lucide にない可能性あり(後述)。それ以外はおおむね統一されている。
|
||||
|
||||
---
|
||||
|
||||
## 2. Posimai Events — 現状と改善点
|
||||
|
||||
### 2.1 実装済み(維持する)
|
||||
|
||||
| 項目 | 内容 |
|
||||
|------|------|
|
||||
| **レイアウト** | 52px ヘッダー、タブ(今日/今週/すべて)、リスト+日付バッジ、ボトムシート詳細 |
|
||||
| **データ** | モック JSON + localStorage キャッシュ/オフライン表示、Synology API URL 対応 |
|
||||
| **パーソナライズ** | 興味タグ・対象者タグ・オプション(無料/申込不要/屋外・屋内)の絞り込み、設定は localStorage で永続化 |
|
||||
| **連携** | Brain 保存(URL+title で開く)、詳細リンク(外部)、場所→Google Maps 検索 |
|
||||
| **PWA** | manifest.json, sw.js, インストール可能、オフラインでキャッシュ表示 |
|
||||
| **テーマ** | ダーク/ライト切替、Events 専用アクセント #6EE7B7 |
|
||||
| **Lucide** | ヘッダー・カード・シート・トーストで `data-lucide` を一貫使用 |
|
||||
|
||||
### 2.2 改善点(優先度順)
|
||||
|
||||
#### 優先度:高(見た目・一貫性・信頼性)
|
||||
|
||||
| # | 内容 | 理由 | 工数目安 |
|
||||
|---|------|------|----------|
|
||||
| E1 | **Lucide アイコン名の検証・修正** | 絞り込みタグで `beer`, `glass-water`, `circle-dollar-sign`, `door-open`, `hand-heart` 等を使用。Lucide にない/名前違いがあると表示欠けやコンソールエラーの原因になる。 | 0.5h |
|
||||
| E2 | **API フォールバック順の明確化** | 現状「Synology URL 優先 → 失敗時 Vercel /api/events」だが、Synology 未構築時は初回から失敗する。Vercel をデフォルトにし、設定で Synology URL を差し替える方が運用しやすい。 | 0.5h |
|
||||
| E3 | **空状態・エラー時の文言統一** | 「この条件のイベントは見つかりません」等、Feed/Brain に近いトーンで簡潔に。 | 0.25h |
|
||||
|
||||
#### 優先度:中(UX の磨き)
|
||||
|
||||
| # | 内容 | 理由 | 工数目安 |
|
||||
|---|------|------|----------|
|
||||
| E4 | **絞り込みシートの「適用」でシートを閉じる** | 現状どおりで問題なし。閉じたあとトーストで「絞り込みを適用しました」と出しているのでそのまま維持でよい。 | - |
|
||||
| E5 | **カードのタップ領域** | カード全体タップで詳細、右端アイコンは別アクション。`event.stopPropagation` が効いているか確認。 | 0.25h |
|
||||
| E6 | **キーボード・スクリーンリーダー** | ボタンに `aria-label`、シートに `aria-modal="true"` を追加し、既存アプリと水準を揃える。 | 0.5h |
|
||||
|
||||
#### 優先度:低(将来)
|
||||
|
||||
| # | 内容 | 理由 |
|
||||
|---|------|------|
|
||||
| E7 | **API URL の設定 UI** | 開発者向け。設定パネルや localStorage のキー説明を README に記載すれば十分。UI は必須ではない。 |
|
||||
| E8 | **Maps 連携** | 「イベント場所→Maps で周辺飲食店」は、Maps 側で URL パラメータが固まってから実装。現状は Google Maps リンクで代替。 |
|
||||
|
||||
### 2.3 意図的にスコープ外(ミニマル維持)
|
||||
|
||||
- イベントの編集・削除(管理画面は別)
|
||||
- 複数地域・複数カレンダー
|
||||
- プッシュ通知
|
||||
- ログイン・ユーザー別データ(単体アプリのまま)
|
||||
|
||||
---
|
||||
|
||||
## 3. Posimai Maps — 現状と改善点
|
||||
|
||||
### 3.1 実装済み(OVERVIEW 等より)
|
||||
|
||||
| 項目 | 内容 |
|
||||
|------|------|
|
||||
| **コア** | Google Places API (New) searchNearby、最大20件、半径・タイプ・ランキング対応、moveend 再検索、0件時 UX |
|
||||
| **UI** | 地図右下に現在地・ズーム、ヘッダーに人気順/近い順、カテゴリタブ、店舗詳細パネル、テーマ切替 |
|
||||
| **連携** | Brain 保存、Google マップ連携、Carto タイル(ダーク/ライト) |
|
||||
| **PWA** | manifest, sw.js |
|
||||
| **Lucide** | locate, sun, utensils, x, map-pin, phone, sticky-note, clock 等を利用 |
|
||||
|
||||
### 3.2 改善点(優先度順)
|
||||
|
||||
#### 優先度:高(負債解消・一貫性)
|
||||
|
||||
| # | 内容 | 理由 | 工数目安 |
|
||||
|---|------|------|----------|
|
||||
| M1 | **ラジアル UI の扱いを確定** | CRITICAL_ACTION_PLAN 通り「削除」か「保留(触らない)」を決定。削除なら関連 JS/CSS を削り、スライダーのみで完成形に。 | 0.5h |
|
||||
| M2 | **位置情報オプション** | `enableHighAccuracy: true`, `timeout: 10000`, `maximumAge` の調整(OVERVIEW 1.3 の推奨)。 | 0.25h |
|
||||
| M3 | **スライダー見た目** | トラック 1.5px・ポインタ 16px リング型(未適用なら実施)。 | 0.25h |
|
||||
| M4 | **Lucide の徹底** | 地図コントロール・リスト・詳細パネルで Lucide 以外のアイコンやインライン SVG が残っていれば Lucide に統一。 | 0.5h |
|
||||
|
||||
#### 優先度:中
|
||||
|
||||
| # | 内容 | 理由 |
|
||||
|---|------|------|
|
||||
| M5 | **カテゴリと API の連携** | タブ選択で `includedTypes` を変え、カテゴリごとに最大20件を取得(OVERVIEW 推奨)。 |
|
||||
| M6 | **取得幅の設定** | 歯車で範囲(m)を設定・保存する場合は、既存「1.5km 固定」を維持しつつオプションとして追加。 |
|
||||
|
||||
#### 優先度:低
|
||||
|
||||
| # | 内容 |
|
||||
|---|------|
|
||||
| M7 | **20件超の表示** | 複数回検索の併合は、需要とコストを見てから。 |
|
||||
| M8 | **営業時間なしの表記** | 詳細パネルで「営業時間未登録」等の文言を仕様化。 |
|
||||
|
||||
### 3.3 意図的にスコープ外(Phase 1 完全体の範囲外)
|
||||
|
||||
- ラジアルの「改良」(削除 or 保留のみ)
|
||||
- Phase 2 の SNS/Web 営業時間補完
|
||||
- レビュー・写真の表示(API 次第)
|
||||
|
||||
---
|
||||
|
||||
## 4. Lucide アイコン統一チェック
|
||||
|
||||
### Events で使用しているアイコン名(絞り込みタグ含む)
|
||||
|
||||
| 用途 | 指定名 | Lucide での存在 |
|
||||
|------|--------|-----------------|
|
||||
| 日本酒 | wine | ○ あり |
|
||||
| クラフトビール | beer | △ **要確認**(無ければ `wine` や `glass-water` で代替) |
|
||||
| ワイン・お酒 | glass-water | ○ あり |
|
||||
| ボランティア | hand-heart | ○ あり |
|
||||
| 無料 | circle-dollar-sign | ○ あり |
|
||||
| 申込不要 | door-open | ○ あり |
|
||||
|
||||
**対応**: ビルドまたはブラウザで実際に表示を確認し、存在しない名前だけ `lucide.dev` で正式名に差し替える。
|
||||
|
||||
### 全アプリ共通ルール
|
||||
|
||||
- アイコンは **Lucide のみ**。絵文字・Font Awesome・インライン SVG は使わない。
|
||||
- サイズは `width`/`height` または CSS で統一(例: ヘッダー 18px、リスト 14px)。
|
||||
- 色は `color: var(--text2)` / `var(--accent)` 等、テーマ変数に合わせる。
|
||||
|
||||
---
|
||||
|
||||
## 5. Maps と Events のどちらを先に「完全体」にするか
|
||||
|
||||
### 判定の観点
|
||||
|
||||
| 観点 | Maps | Events |
|
||||
|------|------|--------|
|
||||
| **バックエンド** | 既存(Google Places API + Vercel serverless)で完結。Synology 不要。 | 本番データは Synology(n8n + PostgreSQL)が前提。未構築ならモックのまま。 |
|
||||
| **負債** | ラジアルの削除/保留の決定が未了。 | なし(新規プロトタイプ)。 |
|
||||
| **スコープ** | 検索・表示・連携まで一通り実装済み。あとは整理と仕様確定。 | UI/UX と絞り込みはある。実データは n8n+DB 構築が必要。 |
|
||||
| **ユーザー価値** | 「今空いている飲食店」は単体で価値が明確。 | 「自分用イベント一覧」はデータが入って初めて価値が立つ。 |
|
||||
| **工数** | ラジアル処理+位置情報+スライダー+Lucide で **1〜2h** 程度。 | Lucide 修正+API デフォルト+軽微 UX で **1h 以内**。Synology 側は別計画。 |
|
||||
|
||||
### 推奨方針:**Maps を先に「完全体」にする**
|
||||
|
||||
1. **完了の定義がはっきりしている**
|
||||
「ラジアル削除 or 保留」「位置情報・スライダー・Lucide 統一」をやれば、Phase 1 として完成と宣言しやすい。
|
||||
|
||||
2. **バックエンドに依存しない**
|
||||
Maps は既存 API のまま完成形にできる。Events の完全体は Synology の n8n + DB が前提で、工数と前提が大きい。
|
||||
|
||||
3. **1アプリずつ仕上げる**
|
||||
まず Maps を「完成」にし、そのあと Events は「UI/UX とクライアント側は完成 → データは Synology 構築で実化」と段階を分けられる。
|
||||
|
||||
4. **Events は「見た目と動きは完成、データはモック or 任意 Synology」**
|
||||
Lucide 修正・API デフォルト・アクセシビリティだけ整え、単体ミニマルアプリとして完成とする。実データは別タスク(n8n + Synology API)で対応。
|
||||
|
||||
### ロードマップ(案)
|
||||
|
||||
```
|
||||
Phase A: Maps 完全体(1〜2h)
|
||||
M1 ラジアル削除 or 保留確定 → 実装
|
||||
M2 位置情報オプション
|
||||
M3 スライダー見た目(未適用なら)
|
||||
M4 Lucide 徹底
|
||||
|
||||
Phase B: Events クライアント完成(1h 以内)
|
||||
E1 Lucide アイコン名検証・修正
|
||||
E2 API フォールバック順(Vercel デフォルト)
|
||||
E3 空状態・エラー文言
|
||||
E6 アクセシビリティ(aria)
|
||||
|
||||
Phase C: ドキュメント・計画の更新
|
||||
CRITICAL_ACTION_PLAN に「Maps 完全体済み」「Events クライアント完成」を反映
|
||||
Events の実データ(n8n + Synology)は別ロードマップで計画
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. まとめ
|
||||
|
||||
| アプリ | 現状 | 完全体の定義 | 優先度 |
|
||||
|--------|------|--------------|--------|
|
||||
| **Maps** | 機能はほぼ揃っている。ラジアル・位置情報・スライダー・Lucide の整理が残り。 | ラジアル処理済み・位置情報推奨値・スライダー見た目・Lucide 統一。Phase 1 として宣言。 | **先に完了** |
|
||||
| **Events** | UI/UX・絞り込み・PWA・Brain 連携まで実装。モック or Synology で表示。 | クライアント: Lucide 修正・API デフォルト・文言・aria。実データは n8n+Synology で別対応。 | **Maps の次** |
|
||||
|
||||
- **シンプル&スタイリッシュ**: 新機能の追加は行わず、上記の「高」と「中」の改善に絞る。
|
||||
- **整合性**: ヘッダー 52px、Inter、CSS 変数、Lucide のみ、PWA、テーマ切替は全アプリで揃える。
|
||||
- **完成の切り分け**: Maps = このドキュメントの Phase A 完了で「完全体」。Events = Phase B 完了で「クライアント完成」とし、データは別計画とする。
|
||||
|
||||
この方針で進めれば、機能を積み増しせず、シンプルな完成形を維持できます。
|
||||
|
|
@ -58,7 +58,7 @@
|
|||
### Basic Auth(dashboard/analytics)
|
||||
- `middleware.ts` で実装
|
||||
- 環境変数: `BASIC_AUTH_USER` / `BASIC_AUTH_PASSWORD`
|
||||
- 環境変数未設定時は認証スキップ(デフォルト認証情報は削除済み)
|
||||
- デフォルト: `mai / posimai`
|
||||
|
||||
### アプリ種別と認証の必要性
|
||||
|
||||
|
|
@ -84,7 +84,7 @@
|
|||
|
||||
### Lucide アイコンのみ使用
|
||||
- stroke: 1.5〜2.0
|
||||
- CDN: `https://unpkg.com/lucide@0.344.0`(バージョン固定必須。`@latest` 禁止)
|
||||
- CDN: `https://unpkg.com/lucide@latest`
|
||||
- 他のアイコンセット(Font Awesome 等)は使わない
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -0,0 +1,401 @@
|
|||
# Posimai System Overview - Brain/Reader/Feed連携
|
||||
|
||||
## システム全体像
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Posimai Ecosystem │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Posimai Feed │ │ Posimai Reader │ │ Posimai Brain │
|
||||
│ (RSS集約) │─────▶│ (広告除去) │─────▶│ (AI分析・保存) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
Vercel Vercel Vercel + Synology
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 データフロー(現在の実装)
|
||||
|
||||
### Phase 1: Feed → Reader
|
||||
```
|
||||
[Posimai Feed]
|
||||
│
|
||||
│ ① RSSフィードから記事一覧を取得
|
||||
│ - タイトル
|
||||
│ - URL
|
||||
│ - 概要
|
||||
│
|
||||
▼
|
||||
[ユーザー] 気になる記事をタップ
|
||||
│
|
||||
▼
|
||||
[Posimai Reader]
|
||||
│ ② URLを受け取る
|
||||
│ ③ Readability.jsで広告除去
|
||||
│ - タイトル
|
||||
│ - 本文全文(クリーン)
|
||||
│ - 画像
|
||||
│
|
||||
▼
|
||||
[ユーザー] 記事を読む
|
||||
```
|
||||
|
||||
### Phase 2: Reader → Brain(現在の実装)
|
||||
```
|
||||
[Posimai Reader]
|
||||
│
|
||||
│ ④ 「Brainに保存」ボタンをクリック
|
||||
│
|
||||
▼
|
||||
[POST /api/save] ← ⚠️ 現状はURLのみ送信
|
||||
{
|
||||
"url": "https://example.com/article"
|
||||
}
|
||||
│
|
||||
▼
|
||||
[Brain API Server - Synology]
|
||||
│
|
||||
│ ⑤ OGPメタデータをフェッチ
|
||||
│ - title (og:title)
|
||||
│ - desc (og:description) ← ⚠️ 短い!
|
||||
│ - ogImage
|
||||
│ - favicon
|
||||
│
|
||||
▼
|
||||
│ ⑥ Gemini AI分析
|
||||
│ Input: title + desc(短い概要のみ)
|
||||
│ Output:
|
||||
│ - summary (3文要約)
|
||||
│ - topics (最大2個)
|
||||
│ - readingTime (推定読了時間)
|
||||
│
|
||||
▼
|
||||
[PostgreSQL - Synology]
|
||||
保存されるデータ:
|
||||
├─ url
|
||||
├─ title
|
||||
├─ summary (AI生成)
|
||||
├─ topics (AI生成)
|
||||
├─ source (抽出元)
|
||||
├─ reading_time (AI推定)
|
||||
├─ favicon
|
||||
├─ og_image
|
||||
└─ status (inbox/favorite/shared)
|
||||
|
||||
⚠️ 保存されないデータ:
|
||||
└─ 広告除去済みの本文全文
|
||||
```
|
||||
|
||||
### Phase 3: Brain UI表示
|
||||
```
|
||||
[Posimai Brain - Vercel]
|
||||
│
|
||||
│ ⑦ GET /api/articles でデータ取得
|
||||
│
|
||||
▼
|
||||
[記事一覧表示]
|
||||
├─ タイトル
|
||||
├─ AI要約(3文)
|
||||
├─ トピックタグ
|
||||
├─ ソース
|
||||
└─ 読了時間
|
||||
|
||||
⚠️ 表示できないデータ:
|
||||
└─ 本文全文(保存されていないため)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔴 現在の問題点
|
||||
|
||||
### 1. **AI分析の精度が低い**
|
||||
- OGPの`description`は短い(50-150文字程度)
|
||||
- 本文全文を使っていないため、詳細な内容を把握できない
|
||||
- トピック分類が不正確になる可能性
|
||||
|
||||
### 2. **本文が保存されていない**
|
||||
- Brain側で記事を読み直せない
|
||||
- 元の記事が削除されたら読めなくなる
|
||||
- もう一度Readerを開く必要がある
|
||||
|
||||
### 3. **Geminiトークンの無駄遣い**
|
||||
- Readerで既に本文全文を取得済み
|
||||
- それを捨てて、BrainでOGPを再フェッチ
|
||||
- 二重の通信が発生
|
||||
|
||||
---
|
||||
|
||||
## ✅ 改善案:本文全文保存フロー
|
||||
|
||||
### 新しいデータフロー
|
||||
```
|
||||
[Posimai Reader]
|
||||
│
|
||||
│ ④ 「Brainに保存」ボタンをクリック
|
||||
│
|
||||
▼
|
||||
[POST /api/save] ← ✅ 本文全文も送信
|
||||
{
|
||||
"url": "https://example.com/article",
|
||||
"title": "記事タイトル",
|
||||
"full_text": "広告除去済みの本文全文...", ← NEW!
|
||||
"images": ["https://...", ...] ← NEW!
|
||||
}
|
||||
│
|
||||
▼
|
||||
[Brain API Server]
|
||||
│
|
||||
│ ⑤ Gemini AI分析
|
||||
│ Input: title + full_text(本文全文!)← 改善!
|
||||
│ Output:
|
||||
│ - summary (より正確な3文要約)
|
||||
│ - topics (より正確な分類)
|
||||
│ - readingTime (本文の長さから計算)
|
||||
│
|
||||
▼
|
||||
[PostgreSQL]
|
||||
保存されるデータ:
|
||||
├─ url
|
||||
├─ title
|
||||
├─ full_text ← NEW! 本文全文
|
||||
├─ summary (AI生成)
|
||||
├─ topics (AI生成)
|
||||
├─ source
|
||||
├─ reading_time
|
||||
├─ favicon
|
||||
├─ og_image
|
||||
└─ status
|
||||
```
|
||||
|
||||
### Brain UI表示(改善後)
|
||||
```
|
||||
[記事一覧]
|
||||
クリック
|
||||
│
|
||||
▼
|
||||
[記事詳細モーダル] ← NEW!
|
||||
├─ タイトル
|
||||
├─ AI要約
|
||||
├─ トピックタグ
|
||||
├─ 元URL(リンク)
|
||||
├─ 保存日時
|
||||
└─ 本文全文(広告除去済み)← NEW!
|
||||
└─ Readerと同じ見た目で表示
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 実装工数見積もり
|
||||
|
||||
### 1. **本文全文保存機能** (1-2時間)
|
||||
- [ ] DB: `articles`テーブルに`full_text TEXT`カラム追加
|
||||
- [ ] Reader: 保存時に本文をPOST
|
||||
- [ ] Brain API: 本文を受け取って保存
|
||||
- [ ] Brain API: AI分析時に本文を使用
|
||||
|
||||
### 2. **Brain UI: 記事詳細表示** (2-3時間)
|
||||
- [ ] モーダルまたは詳細ページ作成
|
||||
- [ ] 本文のマークダウン/HTML表示
|
||||
- [ ] レスポンシブ対応
|
||||
|
||||
### 3. **Reader UI改善** (1-2時間)
|
||||
- [ ] 読むボタンをURL右側に配置(スマホ対応)
|
||||
- [ ] 本文内の長いURLを`word-break: break-all`で折り返し
|
||||
- [ ] フッター要素削除ロジック強化
|
||||
|
||||
### 4. **連携フロー全体のテスト** (1時間)
|
||||
- [ ] Feed → Reader → Brain の一連の流れを確認
|
||||
- [ ] 各種エラーハンドリング
|
||||
|
||||
**合計: 5-8時間**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 優先順位
|
||||
|
||||
### 🔴 高(今すぐやるべき)
|
||||
1. **本文全文保存機能** - データロスを防ぐ
|
||||
2. **Reader UI改善(読むボタン配置)** - UX向上
|
||||
|
||||
### 🟡 中(近日中に)
|
||||
3. **Brain UI: 記事詳細表示** - 利便性向上
|
||||
4. **フッター要素削除** - 読みやすさ向上
|
||||
|
||||
### 🟢 低(余裕があれば)
|
||||
5. **Feed連携強化** - RSSからの直接保存
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 技術詳細
|
||||
|
||||
### DB Schema(改善後)
|
||||
```sql
|
||||
ALTER TABLE articles ADD COLUMN IF NOT EXISTS full_text TEXT;
|
||||
ALTER TABLE articles ADD COLUMN IF NOT EXISTS images TEXT[];
|
||||
```
|
||||
|
||||
### Reader → Brain API呼び出し(改善後)
|
||||
```javascript
|
||||
// posimai-reader/script.js
|
||||
async function saveToBrain(article) {
|
||||
const response = await fetch('https://posimai-lab.tail72e846.ts.net/brain/api/save', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: article.url,
|
||||
title: article.title,
|
||||
full_text: article.textContent, // NEW!
|
||||
images: article.images // NEW!
|
||||
})
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
```
|
||||
|
||||
### Brain API(改善後)
|
||||
```javascript
|
||||
// posimai-api/server.js
|
||||
r.post('/save', authMiddleware, async (req, res) => {
|
||||
const { url, title, full_text, images } = req.body || {};
|
||||
|
||||
// AI分析時に本文全文を使用
|
||||
const ai = await analyzeWithGemini(title, full_text || meta.desc, url);
|
||||
|
||||
// DBに本文も保存
|
||||
await pool.query(`
|
||||
INSERT INTO articles
|
||||
(user_id, url, title, full_text, summary, topics, ...)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,...)
|
||||
`, [req.userId, url, title, full_text, ai.summary, ai.topics, ...]);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Posimai Maps - Phase 1 調査(エンジニア・PM・UX 観点)
|
||||
|
||||
### Phase 1 のスコープ(現状・2026年3月時点)
|
||||
|
||||
- 現在地または地図中心を起点に Google Places API (New) の **searchNearby** で飲食店を検索
|
||||
- 取得件数 **最大20件**(API上限)、半径 **500m〜5km ユーザー選択可**、**6類型**(restaurant, bar, cafe, meal_takeaway, bakery, meal_delivery)
|
||||
- 営業時間は **regularOpeningHours** / **weekdayDescriptions** で表示し、**JST** で「今」を算出
|
||||
- **地図 moveend で再検索**(デバウンス・150m 以上移動時)、**0件時は再検索・範囲を広げる** UI、**人気順/近い順** 選択済み
|
||||
- 取得幅・現在地は **地図右下(ズーム付近)** に配置(Google Maps 風)
|
||||
- ダーク/ライトテーマ、カテゴリフィルター、Brain 保存・Google マップ連携まで実装済み
|
||||
|
||||
### Phase 1 の残課題
|
||||
|
||||
| 観点 | 課題 | 影響 |
|
||||
|------|------|------|
|
||||
| **エンジニア** | **取得件数が20件で固定**(API の maxResultCount 上限) | 繁華街などでは「周辺の飲食店」のごく一部しか表示されない |
|
||||
| **エンジニア** | **半径が1500m固定**(フロントで `radius=1500` のみ送信) | サーバーは 500–5000m を受け付けるが未使用。ユーザーが範囲を変えられない |
|
||||
| **エンジニア** | **includedTypes が4種類のみ**(bakery, meal_delivery 等なし) | ベーカリー・デリバリー専門など、型が違う実在店が検索結果に含まれない可能性 |
|
||||
| **エンジニア** | **rankPreference 未指定**(デフォルト POPULARITY) | 「近い順」で見たいニーズに未対応 |
|
||||
| **PM** | **地図を動かしても再検索しない** | 表示は初回+「現在地」ボタンのみ。エリアを変えても結果が更新されず網羅性が体感しづらい |
|
||||
| **PM** | **0件時の明示的な再検索手段がない** | トーストのみ。リトライや「範囲を広げる」などの導線がない |
|
||||
| **UX** | **営業時間なし店舗を「営業中」としてカウント**(`periods` なしなら `open = true`) | ヘッダーの「N 営業中」が実態より多くなりうる。詳細では「営業時間不明」と表示される |
|
||||
|
||||
### 実在する飲食店の「網羅性」
|
||||
|
||||
**結論: 現状では「網羅的」には取得できていません。**
|
||||
|
||||
- **件数**: 1回の検索で **最大20件** のみ。同一エリアに50店あっても20店しか出ない。
|
||||
- **範囲**: 半径 **1500m** 固定のため、それ以遠の店は初回から対象外。
|
||||
- **種類**: **restaurant, bar, cafe, meal_takeaway** のみ。Google の Table A にある **bakery, meal_delivery** などはリクエストに含めておらず、これらが primary type の店は返ってこない可能性がある(restaurant の下位の sushi_restaurant 等は `restaurant` 指定で取得可能)。
|
||||
- **データソース**: あくまで **Google に登録されている店舗** のみ。未登録・営業時間が未登録の店は「営業時間不明」または検索結果に含まれない。
|
||||
- **地図移動**: 地図をドラッグして別エリアに移っても **再検索されない** ため、表示は「最初の中心+現在地ボタン」の2パターンのみ。
|
||||
|
||||
「世界中の実在する飲食店を漏れなく」という意味での網羅は、Phase 1 の前提(1 API 呼び出し・20件・固定半径)では達成されていません。
|
||||
「現在地付近の、Google に載っている飲食店の一部を、最大20件まで見る」というスコープでは動作しています。
|
||||
|
||||
### 推奨アクション(Phase 1 改善)— 多くは実装済み
|
||||
|
||||
- 網羅性の体感向上(moveend 再検索・デバウンス)— **済**
|
||||
- 取得幅の拡大(bakery / meal_delivery、半径選択)— **済**
|
||||
- ランキング選択(人気順/近い順)— **済**
|
||||
- 0件時の再検索・範囲を広げる UX — **済**
|
||||
- 営業中カウントの「不明」別表示 — 要望により **廃止**(「N 営業中」のみ表示)
|
||||
|
||||
---
|
||||
|
||||
## Posimai Maps - 残タスクの推奨ステップ(PM・UX 観点)
|
||||
|
||||
優先度と影響範囲を踏まえた実行順の目安です。
|
||||
|
||||
| 順 | フェーズ | タスク | 目的・効果 | 工数目安 |
|
||||
|----|----------|--------|------------|----------|
|
||||
| 1 | **安定化** | 本番デプロイ・動作確認 | 地図右下コントロール・スライダー・ヘッダー表示の確認 | 0.5h |
|
||||
| 2 | **検証** | 20件上限の体感テスト | 繁華街で「足りない」と感じるかユーザーテストし、次フェーズの要不要を判断 | 1h |
|
||||
| 3 | **価値向上** | 複数回検索で20件超を表示(オプション) | 中心を少しずらして複数回 searchNearby を叩き、重複除いて最大40〜60件表示。API コスト・レート制限に注意 | 2–3h |
|
||||
| 4 | **信頼性** | 営業時間なし店舗の扱いを仕様化 | 現状「営業中」に含めて表示。必要なら詳細パネルで「営業時間未登録」を明示するなど軽い文言整備 | 0.5h |
|
||||
| 5 | **Phase 2 検討** | SNS/Web からの営業時間補完 | Jina + LLM で公式サイト・Instagram 等から営業時間をパースし補完。設計・コスト・精度の検討から | 設計 2h〜 |
|
||||
|
||||
**運用方針の提案**
|
||||
|
||||
- **まず 1〜2 で現行 Phase 1 を固め、3 は「20件で不足」という声が出てから着手**するのが安全です。
|
||||
- **4 は軽い文言・表示の整理**で、5 は別フェーズとしてロードマップに記載し、必要に応じて着手する形を推奨します。
|
||||
|
||||
### 地図右下ゾーン(右手親指・スマホ)のUX方針
|
||||
|
||||
- **配置順(上→下)**: 現在地 → ズーム(+-)。取得幅は廃止し検索は 1.5km 固定。地図を動かすとその中心で再検索される。
|
||||
- **ラジアル・タイムセレクター(サムゾーン最適化)**: スマホ表示のみ(640px 以下)。**二重円弧(Concentric Arcs)+回転リール(Rotary Wheel)**方式。
|
||||
- 画面右下角を支点に、左上向きの四分の一円(90度)だけが露出。外側円弧で曜日(日月火…)、内側円弧で時刻(00–23)を選択。
|
||||
- なぞると仮想リールが回転し、**弧の中央(約45度位置)**に来た項目が選択される。曜日・時刻とも無限ループ(土→日、23→00)。
|
||||
- **最終洗練**: (1) **常時ガイド** — 二重の円弧(トラック)は未操作時も opacity 0.1 で常時表示。(2) **フェードイン** — タッチ開始時のみ、目盛り(曜日・時刻ラベル)と中央の現在値が opacity 1 で表示。(3) **スナップ** — 指を離すと最も近い曜日/時刻が弧の中央に吸着し、transform の transition(0.15s)で「カチッ」と止まる。選択値は #6EE7B7、それ以外は #4B5563。指離し時に短いハプティック(3ms)。
|
||||
- **PC**: 時刻は横スライダーのみ。ラジアル切替タブは非表示。
|
||||
- **今後の検討**: ラジアルと地図コントロールが同じ右下を共有するため、表示時にボタン縮小/アイコンのみなど、領域の兼ね合いをユーザーテストで調整可能。
|
||||
|
||||
**テーマ切替・地図タイルについて**: テーマ切替は実装済みで、ダーク/ライトの両方で動作します。テーマ切替は Posimai Feed と同様にボタン枠なしの Lucide アイコンのみ表示。地図タイルも**両モードで存在**します。ダーク時は Carto `dark_all`、ライト時は Carto `voyager_labels_under`(Voyager ラベル付き)を使用しています。
|
||||
|
||||
**ヘッダー「人気順/近い順」について**: リスト表示の有無とは無関係です。Google Places API の **検索結果の並び順**(`rankPreference`)を切り替えるためのものです。**人気順**=POPULARITY(口コミ・人気でソート)、**近い順**=DISTANCE(現在地からの距離でソート)。地図上のピンがこの順で返ってくるため、どちらの基準で周辺店舗を見るかを選べるようにする目的で追加しています。
|
||||
|
||||
**カテゴリタグについて**: **現在地周辺の検索結果(currentPlaces)に含まれる店舗のカテゴリだけ**がタグとして表示されます。検索のたびに `initCategoryBar()` が `getCategories()` で「今取得した店舗の categoryLabel のユニーク一覧」を元にタグを再生成するため、エリアや範囲を変えるとタグの種類・順序が変わり、安定しないように見えます。固定のカテゴリリストにするか、前回結果とマージしてタグを安定させるかは今後の検討事項です。
|
||||
|
||||
**取得幅について**: 取得幅プルダウンは削除し、**1500m(1.5km)固定**にしています。**100m は狭すぎます** — 市街地でも 100m 圏内の飲食店は 0〜2 件程度になり「見つかりません」が多くなります。500m〜2km が実用域で、1.5km は「徒歩で選びやすい範囲」の目安です。UI は「現在地」「ズーム」のみとし、空状態の「範囲を広げて検索」は「地図を動かして検索」に変更し、地図ドラッグで再検索される案内にしています。
|
||||
|
||||
**20店舗制限と「20件の精度」について**: **searchNearby の maxResultCount は 1〜20 が上限**(API仕様)です。20件のうち「欲しいもの」に絞るには次のようにできます。
|
||||
- **カテゴリで絞る(API側)**: リクエスト時に `includedTypes` を限定すると、**そのタイプだけを最大20件**取得できます。例: `['cafe']` のみ → カフェ20件、`['bar']` のみ → 居酒屋・バー系20件。現状は複数タイプをまとめて取得しているため、カテゴリタブで「カフェ」を選んでも表示側のフィルタのみです。**「カフェだけ20件」「居酒屋だけ20件」**にしたい場合は、検索時に `includedTypes` を1種類に絞る実装にすると、20件枠をそのジャンルに集中させられます。
|
||||
- **高評価で絞る**: searchNearby には **「評価でフィルタ」するパラメータはありません**。取得後に `places.rating` を利用する場合は FieldMask に rating を追加(Enterprise SKU・請求増)し、**クライアント側で20件を評価順に並べ替え・表示**する形になります。「高評価だけ20件」を API に頼ることはできず、あくまで「取れた20件のなかで評価の高い順に表示」です。
|
||||
- **まとめ**: 20件制限を活かすなら、(1) **カテゴリを API で絞る**(カフェ20件・居酒屋20件など)が最も効果的。(2) 評価は **取得オプションに rating を足し、クライアントでソート** するのが現実的です。
|
||||
|
||||
**取得幅を歯車で設定するUI・カテゴリでAPI検索するUI(助言・未実装)**
|
||||
|
||||
- **取得幅を歯車ダイアログで設定する案**
|
||||
- **メリット**: 普段はシンプルな地図UIのままにでき、必要な人だけ「検索範囲」を変えられる。設定値を保存して次回以降の初期値にすれば、ユーザーごとの好み(狭い範囲で選びたい/広めで探したい)を満たせる。
|
||||
- **注意点**: (1) 歯車は「設定」のメタファーなので、ヘッダーか地図コントロール近くに1つにまとめるのがよい。(2) 数値だけ(例: 500〜5000m)にすると単位を誤解しやすいので、「500m」「1km」「1.5km」などプリセット+任意入力の併用か、スライダー+ラベル表示が分かりやすい。(3) 極端な値(100m/10km)は0件や負荷になりやすいので、最小・最大をAPI仕様(500m〜50km)に合わせて制限し、推奨は 500〜3000m と説明しておくと安心。
|
||||
- **結論**: ニーズはあるので、**歯車で「検索範囲」を設定し、その値を保存して初期値にする**のは妥当。実装時は「設定」を開く導線を1箇所にし、範囲の上限・下限と単位表示をはっきりさせることを推奨。
|
||||
|
||||
- **カテゴリ選択でAPI検索する案(選択カテゴリごとに20件取得)**
|
||||
- **ご認識の通り**、選択したカテゴリで `includedTypes` を絞って検索すれば「カフェ20件」「居酒屋20件」のように**ジャンルごとに最大20件**を精度高く取れる。別カテゴリを選んだらその都度、同じ中心・半径で `includedTypes` を変えて再検索すれば、**カテゴリごとに20件の精度の高い結果**が得られる。
|
||||
- **設計の選択肢**:
|
||||
- **A. カテゴリ切り替え=その場で再検索**: 「カフェ」タップ → 即座に `includedTypes: ['cafe']` で再検索して最大20件表示。「居酒屋」タップ → 同様に再検索。地図上のピンは**常に「今選んでいるカテゴリの最大20件」**だけ。シンプルで「今見ているカテゴリに集中できる」。
|
||||
- **B. 複数カテゴリを溜めて表示**: 「カフェ」で20件取得 → 表示。「居酒屋」でさらに20件取得 → 既存のカフェ20件と合わせて40件表示(重複除く)。カテゴリを増やすほどリクエストとピンが増える。20件制限は「カテゴリごと」なので、**カテゴリ数×20件**まで増やせるが、リクエスト数・料金・地図の混雑が増える。
|
||||
- **推奨**: **まずは A(選択カテゴリ=その場で再検索・表示はそのカテゴリの最大20件のみ)**がよい。理由: (1) 動作が分かりやすい (2) リクエストは「タブ切り替え時のみ」で済む (3) 地図が混雑しにくい。「すべて」タブのときだけ複数タイプをまとめて取得する現行に近い形にし、「カフェ」「居酒屋」など単一カテゴリを選んだときだけ `includedTypes` を1種類に絞る、という役割分担が扱いやすい。
|
||||
- **カテゴリリスト**: 現在は「検索結果に含まれたラベルのユニーク」でタブを出しているため、初回は「すべて」しか出ない。**「カフェ」「居酒屋」「レストラン」など、API の `includedTypes` と対応した固定リストを並べ、選択時にその type で再検索する**形にすると、初回からカテゴリで精度の高い20件検索ができる。
|
||||
|
||||
- **まとめ(実装前の助言)**
|
||||
- 取得幅: 歯車でダイアログを開き、範囲(m)を設定・保存して初期値にする案は**採用してよい**。単位と min/max を明示し、プリセットかスライダーで分かりやすくする。
|
||||
- カテゴリ: **選択したカテゴリでAPIを絞り、そのカテゴリで20件取得する**のは、20件制限を活かすうえで有効。まずは「タブ=そのカテゴリで再検索し、表示はその20件のみ」(上記A)を推奨。カテゴリは `includedTypes` と対応した固定リストにすると初回から使える。
|
||||
|
||||
### 地図視認性と現在地ピン(批判的レビューと実装)
|
||||
|
||||
- **地図ラベルの視認性**: 本アプリは **Leaflet + Carto ラスタータイル** のため、Google Maps の `StyledMapType` や `featureType`/`elementType` による「ラベル塗り・縁取り・不透明度」のコード制御は**不可**。可能な対応は「タイルセットの差し替え」のみ。
|
||||
- **実装**: ライトテーマ時に `light_all` から **Carto Voyager(ラベル付き)**(`rastertiles/voyager_labels_under`)へ変更。地名・道路のコントラストが強く、Google マップに近い読みやすさを優先。
|
||||
- ダークテーマは従来どおり `dark_all`(Voyager 相当のダークは現状なし)。
|
||||
- **現在地ピン**: 店舗ピン(青磁 #6EE7B7)と明確に区別するため **Posimai Brain テーマカラー(#3B82F6)** に変更。外側に青い光の輪のパルスアニメーションを付与し、一目で「自分」が分かるようにした。
|
||||
- **技術的注意**: ラスタータイルのため「POI の重み付け」「情報の引き算」はタイル提供側の仕様に依存。将来的にベクタータイル(Mapbox GL 等)へ切り替える場合に、ラベル優先度・ハロの制御が可能になる。
|
||||
|
||||
---
|
||||
|
||||
## 📝 次のアクション(一元化)
|
||||
|
||||
**次に何をすべきか・優先順位・実施済みの管理は、すべて次の 1 ファイルに集約しています。全 AI(Cursor/Claude/Gemini 等)は実装前にここを参照してください。**
|
||||
|
||||
- **CRITICAL_ACTION_PLAN.md** … 次ステップの優先順位・Claude 批判的レビューの精査結果・参照ドキュメント一覧・実施済み更新ルール
|
||||
|
||||
OVERVIEW 上の「次のアクション」の詳細(Brain 本文保存・Reader UI・Maps ラジアル扱い・Scan 等)は上記に記載されています。新規タスクや優先度変更があった場合も、CRITICAL_ACTION_PLAN.md を更新してから実装に進んでください。
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# ============================================
|
||||
# posimai_lab Docker Compose — 環境変数
|
||||
# ============================================
|
||||
# このファイルを .env という名前でコピーして実際の値を入力してください。
|
||||
# .env は絶対に git commit しないこと!
|
||||
#
|
||||
# Synology での配置場所:
|
||||
# /volume1/docker/posimai_lab/.env
|
||||
|
||||
# PostgreSQL パスワード(Gitea と posimai_api で共用)
|
||||
DB_PASS=ここに実際のDBパスワードを入力
|
||||
|
||||
# Gitea の公開ドメイン(Tailscale ホスト名)
|
||||
GITEA_DOMAIN=posimai-lab.tail72e846.ts.net
|
||||
|
||||
# Gemini API キー
|
||||
GEMINI_API_KEY=ここに実際のGemini APIキーを入力
|
||||
|
||||
# posimai_api の認証キー(カンマ区切り: key:username 形式)
|
||||
API_KEYS=ここに実際のAPIキー一覧を入力
|
||||
|
||||
# CORS 許可オリジン(カンマ区切り)
|
||||
ALLOWED_ORIGINS=https://posimai-brain.vercel.app,https://posimai-reader.vercel.app,https://posimai-feed.vercel.app,http://localhost:3000
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
# Phase 1: Brain 本文全文保存 — Synology 側 実施手順(コピペ用)
|
||||
|
||||
**目的**: Reader から送られた本文を Brain API が受け取り DB に保存し、AI 分析で本文を使うようにする。
|
||||
|
||||
**前提**: Synology NAS に SSH で接続できること。Brain API と PostgreSQL がすでに動いていること。
|
||||
|
||||
**補足**: Synology の「Container Manager」は従来の「Docker」パッケージの後継です。中身は同じなので、SSH では `docker` コマンドが使えます。
|
||||
|
||||
---
|
||||
|
||||
## あなたの環境(docker ps の結果)について
|
||||
|
||||
| コンテナ名 | イメージ | 用途の目安 |
|
||||
|---------------|-----------------|--------------------|
|
||||
| **posimai_api** | node:20-slim (8090) | **Brain API** の可能性が高い |
|
||||
| **gitea_db** | postgres:15 | **Gitea 用** PostgreSQL(Brain 用ではない) |
|
||||
| その他 | gitea, mcp_server, uptime-kuma 等 | - |
|
||||
|
||||
**Brain 専用の PostgreSQL コンテナは一覧にありません。**
|
||||
|
||||
- **posimai_api** が Brain API で、その中で **SQLite** や **別の DB** を使っている場合は、手順 1 の「PostgreSQL に接続」は**そのままでは使えません**。
|
||||
- まず **posimai_api(Brain API)のコードや設定**を確認し、「どの DB に接続しているか」を把握する必要があります。
|
||||
|
||||
**進め方の目安**
|
||||
|
||||
1. **Brain 用に PostgreSQL を別コンテナで用意している場合**
|
||||
→ そのコンテナ名で手順 1 の「1-3. PostgreSQL に接続」を実行し、手順 1 → 2 の順で実施。
|
||||
|
||||
2. **Brain が SQLite など PostgreSQL 以外を使っている場合**
|
||||
→ **手順 1 はいったん飛ばし、手順 2(API のコード修正)だけ先に実施**してください。
|
||||
保存処理で `full_text` を扱うようにし、使っている DB のスキーマに `full_text` カラム(または相当の項目)を後から追加する形になります。
|
||||
(SQLite なら、posimai_api のプロジェクト内のマイグレーションや、DB ファイルを開いて `ALTER TABLE` する方法などがあります。)
|
||||
|
||||
3. **どこに DB があるか分からない場合**
|
||||
posimai_api の設定ファイル(例: `.env` / `config.js` / 環境変数)や、ソース内の `pool` / `createPool` / `sqlite` などの記述を確認すると、接続先が分かります。
|
||||
|
||||
---
|
||||
|
||||
## 手順 1: データベースにカラムを追加する(Brain が PostgreSQL を使っている場合のみ)
|
||||
|
||||
### 1-1. Synology に SSH 接続
|
||||
|
||||
```bash
|
||||
ssh admin@posimai-lab.tail72e846.ts.net
|
||||
```
|
||||
(パスワードを聞かれたら入力。ユーザー名が `mai` の場合は `ssh mai@posimai-lab.tail72e846.ts.net`)
|
||||
|
||||
### 1-2. コンテナ名を確認する(重要)
|
||||
|
||||
手順書の `posimai-brain-postgres` は**例**です。お使いの環境ではコンテナ名が違う場合があります。
|
||||
|
||||
**方法A: コマンドで一覧を表示**
|
||||
|
||||
```bash
|
||||
docker ps
|
||||
```
|
||||
|
||||
表示された一覧の「NAMES」列で、PostgreSQL のコンテナ名を確認してください(例: `postgres` / `brain-postgres` / `synology-postgres` など)。
|
||||
|
||||
**方法B: DSM の Container Manager で確認**
|
||||
|
||||
1. DSM にログイン → **Container Manager** を開く
|
||||
2. 「コンテナ」一覧で、PostgreSQL または Brain 用 DB のコンテナを探す
|
||||
3. コンテナ名(名前の列)をメモする
|
||||
|
||||
**次の 1-3 で使うコマンドの `コンテナ名` を、上で確認した名前に置き換えてください。**
|
||||
|
||||
### 1-3. PostgreSQL に接続
|
||||
|
||||
**Container Manager(Docker)で PostgreSQL を動かしている場合:**
|
||||
|
||||
```bash
|
||||
docker exec -it コンテナ名 psql -U brain_user -d brain_db
|
||||
```
|
||||
|
||||
- `コンテナ名` を 1-2 で確認した名前に置き換えます。
|
||||
- 例: コンテナ名が `postgres` なら
|
||||
`docker exec -it postgres psql -U brain_user -d brain_db`
|
||||
- ユーザー名・DB 名(`brain_user` / `brain_db`)が違う場合は、実際の設定に合わせて変更してください。
|
||||
|
||||
**PostgreSQL を Docker 以外で動かしている場合(NAS に直接インストール等):**
|
||||
|
||||
```bash
|
||||
psql -U brain_user -d brain_db
|
||||
```
|
||||
|
||||
### 1-4. 以下の SQL をそのままコピーして貼り付け、Enter で実行
|
||||
|
||||
```sql
|
||||
-- full_text カラムを追加(本文全文)
|
||||
ALTER TABLE articles ADD COLUMN IF NOT EXISTS full_text TEXT;
|
||||
|
||||
-- オプション: 画像用(将来用)
|
||||
ALTER TABLE articles ADD COLUMN IF NOT EXISTS images TEXT[];
|
||||
|
||||
-- 更新日時用
|
||||
ALTER TABLE articles ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT NOW();
|
||||
|
||||
-- 確認(articles の構造が表示されればOK)
|
||||
\d articles
|
||||
```
|
||||
|
||||
`\d articles` の結果に `full_text` が含まれていれば成功です。
|
||||
|
||||
### 1-5. PostgreSQL を終了
|
||||
|
||||
```bash
|
||||
\q
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 手順 2: Brain API のコードを修正する
|
||||
|
||||
**posimai_api** が Brain API の場合、そのコンテナで動いているコード(NAS 上でマウントしているフォルダ内の `server.js` など)を編集します。
|
||||
|
||||
Brain API の `server.js`(またはルーターを定義しているファイル)を編集します。
|
||||
|
||||
**編集する場所の目安:**
|
||||
- `POST /save` または `POST /brain/api/save` を処理しているブロック
|
||||
- `GET /articles` または `GET /brain/api/articles` を処理しているブロック
|
||||
|
||||
### 2-1. POST /save の置き換え
|
||||
|
||||
**いまの `post('/save', ...)` ~ そのブロックの終わりまで** を、以下で**まるごと置き換え**してください。
|
||||
|
||||
```javascript
|
||||
// POST /save — Reader から url, title, content, source を受け取り本文を保存
|
||||
app.post('/save', authMiddleware, async (req, res) => {
|
||||
const { url, title, content, source } = req.body || {};
|
||||
if (!url || !title) {
|
||||
return res.status(400).json({ error: 'Missing required fields: url and title' });
|
||||
}
|
||||
try {
|
||||
let fullText = content || null;
|
||||
let meta = {};
|
||||
if (!fullText || fullText.trim().length === 0) {
|
||||
meta = await fetchOGP(url); // 既存の OGP 取得関数
|
||||
fullText = meta.desc || '';
|
||||
} else {
|
||||
meta = await fetchOGP(url);
|
||||
}
|
||||
const ai = await analyzeWithGemini(title, fullText, url); // 第2引数を fullText に変更
|
||||
const result = await pool.query(`
|
||||
INSERT INTO articles (user_id, url, title, full_text, summary, topics, source, reading_time, favicon, og_image, status, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'inbox', NOW(), NOW())
|
||||
ON CONFLICT (user_id, url) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
full_text = EXCLUDED.full_text,
|
||||
summary = EXCLUDED.summary,
|
||||
topics = EXCLUDED.topics,
|
||||
reading_time = EXCLUDED.reading_time,
|
||||
favicon = EXCLUDED.favicon,
|
||||
og_image = EXCLUDED.og_image,
|
||||
updated_at = NOW()
|
||||
RETURNING id
|
||||
`, [req.userId, url, title, fullText, ai.summary, ai.topics, source || 'reader', ai.readingTime, meta.favicon || null, meta.ogImage || null]);
|
||||
const articleId = result.rows[0].id;
|
||||
return res.json({ success: true, articleId, fullTextSaved: !!fullText, textLength: fullText?.length || 0 });
|
||||
} catch (error) {
|
||||
console.error('[Brain API] Save failed:', error);
|
||||
return res.status(500).json({ error: 'Failed to save article', message: error.message });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**あわせて確認すること:**
|
||||
- `analyzeWithGemini` の**第2引数**を、これまでの「短い説明」ではなく **本文全文(fullText)** に変更する。
|
||||
- `fetchOGP` はそのまま利用して問題ありません。
|
||||
|
||||
### 2-2. GET /articles の置き換え
|
||||
|
||||
**いまの `get('/articles', ...)` ~ そのブロックの終わりまで** を、以下で**まるごと置き換え**してください。
|
||||
|
||||
```javascript
|
||||
// GET /articles — 一覧取得時に full_text も返す(Brain UI で本文表示するため)
|
||||
app.get('/articles', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT id, url, title, full_text, summary, topics, source, reading_time, favicon, og_image, status, created_at, updated_at
|
||||
FROM articles
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`, [req.userId]);
|
||||
return res.json({
|
||||
articles: rows.map(row => ({
|
||||
id: row.id,
|
||||
url: row.url,
|
||||
title: row.title,
|
||||
fullText: row.full_text,
|
||||
summary: row.summary,
|
||||
topics: row.topics,
|
||||
source: row.source,
|
||||
readingTime: row.reading_time,
|
||||
favicon: row.favicon,
|
||||
ogImage: row.og_image,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Brain API] Failed to fetch articles:', error);
|
||||
return res.status(500).json({ error: 'Failed to fetch articles', message: error.message });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**注意:** お使いのコードが `app` ではなく `router` の場合は、上記の `app.post` / `app.get` を `router.post` / `router.get` に読み替えてください。
|
||||
|
||||
---
|
||||
|
||||
## 手順 3: API サーバーを再起動する
|
||||
|
||||
**Docker で Brain API を動かしている場合:**
|
||||
|
||||
```bash
|
||||
docker restart posimai-brain-api
|
||||
docker logs -f posimai-brain-api
|
||||
```
|
||||
|
||||
ログにエラーが出ていなければ成功です。`Ctrl+C` でログ表示を終了できます。
|
||||
|
||||
**Docker を使っていない場合:**
|
||||
Brain API を起動している方法(systemd や手動実行など)に合わせて再起動してください。
|
||||
|
||||
---
|
||||
|
||||
## 手順 4: 動作確認
|
||||
|
||||
1. **Reader** で記事を開き、「Brainに保存」を押す。
|
||||
2. ブラウザの開発者ツール(F12)の「ネットワーク」で、`/save` へのレスポンスを確認する。
|
||||
- `fullTextSaved: true` かつ `textLength` が数百以上なら、本文が送れて保存されています。
|
||||
3. **Brain** の一覧を開き、該当記事の「本文を読む」が出ていれば、API と DB の対応は問題ありません。
|
||||
|
||||
---
|
||||
|
||||
## うまくいかないとき
|
||||
|
||||
- **「column full_text does not exist」**
|
||||
→ 手順 1 の SQL が実行されていないか、別の DB を見ている可能性があります。もう一度 1-2~1-4 を実行してください。
|
||||
- **「analyzeWithGemini is not a function」**
|
||||
→ `analyzeWithGemini` の定義で、第2引数が「本文」になるように変更してください(従来の短い説明用引数を fullText に変更)。
|
||||
- **Reader から保存しても Brain に反映されない**
|
||||
→ Synology の Brain API のログ(`docker logs posimai-brain-api`)でエラーが出ていないか確認してください。
|
||||
|
||||
---
|
||||
|
||||
**次のステップ:** Brain のフロント(Vercel など)は、すでに「本文を読む」で保存済み本文を表示できるように修正済みです。Synology 側の上記対応が完了すれば Phase 1 は完了です。
|
||||
|
|
@ -0,0 +1,525 @@
|
|||
# Synology 保存機能 — 構成とフロー図解
|
||||
|
||||
## 全体構成図
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Posimai エコシステム │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌─────────────┐ │
|
||||
│ │ Reader │ │ Feed │ │ Brain │ │ Maps │ │
|
||||
│ │ (Vercel) │ │ (Vercel) │ │ (Vercel) │ │ (Vercel) │ │
|
||||
│ │ │ │ │ │ │ │ │ │
|
||||
│ │ 記事閲覧 │ │ RSS収集 │ │ 一覧表示 │ │ 位置情報 │ │
|
||||
│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ └─────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ │ 「Brainに保存」ボタン │ │
|
||||
│ └────────────────┴────────────────┘ │
|
||||
│ │ │
|
||||
│ │ POST /brain/api/save │
|
||||
│ │ { url, title, content, source } │
|
||||
│ ▼ │
|
||||
│ ┌────────────────────────────────────────────┐ │
|
||||
│ │ Synology NAS (Home Server) │ │
|
||||
│ │ Tailscale: posimai-lab.tail72e846.ts.net │ │
|
||||
│ ├────────────────────────────────────────────┤ │
|
||||
│ │ ┌──────────────────────────────────────┐ │ │
|
||||
│ │ │ Brain API (Node.js + Express) │ │ │
|
||||
│ │ │ Port: 8090 │ │ │
|
||||
│ │ │ Container: posimai_api │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ 1. 認証(JWT/APIキー) │ │ │
|
||||
│ │ │ 2. 本文処理(content || OGP取得) │ │ │
|
||||
│ │ │ 3. Gemini AI 分析 ──────────────┐ │ │ │
|
||||
│ │ │ 4. PostgreSQL保存 │ │ │ │
|
||||
│ │ └───────────────────────────┬────────┘ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ ┌───────────────────────────▼──────────┐ │ │
|
||||
│ │ │ PostgreSQL Database │ │ │
|
||||
│ │ │ Port: 5432 │ │ │
|
||||
│ │ │ Container: (別コンテナまたはSQLite) │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ Table: articles │ │ │
|
||||
│ │ │ ・id, user_id, url, title │ │ │
|
||||
│ │ │ ・full_text (本文全文) ← Phase 1 │ │ │
|
||||
│ │ │ ・summary (AI要約) │ │ │
|
||||
│ │ │ ・topics (AIトピック抽出) │ │ │
|
||||
│ │ │ ・reading_time (読了時間) │ │ │
|
||||
│ │ │ ・favicon, og_image │ │ │
|
||||
│ │ │ ・status (inbox/favorite/archived) │ │ │
|
||||
│ │ └──────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────────────────────────────┐ │ │
|
||||
│ │ │ Gemini API (External) │ │ │
|
||||
│ │ │ Model: gemini-2.0-flash-exp │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ Input: title + full_text (5000文字)│ │ │
|
||||
│ │ │ Output: │ │ │
|
||||
│ │ │ ・summary (3文の要約) │ │ │
|
||||
│ │ │ ・topics (2つのテーマ) │ │ │
|
||||
│ │ │ ・readingTime (推定分数) │ │ │
|
||||
│ │ └──────────────────────────────────────┘ │ │
|
||||
│ └────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 保存フロー詳細(シーケンス図)
|
||||
|
||||
### 1. Reader/Feed → Brain 保存フロー
|
||||
|
||||
```
|
||||
┌─────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────┐
|
||||
│ Reader │ │ Synology │ │ PostgreSQL │ │ Gemini │
|
||||
│ (Vercel)│ │ Brain API │ │ Database │ │ API │
|
||||
└────┬────┘ └──────┬───────┘ └──────┬───────┘ └────┬─────┘
|
||||
│ │ │ │
|
||||
│ 1. ユーザーが │ │ │
|
||||
│ 「Brainに保存」 │ │ │
|
||||
│ ボタンをクリック │ │ │
|
||||
│ │ │ │
|
||||
│ 2. POST /save │ │ │
|
||||
│ { │ │ │
|
||||
│ url: "...", │ │ │
|
||||
│ title: "...", │ │ │
|
||||
│ content: "...", │ (本文全文) │ │
|
||||
│ source: "reader"│ │ │
|
||||
│ } │ │ │
|
||||
├───────────────────>│ │ │
|
||||
│ │ │ │
|
||||
│ │ 3. 認証チェック │ │
|
||||
│ │ (JWT/APIキー) │ │
|
||||
│ │ │ │
|
||||
│ │ 4. 本文取得 │ │
|
||||
│ │ ・content あり │ │
|
||||
│ │ → そのまま使用 │ │
|
||||
│ │ ・content なし │ │
|
||||
│ │ → fetchOGP() │ │
|
||||
│ │ (OGP description │ │
|
||||
│ │ を代替使用) │ │
|
||||
│ │ │ │
|
||||
│ │ 5. AI分析リクエスト │ │
|
||||
│ │ analyzeWithGemini( │ │
|
||||
│ │ title, │ │
|
||||
│ │ fullText, │ │
|
||||
│ │ url │ │
|
||||
│ │ ) │ │
|
||||
│ ├───────────────────────────────────────────>│
|
||||
│ │ │ │
|
||||
│ │ │ 6. AI処理 │
|
||||
│ │ │ ・要約生成 │
|
||||
│ │ │ ・トピック抽出 │
|
||||
│ │ │ ・読了時間推定 │
|
||||
│ │ │ (5000文字制限) │
|
||||
│ │ │ │
|
||||
│ │ 7. AI分析結果 │ │
|
||||
│ │ { │ │
|
||||
│ │ summary: "...", │ │
|
||||
│ │ topics: [...], │ │
|
||||
│ │ readingTime: 5 │ │
|
||||
│ │ } │ │
|
||||
│ │<──────────────────────────────────────────┤
|
||||
│ │ │ │
|
||||
│ │ 8. DB保存 │ │
|
||||
│ │ INSERT INTO articles │ │
|
||||
│ │ (user_id, url, │ │
|
||||
│ │ title, full_text, │ ← Phase 1 新規 │
|
||||
│ │ summary, topics, │ │
|
||||
│ │ source, ...) │ │
|
||||
│ │ ON CONFLICT UPDATE │ (重複時は更新) │
|
||||
│ ├──────────────────────>│ │
|
||||
│ │ │ │
|
||||
│ │ 9. 保存完了 │ │
|
||||
│ │<──────────────────────┤ │
|
||||
│ │ │ │
|
||||
│ 10. レスポンス │ │ │
|
||||
│ { │ │ │
|
||||
│ success: true, │ │ │
|
||||
│ articleId: 123, │ │ │
|
||||
│ fullTextSaved: │ │ │
|
||||
│ true, │ │ │
|
||||
│ textLength: 2500│ │ │
|
||||
│ } │ │ │
|
||||
│<───────────────────┤ │ │
|
||||
│ │ │ │
|
||||
│ 11. UI フィードバック │ │
|
||||
│ ・ボタンを緑色に │ │ │
|
||||
│ ・「保存済み」表示 │ │ │
|
||||
│ │ │ │
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Brain 一覧表示フロー
|
||||
|
||||
```
|
||||
┌─────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ Brain │ │ Synology │ │ PostgreSQL │
|
||||
│ (Vercel)│ │ Brain API │ │ Database │
|
||||
└────┬────┘ └──────┬───────┘ └──────┬───────┘
|
||||
│ │ │
|
||||
│ 1. ページを開く │ │
|
||||
│ (初回ロード) │ │
|
||||
│ │ │
|
||||
│ 2. GET /articles │ │
|
||||
│ Authorization: │ │
|
||||
│ Bearer pk_xxx │ │
|
||||
├───────────────────>│ │
|
||||
│ │ │
|
||||
│ │ 3. 認証チェック │
|
||||
│ │ │
|
||||
│ │ 4. 記事一覧取得 │
|
||||
│ │ SELECT id, url, │
|
||||
│ │ title, full_text, │ ← Phase 1 追加
|
||||
│ │ summary, topics, │
|
||||
│ │ ... │
|
||||
│ │ FROM articles │
|
||||
│ │ WHERE user_id = $1 │
|
||||
│ │ ORDER BY │
|
||||
│ │ created_at DESC │
|
||||
│ ├──────────────────────>│
|
||||
│ │ │
|
||||
│ │ 5. 記事データ │
|
||||
│ │<──────────────────────┤
|
||||
│ │ │
|
||||
│ 6. JSON レスポンス│ │
|
||||
│ { │ │
|
||||
│ articles: [ │ │
|
||||
│ { │ │
|
||||
│ id: 123, │ │
|
||||
│ url: "...", │ │
|
||||
│ title: "...│ │
|
||||
│ fullText: │ ← Phase 1 新規 │
|
||||
│ "...", │ (本文表示用) │
|
||||
│ summary: │ │
|
||||
│ "...", │ │
|
||||
│ topics: [...│ │
|
||||
│ } │ │
|
||||
│ ] │ │
|
||||
│ } │ │
|
||||
│<───────────────────┤ │
|
||||
│ │ │
|
||||
│ 7. UI レンダリング│ │
|
||||
│ ・カード一覧表示 │ │
|
||||
│ ・クリックで詳細 │ │
|
||||
│ モーダル表示 │ │
|
||||
│ │ │
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Brain 本文表示フロー(詳細モーダル)
|
||||
|
||||
```
|
||||
┌─────────┐ ┌──────────────┐
|
||||
│ Brain │ │ Memory │
|
||||
│ UI │ │ Cache │
|
||||
└────┬────┘ └──────┬───────┘
|
||||
│ │
|
||||
│ 1. カードをクリック│
|
||||
│ openArticleDetail │
|
||||
│ Modal(article) │
|
||||
│ │
|
||||
│ 2. キャッシュ確認 │
|
||||
│ fullTextCache │
|
||||
│ .has(article.id)│
|
||||
├───────────────────>│
|
||||
│ │
|
||||
│ 3a. キャッシュHIT │
|
||||
│ (2回目以降) │
|
||||
│<───────────────────┤
|
||||
│ fullText を即表示 │
|
||||
│ (APIコールなし) │
|
||||
│ │
|
||||
│ 3b. キャッシュMISS│
|
||||
│ (初回) │
|
||||
│ │
|
||||
│ 4. article.fullText│
|
||||
│ がすでに含まれて │
|
||||
│ いる場合 │
|
||||
│ → 直接表示 │
|
||||
│ (Phase 1で対応済み)│
|
||||
│ │
|
||||
│ 5. Markdown変換 │
|
||||
│ renderMarkdown( │
|
||||
│ fullText │
|
||||
│ ) │
|
||||
│ ・# → <h1> │
|
||||
│ ・**text** → │
|
||||
│ <strong> │
|
||||
│ ・[link](url) → │
|
||||
│ <a href> │
|
||||
│ │
|
||||
│ 6. XSS対策 │
|
||||
│ DOMPurify │
|
||||
│ .sanitize() │
|
||||
│ │
|
||||
│ 7. 表示 │
|
||||
│ モーダル内に │
|
||||
│ HTML レンダリング │
|
||||
│ (CSS: .article- │
|
||||
│ detail-md) │
|
||||
│ │
|
||||
│ 8. キャッシュ保存 │
|
||||
│ fullTextCache │
|
||||
│ .set(id, text) │
|
||||
├───────────────────>│
|
||||
│ │
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## データの流れ(Phase 1: 本文全文保存)
|
||||
|
||||
### Before Phase 1(従来)
|
||||
|
||||
```
|
||||
Reader/Feed
|
||||
│
|
||||
├─ URL + Title のみ送信
|
||||
│
|
||||
▼
|
||||
Synology API
|
||||
│
|
||||
├─ fetchOGP() で OGP description 取得(短い)
|
||||
│
|
||||
├─ Gemini 分析(短い説明のみ)
|
||||
│ → 要約の質が低い
|
||||
│
|
||||
▼
|
||||
PostgreSQL
|
||||
│
|
||||
└─ summary (AI要約、精度低)
|
||||
└─ full_text: NULL ← 本文なし!
|
||||
|
||||
Brain UI
|
||||
│
|
||||
└─ 「本文を読む」ボタンなし
|
||||
└─ AI要約のみ表示(精度低)
|
||||
```
|
||||
|
||||
### After Phase 1(現在)
|
||||
|
||||
```
|
||||
Reader/Feed
|
||||
│
|
||||
├─ URL + Title + Content (本文全文) を送信
|
||||
│
|
||||
▼
|
||||
Synology API
|
||||
│
|
||||
├─ content あり → そのまま使用
|
||||
│ content なし → fetchOGP() で代替
|
||||
│
|
||||
├─ Gemini 分析(本文全文から5000文字)
|
||||
│ → 高精度な要約・トピック抽出
|
||||
│
|
||||
▼
|
||||
PostgreSQL
|
||||
│
|
||||
├─ full_text: "記事の本文全文..." ← Phase 1 新規!
|
||||
├─ summary: "高精度AI要約"
|
||||
└─ topics: ["技術", "Web開発"]
|
||||
|
||||
Brain UI
|
||||
│
|
||||
├─ 一覧: AI要約を表示
|
||||
│
|
||||
└─ クリック → モーダル
|
||||
│
|
||||
├─ 「本文を読む」タブ
|
||||
│ → full_text を Markdown 表示
|
||||
│
|
||||
└─ メモリキャッシュ(2回目以降は即表示)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技術スタック
|
||||
|
||||
| レイヤー | 技術 | 用途 |
|
||||
|---------|------|------|
|
||||
| **Frontend** | HTML/CSS/JS (Vanilla) | Brain UI (Vercel) |
|
||||
| **API Server** | Node.js + Express | Synology NAS (Docker) |
|
||||
| **Database** | PostgreSQL | Synology NAS (Docker) |
|
||||
| **AI** | Gemini 2.0 Flash Exp | 要約・トピック抽出 |
|
||||
| **Network** | Tailscale | Synology へのセキュアアクセス |
|
||||
| **Hosting** | Vercel (Frontend) + Synology (Backend) | ハイブリッド構成 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 の主な変更点
|
||||
|
||||
### 1. データベース
|
||||
|
||||
**Migration**: [synology-brain-migration.sql](synology-brain-migration.sql)
|
||||
|
||||
```sql
|
||||
-- 本文全文カラムを追加
|
||||
ALTER TABLE articles ADD COLUMN IF NOT EXISTS full_text TEXT;
|
||||
|
||||
-- 画像用(将来用)
|
||||
ALTER TABLE articles ADD COLUMN IF NOT EXISTS images TEXT[];
|
||||
|
||||
-- 更新日時用
|
||||
ALTER TABLE articles ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT NOW();
|
||||
```
|
||||
|
||||
### 2. API エンドポイント
|
||||
|
||||
**File**: [synology-brain-api-save-endpoint.js](synology-brain-api-save-endpoint.js)
|
||||
|
||||
#### POST /save の変更点
|
||||
|
||||
- **Before**: `{ url, title, source }` のみ
|
||||
- **After**: `{ url, title, content, source }` ← `content` 追加
|
||||
|
||||
```javascript
|
||||
// Before: OGP description のみ
|
||||
const meta = await fetchOGP(url);
|
||||
const ai = await analyzeWithGemini(title, meta.desc, url);
|
||||
// ^^^^^^^^ 短い
|
||||
|
||||
// After: 本文全文を優先
|
||||
let fullText = content || null;
|
||||
if (!fullText) {
|
||||
meta = await fetchOGP(url);
|
||||
fullText = meta.desc || ''; // フォールバック
|
||||
}
|
||||
const ai = await analyzeWithGemini(title, fullText, url);
|
||||
// ^^^^^^^^^ 長い!
|
||||
```
|
||||
|
||||
#### GET /articles の変更点
|
||||
|
||||
- **Before**: `full_text` カラムを返さない
|
||||
- **After**: `full_text` を含めて返す
|
||||
|
||||
```javascript
|
||||
// Before
|
||||
SELECT id, url, title, summary, topics, ...
|
||||
FROM articles
|
||||
|
||||
// After
|
||||
SELECT id, url, title, full_text, summary, topics, ...
|
||||
// ^^^^^^^^^ 追加
|
||||
FROM articles
|
||||
```
|
||||
|
||||
### 3. Brain UI (Vercel)
|
||||
|
||||
**File**: [posimai-brain/js/ui/actions.js](posimai-brain/js/ui/actions.js)
|
||||
|
||||
#### Markdown レンダリング
|
||||
|
||||
```javascript
|
||||
// 1. Markdown → HTML 変換
|
||||
function renderMarkdown(md) {
|
||||
// # → <h1>, **text** → <strong>, [link](url) → <a>
|
||||
// ...
|
||||
}
|
||||
|
||||
// 2. XSS 対策(DOMPurify)
|
||||
DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: ['p', 'h1', 'h2', 'h3', 'strong', 'em', 'code', 'a', 'hr']
|
||||
});
|
||||
```
|
||||
|
||||
#### メモリキャッシュ
|
||||
|
||||
```javascript
|
||||
const fullTextCache = new Map();
|
||||
|
||||
// 初回: API から取得 → キャッシュ
|
||||
fullTextCache.set(article.id, fullText);
|
||||
|
||||
// 2回目以降: キャッシュから即表示
|
||||
if (fullTextCache.has(article.id)) {
|
||||
return cachedText; // APIコールなし!
|
||||
}
|
||||
```
|
||||
|
||||
**File**: [posimai-brain/style.css](posimai-brain/style.css)
|
||||
|
||||
#### Markdown CSS
|
||||
|
||||
```css
|
||||
.article-detail-md h1 { font-size: 24px; ... }
|
||||
.article-detail-md h2 { font-size: 20px; ... }
|
||||
.article-detail-md a { color: var(--accent); ... }
|
||||
.article-detail-md code { background: var(--surface2); ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## セキュリティ考慮
|
||||
|
||||
| 対策 | 実装内容 | 効果 |
|
||||
|-----|---------|------|
|
||||
| **認証** | JWT/APIキー (authMiddleware) | 未認証アクセス防止 |
|
||||
| **XSS対策** | DOMPurify でサニタイズ | スクリプト埋め込み防止 |
|
||||
| **SQL Injection** | パラメータ化クエリ (`$1`, `$2`, ...) | SQL攻撃防止 |
|
||||
| **HTTPS** | Vercel (自動), Tailscale (暗号化) | 通信盗聴防止 |
|
||||
| **CORS** | 設定済み (Brain API) | 不正ドメインからのアクセス防止 |
|
||||
|
||||
---
|
||||
|
||||
## パフォーマンス最適化
|
||||
|
||||
| 項目 | 実装 | 効果 |
|
||||
|-----|------|------|
|
||||
| **AI トークン制限** | 本文 5000 文字まで | コスト削減 |
|
||||
| **メモリキャッシュ** | Map-based cache | 2回目以降の表示が即座 |
|
||||
| **ON CONFLICT** | PostgreSQL UPSERT | 重複チェック不要 |
|
||||
| **インデックス** | `user_id`, `created_at` | クエリ高速化 |
|
||||
|
||||
---
|
||||
|
||||
## トラブルシューティング
|
||||
|
||||
### 問題 1: 本文が保存されない
|
||||
|
||||
**確認事項**:
|
||||
1. Reader/Feed が `content` を送信しているか?
|
||||
- DevTools → Network → `/save` リクエストの `body` を確認
|
||||
2. Synology API が `full_text` カラムに保存しているか?
|
||||
- PostgreSQL: `SELECT full_text FROM articles ORDER BY created_at DESC LIMIT 1;`
|
||||
3. マイグレーションは実行済みか?
|
||||
- `\d articles` で `full_text` カラムがあるか確認
|
||||
|
||||
### 問題 2: Markdown が表示されない
|
||||
|
||||
**確認事項**:
|
||||
1. DOMPurify が読み込まれているか?
|
||||
- DevTools → Console: `window.DOMPurify` が存在するか
|
||||
2. CSS が適用されているか?
|
||||
- DevTools → Elements: `.article-detail-md` クラスがあるか
|
||||
3. Markdown 変換が動作しているか?
|
||||
- DevTools → Console: `renderMarkdown("# Test")` を実行
|
||||
|
||||
### 問題 3: AI 分析が失敗する
|
||||
|
||||
**確認事項**:
|
||||
1. Gemini API キーは正しいか?
|
||||
- Synology: 環境変数 `GEMINI_API_KEY` を確認
|
||||
2. 本文が長すぎないか?
|
||||
- 5000 文字以上は自動でカットされる
|
||||
3. ログを確認
|
||||
- `docker logs -f posimai-brain-api`
|
||||
|
||||
---
|
||||
|
||||
## まとめ
|
||||
|
||||
Phase 1 の「本文全文保存」機能により:
|
||||
|
||||
✅ **データロスなし**: Reader で読んだ記事の本文が完全保存される
|
||||
✅ **高精度AI分析**: 本文全文から要約・トピック抽出
|
||||
✅ **オフライン閲覧可**: Brain に保存した記事は元サイトが消えても読める
|
||||
✅ **UX向上**: メモリキャッシュで2回目以降の表示が高速
|
||||
✅ **セキュア**: XSS対策、認証、HTTPS完備
|
||||
|
||||
次のステップ: Synology 側の実装完了を確認し、エンドツーエンドテストを実施。
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
version: '3.8'
|
||||
|
||||
# ==========================================
|
||||
# Ponshu Room "AI Factory" Setup
|
||||
# ==========================================
|
||||
# シークレットはすべて .env ファイルで管理します。
|
||||
# このファイルに直接パスワードや API キーを書いてはいけません。
|
||||
#
|
||||
# 初回セットアップ:
|
||||
# 1. .env ファイルを同じディレクトリに作成(.env.example を参照)
|
||||
# 2. Container Manager でプロジェクトを再起動
|
||||
|
||||
services:
|
||||
# ----------------------------------------
|
||||
# 1. Gitea (Git Server)
|
||||
# ----------------------------------------
|
||||
gitea:
|
||||
image: gitea/gitea:1.21
|
||||
container_name: gitea
|
||||
environment:
|
||||
- USER_UID=1026
|
||||
- USER_GID=100
|
||||
- GITEA__database__DB_TYPE=postgres
|
||||
- GITEA__database__HOST=db:5432
|
||||
- GITEA__database__NAME=gitea
|
||||
- GITEA__database__USER=gitea
|
||||
- GITEA__database__PASSWD=${DB_PASS}
|
||||
- GITEA__server__DOMAIN=${GITEA_DOMAIN}
|
||||
- GITEA__server__ROOT_URL=https://${GITEA_DOMAIN}/
|
||||
restart: always
|
||||
networks:
|
||||
- gitea_network
|
||||
volumes:
|
||||
- ./gitea:/data
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "2222:22"
|
||||
|
||||
# ----------------------------------------
|
||||
# 2. PostgreSQL (Database)
|
||||
# ----------------------------------------
|
||||
db:
|
||||
image: postgres:15
|
||||
container_name: gitea_db
|
||||
restart: always
|
||||
environment:
|
||||
- POSTGRES_USER=gitea
|
||||
- POSTGRES_PASSWORD=${DB_PASS}
|
||||
- POSTGRES_DB=gitea
|
||||
networks:
|
||||
- gitea_network
|
||||
volumes:
|
||||
- ./postgres:/var/lib/postgresql/data
|
||||
|
||||
# ----------------------------------------
|
||||
# 3. MCP Server (AI Bridge)
|
||||
# ----------------------------------------
|
||||
mcp-server:
|
||||
image: node:20-slim
|
||||
container_name: mcp_server
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./mcp:/app
|
||||
- ./gitea:/data/gitea_files
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
command: sh -c "npm install && node index.js; tail -f /dev/null"
|
||||
restart: always
|
||||
networks:
|
||||
- gitea_network
|
||||
|
||||
# ----------------------------------------
|
||||
# 5. Posimai Brain API
|
||||
# ----------------------------------------
|
||||
posimai-api:
|
||||
image: node:20-slim
|
||||
container_name: posimai_api
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./posimai-api:/app
|
||||
environment:
|
||||
- DB_HOST=db
|
||||
- DB_PORT=5432
|
||||
- DB_USER=gitea
|
||||
- DB_PASSWORD=${DB_PASS}
|
||||
- GEMINI_API_KEY=${GEMINI_API_KEY}
|
||||
- API_KEYS=${API_KEYS}
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS}
|
||||
- PORT=8090
|
||||
command: sh -c "npm install && node server.js"
|
||||
restart: always
|
||||
ports:
|
||||
- "8090:8090"
|
||||
networks:
|
||||
- gitea_network
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
networks:
|
||||
gitea_network:
|
||||
driver: bridge
|
||||
|
|
@ -0,0 +1,392 @@
|
|||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// Posimai Brain API - /save endpoint (UPDATED)
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// Purpose: Save articles from Reader with full-text content
|
||||
// Location: Copy this to Synology NAS at /app/server.js or /brain/api/save.js
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
/**
|
||||
* POST /brain/api/save
|
||||
*
|
||||
* Saves an article to Brain with full-text content and AI analysis
|
||||
*
|
||||
* Request body:
|
||||
* {
|
||||
* url: string // Article URL (required)
|
||||
* title: string // Article title (required)
|
||||
* content: string // Full article body from Reader (NEW!)
|
||||
* source: string // 'reader', 'feed', or 'bookmarklet'
|
||||
* }
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* success: boolean
|
||||
* articleId: number
|
||||
* fullTextSaved: boolean // Debug info
|
||||
* textLength: number // Debug info
|
||||
* }
|
||||
*/
|
||||
router.post('/save', authMiddleware, async (req, res) => {
|
||||
const { url, title, content, source } = req.body || {};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// 1. Validation
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
if (!url || !title) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields: url and title are required'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 2. Get full text content
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
let fullText = content || null; // Content from Reader
|
||||
let meta = {};
|
||||
|
||||
// If no content provided (e.g., from Feed, Web Share, Command Palette)
|
||||
// Fetch full text via Jina Reader API
|
||||
if (!fullText || fullText.trim().length === 0) {
|
||||
console.log(`[Brain API] No content provided, fetching via Jina Reader for ${url}`);
|
||||
|
||||
try {
|
||||
// Jina Reader API call
|
||||
const jinaResponse = await fetch(`https://r.jina.ai/${url}`, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 Posimai Brain Bot'
|
||||
},
|
||||
timeout: 15000 // 15 second timeout
|
||||
});
|
||||
|
||||
if (jinaResponse.ok) {
|
||||
let markdown = await jinaResponse.text();
|
||||
|
||||
// Extract Markdown content (same logic as Reader)
|
||||
const contentMarker = 'Markdown Content:';
|
||||
const contentIndex = markdown.indexOf(contentMarker);
|
||||
if (contentIndex !== -1) {
|
||||
fullText = markdown.substring(contentIndex + contentMarker.length).trim();
|
||||
} else {
|
||||
fullText = markdown;
|
||||
}
|
||||
|
||||
// Remove image references (same logic as Reader)
|
||||
fullText = fullText.replace(/!\[Image\s+\d+[^\]]*\]\([^)]*\)/gmi, '');
|
||||
fullText = fullText.replace(/!\[Image\s+\d+[^\]]*\]/gmi, '');
|
||||
fullText = fullText.replace(/^\s*\*?\s*!\[?Image\s+\d+[^\n]*/gmi, '');
|
||||
fullText = fullText.replace(/\[\]\([^)]*\)/gm, '');
|
||||
|
||||
console.log(`[Brain API] Fetched full text via Jina Reader (${fullText.length} chars)`);
|
||||
} else {
|
||||
console.warn(`[Brain API] Jina Reader returned status ${jinaResponse.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Brain API] Jina Reader fetch failed:', error);
|
||||
}
|
||||
|
||||
// Fallback: If Jina Reader failed, use OGP description
|
||||
if (!fullText || fullText.trim().length === 0) {
|
||||
console.log(`[Brain API] Jina Reader failed, falling back to OGP for ${url}`);
|
||||
meta = await fetchOGP(url);
|
||||
fullText = meta.desc || '';
|
||||
}
|
||||
} else {
|
||||
console.log(`[Brain API] Received full text content (${fullText.length} chars) for ${url}`);
|
||||
}
|
||||
|
||||
// Fetch OGP metadata for favicon/og_image (regardless of full text source)
|
||||
if (!meta.favicon && !meta.ogImage) {
|
||||
meta = await fetchOGP(url);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 3. AI Analysis with full text
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
const ai = await analyzeWithGemini(title, fullText, url);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 4. Save to database with full_text
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
const result = await pool.query(`
|
||||
INSERT INTO articles (
|
||||
user_id,
|
||||
url,
|
||||
title,
|
||||
full_text,
|
||||
summary,
|
||||
topics,
|
||||
source,
|
||||
favicon,
|
||||
og_image,
|
||||
status,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW()
|
||||
)
|
||||
ON CONFLICT (user_id, url) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
full_text = EXCLUDED.full_text,
|
||||
summary = EXCLUDED.summary,
|
||||
topics = EXCLUDED.topics,
|
||||
favicon = EXCLUDED.favicon,
|
||||
og_image = EXCLUDED.og_image,
|
||||
updated_at = NOW()
|
||||
RETURNING id
|
||||
`, [
|
||||
req.userId, // $1: user_id from authMiddleware
|
||||
url, // $2: url
|
||||
title, // $3: title
|
||||
fullText, // $4: full_text
|
||||
ai.summary, // $5: AI-generated summary
|
||||
ai.topics, // $6: AI-generated topics
|
||||
source || 'reader', // $7: source
|
||||
meta.favicon || null, // $8: favicon
|
||||
meta.ogImage || null, // $9: og_image
|
||||
'inbox' // $10: default status
|
||||
]);
|
||||
|
||||
const articleId = result.rows[0].id;
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 5. Success response
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
console.log(`[Brain API] Article saved successfully: ID=${articleId}, URL=${url}, FullText=${!!fullText}, Length=${fullText?.length || 0}`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
articleId: articleId,
|
||||
fullTextSaved: !!fullText, // Debug: Was full text saved?
|
||||
textLength: fullText?.length || 0 // Debug: How long is the text?
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Brain API] Save failed:', error);
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'Failed to save article',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// Helper Functions
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
/**
|
||||
* Fetch OGP metadata from URL
|
||||
*
|
||||
* @param {string} url - Article URL
|
||||
* @returns {Promise<{desc: string, favicon: string, ogImage: string}>}
|
||||
*/
|
||||
async function fetchOGP(url) {
|
||||
try {
|
||||
// Your existing OGP fetching logic
|
||||
// Example implementation:
|
||||
const response = await fetch(url, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 Posimai Brain Bot' }
|
||||
});
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
// Parse OGP tags (simplified example)
|
||||
const descMatch = html.match(/<meta property="og:description" content="([^"]+)"/);
|
||||
const imageMatch = html.match(/<meta property="og:image" content="([^"]+)"/);
|
||||
const faviconMatch = html.match(/<link rel="icon" href="([^"]+)"/);
|
||||
|
||||
return {
|
||||
desc: descMatch ? descMatch[1] : '',
|
||||
ogImage: imageMatch ? imageMatch[1] : null,
|
||||
favicon: faviconMatch ? faviconMatch[1] : null
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('[fetchOGP] Failed to fetch OGP metadata:', error);
|
||||
return { desc: '', ogImage: null, favicon: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze article with Gemini AI
|
||||
*
|
||||
* @param {string} title - Article title
|
||||
* @param {string} fullText - Full article body
|
||||
* @param {string} url - Article URL
|
||||
* @returns {Promise<{summary: string, topics: string[], readingTime: number}>}
|
||||
*/
|
||||
async function analyzeWithGemini(title, fullText, url) {
|
||||
try {
|
||||
// Limit text length to prevent token overflow
|
||||
// Gemini Flash: ~30k tokens input, ~1 token = 3-4 chars
|
||||
// Safe limit: 5000 chars = ~1250 tokens
|
||||
const maxLength = 5000;
|
||||
const textForAnalysis = fullText?.substring(0, maxLength) || '';
|
||||
|
||||
const prompt = `
|
||||
以下の記事を分析してください:
|
||||
|
||||
タイトル: ${title}
|
||||
本文:
|
||||
${textForAnalysis}
|
||||
|
||||
以下のJSON形式で返してください:
|
||||
{
|
||||
"summary": "3文の要約(本文の核心を捉えた要約)",
|
||||
"topics": ["トピック1", "トピック2"]
|
||||
}
|
||||
|
||||
**重要**:
|
||||
- summaryは本文全体の内容を踏まえた正確な要約にしてください
|
||||
- topicsは記事の主要なテーマを2つ選んでください(技術、ビジネス、健康、エンタメなど)
|
||||
`;
|
||||
|
||||
// Gemini API call
|
||||
const model = genAI.getGenerativeModel({ model: 'gemini-2.0-flash-exp' });
|
||||
const response = await model.generateContent(prompt);
|
||||
const resultText = response.response.text();
|
||||
|
||||
// Parse JSON response
|
||||
const result = JSON.parse(resultText);
|
||||
|
||||
return {
|
||||
summary: result.summary || 'AI分析中...',
|
||||
topics: result.topics || []
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Gemini AI] Analysis failed:', error);
|
||||
|
||||
// Fallback: Return basic info if AI fails
|
||||
return {
|
||||
summary: 'AI分析中...',
|
||||
topics: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// Updated GET /articles endpoint (include full_text in response)
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
/**
|
||||
* GET /brain/api/articles
|
||||
*
|
||||
* Fetch all articles for authenticated user
|
||||
* NOW INCLUDES full_text for Brain UI display
|
||||
*/
|
||||
router.get('/articles', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
id,
|
||||
url,
|
||||
title,
|
||||
full_text,
|
||||
summary,
|
||||
topics,
|
||||
source,
|
||||
favicon,
|
||||
og_image,
|
||||
status,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM articles
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`, [req.userId]);
|
||||
|
||||
return res.json({
|
||||
articles: rows.map(row => ({
|
||||
id: row.id,
|
||||
url: row.url,
|
||||
title: row.title,
|
||||
fullText: row.full_text,
|
||||
summary: row.summary,
|
||||
topics: row.topics,
|
||||
source: row.source,
|
||||
favicon: row.favicon,
|
||||
ogImage: row.og_image,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
}))
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Brain API] Failed to fetch articles:', error);
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'Failed to fetch articles',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// Testing & Debugging
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
/**
|
||||
* GET /brain/api/test-save
|
||||
*
|
||||
* Debug endpoint to test if full_text is being saved correctly
|
||||
*
|
||||
* Usage: https://posimai-lab.tail72e846.ts.net/brain/api/test-save
|
||||
*/
|
||||
router.get('/test-save', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
id,
|
||||
title,
|
||||
LENGTH(full_text) as text_length,
|
||||
LEFT(full_text, 100) as text_preview,
|
||||
source,
|
||||
created_at
|
||||
FROM articles
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 10
|
||||
`, [req.userId]);
|
||||
|
||||
const stats = {
|
||||
total: rows.length,
|
||||
withFullText: rows.filter(r => r.text_length > 0).length,
|
||||
withoutFullText: rows.filter(r => r.text_length === 0 || r.text_length === null).length,
|
||||
articles: rows
|
||||
};
|
||||
|
||||
return res.json(stats);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Brain API] Test failed:', error);
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'Test failed',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// Export
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
module.exports = router;
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// DEPLOYMENT CHECKLIST
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
//
|
||||
// 1. [ ] Run database migration: synology-brain-migration.sql
|
||||
// 2. [ ] Update server.js with this code
|
||||
// 3. [ ] Restart Brain API server: docker restart posimai-brain-api
|
||||
// 4. [ ] Test save from Reader
|
||||
// 5. [ ] Check logs: docker logs -f posimai-brain-api
|
||||
// 6. [ ] Verify database: SELECT * FROM articles ORDER BY created_at DESC LIMIT 5;
|
||||
// 7. [ ] Test GET /articles includes fullText
|
||||
// 8. [ ] Update Brain UI to display full text (next task)
|
||||
//
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
-- Posimai Brain Full-Text Save Migration
|
||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
-- Purpose: Add full_text column to store article body from Reader
|
||||
-- Date: 2026-03-01
|
||||
-- Impact: Critical data loss prevention
|
||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────
|
||||
-- STEP 1: Backup existing data
|
||||
-- ──────────────────────────────────────────────────────────────────────
|
||||
-- Before running migration, create backup:
|
||||
-- pg_dump -U brain_user -d brain_db -t articles > /backup/articles_$(date +%Y%m%d).sql
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────
|
||||
-- STEP 2: Add full_text column
|
||||
-- ──────────────────────────────────────────────────────────────────────
|
||||
BEGIN;
|
||||
|
||||
-- Add full_text column for storing article body
|
||||
ALTER TABLE articles
|
||||
ADD COLUMN IF NOT EXISTS full_text TEXT;
|
||||
|
||||
-- Add images column (optional, for future use)
|
||||
ALTER TABLE articles
|
||||
ADD COLUMN IF NOT EXISTS images TEXT[];
|
||||
|
||||
-- Add updated_at if it doesn't exist
|
||||
ALTER TABLE articles
|
||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT NOW();
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────
|
||||
-- STEP 3: Create indexes for performance
|
||||
-- ──────────────────────────────────────────────────────────────────────
|
||||
BEGIN;
|
||||
|
||||
-- Full-text search index (Japanese language support)
|
||||
-- This enables fast text search in Brain UI
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_full_text_search
|
||||
ON articles USING gin(to_tsvector('english', COALESCE(full_text, '')));
|
||||
|
||||
-- Note: PostgreSQL doesn't have built-in Japanese tokenizer by default
|
||||
-- For better Japanese search, consider installing pg_bigm extension:
|
||||
-- CREATE EXTENSION IF NOT EXISTS pg_bigm;
|
||||
-- CREATE INDEX IF NOT EXISTS idx_articles_full_text_bigm
|
||||
-- ON articles USING gin(full_text gin_bigm_ops);
|
||||
|
||||
-- Index for finding articles without full_text (for migration/cleanup)
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_missing_full_text
|
||||
ON articles (id) WHERE full_text IS NULL;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────
|
||||
-- STEP 4: Verify schema changes
|
||||
-- ──────────────────────────────────────────────────────────────────────
|
||||
-- \d articles
|
||||
|
||||
-- Expected output should include:
|
||||
-- | Column | Type | Nullable | Description |
|
||||
-- |-------------|-----------|----------|--------------------------------|
|
||||
-- | id | SERIAL | NOT NULL | Primary key |
|
||||
-- | user_id | INTEGER | NOT NULL | Foreign key to users table |
|
||||
-- | url | TEXT | NOT NULL | Original article URL |
|
||||
-- | title | TEXT | NOT NULL | Article title |
|
||||
-- | full_text | TEXT | NULL | ← NEW! Article body from Reader|
|
||||
-- | summary | TEXT | NULL | AI-generated 3-sentence summary|
|
||||
-- | topics | TEXT[] | NULL | AI-generated topic tags |
|
||||
-- | source | TEXT | NULL | Source (reader/feed/bookmarklet)|
|
||||
-- | reading_time| INTEGER | NULL | Estimated reading time (minutes)|
|
||||
-- | favicon | TEXT | NULL | Site favicon URL |
|
||||
-- | og_image | TEXT | NULL | OGP image URL |
|
||||
-- | images | TEXT[] | NULL | ← NEW! Article images (future) |
|
||||
-- | status | TEXT | NOT NULL | inbox/favorite/shared/archived |
|
||||
-- | created_at | TIMESTAMP | NOT NULL | Record creation time |
|
||||
-- | updated_at | TIMESTAMP | NULL | ← NEW! Record update time |
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────
|
||||
-- STEP 5: Test query (verify columns exist)
|
||||
-- ──────────────────────────────────────────────────────────────────────
|
||||
SELECT
|
||||
id,
|
||||
title,
|
||||
LENGTH(full_text) as text_length,
|
||||
LEFT(full_text, 50) as text_preview,
|
||||
summary,
|
||||
topics,
|
||||
created_at
|
||||
FROM articles
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5;
|
||||
|
||||
-- Expected: All existing articles will have NULL for full_text
|
||||
-- New articles saved after API update will have full_text populated
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────
|
||||
-- STEP 6: Clean up old articles (optional)
|
||||
-- ──────────────────────────────────────────────────────────────────────
|
||||
-- If you want to delete old articles without full_text after migration:
|
||||
-- (Run this ONLY after confirming new articles are saving correctly)
|
||||
|
||||
-- Count articles without full_text
|
||||
SELECT COUNT(*) as articles_without_fulltext
|
||||
FROM articles
|
||||
WHERE full_text IS NULL;
|
||||
|
||||
-- Optionally delete old articles without full_text (BE CAREFUL!)
|
||||
-- DELETE FROM articles WHERE full_text IS NULL AND created_at < NOW() - INTERVAL '30 days';
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────
|
||||
-- STEP 7: Add constraint for new articles (optional, recommended)
|
||||
-- ──────────────────────────────────────────────────────────────────────
|
||||
-- After confirming Reader is sending full_text correctly,
|
||||
-- you can add a constraint to ensure all new articles have full_text:
|
||||
|
||||
-- ALTER TABLE articles
|
||||
-- ADD CONSTRAINT full_text_required
|
||||
-- CHECK (
|
||||
-- (source = 'reader' AND full_text IS NOT NULL) OR
|
||||
-- (source != 'reader')
|
||||
-- );
|
||||
|
||||
-- This ensures Reader-sourced articles MUST have full_text
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────
|
||||
-- STEP 8: Grant permissions (if needed)
|
||||
-- ──────────────────────────────────────────────────────────────────────
|
||||
-- If using a separate API user, grant access to new columns:
|
||||
-- GRANT SELECT, INSERT, UPDATE ON articles TO brain_api_user;
|
||||
|
||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
-- Migration Complete!
|
||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
-- Next steps:
|
||||
-- 1. Update Brain API server code (see BRAIN_FULLTEXT_IMPLEMENTATION_GUIDE.md)
|
||||
-- 2. Test saving an article from Reader
|
||||
-- 3. Verify full_text is populated in database
|
||||
-- 4. Deploy Brain API changes to production
|
||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
/**
|
||||
* Posimai Events - Synology API エンドポイント
|
||||
*
|
||||
* Brain の server.js に追記する形で統合する。
|
||||
* または /events パス以下を別サービスとして立ち上げる。
|
||||
*
|
||||
* エンドポイント:
|
||||
* GET /events/api/events イベント一覧取得(フィルター対応)
|
||||
* POST /events/api/events n8n からのイベントデータ受信・保存
|
||||
* POST /events/api/events/batch n8n から複数イベントを一括登録
|
||||
* DELETE /events/api/events/:id イベント削除(管理用)
|
||||
*
|
||||
* 環境変数(Brain の .env と同じファイルに追記):
|
||||
* EVENTS_N8N_TOKEN=<n8nからのPOSTに使うトークン>
|
||||
* GEMINI_API_KEY=<既存のBrain用を流用>
|
||||
* DATABASE_URL=<既存のBrain用を流用>
|
||||
*/
|
||||
|
||||
const { Pool } = require('pg');
|
||||
const { GoogleGenerativeAI } = require('@google/generative-ai');
|
||||
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
|
||||
|
||||
// n8n 認証ミドルウェア
|
||||
function n8nAuth(req, res, next) {
|
||||
const token = process.env.EVENTS_N8N_TOKEN;
|
||||
if (!token) return next(); // トークン未設定時はスキップ(開発環境)
|
||||
const auth = req.headers['authorization'];
|
||||
if (auth !== `Bearer ${token}`) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// ---- GET /events/api/events ----
|
||||
// クエリパラメータ:
|
||||
// from=YYYY-MM-DD 開始日以降(デフォルト: 今日から7日前)
|
||||
// to=YYYY-MM-DD 終了日まで
|
||||
// interests=sake,food カンマ区切り
|
||||
// audience=couple,family
|
||||
// free=true
|
||||
// limit=50 (max 100)
|
||||
async function getEvents(req, res) {
|
||||
try {
|
||||
const today = new Date().toISOString().slice(0,10);
|
||||
const from = req.query.from || today;
|
||||
const to = req.query.to;
|
||||
const interests = req.query.interests ? req.query.interests.split(',').map(s=>s.trim()) : [];
|
||||
const audience = req.query.audience ? req.query.audience.split(',').map(s=>s.trim()) : [];
|
||||
const isFree = req.query.free === 'true';
|
||||
const limit = Math.min(parseInt(req.query.limit) || 50, 100);
|
||||
|
||||
let params = [from, limit];
|
||||
let where = ['e.end_date >= $1'];
|
||||
let idx = 3;
|
||||
|
||||
if (to) { where.push(`e.start_date <= $${idx}`); params.splice(idx-1,0,to); idx++; }
|
||||
if (interests.length) { where.push(`e.interest_tags && $${idx}::text[]`); params.splice(idx-1,0,interests); idx++; }
|
||||
if (audience.length) { where.push(`e.audience_tags && $${idx}::text[]`); params.splice(idx-1,0,audience); idx++; }
|
||||
if (isFree) { where.push('e.is_free = TRUE'); }
|
||||
|
||||
const sql = `
|
||||
SELECT
|
||||
e.event_id AS id,
|
||||
e.title, e.start_date AS "startDate", e.start_time AS "startTime",
|
||||
e.end_date AS "endDate", e.end_time AS "endTime",
|
||||
e.location, e.address, e.description, e.category, e.url, e.source,
|
||||
e.interest_tags AS "interestTags", e.audience_tags AS "audienceTags",
|
||||
e.is_free AS "isFree", e.no_rsvp AS "noRsvp", e.is_outdoor AS "isOutdoor",
|
||||
e.scraped_at AS "scrapedAt"
|
||||
FROM events e
|
||||
WHERE ${where.join(' AND ')}
|
||||
ORDER BY e.start_date ASC, e.start_time ASC
|
||||
LIMIT $2
|
||||
`;
|
||||
|
||||
const result = await pool.query(sql, params);
|
||||
const events = result.rows.map(row => ({
|
||||
...row,
|
||||
startDate: row.startDate?.toISOString?.()?.slice(0,10) || row.startDate,
|
||||
endDate: row.endDate?.toISOString?.()?.slice(0,10) || row.endDate,
|
||||
startTime: row.startTime?.slice?.(0,5) || row.startTime,
|
||||
endTime: row.endTime?.slice?.(0,5) || row.endTime,
|
||||
}));
|
||||
|
||||
res.json({ events, updatedAt: new Date().toISOString(), count: events.length });
|
||||
} catch (err) {
|
||||
console.error('GET /events error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- POST /events/api/events ----
|
||||
// n8n から1件ずつ受け取る場合
|
||||
// BodyにはJinaReader + Geminiが生成したイベントJSONを渡す
|
||||
async function createEvent(req, res) {
|
||||
const body = req.body;
|
||||
if (!body?.title || !body?.startDate) {
|
||||
return res.status(400).json({ error: 'title and startDate are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Gemini でタグ自動付与(まだ付いていない場合)
|
||||
let interestTags = body.interestTags || [];
|
||||
let audienceTags = body.audienceTags || [];
|
||||
if (!interestTags.length && body.description) {
|
||||
const tagged = await autoTagEvent(body);
|
||||
interestTags = tagged.interestTags;
|
||||
audienceTags = tagged.audienceTags;
|
||||
}
|
||||
|
||||
const eventId = body.id || `${body.source?.replace(/\s+/g,'-')}-${body.startDate}-${Date.now()}`;
|
||||
|
||||
await pool.query(`
|
||||
INSERT INTO events
|
||||
(event_id, title, start_date, start_time, end_date, end_time,
|
||||
location, address, description, category, url, source,
|
||||
interest_tags, audience_tags, is_free, no_rsvp, is_outdoor, scraped_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,NOW())
|
||||
ON CONFLICT (event_id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
description = EXCLUDED.description,
|
||||
interest_tags = EXCLUDED.interest_tags,
|
||||
audience_tags = EXCLUDED.audience_tags,
|
||||
scraped_at = NOW(),
|
||||
updated_at = NOW()
|
||||
`, [
|
||||
eventId, body.title, body.startDate, body.startTime || null,
|
||||
body.endDate || body.startDate, body.endTime || null,
|
||||
body.location || '', body.address || '',
|
||||
body.description || '', body.category || '',
|
||||
body.url || '', body.source || '',
|
||||
interestTags, audienceTags,
|
||||
body.isFree || false, body.noRsvp || false, body.isOutdoor || false
|
||||
]);
|
||||
|
||||
res.json({ ok: true, eventId });
|
||||
} catch (err) {
|
||||
console.error('POST /events error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- POST /events/api/events/batch ----
|
||||
// n8n から複数件を一括登録する場合
|
||||
async function createEventsBatch(req, res) {
|
||||
const { events } = req.body;
|
||||
if (!Array.isArray(events) || !events.length) {
|
||||
return res.status(400).json({ error: 'events array is required' });
|
||||
}
|
||||
|
||||
let saved = 0, errors = 0;
|
||||
for (const ev of events) {
|
||||
try {
|
||||
const fakeReq = { body: ev };
|
||||
const fakeRes = {
|
||||
json: () => { saved++; },
|
||||
status: (c) => ({ json: () => { errors++; } })
|
||||
};
|
||||
await createEvent(fakeReq, fakeRes);
|
||||
} catch { errors++; }
|
||||
}
|
||||
|
||||
res.json({ ok: true, saved, errors });
|
||||
}
|
||||
|
||||
// ---- Gemini タグ自動付与 ----
|
||||
const INTEREST_IDS = ['sake','beer','wine','food','market','music','art','craft','outdoor','sports','culture','film','learn','volunteer','kids'];
|
||||
const AUDIENCE_IDS = ['couple','family','solo','senior','pet','foreign'];
|
||||
|
||||
async function autoTagEvent(ev) {
|
||||
try {
|
||||
const model = genAI.getGenerativeModel({ model: 'gemini-1.5-flash' });
|
||||
const prompt = `
|
||||
以下のイベント情報に、最も適切なタグを付与してください。
|
||||
|
||||
イベント名: ${ev.title}
|
||||
説明: ${ev.description}
|
||||
カテゴリ: ${ev.category || '不明'}
|
||||
|
||||
【興味タグ候補】(複数選択可):
|
||||
sake=日本酒, beer=クラフトビール, wine=ワイン/お酒, food=グルメ/食,
|
||||
market=マルシェ/市場, music=音楽/ライブ, art=アート/展示, craft=クラフト/手工芸,
|
||||
outdoor=アウトドア/自然, sports=スポーツ/体験, culture=伝統/文化,
|
||||
film=映画/演劇, learn=学び/セミナー, volunteer=ボランティア, kids=子ども向け
|
||||
|
||||
【対象者タグ候補】(複数選択可):
|
||||
couple=カップル向け, family=ファミリー歓迎, solo=ソロ歓迎,
|
||||
senior=シニア向け, pet=ペット同伴OK, foreign=英語対応
|
||||
|
||||
JSONのみ返答:
|
||||
{"interestTags":["tag1","tag2"],"audienceTags":["tag1"]}`;
|
||||
|
||||
const result = await model.generateContent(prompt);
|
||||
const text = result.response.text().trim();
|
||||
const json = JSON.parse(text.match(/\{[\s\S]*\}/)[0]);
|
||||
return {
|
||||
interestTags: (json.interestTags || []).filter(t => INTEREST_IDS.includes(t)),
|
||||
audienceTags: (json.audienceTags || []).filter(t => AUDIENCE_IDS.includes(t)),
|
||||
};
|
||||
} catch {
|
||||
return { interestTags: [], audienceTags: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ルーター統合(server.js に追記する形)----
|
||||
// 既存の Brain server.js の末尾に以下を追加:
|
||||
//
|
||||
// const eventsRouter = require('./events-api');
|
||||
// app.use('/events/api', eventsRouter);
|
||||
//
|
||||
// または Router として export する場合:
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/events', getEvents);
|
||||
router.post('/events', n8nAuth, createEvent);
|
||||
router.post('/events/batch', n8nAuth, createEventsBatch);
|
||||
router.delete('/events/:id', n8nAuth, async (req, res) => {
|
||||
await pool.query('DELETE FROM events WHERE event_id = $1', [req.params.id]);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
-- Posimai Events - PostgreSQL スキーマ(Synology NAS用)
|
||||
-- Brain DB と同じ PostgreSQL インスタンスに events テーブルを追加する
|
||||
-- 実行: psql -U postgres -d posimai -f synology-events-migration.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id SERIAL PRIMARY KEY,
|
||||
event_id TEXT UNIQUE NOT NULL, -- 外部ID(Peatix ID等)または UUID
|
||||
title TEXT NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
start_time TIME,
|
||||
end_date DATE,
|
||||
end_time TIME,
|
||||
location TEXT,
|
||||
address TEXT,
|
||||
description TEXT,
|
||||
category TEXT,
|
||||
url TEXT,
|
||||
source TEXT, -- 情報元 (Peatix / 市役所HP / n8n-scrape 等)
|
||||
|
||||
-- パーソナライズ用タグ(Gemini が自動付与)
|
||||
interest_tags TEXT[] DEFAULT '{}', -- ['sake', 'food', 'market'] 等
|
||||
audience_tags TEXT[] DEFAULT '{}', -- ['couple', 'family', 'solo'] 等
|
||||
is_free BOOLEAN DEFAULT FALSE,
|
||||
no_rsvp BOOLEAN DEFAULT FALSE,
|
||||
is_outdoor BOOLEAN DEFAULT FALSE,
|
||||
|
||||
-- メタデータ
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
scraped_at TIMESTAMPTZ DEFAULT NOW() -- n8n が最後に取得した日時
|
||||
);
|
||||
|
||||
-- インデックス: 日付範囲・ステータス検索用
|
||||
CREATE INDEX IF NOT EXISTS idx_events_start_date ON events(start_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_end_date ON events(end_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_source ON events(source);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_interest ON events USING GIN(interest_tags);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_audience ON events USING GIN(audience_tags);
|
||||
|
||||
-- 更新時刻の自動更新
|
||||
CREATE OR REPLACE FUNCTION update_events_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN NEW.updated_at = NOW(); RETURN NEW; END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_events_updated_at ON events;
|
||||
CREATE TRIGGER trg_events_updated_at
|
||||
BEFORE UPDATE ON events
|
||||
FOR EACH ROW EXECUTE FUNCTION update_events_updated_at();
|
||||
|
|
@ -0,0 +1,313 @@
|
|||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// Posimai Feed API - Media Management Endpoints
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// Purpose: Allow users to add/remove custom RSS feeds
|
||||
// Location: Copy this to Synology NAS at /app/server.js (Feed API section)
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
// Assume: authMiddleware, pool (PostgreSQL) are already imported
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// POST /feed/api/media/add
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
/**
|
||||
* Add a new RSS feed source
|
||||
*
|
||||
* Request body:
|
||||
* {
|
||||
* feedUrl: string // RSS feed URL (required)
|
||||
* feedName: string // Display name (optional, auto-detected from RSS)
|
||||
* category: string // User-defined category (optional)
|
||||
* }
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* success: boolean
|
||||
* mediaSource: { id, feedUrl, feedName, ... }
|
||||
* }
|
||||
*/
|
||||
router.post('/media/add', authMiddleware, async (req, res) => {
|
||||
const { feedUrl, feedName, category } = req.body || {};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// 1. Validation
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
if (!feedUrl) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required field: feedUrl'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
new URL(feedUrl);
|
||||
} catch {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid URL format'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 2. Verify RSS feed (fetch and parse)
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
console.log(`[Feed API] Verifying RSS feed: ${feedUrl}`);
|
||||
|
||||
const rssData = await fetchAndParseRSS(feedUrl);
|
||||
|
||||
if (!rssData) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid RSS feed: Unable to parse feed'
|
||||
});
|
||||
}
|
||||
|
||||
const detectedName = feedName || rssData.title || new URL(feedUrl).hostname;
|
||||
const detectedIcon = rssData.icon || null;
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 3. Save to database
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
const result = await pool.query(`
|
||||
INSERT INTO media_sources (
|
||||
user_id,
|
||||
feed_url,
|
||||
feed_name,
|
||||
feed_icon,
|
||||
category,
|
||||
is_active,
|
||||
last_fetched_at,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, true, NOW(), NOW(), NOW()
|
||||
)
|
||||
ON CONFLICT (user_id, feed_url) DO UPDATE SET
|
||||
feed_name = EXCLUDED.feed_name,
|
||||
feed_icon = EXCLUDED.feed_icon,
|
||||
category = EXCLUDED.category,
|
||||
is_active = true,
|
||||
updated_at = NOW()
|
||||
RETURNING *
|
||||
`, [
|
||||
req.userId, // $1: user_id from authMiddleware
|
||||
feedUrl, // $2: feed_url
|
||||
detectedName, // $3: feed_name
|
||||
detectedIcon, // $4: feed_icon
|
||||
category || null // $5: category
|
||||
]);
|
||||
|
||||
const mediaSource = result.rows[0];
|
||||
|
||||
console.log(`[Feed API] Media source added: ${detectedName} (ID=${mediaSource.id})`);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 4. Success response
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
return res.json({
|
||||
success: true,
|
||||
mediaSource: {
|
||||
id: mediaSource.id,
|
||||
feedUrl: mediaSource.feed_url,
|
||||
feedName: mediaSource.feed_name,
|
||||
feedIcon: mediaSource.feed_icon,
|
||||
category: mediaSource.category,
|
||||
isActive: mediaSource.is_active,
|
||||
createdAt: mediaSource.created_at
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Feed API] Failed to add media source:', error);
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'Failed to add RSS feed',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// GET /feed/api/media
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
/**
|
||||
* Get all media sources for authenticated user
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* mediaSources: [
|
||||
* { id, feedUrl, feedName, feedIcon, category, isActive, ... }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
router.get('/media', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
id,
|
||||
feed_url,
|
||||
feed_name,
|
||||
feed_icon,
|
||||
category,
|
||||
is_active,
|
||||
last_fetched_at,
|
||||
fetch_interval_minutes,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM media_sources
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`, [req.userId]);
|
||||
|
||||
return res.json({
|
||||
mediaSources: rows.map(row => ({
|
||||
id: row.id,
|
||||
feedUrl: row.feed_url,
|
||||
feedName: row.feed_name,
|
||||
feedIcon: row.feed_icon,
|
||||
category: row.category,
|
||||
isActive: row.is_active,
|
||||
lastFetchedAt: row.last_fetched_at,
|
||||
fetchIntervalMinutes: row.fetch_interval_minutes,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
}))
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Feed API] Failed to fetch media sources:', error);
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'Failed to fetch media sources',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// DELETE /feed/api/media/:id
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
/**
|
||||
* Remove a media source (soft delete - set is_active=false)
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* success: boolean
|
||||
* }
|
||||
*/
|
||||
router.delete('/media/:id', authMiddleware, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
UPDATE media_sources
|
||||
SET is_active = false, updated_at = NOW()
|
||||
WHERE id = $1 AND user_id = $2
|
||||
RETURNING id
|
||||
`, [id, req.userId]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
error: 'Media source not found'
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[Feed API] Media source deleted: ID=${id}`);
|
||||
|
||||
return res.json({
|
||||
success: true
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Feed API] Failed to delete media source:', error);
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'Failed to delete media source',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// Helper Functions
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
/**
|
||||
* Fetch and parse RSS feed
|
||||
*
|
||||
* @param {string} feedUrl - RSS feed URL
|
||||
* @returns {Promise<{title: string, icon: string}|null>}
|
||||
*/
|
||||
async function fetchAndParseRSS(feedUrl) {
|
||||
try {
|
||||
const response = await fetch(feedUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 Posimai Feed Bot'
|
||||
},
|
||||
timeout: 10000 // 10 second timeout
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`[RSS] HTTP error: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
// Simple RSS/Atom parsing (you may want to use a library like 'rss-parser')
|
||||
// For now, we'll do basic regex matching
|
||||
|
||||
// Extract <title> tag
|
||||
const titleMatch = text.match(/<title>([^<]+)<\/title>/i);
|
||||
const title = titleMatch ? titleMatch[1].trim() : null;
|
||||
|
||||
// Extract <link> tag for icon (not standard, but some feeds have it)
|
||||
const iconMatch = text.match(/<image>[\s\S]*?<url>([^<]+)<\/url>/i) ||
|
||||
text.match(/<logo>([^<]+)<\/logo>/i);
|
||||
const icon = iconMatch ? iconMatch[1].trim() : null;
|
||||
|
||||
// Fallback: Try to get favicon from feed homepage
|
||||
const linkMatch = text.match(/<link>([^<]+)<\/link>/i);
|
||||
if (linkMatch && !icon) {
|
||||
const siteUrl = linkMatch[1].trim();
|
||||
try {
|
||||
const domain = new URL(siteUrl).origin;
|
||||
const faviconUrl = `${domain}/favicon.ico`;
|
||||
// Note: You might want to verify this URL exists
|
||||
return { title, icon: faviconUrl };
|
||||
} catch { }
|
||||
}
|
||||
|
||||
return { title, icon };
|
||||
|
||||
} catch (error) {
|
||||
console.error('[RSS] Failed to fetch/parse RSS:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// Export
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
module.exports = router;
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// DEPLOYMENT CHECKLIST
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
//
|
||||
// 1. [ ] Run database migration: synology-feed-media-add-migration.sql
|
||||
// 2. [ ] Update server.js to include this router:
|
||||
// app.use('/feed/api', require('./routes/feed-media'));
|
||||
// 3. [ ] Install RSS parser library (optional, for better parsing):
|
||||
// npm install rss-parser
|
||||
// 4. [ ] Restart Feed API server: docker restart posimai-feed-api
|
||||
// 5. [ ] Test POST /media/add with sample RSS URL
|
||||
// 6. [ ] Check logs: docker logs -f posimai-feed-api
|
||||
// 7. [ ] Update Feed UI to call these endpoints
|
||||
//
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
-- Posimai Feed - Media Sources Table Migration
|
||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
-- Purpose: Store user-customizable RSS feed sources
|
||||
-- Date: 2026-03-02
|
||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
-- Create media_sources table
|
||||
CREATE TABLE IF NOT EXISTS media_sources (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id TEXT NOT NULL, -- User identifier (from authMiddleware)
|
||||
feed_url TEXT NOT NULL, -- RSS feed URL
|
||||
feed_name TEXT NOT NULL, -- Display name (e.g., "TechCrunch")
|
||||
feed_icon TEXT, -- Favicon URL
|
||||
category TEXT, -- User-defined category (e.g., "Tech", "News")
|
||||
is_active BOOLEAN DEFAULT true, -- Enable/disable feed without deleting
|
||||
last_fetched_at TIMESTAMP, -- Last time articles were fetched
|
||||
fetch_interval_minutes INTEGER DEFAULT 60, -- How often to fetch (default: 1 hour)
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
|
||||
-- Prevent duplicate feeds per user
|
||||
UNIQUE(user_id, feed_url)
|
||||
);
|
||||
|
||||
-- Create index for efficient queries
|
||||
CREATE INDEX IF NOT EXISTS idx_media_sources_user_active
|
||||
ON media_sources(user_id, is_active);
|
||||
|
||||
-- Create index for scheduled fetching
|
||||
CREATE INDEX IF NOT EXISTS idx_media_sources_fetch_schedule
|
||||
ON media_sources(is_active, last_fetched_at)
|
||||
WHERE is_active = true;
|
||||
|
||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
-- Sample Data (Optional - for testing)
|
||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
-- Example: Add default feeds for new users
|
||||
-- INSERT INTO media_sources (user_id, feed_url, feed_name, category) VALUES
|
||||
-- ('pk_maita_demo', 'https://zenn.dev/feed', 'Zenn', 'Tech'),
|
||||
-- ('pk_maita_demo', 'https://qiita.com/popular-items/feed', 'Qiita', 'Tech');
|
||||
|
||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
-- Rollback (if needed)
|
||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
-- DROP TABLE IF EXISTS media_sources;
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
# テンプレート機能分析 — 追加候補の優先度評価
|
||||
|
||||
作成: 2026-03-10
|
||||
作成者: Claude Code(世界的ベテランエンジニア兼 UI/UX デザイナー視点)
|
||||
|
||||
---
|
||||
|
||||
## 判断基準
|
||||
|
||||
| 軸 | 説明 |
|
||||
|----|------|
|
||||
| **汎用性** | 全アプリで使う可能性が高いか |
|
||||
| **複雑度** | テンプレートに入れたとき AI が正しく使えるか |
|
||||
| **UX インパクト** | ユーザー体験への貢献度 |
|
||||
| **実装コスト** | 追加にかかる工数 |
|
||||
|
||||
---
|
||||
|
||||
## 即追加すべき(Phase 1 — 今すぐ)
|
||||
|
||||
### 1. サイドバーフッター(ユーザー表示エリア)
|
||||
- **理由**: ほぼ全アプリで「誰が使っているか」の表示が必要になる
|
||||
- **実装**: シンプルなアバター + 名前 + 設定歯車アイコン
|
||||
- **複雑度**: 低(静的でよい、API 連携は各アプリが実装)
|
||||
|
||||
### 2. 設定パネル(右スライドアウト)
|
||||
- **理由**: API キー、表示設定、テーマ切替など必ず必要になる
|
||||
- **実装**: `<aside class="settings-panel">` + toggle ボタン
|
||||
- **複雑度**: 低〜中(HTML/CSS のみで実現可能)
|
||||
|
||||
### 3. accordion ナビセクション
|
||||
- **理由**: ナビアイテムが増えたとき折りたたみは必須 UX
|
||||
- **実装**: `<details>/<summary>` で実装するか、`data-accordion` パターン
|
||||
- **複雑度**: 低
|
||||
|
||||
### 4. ページ数バッジ(nav-count)
|
||||
- **理由**: Brain で実績あり、一覧系アプリ全般で有用
|
||||
- **実装**: `<span class="nav-count">0</span>` を nav-item に追加するだけ
|
||||
- **複雑度**: 極低
|
||||
|
||||
---
|
||||
|
||||
## 中優先(Phase 2 — 次のイテレーション)
|
||||
|
||||
### 5. Stale-While-Revalidate API クライアント(`js/api/client.js`)
|
||||
- **理由**: API を持つアプリには必須パターン。Brain で実証済み
|
||||
- **実装**: `loadFromStorage()` → render → `fetch()` → 差分更新 → render
|
||||
- **複雑度**: 中(ただし単体ファイルとして分離すれば AI が使いやすい)
|
||||
- **注意**: API なしアプリには不要 → オプション扱いにする
|
||||
|
||||
### 6. コマンドパレット(Cmd+K)
|
||||
- **理由**: パワーユーザー向け。全アプリ必須ではないが差別化になる
|
||||
- **実装**: フローティング `<dialog>` + `keydown` イベント
|
||||
- **複雑度**: 中
|
||||
- **推奨**: デフォルト OFF、コメントアウト済みで含める
|
||||
|
||||
### 7. Pull to Refresh(モバイル)
|
||||
- **理由**: PWA + モバイルでは標準的な UX
|
||||
- **実装**: `touchstart/touchmove/touchend` + CSS アニメーション
|
||||
- **複雑度**: 中
|
||||
- **推奨**: `js/utils/pull-to-refresh.js` として分離
|
||||
|
||||
---
|
||||
|
||||
## 後回しでよい(Phase 3 — 需要が出てから)
|
||||
|
||||
### 8. Magic Link 認証(`?init_key=xxx`)
|
||||
- **理由**: Synology API と組み合わせた Brain 専用の設計
|
||||
- **推奨**: Brain/Feed 系のアプリのみで使う。テンプレートには不向き
|
||||
|
||||
### 9. BroadcastChannel(タブ間同期)
|
||||
- **理由**: 複数タブを同時に使うアプリのみ必要
|
||||
- **推奨**: 必要なアプリで個別実装
|
||||
|
||||
### 10. PWA Share Target
|
||||
- **理由**: URL 共有受信が必要なアプリのみ
|
||||
- **推奨**: `manifest.json` のコメントアウト済みサンプルとして含める
|
||||
|
||||
### 11. クリップボード URL 検知スナックバー
|
||||
- **理由**: Brain の URL 保存フローに特化した機能
|
||||
- **推奨**: Brain 系のみ
|
||||
|
||||
---
|
||||
|
||||
## 不要(テンプレートに入れるべきでない)
|
||||
|
||||
### Drag & Drop URL 保存
|
||||
- 保存先(API)が確定してから実装すべき。テンプレートでは過剰
|
||||
|
||||
---
|
||||
|
||||
## 推奨: テンプレート次バージョンの構成
|
||||
|
||||
```
|
||||
_template/
|
||||
index.html ← Phase 1 追加済み
|
||||
├ header(52px glassmorphism、PC/モバイル自動切替)
|
||||
├ sidebar(accordion nav + count バッジ + フッター)
|
||||
├ settings-panel(右スライドアウト)
|
||||
└ main content(card / list-item / empty-state)
|
||||
js/
|
||||
├ app.js ← エントリポイント(最小限)
|
||||
└ api/
|
||||
└── client.js ← Stale-While-Revalidate(オプション・コメントアウト)
|
||||
manifest.json
|
||||
sw.js
|
||||
package.json
|
||||
README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## アクセントカラー問題への推奨
|
||||
|
||||
**推奨**: CSS 変数で抽象化し、アプリごとに 1 行だけ変更可能にする
|
||||
|
||||
```css
|
||||
/* ── アクセントカラー(各アプリでここだけ変える) ── */
|
||||
--accent: #6EE7B7; /* Posimai Teal(デフォルト) */
|
||||
/* --accent: #818CF8; */ /* Indigo(旧 Brain/Feed 系) */
|
||||
/* --accent: #A78BFA; */ /* Purple(選択肢) */
|
||||
```
|
||||
|
||||
- テンプレートのデフォルトは `#6EE7B7`(Teal)に統一
|
||||
- 既存の Brain/Feed は当面現状維持(全更新は大工数)
|
||||
- 新規アプリはすべて Teal を使う
|
||||
- **Gemini に「どちらが Posimai ブランドに合うか」を確認するのが理想**
|
||||
|
||||
---
|
||||
|
||||
## まとめ: 今すぐやること
|
||||
|
||||
| 順番 | 作業 | 工数 |
|
||||
|------|------|------|
|
||||
| 1 | サイドバーフッター追加(テンプレート) | 30分 |
|
||||
| 2 | 設定パネル追加(テンプレート) | 1時間 |
|
||||
| 3 | accordion ナビ + count バッジ追加(テンプレート) | 30分 |
|
||||
| 4 | Gemini にレビュー依頼 | 非同期 |
|
||||
| 5 | アクセントカラー決定後、テンプレート更新 | 15分 |
|
||||
Loading…
Reference in New Issue