Qiita を見ていると、一覧にずらっと記事が並びますよね。
人によっては「ChatGPT で作成した記事を読みたくない」という場面もあると思います。
この記事では、記事詳細ページの本文(.it-MdContent)に画像(<img>)が 1 枚も無い記事を、一覧ページの <article> ごと非表示にする Chrome 拡張(Manifest V3)を作ります。
ChatGPT が出力した内容をほぼそのまま掲載している記事は、本文に画像が含まれていないことが多い、という考えのもとで本拡張を作成しています。
一方で、画像を挿入する程度の修正が加えられている記事は、一定の手間がかかっており、ChatGPT の記事が嫌いな人でも読む価値があると考えています。
検索時に stocks:>=1 を指定する方法とは異なり、この拡張は、良質な記事を探すというよりも、ChatGPT で作成した文章をほぼそのまま投稿している記事を一覧から除外することを目的としています。
この拡張の目的は AI を排除することではなく、十分な手が加えられていない記事を一覧から減らすことなのです。
重要:この拡張を入れると「私(uni928)の記事」ともお別れします
この拡張は「本文に画像が無い記事」を非表示にします。
つまり、この記事自体がその条件を満たしている場合、この拡張を有効にすると……
- 検索結果
- タグ一覧
- トップページの一覧
などの 一覧から、この記事が見えなくなります。
場合によっては「自分の記事を自分で消す」ことになりがちなので、そこだけはご注意ください。
(もちろん、記事詳細 URL を直接開けば読めます)
何をする拡張か(仕様)
一覧ページで次を実行します。
- ページ内の
<article>を全部拾う - 各
<article>内にある「記事詳細のリンク(/items/を含むリンク)」を取得 - リンク先(記事詳細ページ)の HTML を
fetchで取得 - 取得した HTML をパースし、
.it-MdContent内にimgがあるか判定 -
imgが無ければ、その<article>をdisplay:noneにして非表示
ポイント
- 判定対象は
.it-MdContentの中だけ
ユーザーアイコンやヘッダーの画像はカウントしません - 無限スクロール等で記事が追加されても動くように、
MutationObserverで DOM 変化を監視します - 何度も同じ記事を取りに行かないよう、
sessionStorageに そのタブ内だけキャッシュします
(タブを閉じる/リロードで消えます)
先に完成形(フォルダ構成)
ローカルに下のフォルダを作り、2ファイルだけ置きます。
qiita-hide-no-image フォルダの中に
- manifest.json
- content.js
を入れる
このサイト で公開しています。
manifest.json(Manifest V3)
この拡張は Qiita のページ上で動くコンテンツスクリプトだけで完結します。
-
matchesはhttps://qiita.com/* - 記事詳細ページを
fetchするのでhost_permissionsも同じく付けます
内容は以下です。
manifest.json
{
"manifest_version": 3,
"name": "Qiita: 本文に画像のない記事を非表示",
"version": "1.0.0",
"description": "Qiita の記事一覧で、本文に画像が無い記事を自動で非表示にします。",
"content_scripts": [
{
"matches": ["https://qiita.com/*"],
"js": ["content.js"],
"run_at": "document_idle"
}
],
"host_permissions": ["https://qiita.com/*"]
}
content.js
(() => {
"use strict";
/* =========================
設定
========================= */
const BODY_SELECTOR = ".it-MdContent";
const ARTICLE_SELECTOR = "article";
const HIDE_STYLE = "display:none !important;";
const MAX_CONCURRENT_FETCH = 3;
const CACHE_PREFIX = "qiita_has_img:";
/* =========================
ユーティリティ
========================= */
function normalizeUrl(href) {
try {
const u = new URL(href, location.origin);
u.hash = "";
u.search = "";
return u.toString();
} catch {
return null;
}
}
function isItemUrl(url) {
try {
const u = new URL(url);
return /^\/[^\/]+\/items\/[^\/]+$/.test(u.pathname);
} catch {
return false;
}
}
function cacheGet(url) {
const v = sessionStorage.getItem(CACHE_PREFIX + url);
if (v === null) return null;
return v === "1";
}
function cacheSet(url, hasImg) {
sessionStorage.setItem(CACHE_PREFIX + url, hasImg ? "1" : "0");
}
async function fetchHtml(url, signal) {
const res = await fetch(url, {
credentials: "include",
headers: { Accept: "text/html" },
signal
});
if (!res.ok) throw new Error(res.status);
return res.text();
}
/* =========================
判定ロジック(超重要)
========================= */
function hasImageInMdContent(html) {
const doc = new DOMParser().parseFromString(html, "text/html");
const md = doc.querySelector(BODY_SELECTOR);
// .it-MdContent が存在しない → 画像なし扱い
if (!md) return false;
// 本文内 img のみを見る
return md.querySelector("img") !== null;
}
/* =========================
非表示
========================= */
function hideArticle(article) {
if (!(article instanceof HTMLElement)) return;
article.style.display = "none";
}
/* =========================
fetch キュー(負荷対策)
========================= */
const queue = [];
let active = 0;
function enqueue(task) {
queue.push(task);
pump();
}
function pump() {
while (active < MAX_CONCURRENT_FETCH && queue.length) {
const task = queue.shift();
active++;
task().finally(() => {
active--;
pump();
});
}
}
/* =========================
メイン処理
========================= */
const processed = new WeakSet();
function scan() {
const articles = document.querySelectorAll(ARTICLE_SELECTOR);
articles.forEach(article => {
if (processed.has(article)) return;
processed.add(article);
// article 内の items リンクを1つ探す
const link = article.querySelector("a[href*='/items/']");
if (!link) return;
const url = normalizeUrl(link.href);
if (!url || !isItemUrl(url)) return;
// キャッシュ確認
const cached = cacheGet(url);
if (cached === false) {
hideArticle(article);
return;
}
if (cached === true) {
return;
}
enqueue(async () => {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 10000);
try {
const html = await fetchHtml(url, ac.signal);
const hasImg = hasImageInMdContent(html);
cacheSet(url, hasImg);
if (!hasImg) {
hideArticle(article);
}
} catch {
// 失敗時は安全側(消さない)
} finally {
clearTimeout(timer);
}
});
});
}
/* =========================
初期化
========================= */
scan();
// 無限スクロール対応
const observer = new MutationObserver(scan);
observer.observe(document.body, { childList: true, subtree: true });
})();
Chrome へのインストール手順(デベロッパーモード)
- どこかにフォルダを作る
例:qiita-hide-no-image/ - manifest.json と content.js を作って、上の内容を貼る
- Chrome で以下を開く
chrome://extensions/ - 右上の「デベロッパーモード」を ON
- 「パッケージ化されていない拡張機能を読み込む」
→ 作成したフォルダ(qiita-hide-no-image)を選択 - Qiita の一覧ページ(検索結果・タグ一覧など)を開く
うまくいけば、本文に画像が無い記事の <article> が一覧から消えます。
動かないときのチェックポイント
1) 拡張が反映されていない
chrome://extensions/ で対象拡張の更新(↻)を押してからページをリロードします。
2) 本文の class 名が変わっている
この拡張は .it-MdContent 固定です。
もし Qiita 側の DOM が変わっていたら、次の 1 行だけ変更すれば OK です。
const BODY_SELECTOR = ".it-MdContent";
3) fetch がブロックされている
環境によっては拡張の権限や企業プロキシ等で fetch が失敗することがあります。
その場合も「安全側」で、記事は消えません(誤判定で消さない設計)。
ページを読み込むほど容量が増える?
この拡張は「増えるけど一時的」です。
-
sessionStorageにqiita_has_img:<URL> = 0/1が増える
→ 記事数に比例して増えます - ただし
sessionStorageは タブを閉じる/リロードで消えます
→ 恒久的に肥大化しません
また、processedArticles は WeakSet なので、DOM が破棄されれば自動的に解放されます。
まとめ
-
<article>内のリンク先を取得して.it-MdContentのimg有無で判定 - 画像が無ければ、その
<article>を一覧から非表示 - 無限スクロールにも追従
- キャッシュは sessionStorage(タブ内だけ)
ChatGPT で作成した文章をそのまま投稿した記事を好まない方に使っていただけたならば、快適な Qiita 巡回をお約束します。
そうした方にとっては、「画像すら貼られていない記事」を省けるため、非常に効率的に Qiita を巡回できるでしょう。
検索結果を新着順にした場合、あなたが苦手と感じる記事はおそらく半分以上を占めています。
それらを非表示にできるのですから、かなり快適になると思われます。
わずか 4.31KB の軽量なファイルが、あなたの Qiita 巡回を快適にします。
そして繰り返しになりますが……
この拡張を入れると、本文に画像が無い記事は一覧から消えます。
つまり「私(uni928)の記事とはお別れ」になります。
自分の記事だけを対象外にするような小細工は考えていません。
フォローしている人を非表示の対象から外す設定欄を追加したり、除外リスト機能を用意して、あわよくば自分(uni928)を入れてもらおうと考えたりもしていません。
そのため、今後この拡張を永続的に導入される方に関しては、これまで自分の記事を読んでくださり、ありがとうございました、という気持ちです。
以上です!