😺
Zennの記事をexportする
モチベーション
- zennとGitHubを連携した
- 既存の記事をGitHubに移行したくなった
- 手作業の不安定さと手間が嫌になった
- コード書きたい
記事を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は構造が複雑なこともあり、ざっくりな実装
- 要素なしとか考えたくない
- セルの過不足考えたくない
Discussion
書き進めるにつれ、トップレベルの要素間に空白を設けるのは各
parse_*
の役目ではない気がしている。Zennの開発者です。
このあたり不便ですみません。現在、zenn.devから連携したGitHubリポジトリへコミットする機能の実装を検討しています。
ちなみにユーザーご自身の記事であれば、以下のURLを打ち込むことでマークダウンを取得できます。
YajamonさんがZennにログインしているブラウザで
https://zenn.dev/api/articles/ebd678f0dd57936e7673/markdown
と打ち込めば、この記事のマークダウンが取得できるかと思います。メタデータも取得したいのですが、それは手動で入力するしかないのでしょうか?
近日中に対応する予定なのでもうしばらくお待ちください。