1058文字
5分
編集

投稿のひな型を生成するスクリプトを新しくした

Geminiを利用して投稿のタグやスラッグを自動生成し、ファイルが作成されたら現在開いているエディタで自動的に開かれるようにした。

#Geminiによるスラッグとタグの生成

Geminiを利用してスラッグとタグを生成する関数を作成した。

Gemini APIではgenerationConfigresponseMineTyperesponseSchemaを指定することで、生成されるデータの形式を指定できる。

js
async function generateMetadataWithGemini(title) {
  const API_URL = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-05-20:generateContent?key=${GEMINI_API_KEY}`;

  const payload = {
    contents: [{
      parts: [{
        text: `Based on the following blog post title, please generate a URL-friendly English slug and an array of relevant tags. The tags should only include the names of any libraries or services that are likely to be mentioned. \ntitle: "${title}"`
      }]
    }],
    // @see https://cloud.google.com/vertex-ai/generative-ai/docs/reference/rest/v1/GenerationConfig
    generationConfig: {
      responseMimeType: "application/json",
      responseSchema: {
        type: "OBJECT",
        properties: {
          slug: {
            type: "STRING",
            description: "URL-friendly slug in English, using hyphens as separators."
          },
          tags: {
            type: "ARRAY",
            items: {
              type: "STRING"
            },
            description: "An array of relevant tags for the blog post, focusing on library and service names."
          }
        },
        required: ["slug", "tags"]
      }
    }
  };

    const response = await fetch(API_URL, {
        method: 'POST',
        headers: {
        'Content-Type': 'application/json',
        },
        body: JSON.stringify(payload),
    });

    if (!response.ok) {
        const errorBody = await response.text();
        throw new Error(`API request failed with status ${response.status}: ${errorBody}`);
    }

    const result = await response.json();

    return JSON.parse(result.candidates[0].content.parts[0].text); 
}

#エディタの特定

ひな型が作成されたら自動的にファイルが開かれてほしいが、エディタとしてVSCodeとCursorを気分で使い分けているためエディタを特定する必要がある。

エディタ毎に環境変数を独自に読み込ませているだろうと推測し、それぞれのエディタで環境変数の一覧を取得した。

shell
$ printenv
TERM_PROGRAM=vscode
CURSOR_TRACE_ID=91348...47782

CursorはVSCodeをベースにしているため、どちらもTERM_PROGRAMvscodeになっている。 一方、CURSOR_TRACE_IDはCursor固有の環境変数である。

これを利用して、エディタを特定してコマンドを実行する。

js
import { exec } from "node:child_process";

function detectEditor() {
  if (process.env.TERM_PROGRAM === "vscode") {
    if (process.env.CURSOR_TRACE_ID ) {
      return "cursor";
    }
    return "code";
  }
  return null
}

// ...ファイルを生成する処理
const editor = detectEditor();
if (editor) {
    exec(`${editor} "${fullPath}"`);
}

#Node.jsの標準ライブラリでユーザーの入力を受け付ける

コマンドの引数を覚えるのは面倒なので、実行してから入力したい。

Node.jsのreadlineモジュールを利用することで、ユーザーの入力を簡単に受け付けることができる。

js
import readline from "node:readline";
import { exec } from "node:child_process";

function askQuestion(rl, question) {
  return new Promise((resolve) => {
    rl.question(question, (answer) => {
      resolve(answer.trim());
    });
  });
}

const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout
});

console.log("Create a new post.");
const title = await askQuestion(rl, "Enter the title of the post: ");
if (!title) {
    console.error("Error: title is required");
    process.exit(1);
}

#大まかな全体の実装

js
import fs from "node:fs";
import path from "node:path";
import readline from "node:readline";
import { exec } from "node:child_process";

const GEMINI_API_KEY = process.env.GEMINI_API_KEY;

function getDate() {
  const today = new Date();
  const year = today.getFullYear();
  const month = String(today.getMonth() + 1).padStart(2, "0");
  const day = String(today.getDate()).padStart(2, "0");
  return `${year}-${month}-${day}`;
}

async function generateMetadataWithGemini(title) {
  const API_URL = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-05-20:generateContent?key=${GEMINI_API_KEY}`;

  const payload = {
    contents: [{
      parts: [{
        text: `Based on the following blog post title, please generate a URL-friendly English slug and an array of relevant tags. The tags should only include the names of any libraries or services that are likely to be mentioned. \ntitle: "${title}"`
      }]
    }],
    generationConfig: {
      responseMimeType: "application/json",
      responseSchema: {
        type: "OBJECT",
        properties: {
          slug: {
            type: "STRING",
            description: "URL-friendly slug in English, using hyphens as separators."
          },
          tags: {
            type: "ARRAY",
            items: {
              type: "STRING"
            },
            description: "An array of relevant tags for the blog post, focusing on library and service names."
          }
        },
        required: ["slug", "tags"]
      }
    }
  };

  try {
    const response = await fetch(API_URL, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(payload),
    });

    if (!response.ok) {
      const errorBody = await response.text();
      throw new Error(`API request failed with status ${response.status}: ${errorBody}`);
    }

    const result = await response.json();
    
    if (result.candidates && result.candidates.length > 0 &&
        result.candidates[0].content && result.candidates[0].content.parts &&
        result.candidates[0].content.parts.length > 0) {
      
      const text = result.candidates[0].content.parts[0].text;
      return JSON.parse(text); 
    }
    
    console.warn("Warning: No valid metadata was retrieved from Gemini. Generating a basic slug.");
    const fallbackSlug = title.toLowerCase().replace(/\s+/g, '-');
    return { slug: fallbackSlug, tags: [] };

  } catch (error) {
    console.error("Error calling Gemini API:", error);
    throw error; 
  }
}

function detectEditor() {
  if (process.env.TERM_PROGRAM === "vscode") {
    if (process.env.CURSOR_TRACE_ID ) {
      return "cursor";
    }
    return "code";
  }
  return null
}

function askQuestion(rl, question) {
  return new Promise((resolve) => {
    rl.question(question, (answer) => {
      resolve(answer.trim());
    });
  });
}

async function createNewPost() {
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout
  });

  try {
    console.log("Create a new post.");

    const title = await askQuestion(rl, "Enter the title of the post: ");
    if (!title) {
      console.error("Error: title is required");
      process.exit(1);
    }

    const { slug, tags } = await generateMetadataWithGemini(title);

    const targetDir = "./contents/posts/";
    const postDir = path.join(targetDir, title); 
    const fullPath = path.join(postDir, "index.md");

    if (fs.existsSync(fullPath)) {
      console.error(`Error: file ${fullPath} already exists`);
      process.exit(1);
    }

    // recursive mode creates multi-level directories
    if (!fs.existsSync(postDir)) {
      fs.mkdirSync(postDir, { recursive: true });
    }

    const content = `---
title: ${title}
published: ${getDate()}
slug: ${slug}
draft: true
description: ''
tags:
${tags.map(tag => `  - ${tag}`).join("\n")}
---

`;

    fs.writeFileSync(fullPath, content);

    const editor = detectEditor();
    if (editor) {
      exec(`${editor} "${fullPath}"`);
    }

  } catch (error) {
    console.error(error);
    process.exit(1);
  } finally {
    rl.close();
  }
}

createNewPost();
編集