Skip to content

Commit e5f8bd0

Browse files
authored
Merge pull request #9 from MrSmart00/feature/convex-integration
feat: Convexリアルタイムバックエンド統合の完全実装
2 parents 6832774 + 24942a0 commit e5f8bd0

File tree

14 files changed

+1144
-45
lines changed

14 files changed

+1144
-45
lines changed

.github/workflows/deploy.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ jobs:
3232
run: npm ci
3333

3434
- name: Build
35+
env:
36+
VITE_CONVEX_URL: ${{ secrets.VITE_CONVEX_URL }}
3537
run: npm run build
3638

3739
- name: Setup Pages

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,9 @@ dist/
124124
# Documentation folder
125125
.docs/
126126

127+
# Convex generated files
128+
convex/_generated/
129+
127130
# OS generated files
128131
.DS_Store
129132
.DS_Store?

README.md

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
# Astute Crow
22

3-
TypeScript + Vite を使用したモダンな Web アプリケーションです。現在は Hello World をベースとしつつ、Zenn トレンド表示機能の開発を進めています。
3+
TypeScript + Vite を使用したモダンな Web アプリケーションです。現在は Hello World をベースとしつつ、Zenn トレンド表示機能の開発を進めています。Convex をバックエンドとして導入し、トレンドデータのキャッシュや永続化に対応しました。
44

55
## 🚀 特徴
66

