import { Page } from "playwright";
import path from "path";
/**
* Markdownの要素タイプ
*/
export type MarkdownElementType =
| "heading2" // ## 見出し
| "heading3" // ### 小見出し
| "paragraph" // 通常のテキスト
| "bulletList" // - 箇条書き
| "numberedList" // 1. 番号付きリスト
| "quote" // > 引用
| "code" // ```コードブロック```
| "image" //  画像
| "hr"; // --- 区切り線
/**
* パースされたMarkdown要素
*/
export interface MarkdownElement {
type: MarkdownElementType;
content: string;
language?: string; // コードブロックの言語
imagePath?: string; // 画像のパス
caption?: string; // 画像のキャプション
}
/**
* MarkdownをnoteエディタのUI操作用に解析
*/
export function parseMarkdown(markdown: string): MarkdownElement[] {
const elements: MarkdownElement[] = [];
const lines = markdown.split("\n");
let i = 0;
while (i < lines.length) {
const line = lines[i];
// 空行はスキップ
if (line.trim() === "") {
i++;
continue;
}
// ai-summaryタグブロックの処理
// <!-- ai-summary:start id="img1" ... -->
// ![[image.png]]
// *キャプションテキスト*
// <!-- ai-summary:end id="img1" -->
const aiSummaryStartMatch = line.match(/^<!--\s*ai-summary:start\s+id="([^"]+)"/);
if (aiSummaryStartMatch) {
const blockId = aiSummaryStartMatch[1];
i++;
let imagePath: string | null = null;
let caption: string | null = null;
// ai-summary:end まで読み進める
while (i < lines.length && !lines[i].match(/^<!--\s*ai-summary:end/)) {
const currentLine = lines[i].trim();
// 画像行を検出
const obsidianImg = currentLine.match(/^!\[\[([^\]|]+)(?:\|[^\]]+)?\]\]$/);
const mdImg = currentLine.match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
if (obsidianImg) {
imagePath = obsidianImg[1];
} else if (mdImg) {
imagePath = mdImg[2];
}
// キャプション行を検出(*で囲まれたテキスト)
const captionMatch = currentLine.match(/^\*(.+)\*$/);
if (captionMatch) {
caption = captionMatch[1].trim();
}
i++;
}
// ai-summary:end 行をスキップ
if (i < lines.length && lines[i].match(/^<!--\s*ai-summary:end/)) {
i++;
}
// 画像要素を追加
if (imagePath) {
elements.push({
type: "image",
content: imagePath,
imagePath: imagePath,
caption: caption || undefined,
});
}
continue;
}
// コードブロック
if (line.startsWith("```")) {
const language = line.slice(3).trim();
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith("```")) {
codeLines.push(lines[i]);
i++;
}
elements.push({
type: "code",
content: codeLines.join("\n"),
language,
});
i++;
continue;
}
// 見出し
if (line.startsWith("## ")) {
elements.push({ type: "heading2", content: line.slice(3).trim() });
i++;
continue;
}
if (line.startsWith("### ")) {
elements.push({ type: "heading3", content: line.slice(4).trim() });
i++;
continue;
}
// 区切り線
if (line.match(/^---+$/)) {
elements.push({ type: "hr", content: "" });
i++;
continue;
}
// 引用
if (line.startsWith("> ")) {
elements.push({ type: "quote", content: line.slice(2).trim() });
i++;
continue;
}
// 箇条書き
if (line.match(/^[-*] /)) {
const listItems: string[] = [];
while (i < lines.length && lines[i].match(/^[-*] /)) {
listItems.push(lines[i].replace(/^[-*] /, "").trim());
i++;
}
elements.push({ type: "bulletList", content: listItems.join("\n") });
continue;
}
// 番号付きリスト
if (line.match(/^\d+\. /)) {
const listItems: string[] = [];
while (i < lines.length && lines[i].match(/^\d+\. /)) {
listItems.push(lines[i].replace(/^\d+\. /, "").trim());
i++;
}
elements.push({ type: "numberedList", content: listItems.join("\n") });
continue;
}
// 画像 ![[filename]] または 
const obsidianImageMatch = line.match(/^!\[\[([^\]|]+)(?:\|[^\]]+)?\]\]$/);
const mdImageMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
if (obsidianImageMatch) {
const imageElement: MarkdownElement = {
type: "image",
content: obsidianImageMatch[1],
imagePath: obsidianImageMatch[1],
};
elements.push(imageElement);
i++;
// 画像直後の空行を1行だけスキップ
if (i < lines.length && lines[i].trim() === "") {
i++;
}
// 次の行がキャプションかチェック
if (
i < lines.length &&
lines[i].trim() &&
!lines[i].match(/^#{1,6}\s/) &&
!lines[i].match(/^[-*]\s/) &&
!lines[i].match(/^\d+\.\s/) &&
!lines[i].match(/^>\s/) &&
!lines[i].match(/^```/) &&
!lines[i].match(/^---+$/) &&
!lines[i].match(/^!\[/)
) {
// 画像直後のテキスト行をキャプションとして扱う
imageElement.caption = lines[i].trim();
i++;
}
continue;
}
if (mdImageMatch) {
const imageElement: MarkdownElement = {
type: "image",
content: mdImageMatch[1],
imagePath: mdImageMatch[2],
caption:
mdImageMatch[1] && mdImageMatch[1] !== path.basename(mdImageMatch[2])
? mdImageMatch[1]
: undefined,
};
elements.push(imageElement);
i++;
// 画像直後の空行を1行だけスキップ
if (i < lines.length && lines[i].trim() === "") {
i++;
}
// 次の行がキャプションかチェック(altテキストがない場合)
if (
!mdImageMatch[1] &&
i < lines.length &&
lines[i].trim() &&
!lines[i].match(/^#{1,6}\s/) &&
!lines[i].match(/^[-*]\s/) &&
!lines[i].match(/^\d+\.\s/) &&
!lines[i].match(/^>\s/) &&
!lines[i].match(/^```/) &&
!lines[i].match(/^---+$/) &&
!lines[i].match(/^!\[/)
) {
// 画像直後のテキスト行をキャプションとして扱う
imageElement.caption = lines[i].trim();
i++;
}
continue;
}
// 通常のテキスト(段落)
elements.push({ type: "paragraph", content: line.trim() });
i++;
}
return elements;
}
/**
* noteエディタにMarkdown要素を入力
*/
export async function formatToNoteEditor(
page: Page,
elements: MarkdownElement[],
imageBasePath: string,
insertImageFn: (page: Page, bodyBox: any, imagePath: string) => Promise<void>
): Promise<void> {
const bodyBox = page.locator('div[contenteditable="true"][role="textbox"]').first();
await bodyBox.waitFor({ state: "visible" });
await bodyBox.click();
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
switch (element.type) {
case "heading2":
await insertHeading(page, element.content, "h2");
break;
case "heading3":
await insertHeading(page, element.content, "h3");
break;
case "paragraph":
await page.keyboard.type(element.content);
await page.keyboard.press("Enter");
break;
case "bulletList":
await insertBulletList(page, element.content.split("\n"));
break;
case "numberedList":
await insertNumberedList(page, element.content.split("\n"));
break;
case "quote":
await insertQuote(page, element.content);
break;
case "code":
await insertCodeBlock(page, element.content, element.language);
break;
case "image":
if (element.imagePath) {
const fullPath = element.imagePath.startsWith("/")
? element.imagePath
: `${imageBasePath}/${element.imagePath}`;
await insertImageFn(page, bodyBox, fullPath);
// キャプションがあれば挿入
if (element.caption) {
await page.waitForTimeout(1500);
// 「キャプションを入力」テキストをクリック
try {
const captionText = page.locator("text=キャプションを入力").last();
await captionText.waitFor({ state: "visible", timeout: 5000 });
await captionText.click();
await page.waitForTimeout(500);
await page.keyboard.type(element.caption);
await page.keyboard.press("Escape");
await page.waitForTimeout(300);
} catch (e) {
// フォールバック: 本文の次の行に入力
console.log("キャプション入力欄が見つかりません、本文に入力します");
}
}
}
break;
case "hr":
await insertHorizontalRule(page);
break;
}
// 要素間に少し待機
await page.waitForTimeout(200);
}
}
/**
* 見出しを挿入(「+」メニューから)
*/
async function insertHeading(page: Page, text: string, level: "h2" | "h3"): Promise<void> {
// 「+」ボタンをクリック
await clickPlusButton(page);
// メニューから見出しを選択
const menuText = level === "h2" ? "大見出し" : "小見出し";
const menuItem = page.locator(`[role="menuitem"]:has-text("${menuText}")`).first();
try {
await menuItem.waitFor({ state: "visible", timeout: 3000 });
await menuItem.click();
await page.waitForTimeout(500);
} catch (e) {
// メニューが開かない場合はテキストとして入力
await page.keyboard.type(level === "h2" ? `## ${text}` : `### ${text}`);
await page.keyboard.press("Enter");
return;
}
// テキストを入力
await page.keyboard.type(text);
await page.keyboard.press("Enter");
}
/**
* 箇条書きリストを挿入
*/
async function insertBulletList(page: Page, items: string[]): Promise<void> {
// 「+」ボタンをクリック
await clickPlusButton(page);
// メニューから箇条書きを選択
const menuItem = page.locator('[role="menuitem"]:has-text("箇条書きリスト")').first();
try {
await menuItem.waitFor({ state: "visible", timeout: 3000 });
await menuItem.click();
await page.waitForTimeout(500);
} catch (e) {
// フォールバック: テキストとして入力
for (const item of items) {
await page.keyboard.type(`- ${item}`);
await page.keyboard.press("Enter");
}
return;
}
// 各項目を入力
for (let i = 0; i < items.length; i++) {
await page.keyboard.type(items[i]);
if (i < items.length - 1) {
await page.keyboard.press("Enter");
}
}
// リストを終了
await page.keyboard.press("Enter");
await page.keyboard.press("Enter");
}
/**
* 番号付きリストを挿入
*/
async function insertNumberedList(page: Page, items: string[]): Promise<void> {
// 「+」ボタンをクリック
await clickPlusButton(page);
// メニューから番号付きリストを選択
const menuItem = page.locator('[role="menuitem"]:has-text("番号付きリスト")').first();
try {
await menuItem.waitFor({ state: "visible", timeout: 3000 });
await menuItem.click();
await page.waitForTimeout(500);
} catch (e) {
// フォールバック
for (let i = 0; i < items.length; i++) {
await page.keyboard.type(`${i + 1}. ${items[i]}`);
await page.keyboard.press("Enter");
}
return;
}
// 各項目を入力
for (let i = 0; i < items.length; i++) {
await page.keyboard.type(items[i]);
if (i < items.length - 1) {
await page.keyboard.press("Enter");
}
}
// リストを終了
await page.keyboard.press("Enter");
await page.keyboard.press("Enter");
}
/**
* 引用を挿入
*/
async function insertQuote(page: Page, text: string): Promise<void> {
// 「+」ボタンをクリック
await clickPlusButton(page);
// メニューから引用を選択
const menuItem = page.locator('[role="menuitem"]:has-text("引用")').first();
try {
await menuItem.waitFor({ state: "visible", timeout: 3000 });
await menuItem.click();
await page.waitForTimeout(500);
} catch (e) {
// フォールバック
await page.keyboard.type(`> ${text}`);
await page.keyboard.press("Enter");
return;
}
await page.keyboard.type(text);
await page.keyboard.press("Enter");
await page.keyboard.press("Enter");
}
/**
* コードブロックを挿入
*/
async function insertCodeBlock(page: Page, code: string, language?: string): Promise<void> {
// 「+」ボタンをクリック
await clickPlusButton(page);
// メニューからコードを選択
const menuItem = page.locator('[role="menuitem"]:has-text("コード")').first();
try {
await menuItem.waitFor({ state: "visible", timeout: 3000 });
await menuItem.click();
await page.waitForTimeout(500);
} catch (e) {
// フォールバック
await page.keyboard.type("```" + (language || ""));
await page.keyboard.press("Enter");
await page.keyboard.type(code);
await page.keyboard.press("Enter");
await page.keyboard.type("```");
await page.keyboard.press("Enter");
return;
}
await page.keyboard.type(code);
await page.keyboard.press("Enter");
await page.keyboard.press("Enter");
}
/**
* 区切り線を挿入
*/
async function insertHorizontalRule(page: Page): Promise<void> {
// 「+」ボタンをクリック
await clickPlusButton(page);
// メニューから区切り線を選択
const menuItem = page.locator('[role="menuitem"]:has-text("区切り線")').first();
try {
await menuItem.waitFor({ state: "visible", timeout: 3000 });
await menuItem.click();
await page.waitForTimeout(500);
} catch (e) {
// フォールバック
await page.keyboard.type("---");
await page.keyboard.press("Enter");
}
}
/**
* 「+」ボタンをクリック
*/
async function clickPlusButton(page: Page): Promise<void> {
const bodyBox = page.locator('div[contenteditable="true"][role="textbox"]').first();
const bodyBoxHandle = await bodyBox.boundingBox();
if (!bodyBoxHandle) {
throw new Error("本文エリアが見つかりません");
}
// 「+」ボタンを探す
const allBtns = await page.$$("button");
let clicked = false;
for (const btn of allBtns) {
const box = await btn.boundingBox();
if (!box) continue;
if (
box.x > bodyBoxHandle.x - 100 &&
box.x < bodyBoxHandle.x &&
box.y > bodyBoxHandle.y &&
box.y < bodyBoxHandle.y + 300 &&
box.width < 60
) {
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);
clicked = true;
await page.waitForTimeout(1000);
break;
}
}
// フォールバック
if (!clicked) {
const plusX = bodyBoxHandle.x - 30;
const plusY = bodyBoxHandle.y + 50;
await page.mouse.click(plusX, plusY);
await page.waitForTimeout(1000);
}
}
/**
* Markdownからタイトルを抽出
*/
export function extractTitle(markdown: string): string {
const match = markdown.match(/^# (.+)$/m);
return match ? match[1].trim() : "無題";
}
/**
* Markdownからタイトル行を除去
*/
export function removeTitle(markdown: string): string {
return markdown.replace(/^# .+\n?/, "");
}
/**
* Frontmatterを除去
*/
export function removeFrontmatter(markdown: string): string {
return markdown.replace(/^---\n[\s\S]*?\n---\n?/, "");
}