0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Webアプリ勉強中】Pixiv作品管理アプリを作った

Posted at

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についても「なんとなく使えてる」状態。仕組みをちゃんと理解したい。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?