import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { hasAuth } from "../utils/auth.js";
import fs from "fs";
import path from "path";
import os from "os";
import { chromium, Browser, Locator, Page } from "playwright";
import {
parseMarkdown,
formatToNoteEditor,
extractTitle,
removeTitle,
removeFrontmatter,
MarkdownElement,
} from "../utils/note-editor-formatter.js";
/**
* 現在のカーソル位置に画像を挿入
*/
async function insertImageAtCurrentPosition(
page: Page,
bodyBox: any,
imagePath: string
): Promise<void> {
// 新しいパラグラフを作成
await page.keyboard.press("Enter");
await page.keyboard.press("Enter");
await page.waitForTimeout(500);
// 本文エリアの位置を再取得
const bodyBoxHandle = await bodyBox.boundingBox();
// 「+」ボタンを探す(本文エリアの左側)
const allBtns = await page.$$("button");
let plusBtnFound = false;
for (const btn of allBtns) {
const box = await btn.boundingBox();
if (!box) continue;
// 条件: 本文エリアの左側(x - 100 ~ x)、本文エリア内(y ~ y + 200)、幅60以下
if (
bodyBoxHandle &&
box.x > bodyBoxHandle.x - 100 &&
box.x < bodyBoxHandle.x &&
box.y > bodyBoxHandle.y &&
box.y < bodyBoxHandle.y + bodyBoxHandle.height &&
box.width < 60
) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.waitForTimeout(300);
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
plusBtnFound = true;
await page.waitForTimeout(1500);
break;
}
}
// フォールバック: 本文エリアの左側を直接クリック
if (!plusBtnFound && bodyBoxHandle) {
const plusX = bodyBoxHandle.x - 30;
const plusY = bodyBoxHandle.y + 50;
await page.mouse.click(plusX, plusY);
await page.waitForTimeout(1500);
plusBtnFound = true;
}
if (!plusBtnFound) {
throw new Error("「+」ボタンが見つかりません");
}
// 「画像」メニュー項目をクリック
const imageMenuItem = page.locator('[role="menuitem"]:has-text("画像")').first();
const [chooser] = await Promise.all([
page.waitForEvent("filechooser", { timeout: 10000 }),
imageMenuItem.click(),
]);
// ファイルを設定
await chooser.setFiles(imagePath);
await page.waitForTimeout(3000);
// トリミングダイアログがあれば保存
const dialog = page.locator('div[role="dialog"]');
try {
await dialog.waitFor({ state: "visible", timeout: 5000 });
const saveBtn = dialog.locator('button:has-text("保存")').first();
await saveBtn.waitFor({ state: "visible", timeout: 5000 });
await saveBtn.click();
await dialog.waitFor({ state: "hidden", timeout: 10000 }).catch(() => {});
await page.waitForTimeout(3000);
} catch (e) {}
}
async function setEyecatchImage(page: Page, imagePath: string): Promise<void> {
const selectors = [
'button[aria-label="画像を追加"]',
'button:has-text("画像を追加")',
'button[aria-label*="画像をアップロード"]',
'button:has-text("画像をアップロード")',
'button[aria-label*="アイキャッチ"]',
'button[aria-label*="サムネ"]',
'button[aria-label*="カバー"]',
'[role="button"][aria-label*="画像"]',
'[role="button"][aria-label*="アイキャッチ"]',
'[role="button"][aria-label*="サムネ"]',
'[role="button"][aria-label*="カバー"]',
];
const uploadMenuSelector =
'[role="menuitem"]:has-text("画像をアップロード"), [role="option"]:has-text("画像をアップロード"), button:has-text("画像をアップロード"), div:has-text("画像をアップロード"):not(:has(*:has-text("画像をアップロード")))';
const fallbackMenuSelector =
'[role="menuitem"]:has-text("画像"), [role="option"]:has-text("画像"), button:has-text("画像"), div:has-text("画像"):not(:has(*:has-text("画像")))';
const openMenuAndGetFileChooser = async (): Promise<any | null> => {
const uploadMenuItem = page.locator(uploadMenuSelector).first();
const fallbackMenuItem = page.locator(fallbackMenuSelector).first();
let menuItem = uploadMenuItem;
try {
await menuItem.waitFor({ state: "visible", timeout: 5000 });
} catch {
menuItem = fallbackMenuItem;
try {
await menuItem.waitFor({ state: "visible", timeout: 5000 });
} catch {
await page.keyboard.press("Escape").catch(() => {});
return null;
}
}
try {
const [fc] = await Promise.all([
page.waitForEvent("filechooser", { timeout: 10000 }),
menuItem.click(),
]);
return fc;
} catch {
return null;
}
};
let chooser: any = null;
for (const selector of selectors) {
const btn = page.locator(selector).first();
try {
await btn.waitFor({ state: "visible", timeout: 3000 });
} catch (e) {
continue;
}
await btn.click().catch(() => {});
await page.waitForTimeout(500);
chooser = await openMenuAndGetFileChooser();
if (chooser) {
break;
}
}
if (!chooser) {
const bodyBox = page.locator('div[contenteditable="true"][role="textbox"]').first();
const bodyBoxHandle = await bodyBox.boundingBox();
if (bodyBoxHandle) {
const candidates = await page.$$('button, [role="button"]');
for (const el of candidates) {
const box = await el.boundingBox();
if (!box) continue;
if (box.y >= bodyBoxHandle.y) continue;
if (box.y < Math.max(bodyBoxHandle.y - 500, 0)) continue;
if (box.x < bodyBoxHandle.x || box.x > bodyBoxHandle.x + bodyBoxHandle.width) continue;
if (box.width > 160 || box.height > 160) continue;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.waitForTimeout(200);
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
await page.waitForTimeout(500);
chooser = await openMenuAndGetFileChooser();
if (chooser) {
break;
}
await page.keyboard.press("Escape").catch(() => {});
}
if (!chooser) {
const x = bodyBoxHandle.x + bodyBoxHandle.width / 2;
const y = Math.max(bodyBoxHandle.y - 120, 20);
await page.mouse.click(x, y).catch(() => {});
await page.waitForTimeout(500);
chooser = await openMenuAndGetFileChooser();
}
}
}
if (!chooser) {
throw new Error("アイキャッチ画像の追加ボタンが見つかりません");
}
await chooser.setFiles(imagePath);
await page.waitForTimeout(3000);
const dialog = page.locator('div[role="dialog"]');
try {
await dialog.waitFor({ state: "visible", timeout: 5000 });
const saveBtn = dialog.locator('button:has-text("保存")').first();
await saveBtn.waitFor({ state: "visible", timeout: 5000 });
await saveBtn.click();
await dialog.waitFor({ state: "hidden", timeout: 10000 }).catch(() => {});
await page.waitForTimeout(3000);
} catch (e) {
// トリミングダイアログなし
}
}
async function waitForFirstVisibleLocator(
page: Page,
selectors: string[],
timeoutMs: number
): Promise<Locator> {
const perSelectorTimeout = Math.max(Math.floor(timeoutMs / selectors.length), 3000);
let lastError: Error | undefined;
for (const selector of selectors) {
const locator = page.locator(selector).first();
try {
await locator.waitFor({ state: "visible", timeout: perSelectorTimeout });
return locator;
} catch (error) {
lastError = error as Error;
}
}
throw new Error(
`タイトル入力欄が見つかりませんでした: ${selectors.join(", ")}\n${lastError?.message || ""}`
);
}
async function fillNoteTitle(page: Page, title: string): Promise<void> {
const titleSelectors = [
'textarea[placeholder*="タイトル"]',
'input[placeholder*="タイトル"]',
'textarea[aria-label*="タイトル"]',
'input[aria-label*="タイトル"]',
'[data-testid*="title"] textarea',
'[data-testid*="title"] input',
'[contenteditable="true"][data-placeholder*="タイトル"]',
'h1[contenteditable="true"]',
"textarea",
'input[type="text"]',
];
const titleArea = await waitForFirstVisibleLocator(page, titleSelectors, 30000);
await titleArea.click();
try {
await titleArea.fill(title);
} catch {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+A`);
await page.keyboard.press("Backspace");
await page.keyboard.type(title);
}
}
/**
* Playwrightでnoteエディタに記事を作成
*/
async function createNoteWithPlaywright(
title: string,
markdown: string,
imageBasePath: string,
options: {
headless?: boolean;
saveAsDraft?: boolean;
} = {}
): Promise<{ success: boolean; noteUrl?: string; error?: string }> {
const { headless = true, saveAsDraft = true } = options;
const NOTE_EMAIL = process.env.NOTE_EMAIL;
const NOTE_PASSWORD = process.env.NOTE_PASSWORD;
if (!NOTE_EMAIL || !NOTE_PASSWORD) {
return { success: false, error: "NOTE_EMAILとNOTE_PASSWORDが設定されていません" };
}
let browser: Browser | null = null;
try {
browser = await chromium.launch({
headless,
slowMo: 100,
});
const context = await browser.newContext({
viewport: { width: 1280, height: 900 },
locale: "ja-JP",
});
const page = await context.newPage();
page.setDefaultTimeout(60000);
// ログイン
await page.goto("https://note.com/login", { waitUntil: "networkidle" });
await page.waitForTimeout(2000);
const inputs = await page.$$('input:not([type="hidden"])');
if (inputs.length >= 2) {
await inputs[0].fill(NOTE_EMAIL);
await inputs[1].fill(NOTE_PASSWORD);
}
await page.click('button:has-text("ログイン")');
await page.waitForURL((url) => !url.href.includes("/login"), { timeout: 30000 });
// 新規記事作成
await page.goto("https://editor.note.com/new", { waitUntil: "domcontentloaded" });
await page.waitForTimeout(3000);
// タイトル入力
await fillNoteTitle(page, title);
// Markdownを解析
const elements = parseMarkdown(markdown);
let bodyElements = elements;
let eyecatchImagePath: string | null = null;
let eyecatchCaption: string | null = null;
const eyecatchIndex = elements.findIndex(
(element) => element.type === "image" && Boolean(element.imagePath)
);
if (eyecatchIndex !== -1) {
const eyecatchElement = elements[eyecatchIndex];
const imagePath = eyecatchElement.imagePath!;
eyecatchImagePath = imagePath.startsWith("/")
? imagePath
: path.join(imageBasePath, imagePath);
eyecatchCaption = eyecatchElement.caption || null;
bodyElements = [...elements.slice(0, eyecatchIndex), ...elements.slice(eyecatchIndex + 1)];
}
if (eyecatchImagePath) {
await setEyecatchImage(page, eyecatchImagePath);
// アイキャッチ画像にキャプションがあれば本文の先頭に追加
if (eyecatchCaption) {
await page.waitForTimeout(500);
const bodyBox = page.locator('div[contenteditable="true"][role="textbox"]').first();
await bodyBox.click();
await page.keyboard.type(eyecatchCaption);
await page.keyboard.press("Enter");
await page.keyboard.press("Enter");
}
}
// エディタに書式付きで入力
await formatToNoteEditor(page, bodyElements, imageBasePath, insertImageAtCurrentPosition);
// 下書き保存
if (saveAsDraft) {
const saveBtn = page.locator('button:has-text("下書き保存")').first();
await saveBtn.waitFor({ state: "visible" });
if (await saveBtn.isEnabled()) {
await saveBtn.click();
await page.waitForTimeout(3000);
}
}
const noteUrl = page.url();
return { success: true, noteUrl };
} catch (error: any) {
return { success: false, error: error.message };
} finally {
if (browser) {
await browser.close();
}
}
}
/**
* 公開ツールを登録する
*/
export function registerPublishTools(server: McpServer): void {
/**
* Obsidian記事をnoteに公開(書式付き + 画像自動挿入)
*/
server.tool(
"publish-from-obsidian",
"Obsidian記事をnoteに公開(エディタUI操作で書式を適用、画像を自動挿入)",
{
markdownPath: z.string().describe("Markdownファイルのパス"),
imageBasePath: z
.string()
.optional()
.describe("画像ファイルの基準パス(デフォルト: Markdownファイルと同じディレクトリ)"),
tags: z.array(z.string()).optional().describe("タグ(最大10個)"),
headless: z
.boolean()
.optional()
.default(false)
.describe("ヘッドレスモードで実行(デフォルト: false - ブラウザ表示)"),
saveAsDraft: z
.boolean()
.optional()
.default(true)
.describe("下書きとして保存(デフォルト: true)"),
},
async ({ markdownPath, imageBasePath, tags, headless, saveAsDraft }) => {
// 認証チェック
if (!hasAuth()) {
return {
content: [
{
type: "text",
text: JSON.stringify(
{
error: "認証が必要です",
message: "NOTE_EMAILとNOTE_PASSWORDを.envファイルに設定してください",
},
null,
2
),
},
],
};
}
try {
// ファイル存在確認
if (!fs.existsSync(markdownPath)) {
throw new Error(`ファイルが見つかりません: ${markdownPath}`);
}
const markdown = fs.readFileSync(markdownPath, "utf-8");
const basePath = imageBasePath || path.dirname(markdownPath);
// タイトルを抽出
const title = extractTitle(markdown);
// 本文を準備(タイトルとFrontmatterを除去)
let body = removeTitle(markdown);
body = removeFrontmatter(body).trim();
// Markdownを解析して画像を確認
const elements = parseMarkdown(body);
const imageElements = elements.filter((e) => e.type === "image");
// 画像の存在確認
const imageInfo = imageElements.map((img) => {
const fullPath = img.imagePath?.startsWith("/")
? img.imagePath
: path.join(basePath, img.imagePath || "");
return {
fileName: img.imagePath,
localPath: fullPath,
exists: fs.existsSync(fullPath),
};
});
const missingImages = imageInfo.filter((i) => !i.exists);
if (missingImages.length > 0) {
return {
content: [
{
type: "text",
text: JSON.stringify(
{
error: "画像ファイルが見つかりません",
missingImages: missingImages.map((i) => i.fileName),
hint: "imageBasePathを確認してください",
},
null,
2
),
},
],
};
}
// Playwrightで記事を作成
const result = await createNoteWithPlaywright(title, body, basePath, {
headless,
saveAsDraft,
});
if (!result.success) {
throw new Error(result.error);
}
return {
content: [
{
type: "text",
text: JSON.stringify(
{
success: true,
message: saveAsDraft ? "下書きを作成しました" : "記事を作成しました",
title,
noteUrl: result.noteUrl,
imageCount: imageElements.length,
images: imageInfo.map((i) => i.fileName),
tags: tags || [],
note: "エディタのUI操作で書式(見出し、リスト、引用など)を適用しました",
},
null,
2
),
},
],
};
} catch (error: any) {
return {
content: [
{
type: "text",
text: JSON.stringify(
{
error: "公開に失敗しました",
message: error.message,
},
null,
2
),
},
],
};
}
}
);
/**
* 本文に画像を挿入(既存の下書きに対して)
*/
server.tool(
"insert-images-to-note",
"noteエディタで本文に画像を挿入(Playwright使用)",
{
imagePaths: z.array(z.string()).describe("挿入する画像ファイルのパスの配列"),
noteId: z
.string()
.optional()
.describe("既存下書きのnoteIdまたはnoteKey(例: 12345 / n4f0c7b884789)"),
editUrl: z
.string()
.optional()
.describe("既存下書きの編集URL(例: https://editor.note.com/notes/nxxxx/edit/)"),
headless: z
.boolean()
.optional()
.default(false)
.describe("ヘッドレスモードで実行(デフォルト: false)"),
},
async ({ imagePaths, noteId, editUrl, headless }) => {
// 認証チェック
if (!hasAuth()) {
return {
content: [
{
type: "text",
text: JSON.stringify(
{
error: "認証が必要です",
message: "NOTE_EMAILとNOTE_PASSWORDを.envファイルに設定してください",
},
null,
2
),
},
],
};
}
// 画像ファイルの存在確認
const missingImages = imagePaths.filter((p) => !fs.existsSync(p));
if (missingImages.length > 0) {
return {
content: [
{
type: "text",
text: JSON.stringify(
{
error: "画像ファイルが見つかりません",
missingImages,
},
null,
2
),
},
],
};
}
try {
const NOTE_EMAIL = process.env.NOTE_EMAIL;
const NOTE_PASSWORD = process.env.NOTE_PASSWORD;
const browser = await chromium.launch({
headless,
slowMo: 100,
});
const context = await browser.newContext({
viewport: { width: 1280, height: 900 },
locale: "ja-JP",
});
const page = await context.newPage();
page.setDefaultTimeout(60000);
// ログイン
await page.goto("https://note.com/login", { waitUntil: "networkidle" });
await page.waitForTimeout(2000);
const inputs = await page.$$('input:not([type="hidden"])');
if (inputs.length >= 2) {
await inputs[0].fill(NOTE_EMAIL!);
await inputs[1].fill(NOTE_PASSWORD!);
}
await page.click('button:has-text("ログイン")');
await page.waitForURL((url) => !url.href.includes("/login"), { timeout: 30000 });
const normalizedEditUrl = editUrl?.trim();
const normalizedNoteId = noteId?.trim();
let targetUrl = "https://editor.note.com/new";
if (normalizedEditUrl) {
targetUrl = normalizedEditUrl;
} else if (normalizedNoteId) {
const noteKey = normalizedNoteId.startsWith("n")
? normalizedNoteId
: `n${normalizedNoteId}`;
targetUrl = `https://editor.note.com/notes/${noteKey}/edit/`;
}
await page.goto(targetUrl, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(3000);
if (!normalizedEditUrl && !normalizedNoteId) {
await fillNoteTitle(page, "画像テスト");
}
// 本文エリア
const bodyBox = page.locator('div[contenteditable="true"][role="textbox"]').first();
await bodyBox.waitFor({ state: "visible" });
await bodyBox.click();
const keyCombos =
process.platform === "darwin" ? ["Meta+ArrowDown", "End"] : ["Control+End", "End"];
for (const combo of keyCombos) {
try {
await page.keyboard.press(combo);
break;
} catch {}
}
await page.waitForTimeout(300);
// 各画像を挿入
const insertedImages: string[] = [];
for (const imagePath of imagePaths) {
try {
await insertImageAtCurrentPosition(page, bodyBox, imagePath);
insertedImages.push(path.basename(imagePath));
} catch (e: any) {
console.error(`画像挿入エラー: ${imagePath}`, e.message);
}
}
// 下書き保存
const saveBtn = page.locator('button:has-text("下書き保存")').first();
await saveBtn.waitFor({ state: "visible" });
if (await saveBtn.isEnabled()) {
await saveBtn.click();
await page.waitForTimeout(3000);
}
const noteUrl = page.url();
await browser.close();
return {
content: [
{
type: "text",
text: JSON.stringify(
{
success: true,
message: "画像を挿入しました",
noteUrl,
insertedImages,
totalImages: imagePaths.length,
successCount: insertedImages.length,
},
null,
2
),
},
],
};
} catch (error: any) {
return {
content: [
{
type: "text",
text: JSON.stringify(
{
error: "画像挿入に失敗しました",
message: error.message,
},
null,
2
),
},
],
};
}
}
);
/**
* Obsidian記事をnoteに公開(リモートモード - 画像をBase64で受信)
* Obsidianプラグインからの呼び出し用
*/
server.tool(
"publish-from-obsidian-remote",
"Obsidian記事をnoteに公開(画像データをBase64で受信、リモートサーバー用)",
{
title: z.string().describe("記事タイトル"),
markdown: z.string().describe("Markdown本文(タイトルなし)"),
images: z
.array(
z.object({
fileName: z.string().describe("ファイル名(例: image.png)"),
base64: z.string().describe("Base64エンコードされた画像データ"),
mimeType: z.string().optional().describe("MIMEタイプ(例: image/png)"),
})
)
.optional()
.describe("Base64エンコードされた画像の配列"),
tags: z.array(z.string()).optional().describe("タグ(最大10個)"),
headless: z
.boolean()
.optional()
.default(true)
.describe("ヘッドレスモードで実行(デフォルト: true)"),
saveAsDraft: z
.boolean()
.optional()
.default(true)
.describe("下書きとして保存(デフォルト: true)"),
},
async ({ title, markdown, images, tags, headless, saveAsDraft }) => {
// 認証チェック
if (!hasAuth()) {
return {
content: [
{
type: "text",
text: JSON.stringify(
{
error: "認証が必要です",
message: "NOTE_EMAILとNOTE_PASSWORDを.envファイルに設定してください",
},
null,
2
),
},
],
};
}
let tempDir: string | null = null;
try {
// 一時ディレクトリを作成
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "note-images-"));
// Base64画像をデコードして一時ファイルに保存
const imageMap = new Map<string, string>();
const decodedImages: { fileName: string; tempPath: string }[] = [];
if (images && images.length > 0) {
for (const img of images) {
try {
const buffer = Buffer.from(img.base64, "base64");
const tempPath = path.join(tempDir, img.fileName);
fs.writeFileSync(tempPath, buffer);
imageMap.set(img.fileName, tempPath);
decodedImages.push({ fileName: img.fileName, tempPath });
} catch (e: any) {
console.error(`画像デコードエラー: ${img.fileName}`, e.message);
}
}
}
// Markdownを解析して画像パスを一時ファイルパスに置換
let processedMarkdown = markdown;
// Obsidian形式の画像参照を置換: ![[filename.png]]
processedMarkdown = processedMarkdown.replace(
/!\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g,
(match, fileName) => {
const cleanFileName = fileName.trim();
const baseName = path.basename(cleanFileName);
if (imageMap.has(baseName)) {
// 一時ファイルパスを使用
return `})`;
}
return match;
}
);
// 標準Markdown形式の画像参照を置換: 
processedMarkdown = processedMarkdown.replace(
/!\[([^\]]*)\]\(([^)]+)\)/g,
(match, alt, srcPath) => {
if (srcPath.startsWith("http")) return match;
const baseName = path.basename(srcPath);
if (imageMap.has(baseName)) {
return `})`;
}
return match;
}
);
// Playwrightで記事を作成
const result = await createNoteWithPlaywright(
title,
processedMarkdown,
tempDir, // 一時ディレクトリを画像ベースパスとして使用
{ headless, saveAsDraft }
);
if (!result.success) {
throw new Error(result.error);
}
return {
content: [
{
type: "text",
text: JSON.stringify(
{
success: true,
message: saveAsDraft ? "下書きを作成しました" : "記事を作成しました",
title,
noteUrl: result.noteUrl,
imageCount: decodedImages.length,
images: decodedImages.map((i) => i.fileName),
tags: tags || [],
note: "エディタのUI操作で書式(見出し、リスト、引用など)を適用しました",
},
null,
2
),
},
],
};
} catch (error: any) {
return {
content: [
{
type: "text",
text: JSON.stringify(
{
error: "公開に失敗しました",
message: error.message,
},
null,
2
),
},
],
};
} finally {
// 一時ディレクトリをクリーンアップ
if (tempDir && fs.existsSync(tempDir)) {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch (e) {
console.error("一時ディレクトリの削除に失敗:", e);
}
}
}
}
);
}