個人で運営しているWebツールの解説文を増やそうとして、先に今の字数を数えたら、話が変な方向に行った。
2026-09-13、あるツール面を curl で取ると本文は2,570字あった。十分に見える。ところが同じページをスマホ幅(375px)で描画して、解説の段落のうち画面に出ている要素を数えると 0件 だった(2026-09-13 実測)。解説はアプリ本体の入れ物(初回訪問時は hidden)の中に書いてあって、初めて来た人の画面には一度も出ていなかった。
翌日、別のツール面4つを調べると、今度は逆の症状が出た。サーバーが返すHTMLの可読文字は268〜509字しかない。ソースを見ると導入文は書いてある。if (!today) return <p>読み込み中…</p> が見出しごと本文を飲み込んでいたり、解説が「出題中」の画面の分岐にだけ入っていたりした。さらに別の3面は、本文を書いてコミットしたのに push していなかった。
「書いた」「HTMLに出た」「画面に見えた」は、3つとも違う数字になる。これが今回の話の全部なので、同じ311字の解説文を置き場所だけ変えた9ページを作って、その差を数え直した。
環境は Chrome 153.0.8010.36(headless=new)、macOS、計測は CDP の Runtime.evaluate 経由、2026-09-15 実測。デモも計測コードも全部この記事に載せてあるので、そのまま再現できる。
目次
- 1. 結論
- 2. 実際に踏んだ4件
- 3. 同じ解説文を9通りに置いて数える
- 4. early return は見出しごと消す
- 5. 解説を置く画面を間違えると、逆の場面にだけ出る
- 6. 「見えている字数」の数え方で3倍ずれる
- 7. テキストノード単位で、箱と可視性を両方見る
- 8. ヘッドレスの --window-size では375pxにならない
- 9. 実サイトで直した結果
- 10. 3つの字数のずれから原因を引く
- 11. 再現手順
- 12. まとめ
- 13. 参考
- 14. 関連と次にやること
1. 結論
数字はすべて 2026-09-15 に Chrome 153 で実測したもの。
-
curlの文字数は「書いたか」の検査で、「見えているか」の検査ではない。解説311字を入れた9ページのうち、HTMLと375pxの画面の両方で300字を超えたのは3ページだけだった - 読者に届かない置き場所は6つあった。初回
hidden、early return、初期状態で通らない分岐、スマホ幅だけのdisplay:none、閉じた<details>、opacity:0のままのフェードイン - スマホ幅だけの
display:noneは、1280pxでは322字、375pxでは11字。PCで確認している限り気づけない - 「見えている字数」を数えるコードは、書き方で答えが3倍ずれた(p0 で実測)。葉要素だけ数える書き方は、見えている322字を95字と数え、閉じた
<details>の中身を「見える」と数えた。innerTextは透明な文字を数えた - ヘッドレス Chrome の
--window-size=375,812は、innerWidthが500で止まった。これで「375pxで確認した」と書くと、スマホ用のメディアクエリは一度も効いていない - 実サイトの5面は、分岐の外に出して書き足した結果、HTMLも375pxの画面も約2,000字になった。ただし増えた分の大半は書き足しで、「分岐から出しただけで戻った」とは言えない
2. 実際に踏んだ4件
同じ週(2026-09-13〜14)に、症状の見た目が違う4件を踏んだ。字数はそれぞれの日の実測。並べると全部同じ形をしている。
| 面 | 何が起きていたか | curl で見ると | 画面で見ると |
|---|---|---|---|
| ツール面A | 解説がアプリ本体の入れ物(初回 hidden)の中 |
2,570字(十分に見える) | 解説の可視要素 0件 |
| ツール面B |
if (!today) return 読み込み中… が見出しごと飲む |
268字 | JSが動けば出る |
| ツール面C〜E | 解説が「対戦中」「出題中」「一覧」の分岐にだけある | 480〜509字 | 最初の画面に解説0字 |
| 別の3面 | 本文をコミットしたが push していない | 古い本文のまま | 古い本文のまま |
2-1. hidden の中にあった
ツール面Aは、初めて来た人にはアプリの説明を見せて、記録を始めたらアプリ本体を見せる作りだった。解説の段落を、うっかりアプリ本体側の <div id="app" hidden> の中に書いていた。HTMLには文字として全部入っているので、curl で数える検査は通る。hidden 属性は display:none と同じく描画されないので、初回の画面には1文字も出ない。
2-2. early return が見出しごと飲んでいた
ツール面Bは Next.js のページで、日替わりの語をデータから引いてから描く。データが無い間は 読み込み中… を返していた。この return が、その下にある見出し・解説・更新日をまとめて消していた。サーバーが返すHTMLは「読み込み中…」とヘッダー・フッターだけ、可読268字だった。
2-3. 分岐の中にだけあった
ツール面C〜Eは、phase === 'playing' のような画面状態で表示を切り替えるクイズ型・対戦型だった。解説が「出題中」の分岐にだけ入っていて、最初の画面(HTMLに出る画面)には0字。しかも、問題に集中してほしい場面にだけ長文が出る、逆の配置になっていた。
2-4. そもそも本番に出ていなかった
別の3面は、本文を書いてコミットまでして、push していなかった。ローカルで grep すると本文はある。本番の curl には古い本文しか無い。これも「書いた字数」と「HTMLの字数」がずれる形のひとつなので、同じ表に入れておく。
3. 同じ解説文を9通りに置いて数える
実サイトは他の変更も混ざるので、条件をそろえるためにデモを作った。
3-1. デモの作り方
同じ解説文 EXPL(可読311字、<b> と <a> を含む4段落)を、置き場所だけ変えて9ページに書き出す。HTMLは「サーバーが返すもの」を手で書いた。つまり、JSが動く前の姿がそのままファイルになっている。
PAGES = {
# 対照:静的HTMLにそのまま置く
"p0": ("<h1>今日のひとこと</h1><section>%s</section><button>はじめる</button>" % EXPL, ""),
# 初回 hidden の入れ物の中
"p1": ("<h1>今日のひとこと</h1><button id=\"go\">はじめる</button>"
"<div id=\"app\" hidden><section>%s</section></div>" % EXPL,
"document.getElementById('go').onclick=()=>{document.getElementById('app').hidden=false};"),
# if (!data) return 読み込み中… が h1 ごと飲む(HTMLは読み込み中だけ)
"p2": ("<main id=\"root\"><p>読み込み中…</p></main>",
"...if(!data){root.innerHTML='<p>読み込み中…</p>';return}..."),
# スマホ幅だけ display:none
"p4": ("<h1>今日のひとこと</h1><section class=\"pc-only\">%s</section>"
"<button>はじめる</button>" % EXPL, ""),
# 閉じた details の中
"p5": ("<h1>今日のひとこと</h1><button>はじめる</button>"
"<details><summary>このツールについて</summary>%s</details>" % EXPL, ""),
# スクロールで出るフェードイン(高さ1200pxのヒーローの下)
"p6": ("<h1>今日のひとこと</h1><div class=\"hero\">(大きな画像の場所)</div>"
"<section class=\"reveal\">%s</section>" % EXPL,
"const io=new IntersectionObserver(es=>es.forEach(e=>{if(e.isIntersecting)"
"e.target.classList.add('in')}));document.querySelectorAll('.reveal').forEach(el=>io.observe(el));"),
}
p2b と p3b は、それぞれ p2 と p3 の修正版(見出しと解説を分岐の外に出したもの)。p3 は「入口」「出題中」の2画面を持ち、解説は「出題中」にだけある。CSSは共通で、.reveal{opacity:0} .reveal.in{opacity:1} @media (max-width:480px){.pc-only{display:none}} を入れた。
HTMLの字数は、script style noscript template とコメントを除き、タグを外して空白を数えない。curl で取ったHTMLに同じ関数を当てれば、同じ数え方になる。
def readable(html):
h = re.sub(r"<!--.*?-->", "", html, flags=re.S)
h = re.sub(r"<(script|style|noscript|template)\b.*?</\1>", "", h, flags=re.S | re.I)
h = re.sub(r"<title>.*?</title>", "", h, flags=re.S | re.I)
t = re.sub(r"<[^>]+>", "", h)
return len(re.sub(r"\s+", "", t))
3-2. 結果
| ページ | 解説の置き場所 | ② HTML | ③ 375px | ③ 1280px |
|---|---|---|---|---|
| p0 | 対照:静的HTML | 322 | 322 | 322 |
| p1 | 初回 hidden の中 | 322 | 11 | 11 |
| p2 | early return が飲む | 6 | 324 | 324 |
| p2b | p2 の修正版 | 324 | 324 | 324 |
| p3 | 出題中の分岐にだけ | 11 | 11 | 11 |
| p3b | p3 の修正版 | 322 | 322 | 322 |
| p4 | スマホ幅だけ display:none | 322 | 11 | 322 |
| p5 | 閉じた details | 331 | 20 | 20 |
| p6 | opacity:0 のフェードイン | 328 | 17 | 17 |
③は JS 実行から1.5秒待って数えた値(p2 の疑似フェッチは600ms)。p1 と p3 は開始ボタンを押す前の、最初に開いた画面の値。
HTMLと画面の両方で300字を超えたのは、p0・p2b・p3b の3ページだけだった。
3-3. 置き場所ごとに、どの検査で見つかるかが違う
p1・p5・p6 は、HTMLには全部あるのに画面には出ていない。curl で数える検査は素通りする。
p2 は逆で、HTMLには6字しか無い。JSが動けば324字出るので、ブラウザで開いて目で見る検査は素通りする。
p3 は HTML にも最初の画面にも無い。ソースを grep したときだけ見つかる。
p4 は1280pxでは322字、375pxでは11字。デスクトップのブラウザで確認している限り、ずっと気づけない。
つまり、どれか1つの数え方だけでは6つを全部は拾えない。
4. early return は見出しごと消す
4-1. 何がいっしょに消えていたか
React の条件付きレンダーで early return を書くと、その下の JSX は全部描かれない。消したかったのは「データが無いと描けない部品」だけなのに、見出し・解説・更新日まで巻き込まれる。
実サイトの修正から、要点の2行だけ抜き出すとこうなる(前後の JSX は省略)。
- if (!today) return <p className="text-center text-sub py-16">読み込み中…</p>;
+ {today ? <TodayHero today={today} /> : <p className="text-center text-sub py-16">読み込み中…</p>}
削除した1行目はコンポーネントの先頭にあり、追加した行は見出しと解説にはさまれた位置に入れた。
4-2. early return を書く前に見るところ
early return そのものが悪いわけではない。書くときに「この return が、下の何を一緒に消しているか」を1回見る。見出し・解説・日付のように、データが無くても出せるものは分岐の外に出す。デモでは、この修正だけで HTML が6字から324字になった(p2 → p2b)。
5. 解説を置く画面を間違えると、逆の場面にだけ出る
5-1. 入口と結果に出して、操作中には出さない
クイズ型のツールは「入口」「出題中」「結果」の3画面を持つことが多い。HTMLとして返るのは入口の画面なので、解説は入口に無いと HTML に出ない。
読み手の側から見ても、解説が要るのは「これは何のツールか」を知りたい入口と、「今の結果は何を意味するか」を知りたい結果の画面だ。出題中は問題に集中したいので、長文はむしろ邪魔になる。修正前はちょうどこの逆に置いていた。
p3b では、解説を分岐の外に出したうえで、出題中だけ hidden にした。HTMLは11字から322字になり、出題中の画面には解説が出ない。
5-2. 意図して畳んだものは「ずれ」として記録する
p5 の閉じた <details> は、details 要素の仕様どおり、開くまで中身が描画されない。これを「見えていない」と数えるのは測り方として正しいが、直すべきかどうかは別の話で、FAQのように意図して畳んでいるならそれでよい。
大事なのは、ずれが出たときに「意図して畳んだ」と「うっかり隠れた」を区別して書き残すこと。区別しないと、次に数えた人がまた同じ場所で止まる。
6. 「見えている字数」の数え方で3倍ずれる
画面の字数を数えるコードは、最初に自分で書いたものが間違っていた。
6-1. 葉要素だけ数える書き方は、見えている文字を落とす
最初に書いた検査は、子要素を持たない要素(葉)のうち、高さがあって visibility:hidden でないものの文字を足していた。
// 最初に書いた版(間違い)
[...document.body.querySelectorAll('*')]
.filter(e => !e.children.length && e.textContent.trim()
&& e.getBoundingClientRect().height > 0
&& getComputedStyle(e).visibility !== 'hidden')
.map(e => e.textContent.trim()).join('').length
これだと <p>……<b>全国の言い方</b>……</p> の <p> は子要素を持つので数えられず、<b> の6字だけが足される。対照ページ p0 で、見えている322字を 95字 と数えた(−70%・2026-09-15 実測)。解説文に強調やリンクが多いほど、ずれは大きくなる。
実サイト6面に 2026-09-15 に当てて実測すると、ずれは −2.5%〜−23% だった。Next.js で書いた5面は文字がほぼ葉要素に入っていたので −2.5%〜−7.6% と小さく、残る1面は −23% だった。マークアップの書き方でずれ幅が変わるので、「この検査は少し少なめに出る」という補正もできない。
6-2. 閉じた details と opacity:0 は「見える」と数える
同じ書き方は、逆方向にも間違える。閉じた <details> の中の要素は getBoundingClientRect() の高さが0にならず、p5 で見えている20字を 104字 と数えた。opacity:0 の段落も高さはあるので、p6 で17字を 101字 と数えた。
innerText に替えると、p0 と p5 は正しくなる。ただし innerText は描画されない要素(display:none など)を除くだけで、透明な文字は除かない。p6 で17字を 328字 と数えた。
| 数え方 | p0 対照(正解322) | p5 details(正解20) | p6 opacity:0(正解17) |
|---|---|---|---|
| 葉要素だけ | 95 | 104 | 101 |
innerText |
322 | 20 | 328 |
| テキストノード版 | 322 | 20 | 17 |
7. テキストノード単位で、箱と可視性を両方見る
3ページとも正解と一致したのは、テキストノードを1つずつ歩いて、そのノードに描画された箱があるか(Range.getClientRects())と、親要素が祖先まで含めて見えているか(Element.checkVisibility())の両方を見る書き方だった。
(async () => {
await new Promise(r => setTimeout(r, 1500)); // 非同期の描画を待つ
const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'TITLE']);
const strip = s => s.replace(/\s+/g, '');
let dom = 0, vis = 0;
const w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
for (let n; (n = w.nextNode());) {
const el = n.parentElement;
if (!el || SKIP.has(el.tagName)) continue;
const t = strip(n.nodeValue);
if (!t) continue;
dom += t.length; // DOM に入っている文字
const r = document.createRange();
r.selectNodeContents(n);
const hasBox = [...r.getClientRects()].some(b => b.width > 0 && b.height > 0);
const shown = el.checkVisibility({
opacityProperty: true, visibilityProperty: true, contentVisibilityAuto: true });
if (hasBox && shown) vis += t.length; // 見えている文字
}
const inner = strip(document.body.innerText).length;
return { vw: innerWidth, dom, inner, visible: vis };
})()
7-1. 何を見ているか
TreeWalker でテキストノードを歩くので、インライン要素で段落が分かれていても落ちない。checkVisibility の opacityProperty は、要素か祖先の opacity が0なら false を返す。
7-2. この数え方でも拾えないもの
「背景と同じ色の文字」「font-size:0」「clip-path で切り取られた文字」は、この数え方では見えている側に数える。今回のデモには入れていないし、実サイトでも踏んでいない。必要になったら足す前提で、ここでは書かないでおく。
また、画面の外(スクロールしないと見えない位置)にある文字は「見えている」と数える。スクロールすれば読めるので、これは意図どおり。
8. ヘッドレスの --window-size では375pxにならない
8-1. 幅500で止まる
最初は --window-size=375,812 でヘッドレス Chrome を起動して数えていた。結果の innerWidth を出してみると、9ページとも 500 だった。
p4 の @media (max-width:480px) は幅500では効かないので、スマホ幅だけ消える322字を「見えている」と数えていた。「375pxで確認済み」と書けてしまう状態で、実際は一度もスマホ幅で描画していなかった。
8-2. Emulation.setDeviceMetricsOverride を送る
CDP で Emulation.setDeviceMetricsOverride を送ってから評価すると、innerWidth は375になり、p4 は11字になった。前回の記事で書いた依存なしの CDP クライアントに、フラグを1つ足しただけで済んだ。
if mobile:
mw, mh = (int(x) for x in mobile.split(","))
ws.send({"id": 4, "method": "Emulation.setDeviceMetricsOverride",
"params": {"width": mw, "height": mh, "deviceScaleFactor": 2, "mobile": True}})
ws.send({"id": 3, "method": "Runtime.evaluate",
"params": {"expression": expr, "awaitPromise": True, "returnByValue": True}})
計測の結果には、必ず innerWidth を一緒に出す。出していなかったら、この500には気づけなかった。
9. 実サイトで直した結果
9-1. 5面の前後
| 面 | 原因 | 修正前 HTML | 修正後 HTML | 2026-09-15 の375px可視 |
|---|---|---|---|---|
| B 日替わり | early return | 268 | 2,044 | 2,331 |
| C 対戦型 | 出題中の分岐 | 480 | 1,999 | 1,964 |
| D 一覧型 | 一覧の分岐 | 492 | 2,089 | 2,062 |
| E クイズ型 | 出題中の分岐 | 509 | 2,071 | 2,029 |
| F 診断型 | 分岐の問題なし | 692 | 2,059 | 2,025 |
修正前後はコミット時の実測。右端は今回のテキストノード版で数えた値で、その後のコミットが入っているのと、数え方が違うので、修正後の HTML とは27〜287字ずれる。
同じ 2026-09-15 に本番の HTML も実測し直すと、C〜F の4面は HTML と375px可視の差が0.5%以内だった。B だけは可視のほうが186字多い(HTML 2,145字・可視 2,331字)。日替わりの語と例文を JS が描き足すためで、こちらは見えている側が多いので問題にしていない。
9-2. 増えた分の大半は書き足しだった
最初は「解説はもう書いてあって、置き場所を変えただけで字数が戻った」と記録していた。この記事を書くために修正コミットの差分を面ごとに数え直したら、日本語は1面あたり 追加1,540〜1,946字、削除212〜324字 だった。
分岐の中にあったのは数百字の導入文で、字数が増えた大半は同じコミットでの書き足しだった。分岐から出しただけで何字戻ったかは、この差分からは分けられない。原因(分岐の中に置いていた)は合っていたが、効果の内訳を確かめずに書いていたので、記録を訂正した。
9-3. 修正後も見えていない443字の中身
ツール面Aは、今の375pxの画面でも HTML 2,533字に対して可視2,102字で、443字が見えていない。見えていない文字を、id か hidden か display:none を持つ最寄りの祖先で集計した。
let a = el;
while (a.parentElement && !a.id && !a.hidden && a.tagName !== 'DETAILS'
&& getComputedStyle(a).display !== 'none') a = a.parentElement;
const key = a.tagName.toLowerCase() + (a.id ? '#' + a.id : '')
+ ' display=' + getComputedStyle(a).display;
agg[key] = (agg[key] || 0) + t.length;
443字は全部アプリ本体の中(設定画面126字、おすすめ画面146字、ホーム画面75字、記録画面33字など)で、初回訪問者には見せない操作部品だった。解説の段落は0件。これは意図どおりなので、直さずに理由をつけて記録した。
10. 3つの字数のずれから原因を引く
10-1. ソースと本番がずれたら、まず push を疑う
いちばん確かめるのが簡単で、いちばん恥ずかしいのがこれだった。
git fetch && git log --oneline @{u}..HEAD # push していないコミット
curl -s https://example.com/page | grep -c "解説文の一部の文字列" # 本番に出ているか
コミットがあることは、本番に出ていることの証拠にならない。本番の HTML に、書いた文の一部が入っているかを grep するまで「出した」と書かない。
10-2. HTML と画面がずれたら、最寄りの祖先で集計する
9-3 の集計をそのまま使う。#app、[hidden]、display=none、DETAILS のどれに文字がたまっているかで、6つの置き場所のどれかがほぼ決まる。
10-3. 375px と 1280px がずれたら、メディアクエリを疑う
p4 のように、PC専用クラスやメディアクエリの後勝ちで、スマホ幅だけ本文が消えることがある。1280pxだけで確認していると一生気づかないので、幅は必ず2つで数える。
10-4. 検索エンジンから何が見えるかは、ここでは測っていない
Google がJSを実行したあとの内容をどう扱うかは、JavaScript SEO の基本に説明がある。今回測ったのは「サーバーが返した HTML」と「375pxのブラウザ画面」の字数だけで、検索やクローラの評価との関係は測っていない。この記事の数字をSEOの効果として読まないでほしい。
11. 再現手順
mkdir demo && cd demo
# build.py(3-1 の PAGES と readable を含む全体)と measure.js(7章)を置く
python3 build.py # p0〜p6 の9ページと html_chars.json ができる
# CDP クライアントは前回記事のもの(100行・標準ライブラリのみ)に --mobile を足したもの
for p in p0 p1 p2 p2b p3 p3b p4 p5 p6; do
python3 cdp_eval.py "file://$PWD/$p.html" measure.js --timeout 40 --window 500,812 --mobile 375,812
python3 cdp_eval.py "file://$PWD/$p.html" measure.js --timeout 40 --window 1280,900
done
公開ページに当てるときは、--wait 6 を足す(初期の about:blank から遷移するのを待つため)。出力の vw が375になっていることを、毎回確かめる。
12. まとめ
自分のコードに入れた判定は次の5行にした。
- 解説・見出し・更新日を、画面状態の分岐(loading・phase・tab)の中に置かない。入口と結果の画面に出し、操作中には出さない
- early return を書くときは、その return が下の何を一緒に消すかを見る
- 「充実させた」と書く前に、ソース・本番HTML・375px画面の3つの字数を数え、ずれを原因の候補に当てる
- 画面の字数はテキストノード単位で、箱と
checkVisibilityの両方を見て数える。結果にはinnerWidthを必ず出す - 意図して畳んだもののずれは、直さずに理由をつけて記録する
測ってみて一番こたえたのは、検査を作った本人が、その検査の数え方を一度も正解と突き合わせていなかったことだった。葉要素だけ数える版は、全部見えているページで7割を落としていた。curl の字数を信じた失敗を直すために作った検査が、同じ種類の失敗をしていた。
13. 参考
13-1. 一次情報
- MDN: Element.checkVisibility()
- MDN: HTMLElement.innerText
- HTML Standard: innerText の定義
- MDN: Range.getClientRects()
- MDN: TreeWalker
- MDN: hidden 属性
- MDN: details 要素
- MDN: Intersection Observer API
- React: 条件付きレンダー
- Chrome DevTools Protocol: Emulation.setDeviceMetricsOverride
- Google 検索セントラル: JavaScript SEO の基本
14. 関連と次にやること
14-1. 関連
計測に使った依存なしの CDP クライアントは、スクロールしても増えないから終端と判定したら、40件中5件しか拾えていなかった話で全文を載せている。「指定した値と、実際に画面で起きていることが食い違うのに、何のエラーも出ない」系の話としては、図に15pxで書いた文字が、スマホでは実効7pxで表示されていた話も書いている。
検査の数え方を正解と突き合わせていなかった、という点では誤検出する検査は必ず無視される。自作の静的検査を1日に4回作り直してわかったことと裏表になっている。あちらは鳴りすぎる検査の話で、こちらは数え落とす検査の話。
14-2. 次にやること
1つだけ選ぶなら、自分のサイトでいちばん大事なページを1つ選んで、7章の measure.js をスマホ幅にしたブラウザのコンソールに貼ってみてほしい(await の部分はそのまま動く)。dom と visible が大きくずれていたら、そのページには書いたのに届いていない文章がある。まず1ページだけ、試してみてほしい。
自分が運営している複数サイトも、この記事に書いた9パターンの置き場所の罠を実際に踏んでいました。書いたはずの文章が読者に届いているかを機械で検査する仕組み作りは仕事としても引き受けています → サイト監査のご相談(毎日ラボ)
本文の数値は、すべて自分の環境での実測値です(2026-09-13〜09-15時点)。9パターンの字数もヘッドレス Chrome の挙動も、他環境で完全に同じ結果になる保証はありません。再現に必要なコードは本文にそのまま載せてあります。






