😺

Zennの記事をexportする

commits10 min read 4

モチベーション

  1. zennとGitHubを連携した
  2. 既存の記事をGitHubに移行したくなった
  3. 手作業の不安定さと手間が嫌になった
  4. コード書きたい

記事をmarkdownとして抽出するスクリプト

  • JavaScript を開発者コンソールに流し込んで取り出す
    • zenn は SPA (React製) のようだし、レンダリングしてもらわなきゃ話にならない
  • 編集ページは要素が複雑なので、公開された記事やプレビューのページを使う
    • エディターのDOM.innerText である程度拾えるが、インデントの扱いが面倒

成果物

// configure
var INDENT = '  ';
var UL_PREFIX = '-';

var title = document.body.querySelector("h1[class^=ArticleHeader_title] > span").innerText;
var emoji = document.body.querySelector("div[class^=ArticleHeader_emoji]").innerText;
var type = document.body.querySelector("a[class^=ArticleHeader_category] > span").innerText.toLowerCase();
var topics = Array.from(document.body.querySelector("div[class^=ArticleSidebar_topic]")
    .querySelectorAll("div[class^=ArticleSidebar_topicName]"))
    .map((e) => e.innerText.toLowerCase())
    .map((s) => `"${s}"`)
    .join(", ");
var published = document.body.querySelector("div[class^=ArticleHeader_draft]") == null ? "true": "false";

// contents
var parse_textnode = (node) => node.nodeValue;
var parse_code_inline = (node) => `\`${node.innerText}\``;
var parse_br = (node) => "\n";
var parse_a = (node) => `[${node.innerText}](${node.href})`;
var parse_img = (node) => {
    let content = "";
    let alt = node.alt;
    let src = node.src;
    let width = node.attributes.width;
    if (width) {
        content += `![${alt}](${src} ${width.value}x)`;
    } else {
        content += `![${alt}](${src})`;
    }

    return content;
};
var parse_em = (node) => `*${node.innerText}*`;
var parse_strong = (node) => `**${node.innerText}**`
var parse_s = (node) => `~~${node.innerText}~~`
var parse_node_for_statement = (node) => {
    if (node.nodeType === Node.TEXT_NODE) {
        return parse_textnode(node);
    }
    if (node.nodeType === Node.ELEMENT_NODE) {
        let content = "";
        let tagName = node.tagName.toLowerCase();
        switch (tagName) {
            // case "br": content += parse_br(node); break; // 大本の改行コードはTextNodeに仕込んである模様
            case "code": content += parse_code_inline(node); break;
            case "a": content += parse_a(node); break;
            case "img": content += parse_img(node); break;
            case "em": content += parse_em(node); break; // キャプションとイタリックで同じ記法
            case "strong": content += parse_strong(node); break;
            case "s": content += parse_s(node); break;
        }

        return content;
    }
    return "";
};

var parse_h1 = (node) => `# ${node.innerText}` + "\n\n";
var parse_h2 = (node) => `## ${node.innerText}` + "\n\n";
var parse_h3 = (node) => `### ${node.innerText}` + "\n\n";
var parse_h4 = (node) => `#### ${node.innerText}` + "\n\n";
var parse_h5 = (node) => `##### ${node.innerText}` + "\n\n";
var parse_h6 = (node) => `###### ${node.innerText}` + "\n\n";
var parse_hr = (node) => "---" + "\n\n";
var parse_p = (node) => {
    let content = "";
    for (let child of node.childNodes) {
        content += parse_node_for_statement(child);
    }
    return `${content}` + "\n\n";
}
var parse_ul = (node, indentLevel = 0) => {
    let isTop = indentLevel === 0;
    let content = "";
    for (let li of node.children) {
        content += INDENT.repeat(indentLevel) + `${UL_PREFIX} ` + parse_li(li, indentLevel);
    }
    return `${content}` + (isTop? "\n": "");
};
var parse_ol = (node, indentLevel = 0) => {
    let isTop = indentLevel === 0;
    let content = "";
    let number = 0;
    for (let li of node.children) {
        number += 1;
        let prefix = `${number}.`;
        content += INDENT.repeat(indentLevel) + `${prefix} ` + parse_li(li, indentLevel);
    }
    return `${content}` + (isTop? "\n": "");
};
var parse_li = (node, indentLevel = 0) => {
    let content = "";
    let hasListOrNested = false || indentLevel !== 0;
    for (let child of node.childNodes) {
        if (child.nodeType === Node.TEXT_NODE) {
            content += parse_textnode(child);
            continue;
        }
        if (child.nodeType === Node.ELEMENT_NODE) {
            let tagName = child.tagName.toLowerCase();
            switch (tagName) {
                case "code": content += parse_code_inline(child); break;
                case "ul": content += parse_ul(child, indentLevel + 1); hasListOrNested = true; break;
                case "ol": content += parse_ol(child, indentLevel + 1); hasListOrNested = true; break;
            }
            continue;
        }
    }
    return content + (!hasListOrNested? "\n": "");
}
var parse_code_block_container = (node) => {
    let content = "```\n" + node.innerText + "```\n";
    return content + "\n";
}
var parse_message = (node) => {
    let content = "";
    let isAlert = node.classList.contains("alert");
    content += ":::message" + (isAlert? " alert": "") + "\n";
    for (let child of node.firstElementChild.childNodes) {
        content += parse_node_for_statement(child);
    }
    content += "\n:::\n";
    return content + "\n"
}
var parse_details = (node) => {
    let content = "";
    let title = node.getElementsByTagName("SUMMARY")[0].innerText;
    content += `:::details ${title}\n`;
    for (let child of node.querySelector(".details-content > p").childNodes) {
        content += parse_node_for_statement(child);
    }
    content += "\n:::\n";
    return content + "\n";
};
var parse_blockquote = (node) => {
    let content = "";
    for (let child of node.firstElementChild.childNodes) {
        content += parse_node_for_statement(child);
    }
    // '>' を差し込む
    content = content.split("\n").map(s => `> ${s}`).join("\n");
    return content + "\n\n";
};
var parse_table = (node) => {
    let content = "";
    content += parse_thead(node.tHead);
    content += parse_tbody(node.tBodies[0]);
    return content + "\n";
};
var parse_thead = (node) => {
    let content = "";

    let row = node.rows[0];
    let cell_count = row.childElementCount;
    if (row == null || cell_count == null) {
        return "\n";
    }
    content += "|";
    for (let th of row.children) {
        content += " ";
        for (let th_node of th.childNodes) {
            content += parse_node_for_statement(th_node);
        }
        content += " |";
    }
    content += "\n";
    content += "|" + "--|".repeat(cell_count) + "\n";

    return content;
}
var parse_tbody = (node) => {
    let content = "";
    for (let row of node.rows) {
        content += "|";
        for (let td of row.children) {
            content += " ";
            for (let td_node of td.childNodes) {
                content += parse_node_for_statement(td_node);
            }
            content += " |";
        }
        content += "\n";
    }
    return content;
};

