HTMLソースコードの差分を抽出するライブラリはたくさんありますが、ブラウザなどでレンダリングされたテキストの差分を抽出するというのが無かったので作ってみました。
元々VSCodeの拡張機能としてつくっていたものをアプリ化したのでElectronにしてみました。
Microsoftストア公開できました!
GitHubはこちら
環境
windows11
VSCode
node v22.19.0
差分の処理
- old,newのHTMLを読み込む
- テキストノードだけを抽出する
- oldHTMLとnewHTMLのテキストをそれぞれ一つの文字列に連結する
- diff(文字単位の差分)を確認する
- newHTMLをベースにして、テキストノードを差分に基づいて置き換える
- 削除部分は赤+取り消し線、追加部分は緑でハイライトする
- 差分入りのHTMLを返す
注意
- レイアウト構造に大きな差異があるHTMLでは、差分マークが意図した位置に付かない場合があります
- また、レイアウトが崩れる可能性があります
- script/style/noscript配下のテキストは差分対象外です
const fs = require("fs");
const cheerio = require("cheerio");
const { diffChars } = require("diff");
// HTML内の「テキストノード」だけを収集する処。
// script / style / noscript 内のテキストは除外
// HTML構造は無視して、純粋に表示テキストだけを比較するために使う
function collectTextNodes($) {
const nodes = [];
$("*")
.contents()
.each((_, el) => {
if (el.type !== "text") {
return;
}
const parent =
el.parent && el.parent.tagName ? el.parent.tagName.toLowerCase() : "";
if (["script", "style", "noscript"].includes(parent)) {
return;
}
nodes.push(el);
});
return nodes;
}
// 2つのHTMLファイルを比較し、差分をハイライトしたHTMLを生成する
// 処理の流れ
// HTMLを読み込む
// cheerioでパース
// テキストノードを抽出
// 全テキストを連結して一つの文字列にする
// diffCharsで文字単位の差分を計算
// newHTMLをベースにして、テキストノードへ差分を埋め込む
// 削除部分は赤背景+取り消し線、追加部分は緑背景で表示
function buildDiffHtml(oldPath, newPath) {
const oldHtml = fs.readFileSync(oldPath, "utf8");
const newHtml = fs.readFileSync(newPath, "utf8");
const $old = cheerio.load(oldHtml, { decodeEntities: false });
const $new = cheerio.load(newHtml, { decodeEntities: false });
const oldNodes = collectTextNodes($old);
const newNodes = collectTextNodes($new);
const oldFull = oldNodes.map((n) => n.data).join("");
const newFull = newNodes.map((n) => n.data).join("");
// diffCharsで文字単位の差分を取得
// -1: 削除、0: 同じ、1: 追加
const diffs = diffChars(oldFull, newFull)
.map((part) => {
if (part.removed) {
return [-1, part.value];
}
if (part.added) {
return [1, part.value];
}
return [0, part.value];
})
.filter(([, value]) => value.length > 0);
let diffIndex = 0;
let diffPos = 0;
const currentDiff = () => diffs[diffIndex] || null;
const advanceDiff = () => {
diffIndex += 1;
diffPos = 0;
};
for (const node of newNodes) {
const text = node.data || "";
let i = 0;
const frag = [];
while (i < text.length) {
const cd = currentDiff();
if (!cd) {
frag.push(text.slice(i));
i = text.length;
break;
}
const [op, chunkText] = cd;
const remainingInDiff = chunkText.length - diffPos;
if (remainingInDiff <= 0) {
advanceDiff();
continue;
}
if (op === -1) {
const delChunk = chunkText.slice(diffPos, diffPos + remainingInDiff);
frag.push(
`<span style=\"background:#fbb6b6;text-decoration:line-through;\">${delChunk}</span>`,
);
diffPos += remainingInDiff;
advanceDiff();
continue;
}
if (op === 0) {
const take = Math.min(remainingInDiff, text.length - i);
const piece = chunkText.slice(diffPos, diffPos + take);
frag.push(piece);
i += take;
diffPos += take;
if (diffPos >= chunkText.length) {
advanceDiff();
}
continue;
}
if (op === 1) {
const take = Math.min(remainingInDiff, text.length - i);
const piece = chunkText.slice(diffPos, diffPos + take);
frag.push(`<span style=\"background:#d4fcbc;\">${piece}</span>`);
i += take;
diffPos += take;
if (diffPos >= chunkText.length) {
advanceDiff();
}
}
}
while (true) {
const cd2 = currentDiff();
if (!cd2) {
break;
}
const [op2, chunk2] = cd2;
const remaining2 = chunk2.length - diffPos;
if (remaining2 <= 0) {
advanceDiff();
continue;
}
if (op2 === -1) {
const delChunk = chunk2.slice(diffPos, diffPos + remaining2);
frag.push(
`<span style=\"background:#fbb6b6;text-decoration:line-through;\">${delChunk}</span>`,
);
diffPos += remaining2;
advanceDiff();
continue;
}
break;
}
$new(node).replaceWith(frag.join(""));
}
while (true) {
const cd = currentDiff();
if (!cd) {
break;
}
const [op, text] = cd;
const remaining = text.length - diffPos;
if (remaining <= 0) {
advanceDiff();
continue;
}
if (op === -1) {
$new("body").append(
`<span style=\"background:#fbb6b6;text-decoration:line-through;\">${text.slice(diffPos)}</span>`,
);
advanceDiff();
continue;
}
break;
}
return $new.html();
}
module.exports = {
buildDiffHtml,
};
- その他、UI部分・preload・rendererのほとんどはAI(GitHub Copilot)に作ってもらいました
Microsoftストアで公開
ストアで公開してみましたがこちらも大変でした。
現在は個人開発者であっても無料で登録できます。
パートナーセンターへの登録は個人情報の入力が必要ですが、ストアで公開されるわけではありません。
msixパッケージ化する
-
配布用に Electron アプリをパッケージ化する
自己証明書を使っている所でパスワードなしだとエラーになったのでパスワード付きのコマンドにしました。 -
パッケージ署名用の証明書を作成する
ストアへ提出するmsixパッケージは証明書なしでも、自己証明書ありでも関係は無いようです。
最終的にストア側の証明書が入れられます。 -
タスクバーのアイコン
タスクバーのアイコンを透過にしたかったのですが、結局できずあきらめました。
pngファイル名に装飾子を付けても上手くいきませんでした。(_altform-unplated、_altform-lightunplated) -
Electron Windowsストア ガイド
「electron windows store」とかで検索するとこれが出てくるのですが古いようなのでマイクロソフトのページの方法がいいと思います。
ストアへ提出
- 各項目に沿って入力していけば大丈夫でした
- アイコン・ストアロゴなどは忘れずに入れます
- Privacy PolicyやContactはGitHubへ入れてリンクとしました
-
appxmanifest.xmlがストアで設定した名前などと合わせないといけないエラーがでましたが、そのまま対応すればできました
分かりやすいエラー文で出ました - ストアへの提出から公開まで半日くらいとはやかったです
パートナーセンターのページ更新がとても!遅い!のでいらいらせずに更新しましょう。
その他参考
- https://github.com/cheeriojs/cheerio
- https://github.com/kpdecker/jsdiff
- 言語、スケール、ハイ コントラスト、その他の修飾子に合わせてリソースを調整する
- Make app icon on task bar have a transparent background (UWA)
- https://qiita.com/dhq_boiler/items/781b2ef98ce26a090566
- https://qiita.com/k_okano/items/4dcfe8ef22484ff574c0
- https://note.com/langdiclab/n/nb0ea2f9a00cb