WEBアプリの勉強の一環&趣味用に「Pixivの外部アプリ」を作ってみました。 好きな作品をタグで整理して保存できる作品管理サイトです。
開発の経緯
Pixiv ヘビーユーザーとして、毎日1時間以上は見てるんですが、不満がひとつありました。
「過去に見た作品を探すのが大変!」
Pixivの公式機能だと「♥(ブックマーク)」くらいしかできなくて、あまり整理できないんですよね。
そこで思ったのが、こんな機能を持ったサイトがほしいということ:
1.好きな作品を保存できる
2.自分でタグをつけられる
3.保存した作品をタグ検索できる
→ 「じゃあ自分で作るか!」となったのが開発のきっかけです。
WEBアプリの紹介
サイトURL 👉 https://mypixiv-front.vercel.app/login
<使い方>
-ユーザー登録-
ユーザー名とパスワードを登録(※セキュリティは勉強中なので、他のサービスと同じパスワードは使わないでください🙏)
-ログイン-
登録したユーザーでログイン。JWT(トークン)を使ってセッションを管理しています。
-作品登録フォーム
Pixiv ID → 作品URLの末尾にある数字
自分用タイトル → 好きなタイトルを入力
作品タイプ → イラスト or 小説(漫画はタグで区別すればOK)
タグ → 自由につけられる。複数OK。あとで検索する基準になります。
※入力補助として、これまで登録された全ユーザーのタグがサジェストで出ます。
-作品検索フォーム-
登録した作品を検索フォームから表示可能。タグは AND検索 です(指定したタグ全部を含む作品を探す)。
さらに「削除ボタン」で作品をDBから消すこともできます。
使った技術・言語
-フロントエンド-
Vue 3 + Vite
JavaScript
-バックエンド-
Node.js + Express
PostgreSQL(Neon のクラウドDBを利用)
JWT(JSON Web Token)でログイン認証
-インフラ-
Vercel(フロントデプロイ)
Render(バックエンドデプロイ)
👉 技術的には、NoSQLしか触ったことがなかったので、今回初めて PostgreSQL を使ってSQLを本格的に触りました。
開発の過程
1.まずはシンプルに
・Vueでフロントのフレームを作成。最初は認証なしで「Pixiv ID + タグを登録して検索」だけ動かしました。
//作品追加
app.post("/api/works", authMiddleware, async (req, res) => {
try {
const { pixivId, title, type, tags } = req.body;
const userId = req.user.userId; // ← JWTから取得
console.log("受け取ったデータ:", req.body);
// works追加
const workResult = await pool.query(
"INSERT INTO works (user_id, pixiv_id, title, type) VALUES ($1, $2, $3, $4) RETURNING id",
[userId, pixivId, title, type]
);
const workId = workResult.rows[0].id;
console.log("作成したworkId:", workId);
if (!tags || tags.length === 0) {
console.log("⚠ タグが空のためスキップ");
} else {
for (const tag of tags) {
console.log("処理中のタグ:", tag);
let tagResult = await pool.query("SELECT id FROM tags WHERE name = $1", [tag]);
let tagId;
if (tagResult.rows.length === 0) {
const insertTag = await pool.query(
"INSERT INTO tags (name) VALUES ($1) RETURNING id",
[tag]
);
tagId = insertTag.rows[0].id;
console.log("新規タグ追加:", tag, "→ id:", tagId);
} else {
tagId = tagResult.rows[0].id;
console.log("既存タグ使用:", tag, "→ id:", tagId);
}
await pool.query(
"INSERT INTO work_tags (work_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
[workId, tagId]
);
console.log("work_tags に追加:", workId, tagId);
}
}
res.json({ success: true });
} catch (err) {
console.error("❌ エラー発生:", err);
res.status(500).json({ success: false, error: err.message });
}
});
// 検索
app.post("/api/search", authMiddleware, async (req, res) => {
try {
const { type, tags } = req.body;
const userId = req.user.userId; // JWTから取得
// パラメータ配列
let params = [type, userId];
// 基本SQL
let query = `
SELECT w.id,
w.pixiv_id,
w.title,
w.type,
COALESCE(json_agg(t.name) FILTER (WHERE t.id IS NOT NULL), '[]') AS tags
FROM works w
LEFT JOIN work_tags wt ON w.id = wt.work_id
LEFT JOIN tags t ON wt.tag_id = t.id
WHERE w.type = $1 AND w.user_id = $2
`;
if (tags && tags.length > 0) {
query += `
GROUP BY w.id
HAVING (
SELECT COUNT(DISTINCT t2.name)
FROM work_tags wt2
JOIN tags t2 ON wt2.tag_id = t2.id
WHERE wt2.work_id = w.id
AND t2.name = ANY($3::text[])
) = array_length($3::text[], 1)
`;
params.push(tags);
} else {
query += ` GROUP BY w.id `;
}
query += ` ORDER BY w.id DESC `;
const result = await pool.query(query, params);
res.json(result.rows);
} catch (err) {
console.error("検索エラー:", err);
res.status(500).json({ success: false, error: err.message });
}
});
2.ユーザー認証を追加
・DBに user_id と password_hash を追加
・ユーザーごとに作品を管理できるようにした
・JWTを使って「リクエストするたびにユーザーを判別」できる仕組みを実装
・Expressのミドルウェアで認証処理を挟むようにして、セキュアに動くように
👉 JWT の仕組みを簡単にいうと、ログイン時にサーバーから署名付きのトークンを発行し、それをクライアントが保存。APIを叩くときに一緒に送って、サーバー側で検証して本人確認する仕組みです。
//認証ミドルウェア
function authMiddleware(req, res, next) {
const authHeader = req.headers["authorization"];
if (!authHeader) return res.status(401).json({ error: "認証が必要です" });
const token = authHeader.split(" ")[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded; // userId を格納
next();
} catch (err) {
return res.status(401).json({ error: "トークン無効" });
}
}
// ================= ユーザー登録 & ログイン =================
app.post("/api/signup", async (req, res) => {
try {
const { username, password } = req.body;
const hash = await bcrypt.hash(password, 10);
const result = await pool.query(
"INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id",
[username, hash]
);
const user = result.rows[0];
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, { expiresIn: "1h" });
res.json({ token });
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
app.post("/api/login", async (req, res) => {
try {
const { username, password } = req.body;
const result = await pool.query("SELECT * FROM users WHERE username = $1", [username]);
if (result.rows.length === 0) return res.status(401).json({ error: "ユーザーが存在しません" });
const user = result.rows[0];
const match = await bcrypt.compare(password, user.password_hash);
if (!match) return res.status(401).json({ error: "パスワードが違います" });
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, { expiresIn: "1h" });
res.json({ token });
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
3.タグサジェスト機能
・タグを入力するたびにDBにクエリを投げて、候補を出すようにした
・使い勝手が一気に良くなった
// タグサジェスト(前方一致)
app.get("/api/tags", async (req, res) => {
try {
const q = req.query.q || "";
const result = await pool.query(
`SELECT name FROM tags WHERE name ILIKE $1 ORDER BY name LIMIT 10`,
[`${q}%`] // ← 前方一致
);
res.json(result.rows.map(r => r.name));
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
4.削除機能の修正
・最初は「作品を削除したら関連するタグが残ってしまう」という外部キー制約エラーが発生
・ON DELETE CASCADE をつけて、作品を削除すると関連する中間テーブル work_tags も一緒に消えるようにした
振り返り
・これまで NoSQL(Firebaseとか) しか触ったことがなかったので、SQLはChatGPTに頼りまくりでした。
→ 今後は本を読んで基礎から学びたい。
・Pixivのイラストを直接表示する機能は、知識不足でまだ実装できてません。
→ APIの使い方をもっと調べたい。
・JWTについても「なんとなく使えてる」状態。仕組みをちゃんと理解したい。