77
-**Vite**: 高速な開発サーバーとビルドツール
88
- 🎯 **TypeScript**: 型安全な JavaScript 開発
99
- 🎨 **モダンCSS**: CSS Variables、Grid、Flexbox を使用したレスポンシブデザイン
1010
- 🌙 **ダークテーマ**: 目に優しいダークモードUI
11-
- 📈 **Zennトレンド表示**: [非公式API](https://github.com/kaisugi/zenn-trend-api)を利用したZennの人気記事情報の取得・表示(開発中)
11+
- 📈 **Convex バックエンド連携**: Convex によるトレンドデータの取得・キャッシュ・永続化
12+
- 🌐 **フォールバックAPI**: Convex が利用できない環境でも Zenn 非公式 API 経由でデータを取得
1213

1314
## 📋 必要な環境
1415

1516
- Node.js 18.0.0 以上
1617
- npm または yarn
18+
- Convex アカウント(バックエンドを稼働させる場合)
1719

1820
## 🛠️ セットアップ
1921

@@ -25,13 +27,28 @@ cd astute-crow
2527
# 依存関係をインストール
2628
npm install
2729

30+
# Convex の型定義を生成(初回のみ)
31+
npm run convex:codegen
32+
33+
# .env.local を作成して Convex の URL を設定
34+
cat <<'EOF' > .env.local
35+
VITE_CONVEX_URL=https://your-project.convex.cloud
36+
EOF
37+
# 既存の .env.local がある場合は上書きしないよう注意し、URL を自身のものに変更してください
38+
2839
# 開発サーバーを起動
2940
npm run dev
3041
```
3142

43+
Convex の開発サーバーをローカルで起動する場合は別ターミナルで以下を実行します。
44+
45+
```bash
46+
npm run convex:dev
47+
```
48+
3249
## 📱 使用方法
3350

34-
開発サーバーを起動すると、自動的にブラウザが開きます。ポートが使用中の場合は、利用可能なポート(通常は3001など)で起動します。
51+
開発サーバーを起動すると、自動的にブラウザが開きます。ポートが使用中の場合は、利用可能なポート(通常は3001など)で起動します。Convex が利用可能であれば Convex 経由で、利用できない場合は自動的に Zenn API にフォールバックします。
3552

3653
## 🔧 利用可能なコマンド
3754

@@ -40,6 +57,8 @@ npm run dev
4057
| `npm run dev` | 開発サーバーを起動 |
4158
| `npm run build` | プロダクション用ビルドを作成 |
4259
| `npm run preview` | ビルド結果をプレビュー |
60+
| `npm run convex:dev` | Convex の開発サーバー(ローカル)を起動 |
61+
| `npm run convex:codegen` | Convex の型定義を生成 |
4362

4463
## 📁 プロジェクト構造
4564

@@ -50,14 +69,22 @@ npm run dev
5069
│ ├── components/
5170
│ │ └── ZennTrends.ts # Zennトレンド表示コンポーネント
5271
│ ├── services/
53-
│ │ └── zennService.ts # Zenn API連携サービス
72+
│ │ ├── convexZennService.ts # Convex 連携とフォールバックロジック
73+
│ │ └── zennService.ts # フォールバックAPI用サービス(開発検証用)
5474
│ └── types/
5575
│ └── zenn.ts # Zenn関連の型定義
56-
├── frontend/ # フロントエンド用ディレクトリ(将来の拡張予定)
76+
├── convex/ # Convex 関連のサーバーコード
77+
│ ├── schema.ts # Convex データモデル
78+
│ ├── trends.ts # トレンド取得アクション/クエリ
79+
│ ├── zennApi.ts # Zenn 非公式API呼び出しアクション
80+
│ └── zennData.ts # DB とのやり取り(クエリ/ミューテーション)
5781
├── backend/ # バックエンド用ディレクトリ(将来の拡張予定)
82+
├── frontend/ # フロントエンド用ディレクトリ(将来の拡張予定)
5883
├── index.html # HTMLテンプレート
5984
├── tsconfig.json # TypeScript設定
6085
├── vite.config.ts # Vite設定
86+
├── convex.json # Convex CLI 設定
87+
├── .env.local # Convex URL などの環境変数(各自作成、Git未追跡)
6188
└── package.json # パッケージ設定
6289
```
6390

@@ -67,6 +94,7 @@ npm run dev
6794
- Hello World アプリケーションのベース
6895
- TypeScript + Vite の開発環境
6996
- ダークテーマ対応のモダンUI
97+
- Convex バックエンドとの統合とフォールバックロジック
7098

7199
### 開発中
72100
- Zenn トレンド表示機能
@@ -82,6 +110,7 @@ npm run dev
82110

83111
- **TypeScript 5.9+**: 型安全性とモダンな開発体験
84112
- **Vite 7.1+**: 高速なバンドラーと開発ツール
113+
- **Convex 1.27+**: サーバーレスなリアルタイムバックエンド
85114
- **ES2020**: モダンな JavaScript 機能
86115
- **CSS3**: フレキシブルレイアウトとアニメーション
87116

@@ -98,4 +127,4 @@ MIT License - 詳細は [LICENSE](LICENSE) ファイルをご確認ください
98127

99128
## 👨‍💻 作者
100129

101-
**Hiroya Hinomori** - [@MrSmart00](https://github.com/MrSmart00)
130+
**Hiroya Hinomori** - [@MrSmart00](https://github.com/MrSmart00)

convex.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"functions": "./convex/"
3+
}

convex/schema.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { defineSchema, defineTable } from "convex/server";
2+
import { v } from "convex/values";
3+
4+
export default defineSchema({
5+
// Zennユーザー情報
6+
zennUsers: defineTable({
7+
username: v.string(),
8+
name: v.string(),
9+
avatarSmallUrl: v.string(),
10+
}).index("by_username", ["username"]),
11+
12+
// Zenn記事・本の投稿データ
13+
zennPosts: defineTable({
14+
externalId: v.string(), // ZennのAPI上のID
15+
title: v.string(),
16+
slug: v.string(),
17+
likedCount: v.number(),
18+
publishedAt: v.string(),
19+
emoji: v.string(),
20+
postType: v.union(v.literal("Article"), v.literal("Book")),
21+
22+
// 記事の場合
23+
articleType: v.optional(v.union(v.literal("tech"), v.literal("idea"))),
24+
25+
// 本の場合
26+
price: v.optional(v.number()),
27+
isFree: v.optional(v.boolean()),
28+
summary: v.optional(v.string()),
29+
30+
// ユーザー情報への参照
31+
userId: v.id("zennUsers"),
32+
33+
// メタデータ
34+
createdAt: v.number(),
35+
updatedAt: v.number(),
36+
})
37+
.index("by_external_id", ["externalId"])
38+
.index("by_post_type", ["postType"])
39+
.index("by_likes", ["likedCount"])
40+
.index("by_published", ["publishedAt"]),
41+
42+
// トレンドデータのキャッシュ情報
43+
trendCache: defineTable({
44+
cacheKey: v.string(), // "trends_all", "trends_tech", "trends_idea", "trends_books"
45+
expiresAt: v.number(),
46+
lastFetched: v.number(),
47+
isValid: v.boolean(),
48+
}).index("by_cache_key", ["cacheKey"]),
49+
});

convex/trends.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { action, query, mutation, type ActionCtx } from "./_generated/server";
2+
import { v } from "convex/values";
3+
4+
const CACHE_DURATION = 5 * 60 * 1000; // 5分
5+
6+
type TrendsArgs = {
7+
forceRefresh?: boolean;
8+
postType?: "Article" | "Book";
9+
articleType?: "tech" | "idea";
10+
};
11+
12+
type TrendsResult = Promise<any[]>;
13+
14+
const getTrendsHandler = async (ctx: ActionCtx, args: TrendsArgs): TrendsResult => {
15+
const cacheKey = `trends_${args.postType || "all"}_${args.articleType || "all"}`;
16+
const now = Date.now();
17+
18+
if (!args.forceRefresh) {
19+
const cacheInfo = await ctx.runQuery("zennData:getCacheInfo" as any, { cacheKey });
20+
if (cacheInfo && cacheInfo.isValid && cacheInfo.expiresAt > now) {
21+
return await ctx.runQuery("zennData:getTrendPosts" as any, {
22+
postType: args.postType,
23+
articleType: args.articleType,
24+
});
25+
}
26+
}
27+
28+
const freshData = await ctx.runAction("zennApi:fetchAllTrends" as any, {});
29+
30+
await ctx.runMutation("trends:syncPostsToDatabase" as any, { posts: freshData });
31+
32+
await ctx.runMutation("zennData:updateCacheInfo" as any, {
33+
cacheKey,
34+
expiresAt: now + CACHE_DURATION,
35+
});
36+
37+
return await ctx.runQuery("zennData:getTrendPosts" as any, {
38+
postType: args.postType,
39+
articleType: args.articleType,
40+
});
41+
};
42+
43+
// メイン関数:トレンドデータを取得または更新
44+
export const getTrends = action({
45+
args: {
46+
forceRefresh: v.optional(v.boolean()),
47+
postType: v.optional(v.union(v.literal("Article"), v.literal("Book"))),
48+
articleType: v.optional(v.union(v.literal("tech"), v.literal("idea"))),
49+
},
50+
handler: getTrendsHandler,
51+
});
52+
53+
// データベースに投稿データを同期
54+
export const syncPostsToDatabase = mutation({
55+
args: {
56+
posts: v.array(v.any()),
57+
},
58+
handler: async (ctx, args) => {
59+
for (const post of args.posts) {
60+
const userId = await ctx.runMutation("zennData:upsertUser" as any, {
61+
username: post.user.username,
62+
name: post.user.name,
63+
avatarSmallUrl: post.user.avatarSmallUrl,
64+
});
65+
66+
await ctx.runMutation("zennData:upsertPost" as any, {
67+
externalId: post.id,
68+
title: post.title,
69+
slug: post.slug,
70+
likedCount: post.likedCount,
71+
publishedAt: post.publishedAt,
72+
emoji: post.emoji,
73+
postType: post.postType,
74+
articleType: post.articleType,
75+
price: post.price,
76+
isFree: post.isFree,
77+
summary: post.summary,
78+
userId,
79+
});
80+
}
81+
},
82+
});
83+
84+
// すべてのトレンドを取得(フロントエンド用)
85+
export const getAllTrends = query({
86+
args: {},
87+
handler: async (ctx) => {
88+
return await ctx.runQuery("zennData:getTrendPosts" as any, {});
89+
},
90+
});
91+
92+
// 技術記事のトレンドを取得
93+
export const getTechTrends = query({
94+
args: {},
95+
handler: async (ctx) => {
96+
return await ctx.runQuery("zennData:getTrendPosts" as any, {
97+
postType: "Article",
98+
articleType: "tech",
99+
});
100+
},
101+
});
102+
103+
// アイデア記事のトレンドを取得
104+
export const getIdeaTrends = query({
105+
args: {},
106+
handler: async (ctx) => {
107+
return await ctx.runQuery("zennData:getTrendPosts" as any, {
108+
postType: "Article",
109+
articleType: "idea",
110+
});
111+
},
112+
});
113+
114+
// 本のトレンドを取得
115+
export const getBookTrends = query({
116+
args: {},
117+
handler: async (ctx) => {
118+
return await ctx.runQuery("zennData:getTrendPosts" as any, {
119+
postType: "Book",
120+
});
121+
},
122+
});
123+
124+
// 手動でデータを更新
125+
export const refreshTrends = action({
126+
args: {},
127+
handler: async (ctx) => getTrendsHandler(ctx, { forceRefresh: true }),
128+
});
129+
130+
// キャッシュをクリア
131+
export const clearCache = mutation({
132+
args: {},
133+
handler: async (ctx) => {
134+
return await ctx.runMutation("zennData:clearAllCache" as any, {});
135+
},
136+
});

convex/zennApi.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { action } from "./_generated/server";
2+
3+
const fetchTechArticlesRaw = async () => {
4+
const response = await fetch("https://zenn-api.vercel.app/api/trendTech");
5+
if (!response.ok) {
6+
throw new Error(`Tech API request failed: ${response.status}`);
7+
}
8+
const data: any[] = await response.json();
9+
return data.map(article => ({
10+
...article,
11+
id: String(article.id ?? article.slug ?? `tech-${Math.random().toString(36).slice(2)}`),
12+
postType: "Article" as const,
13+
articleType: "tech" as const
14+
}));
15+
};
16+
17+
const fetchIdeaArticlesRaw = async () => {
18+
const response = await fetch("https://zenn-api.vercel.app/api/trendIdea");
19+
if (!response.ok) {
20+
throw new Error(`Idea API request failed: ${response.status}`);
21+
}
22+
const data: any[] = await response.json();
23+
return data.map(article => ({
24+
...article,
25+
id: String(article.id ?? article.slug ?? `idea-${Math.random().toString(36).slice(2)}`),
26+
postType: "Article" as const,
27+
articleType: "idea" as const
28+
}));
29+
};
30+
31+
const fetchBooksRaw = async () => {
32+
const response = await fetch("https://zenn-api.vercel.app/api/trendBook");
33+
if (!response.ok) {
34+
throw new Error(`Book API request failed: ${response.status}`);
35+
}
36+
const data: any[] = await response.json();
37+
return data.map(book => ({
38+
...book,
39+
id: String(book.id ?? book.slug ?? `book-${Math.random().toString(36).slice(2)}`),
40+
emoji: book.emoji || "📚",
41+
postType: "Book" as const,
42+
price: typeof book.price === "number" ? book.price : Number(book.price ?? 0),
43+
isFree: book.isFree || book.price === 0,
44+
summary: book.summary || ""
45+
}));
46+
};
47+
48+
// Zenn APIからデータを取得するアクション関数
49+
export const fetchTechArticles = action({
50+
args: {},
51+
handler: fetchTechArticlesRaw,
52+
});
53+
54+
export const fetchIdeaArticles = action({
55+
args: {},
56+
handler: fetchIdeaArticlesRaw,
57+
});
58+
59+
export const fetchBooks = action({
60+
args: {},
61+
handler: fetchBooksRaw,
62+
});
63+
64+
export const fetchAllTrends = action({
65+
args: {},
66+
handler: async () => {
67+
const [techArticles, ideaArticles, books] = await Promise.all([
68+
fetchTechArticlesRaw(),
69+
fetchIdeaArticlesRaw(),
70+
fetchBooksRaw(),
71+
]);
72+
73+
return [...techArticles, ...ideaArticles, ...books];
74+
},
75+
});

0 commit comments

Comments
 (0)