var content = '';
var topNodeList = document.body.querySelectorAll("#toc-target-content > div > *");
var iter = topNodeList.values();
var result = iter.next();
while(!result.done) {
    let node = result.value;
    let tagName = node.tagName.toLowerCase();
    switch (tagName) {
        case "h1": content += parse_h1(node); break;
        case "h2": content += parse_h2(node); break;
        case "h3": content += parse_h3(node); break;
        case "h4": content += parse_h4(node); break;
        case "h5": content += parse_h5(node); break;
        case "h6": content += parse_h6(node); break;
        case "hr": content += parse_hr(node); break;
        case "p": content += parse_p(node); break;
        case "ul": content += parse_ul(node); break;
        case "ol": content += parse_ol(node); break;
        case "details": content += parse_details(node); break;
        case "blockquote": content += parse_blockquote(node); break;
        case "table": content += parse_table(node); break;
        case "div": {
            let classList = node.classList;
            if (classList.contains("code-block-container")) {
                content += parse_code_block_container(node);
            } else if (classList.contains("msg")) {
                content += parse_message(node);
            } else if (classList.contains("embed-zenn-link")) {
                // 特殊対応: リンクカードの次の要素にはaタグがあり、もととなったURLが存在する
                result = iter.next();
                content += `${result.value.href}\n\n`;
            }
            break;
        }
    }
    result = iter.next();
}


var markdown = `
---
title: "${title}"
emoji: "${emoji}"
type: "${type}" # tech: 技術記事 / idea: アイデア
topics: [${topics}]
published: ${published}
---
${content}
`;

markdown

title

  • title要素って、結構余計なものが付きがち
    • プラットフォームの名称とか
    • 通知がある時に [!] が先頭に付いたりとか
  • 記事のtitleとブラウザ(タブ)のtitleとで、求められる仕事が違う

emoji

  • 拾う

type

  • 拾って小文字にする

topics

  • 拾って小文字にする

published

  • 下書き要素の有無で自明

content

対応状況

  • h1 - h6
  • p
  • ul
  • ol
  • code block
  • code inline
  • a (text)
  • a (image)
  • img
  • img (width付き)
  • caption
  • table
  • KaTex
  • 引用
  • 注釈
  • hr
  • イタリック
  • 太字
  • 打ち消し線
  • コメント (対応不能)
  • Zenn記法: メッセージ
  • Zenn記法: 警告メッセージ
  • Zenn記法: アコーディオン
  • コンテンツ埋込: リンク
  • コンテンツ埋込: Twitter
  • コンテンツ埋込: YouTube
  • コンテンツ埋込: GitHub Gist
  • コンテンツ埋込: CodePen
  • コンテンツ埋込: SlideShare
  • コンテンツ埋込: SpeakerDeck
  • コンテンツ埋込: JSFiddle
  • コンテンツ埋込: CodeSandbox
  • コンテンツ埋込: StackBlitz

感想

  • ul, ol の入れ子構造が本当に面倒くさい
  • 注釈は記事全体の通し番号が必要なので厄介
  • tableは構造が複雑なこともあり、ざっくりな実装
    • 要素なしとか考えたくない
    • セルの過不足考えたくない
GitHubで編集を提案

Discussion

書き進めるにつれ、トップレベルの要素間に空白を設けるのは各parse_*の役目ではない気がしている。

Zennの開発者です。

このあたり不便ですみません。現在、zenn.devから連携したGitHubリポジトリへコミットする機能の実装を検討しています。

ちなみにユーザーご自身の記事であれば、以下のURLを打ち込むことでマークダウンを取得できます。

https://zenn.dev/api/articles/記事のslug/markdown

YajamonさんがZennにログインしているブラウザでhttps://zenn.dev/api/articles/ebd678f0dd57936e7673/markdownと打ち込めば、この記事のマークダウンが取得できるかと思います。

メタデータも取得したいのですが、それは手動で入力するしかないのでしょうか?

近日中に対応する予定なのでもうしばらくお待ちください。

ログインするとコメントできます