タグ: SEO

  • AIのSEO診断の指摘を、渡した資料とページの目的で確かめてWordPressの直す場所を決める

    AIのSEO診断の指摘を、渡した資料とページの目的で確かめてWordPressの直す場所を決める

    AIにWordPressサイトのSEO診断を頼むと、noindex、canonical、見出し、速度などの指摘が重要度つきで並びます。どれも直したほうがよさそうに見えますが、指摘の中には、AIが渡された資料から確かめられたことと、資料にないことを推測で言い切ったことが混ざっています。どちらなのかを見分けないまま直すと、必要のない修正をしたり、本当に直すべき設定を後回しにしたりします。

    例として用意したHTML・robots.txt・sitemapと、各ページの目的(検索に出したいかどうか)を5件分まとめてClaudeに渡し、普段どおりにSEO診断を頼みました。掲載したいページにnoindexとクロールの遮断が両方あるような、目的と食い違う設定の判断は、5件とも公式仕様と合っていました。一方で、本文だけの断片には、headとtitleがないこと、本文が薄いことを重要度「高」で断定し、同じ回答の中で、保存のときに一部しか保存されなかった可能性があるので配信中のHTMLを先に確かめるように、とも勧めていました。採ってよい助言と、確かめずに直してはいけない断定が、1つの回答に並んでいたのです。

    この記事では、AIの指摘を1つずつ「渡した資料」「ページの目的」「公式仕様」に戻し、「問題候補」「確認した範囲では正常」「判定不能」の3つに分ける手順を、この診断結果で示します。読み終えたら、どの指摘を直し、どの指摘のために情報を集め直し、WordPressのどこを見に行くかを決められます。

    完成形:指摘を3つに分けた判定表

    手順の最後には、指摘ごとに次の3つのどれかを付けた表が手元に残ります。

    分け方 当てはまる指摘 次にすること
    問題候補 渡した資料で確かめられ、ページの目的と食い違う(検索に出したいのにnoindexがある、など) 公開中のページでも同じかを確かめ、設定の出力元を探す
    確認した範囲では正常 渡した資料の範囲では、目的どおりになっている 何を確かめたか(ファイルと取得日時)を残す。資料にない項目は別に確かめる
    判定不能 資料に含まれていない、ページの目的が分からない、実測値がない 必要な資料を集めてから判断する

    「問題候補」は、すぐ直せという意味ではありません。検索に出す必要のないページなら、noindexは目的どおりです。「正常」にも、何を確かめた結果かを添えます。HTMLに望まない指定がなくても、HTTPのヘッダーから別の指定が出ている可能性は残るからです。

    AIの指摘を渡した資料とページの目的に戻し、問題候補・確認した範囲では正常・判定不能に分けて公開状態を確かめる流れ
    指摘を、渡した資料と目的に戻してから分けます。

    手順1:AIに渡した資料と、ページの目的を書き出す

    まず、診断の前提を2つ書き出します。

    • 渡した資料:完全なHTMLか本文だけの断片か、取得した日時、HTTPヘッダー・robots.txt・sitemapを渡したか。

    • ページの目的:検索結果に出したいか、出したくないか。代表にしたいURL(canonical)はどれか。

    今回は、5つのページの目的と保存ファイルを、ツールを使わずに資料だけで答えるよう指示して、Claudeに1回送りました(2026年10月12日)。依頼文は「ケースごとに問題点と修正案を、重要度(高・中・低)付きで」という、普段頼むのに近い形だけにしました。

    ケース 渡した資料 ページの目的
    A 本文だけの断片(<h1> の1行) 検索に出したい
    B noindexのある完全なHTML 会員向けで、検索に出したくない
    C H1が2つ、コメントとscriptの中にnoindexの文字があるHTML 検索に出したい
    D noindexのあるHTMLと、そのパスを遮断するrobots.txt 検索に出したい
    E WordPress標準に近いrobots.txtとsitemap index 検索に出したい

    手順2:指摘を、資料と目的と公式仕様に戻す

    次に、AIの指摘を1つずつ、渡した資料に実際に書かれていること、ページの目的、公式仕様の3つに照らします。今回の回答の主な指摘と、照らした結果は次のとおりです。

    ケース AIの主な指摘 照らした結果 分け方と次の確認
    A 断片 headとtitleがない、本文が薄い(どちらも重要度「高」)。同時に、配信中のHTMLを先に確かめるよう勧めた 渡したのは断片なので、headの欠落も本文の薄さも資料からは言えない 判定不能。断定は採らず、配信HTMLの確認を採る
    B 会員向け noindexの削除は勧めず、noindexは閲覧の制限にはならないこと、sitemapから外すことを挙げた noindexは検索結果への掲載を防ぐ指示で、閲覧は制限しない。sitemapには検索に出したいURLを入れる 確認した範囲では正常。会員限定ならログインでの制限を検討し、sitemapの生成元を確かめる
    C 見出し コメントやscriptの中のnoindexは効いていない。H1が複数でも問題ない どちらもrobots metaとして読まれる要素ではない。Googleのガイドに理想の見出し数はない 確認した範囲では正常。2つ目をh2にするかは内容で決める
    D 二重の遮断 noindexとクロールの遮断で二重に止めていて、片方だけ直しても解決しない クロールできなければnoindexは読まれない 問題候補。最優先で両方の出力元を確かめる
    E WordPress標準 robots.txtは標準どおりで問題ない。sitemap indexに対象URLが載っていない可能性 robots.txtは最も長く一致する規則が使われるので、対象パスは遮断されない。投稿か固定ページかは資料にない robotsは確認した範囲では正常。sitemapは判定不能で、子sitemapを開いて確かめる

    ケースAの回答には、次の行がありました(Claudeの回答の原文の一部)。

    <head>がなく、<title>もない。検索結果の見出しはGoogleが自動で作るため、ページの中身が伝わらない

    最初に確認すること: 保存のときに一部しか保存されなかった可能性があります。修正の前に、ブラウザの「ページのソースを表示」かSearch ConsoleのURL検査で、実際に配信されているHTMLを確かめてください。

    前者は資料の外の断定で、後者はその断定を確かめるための正しい助言です。重要度「高」の表だけを読んで直し始めると、後者を読み飛ばします。指摘を表から拾うときは、同じ回答の中の「確認すること」まで読みます。

    5件すべてに共通して、タイトルが汎用的なこと、viewportとmeta descriptionがないことも指摘されましたが、これは例として簡略化したファイルの性質です。自分のページで同じ指摘が出たら、公開中のHTMLで確かめる項目として扱います。

    照らすときに使う公式仕様は、主に次の4つです(いずれも確認日:2026年10月12日)。

    • noindexはHTMLのrobots metaか、HTTPの X-Robots-Tag で指定する。robots.txtでクロールを遮断すると、noindexは読まれない(Googleのnoindexの説明)。body内のrobots metaも認識される(robots metaの仕様)。

    • canonicalは代表にしたいURLを伝える強いシグナルだが、指定どおりに選ばれる保証はない。別のURLを意図して代表にしているページもある(正規URLの説明)。

    • robots.txtは、最も長く一致する規則が使われる(robots.txtの仕様)。WordPressが標準で出すrobots.txtには Allow: の行があり、標準のsitemapはsitemap indexになっている(WordPressのrobots.txt生成)。

    • 見出しの数に理想の値はない(Google SEOスターターガイド)。

    手順3:判定表に、WordPressで見る場所を書き足す

    問題候補と判定不能の指摘には、WordPressで最初に見る場所と、変更したあとの確かめ方を書き足します。記事の本文を書き直しても、head内のcanonicalやHTTPヘッダーは変わらないことがあります。

    症状・確かめる対象 最初に見る場所 変更後の確かめ方
    記事の見出し・説明・本文のリンク 記事エディター。ページタイトルのH1はテーマ側の場合もある 公開ページの表示とHTMLで、見出しの対応を見る
    サイト全体に意図しないnoindex 設定→表示設定の「検索エンジンでの表示」、有効なプラグインの設定 公開HTMLとヘッダーを取り直す。設定画面だけで完了にしない
    特定ページのnoindex・canonical SEOプラグインのページ別設定、テーマや追加コード HTML全体のrobots meta、head内のcanonical、ヘッダーに望む指定が1つだけ出ているか
    X-Robots-Tag やHTTPの Link のcanonical サーバー設定、CDN、プラグインなどのヘッダーの生成元 対象URLへのGETの応答で、ステータス・転送先・ヘッダーを見る
    robots.txt・sitemap WordPressやSEOプラグインの生成設定、サーバー上の実ファイル 公開中のファイルを取り直し、対象URLと規則を照らす

    サイト全体の「検索エンジンでの表示」の設定は、WordPress公式の表示設定の説明にあります(確認日:2026年10月12日)。実際の出力元はテーマとプラグインの組み合わせで変わるので、表は見に行く順番として使います。

    直す順番は、まず「検索に出したいページが、意図せずnoindexやクロールの遮断になっていないか」(今回のケースD)、次に「代表URLの指定が重複していないか」です。見出しの整理は内容を読んで決めます。変える値・対象URL・戻し方を決め、1件ずつ確かめてから反映します。

    手順4:判定不能の指摘のために、公開状態を取り直す

    判定不能に分けた指摘は、資料を足してから判断します。自分が管理するページなら、次の記録をそろえます。

    1. 対象URL、最終的なURL、取得日時、HTTPステータスと転送、GETの応答のヘッダー、完全なHTML。

    2. 同じサイトの公開中のrobots.txtと、対象URLが属するsitemap。

    3. Search ConsoleのURL検査の結果。Googleが持つ登録情報と、今のページへのライブテストを分けて記録し、Googleが選んだ正規URLも見る(URL検査の公式説明)。

    4. 変更したあとに、同じURL・同じ条件で取り直した記録。

    速度の指標(LCP・INP・CLS)を指摘されたら、測った元、期間、端末、ラボの測定か実際の利用者のデータかを確かめます。保存したHTMLから値は出せません(GoogleのWeb Vitalsの説明)。

    AIに、指摘の根拠を出し直してもらう

    診断が長く、自分で照らすのが大変なときは、修正案を増やす前に、AIに指摘を判定表の形で出し直してもらいます。貼る情報に、認証情報や個人情報を含めないでください。

    対象URLとページの目的:[URL/検索に出したいか/希望する代表URL]
    渡す資料:[完全なHTMLか本文断片か、取得日時、ヘッダー・robots・sitemapの有無]
    元の診断:[ここに貼る]
    各指摘を「観測した値」「根拠となる箇所」「公式資料」「問題候補/確認範囲内で正常/判定不能」「次の確認」に分けてください。
    ページの目的と食い違う指摘を優先してください。
    取得していないヘッダー、実インデックス、順位、速度の値は判定不能にしてください。
    修正箇所が本文・テーマ・プラグイン・サーバーのどこか、まだ特定できない場合も書いてください。

    返ってきた表も、該当するHTMLや公式資料と照らします。根拠の箇所が示されない指摘は、直す判断を保留します。

    付録:保存したファイルを3つに分ける小さなPythonコード

    AIの指摘と照らすための比較用に、保存したHTML・robots.txt・sitemapから、確かめられる範囲だけを判定するコードを用意しました。Python 3.10以降の標準ライブラリだけで動き、通信はしません。長いコードは折りたたんでいます。3つのファイルを空のフォルダにUTF-8で保存し、python3 run_checks.py を実行すると、18の例で判定を確かめられます。

    入力例 このコードの判定 読み方
    noindex:掲載したいページにnoindex metaが問題候補 公開HTMLと設定を確かめる
    body-noindex:body内にnoindex metaが問題候補 headだけでなくHTML全体を見る
    intended:掲載を望まないページにnoindex metaは範囲内で正常 noindexの削除は不要
    conflict:異なるcanonicalが2つ canonicalが問題候補 重複して出力している元を探す
    fragment:本文だけ metaとcanonicalは判定不能 「titleがない」とは言えない
    two-h1:H1が2つ、コメントにnoindexの文字 metaは範囲内で正常、H1数は2として記録 H1の数は判定に使わない
    wp-default:WordPress標準のrobots.txtとsitemap index robotsとsitemapは判定不能 規則と子sitemapを手で確かめる

    このコードのrobots.txtの判定は、単一の User-agent: * と単純な Disallow だけを扱い、Allow: や Sitemap: の行があれば判定不能にします。WordPressの実際のrobots.txtはこの形に当たるので、手順2のケースEのように、Googleの仕様に沿って手で確かめます。http(ヘッダー)、index(登録状態)、cwv(速度)は、入力に含まれないので常に判定不能です。コードの出力では、「確認した範囲では正常」を「範囲内で正常」と表示します。

    fixtures.py:18の例を作る

    want_index は検索に出したいなら true、出したくないなら false、分からなければ null です。preferred は運営者が代表にしたいURLで、Googleが選ぶURLとは別です。URLはすべて example.com です。

    コードを開く
    import json
    from pathlib import Path
    
    URL = 'https://example.com/guide/'
    OTHER = 'https://example.com/original/'
    CANON = f'<link rel="canonical" href="{URL}">'
    XML = f'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>{URL}</loc></url></urlset>'
    
    def fixture(name, meta='', canonical=CANON, body='<h1>手順</h1>', want=True,
                preferred=URL, robots='User-agent: *\nDisallow:\n', sitemap=XML, fragment=False, base=''):
        d = Path('fixtures') / name
        d.mkdir(parents=True, exist_ok=True)
        html = body if fragment else f'<!doctype html><html lang="ja"><head><meta charset="utf-8"><title>手順</title>{base}{meta}{canonical}</head><body>{body}</body></html>'
        for file, text in [('page.html', html), ('robots.txt', robots), ('sitemap.xml', sitemap),
                           ('intent.json', json.dumps({'url': URL, 'want_index': want, 'preferred': preferred}, ensure_ascii=False, indent=2))]:
            (d / file).write_text(text, encoding='utf-8')
    
    fixture('normal')
    fixture('noindex', meta='<meta name="robots" content="noindex, follow">')
    fixture('fragment', body='<h1>本文だけ</h1>', fragment=True)
    fixture('conflict', canonical=CANON + f'<link rel="canonical" href="{OTHER}">')
    fixture('intended', meta='<meta name="robots" content="noindex">', want=False)
    fixture('unknown-intent', meta='<meta name="robots" content="noindex">', want=None)
    fixture('googlebot-none', meta='<meta name="googlebot" content="NONE">')
    fixture('cross-canonical', canonical=f'<link rel="canonical" href="{OTHER}">', preferred=OTHER,
            sitemap=XML.replace(URL, OTHER))
    fixture('missing-canonical', canonical='')
    fixture('blocked-noindex', meta='<meta name="robots" content="noindex">', robots='User-agent: *\nDisallow: /guide/\n')
    fixture('complex-robots', robots='User-agent: *\nDisallow: /\nAllow: /guide/\n')
    fixture('broken-sitemap', sitemap='<urlset>')
    fixture('two-h1', body='<h1>手順</h1><h1>別の区分</h1>',
            meta="""<!-- <meta name="robots" content="noindex"> --><script>const s = '<meta name="robots" content="noindex">';</script>""")
    fixture('sitemap-index', sitemap='<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></sitemapindex>')
    fixture('base-relative', canonical='<link rel="canonical" href="guide/">', base='<base href="https://example.com/">')
    fixture('relative', canonical='<link rel="canonical" href="/guide/">')
    fixture('body-noindex', body='<meta name="robots" content="noindex"><h1>手順</h1>')
    # WordPress標準に近いrobots.txtとコアのsitemap index(この最小コードでは判定不能になる例)
    WP_ROBOTS = 'User-agent: *\nDisallow: /wp-admin/\nAllow: /wp-admin/admin-ajax.php\n\nSitemap: https://example.com/wp-sitemap.xml\n'
    WP_INDEX = '<?xml version="1.0" encoding="UTF-8"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><sitemap><loc>https://example.com/wp-sitemap-posts-post-1.xml</loc></sitemap></sitemapindex>'
    fixture('wp-default', robots=WP_ROBOTS, sitemap=WP_INDEX)

    audit.py:確かめられる範囲だけ判定する

    コードを開く
    import json
    import re
    import sys
    from html.parser import HTMLParser
    from pathlib import Path
    from urllib.parse import urljoin, urlsplit
    import xml.etree.ElementTree as ET
    
    P, N, U = '問題候補', '範囲内で正常', '判定不能'
    
    class Page(HTMLParser):
        def __init__(self):
            super().__init__()
            self.head = False
            self.events, self.robots, self.canon = [], [], []
            self.base, self.h1 = False, 0
    
        def handle_starttag(self, tag, attrs):
            a = dict(attrs)
            if tag in ('html', 'head', 'body'):
                self.events.append(tag)
            if tag == 'head':
                self.head = True
            if tag == 'h1':
                self.h1 += 1
            if tag == 'meta' and (a.get('name') or '').lower() in ('robots', 'googlebot'):
                self.robots += re.split(r'[\s,]+', (a.get('content') or '').lower())
            if not self.head:
                return
            if tag == 'base':
                self.base = True
            if tag == 'link' and 'canonical' in (a.get('rel') or '').lower().split():
                self.canon.append(a.get('href') or '')
    
        def handle_endtag(self, tag):
            if tag in ('html', 'head', 'body'):
                self.events.append('/' + tag)
            if tag == 'head':
                self.head = False
    
    
    def audit(folder):
        d = Path(folder)
        cfg = json.loads((d / 'intent.json').read_text(encoding='utf-8'))
        url, want, preferred = cfg['url'], cfg['want_index'], cfg['preferred']
        h = Page()
        h.feed((d / 'page.html').read_text(encoding='utf-8'))
        full = h.events == ['html', 'head', '/head', 'body', '/body', '/html']
        out = {k: U for k in ('meta', 'canonical', 'robots', 'sitemap', 'http', 'index', 'cwv')}
        noindex = bool({'noindex', 'none'} & set(h.robots))
        if full:
            out['meta'] = U if want is None else (P if noindex and want else N)
            targets = {urljoin(url, x) for x in h.canon if x}
            if not h.base and h.canon:
                if any(not x for x in h.canon) or len(targets) > 1:
                    out['canonical'] = P
                elif preferred and all(urlsplit(x).scheme in ('http', 'https') for x in targets):
                    out['canonical'] = N if targets == {preferred} else P
        # 教材専用: 単一の User-agent: * と単純な Disallow 接頭辞だけ扱う。
        lines = [(x.split('#', 1)[0]).strip() for x in (d / 'robots.txt').read_text(encoding='utf-8').splitlines()]
        lines = [x for x in lines if x]
        rules, supported = [], bool(lines) and lines[0].lower() == 'user-agent: *'
        for line in lines[1:]:
            key, sep, value = line.partition(':')
            value = value.strip()
            if not sep or key.lower() != 'disallow' or (value and not value.startswith('/')) or any(c in value for c in '*$%?'):
                supported = False
            rules.append(value)
        if supported:
            blocked = any(x and urlsplit(url).path.startswith(x) for x in rules)
            out['robots'] = (U if want is None else P if want else N) if blocked else N
        try:
            root = ET.fromstring((d / 'sitemap.xml').read_text(encoding='utf-8'))
            ns = '{http://www.sitemaps.org/schemas/sitemap/0.9}'
            if root.tag == ns + 'urlset':
                locs = [x.text for x in root.findall(ns + 'url/' + ns + 'loc')]
                out['sitemap'] = N if preferred and preferred in locs else U
        except ET.ParseError:
            out['sitemap'] = P
        return {'checks': out, 'observed': {'complete_markers': full, 'html_noindex': noindex if full else None,
                'canonical_hrefs': h.canon if full else None, 'h1_count': h.h1},
                'scope': '確認したのは保存したHTML・robots.txt・sitemap.xml。正常は各項目の確認範囲に限る。HTTP・登録状態・CWVは別に取得して確かめる'}
    
    if __name__ == '__main__':
        print(json.dumps(audit(sys.argv[1]), ensure_ascii=False, indent=2))

    run_checks.py:例ごとの判定を照らす

    コードを開く
    import json
    import runpy
    from pathlib import Path
    from audit import audit, P, N, U
    
    runpy.run_path('fixtures.py')
    # 期待値:例ごとに、記事の3分類(問題候補/範囲内で正常/判定不能)で決めた値。
    expected = {
        'normal': [N,N,N,N], 'noindex': [P,N,N,N], 'fragment': [U,U,N,N],
        'conflict': [N,P,N,N], 'intended': [N,N,N,N], 'unknown-intent': [U,N,N,N],
        'googlebot-none': [P,N,N,N], 'cross-canonical': [N,N,N,N],
        'missing-canonical': [N,U,N,N], 'blocked-noindex': [P,N,P,N],
        'complex-robots': [N,N,U,N], 'broken-sitemap': [N,N,N,P],
        'two-h1': [N,N,N,N], 'sitemap-index': [N,N,N,U], 'base-relative': [N,U,N,N],
        'relative': [N,N,N,N], 'body-noindex': [P,N,N,N], 'wp-default': [N,N,U,U],
    }
    results = {}
    for name, values in expected.items():
        result = audit(Path('fixtures') / name)
        assert [result['checks'][k] for k in ('meta','canonical','robots','sitemap')] == values, name
        assert all(result['checks'][k] == U for k in ('http','index','cwv')), name
        results[name] = result
    assert results['two-h1']['observed']['h1_count'] == 2
    assert results['fragment']['observed']['html_noindex'] is None
    assert results['two-h1']['observed']['html_noindex'] is False
    Path('results.json').write_text(json.dumps(results, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
    Path('checks.json').write_text(json.dumps({'fixture_cases':len(expected),'status_assertions':len(expected)*7,
        'observation_assertions':3,'passed':True,'expected':expected}, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
    print(f'CHECK_OK: {len(expected)} cases, {len(expected)*7} statuses, 3 observations')

    python3 run_checks.py の出力は次のとおりで、全例の結果は results.json に保存されます。

    CHECK_OK: 18 cases, 126 statuses, 3 observations

    1つの例を見るときは python3 audit.py fixtures/noindex を実行します。

    {
      "checks": {
        "meta": "問題候補",
        "canonical": "範囲内で正常",
        "robots": "範囲内で正常",
        "sitemap": "範囲内で正常",
        "http": "判定不能",
        "index": "判定不能",
        "cwv": "判定不能"
      },
      "observed": {
        "complete_markers": true,
        "html_noindex": true,
        "canonical_hrefs": [
          "https://example.com/guide/"
        ],
        "h1_count": 1
      },
      "scope": "確認したのは保存したHTML・robots.txt・sitemap.xml。正常は各項目の確認範囲に限る。HTTP・登録状態・CWVは別に取得して確かめる"
    }

    meta が問題候補でも、http や index は判定不能のままです。この例で採る行動は「掲載したいページのnoindexを、公開HTMLと設定で確かめる」で、AIの指摘を一括で直す前に、同じように観測した値と目的を照らします。

  • AIで既存記事に1節足す前に、残す情報を決めて改稿後に照合する

    AIで既存記事に1節足す前に、残す情報を決めて改稿後に照合する

    公開済みの記事をAIに直させると、説明は増えても、元の注意事項や日付、リンク、コードが消えていることがあります。消えた情報は、改稿後の文章だけを読んでも気づけません。反対に、元の情報がすべて残っていても、足された文の中に、根拠のない一文や読者に不要な一文が混ざることがあります。

    WordPressのバックアップ記事に「復元候補を選ぶ」節を1つ足す依頼を、禁止事項(具体的な画面操作、固定の保存間隔、成功の保証などを加えない)を書いて、AIに2回出しました。2回とも、元の注意文・表・画像参照・リンク・コードはすべて残り、保持の検査に合格しました。ところが追加された文には、2回とも、禁止事項をそのまま読者向けの断り書きにした文がありました(「…推奨値ではありません」「…目安となる時間差を定めません」など)。どの禁止事項が断り書きになったかは回ごとに違い、2回目は、依頼に添えた出典のメモまで記事の本文として出力されました。

    この記事では、改稿の前に読者の問いと「変えずに残す情報」を決める改善ブリーフ、改稿後に照合する保持対象表とPythonの検査、追加された文を読み分ける手順を、この2回の実例で示します。読み終えたら、AIの改稿案を採用するか、直してから採るか、保留するかを決められます。

    使う場面と、手元に残るもの

    この手順は、すでに公開している記事の一部だけをAIに書き足させたいときに使います。手元に残るのは次の3つです。

    成果物 中身 使うとき
    改善ブリーフ 読者の問い、記事の役割、残す情報、足す情報、根拠がないときの扱い、扱わないこと AIに依頼する前
    保持対象表 残す情報ごとの照合方法と、照合だけでは分からないこと 改稿案を受け取った直後
    追加文の読み分け 足された文を「採る/削る/出典に戻す」に分ける基準 保持の照合に合格したあと
    改稿前に保持対象を決め、限定改稿のあとに文字列の照合と追加文の読み分けを行う流れ
    元の情報が残っていても、足された文は別に読みます。

    手順1:読者の問いを1文で決める

    最初に、この記事に来た読者が片づけたいことを1文にします。検索語と今の本文から考えます。今回の例は「WordPressのバックアップ設定と保存先」という記事で、Search Consoleでは「WordPress バックアップ 復元」でも表示され、その語の掲載順位が下がっていました(直す記事を選ぶ手順の記事の練習データ)。保存の仕方の記事に、戻し方を探す人も来ていると読めます。

    そこで問いを「保存済みのバックアップから、どれを復元候補にすればよいか」と決めました。問いが今の記事の役割(保存対象と保存先の確認)の続きなら、既存の記事に足します。プラグインの比較のように目的が違う問いなら、別の記事にします。特定のサービスの画面操作のように根拠をまだ確かめていない問いは、保留にします。

    手順2:改善ブリーフに、残す情報と扱わないことを書く

    問いが決まったら、改善ブリーフを書きます。今回のブリーフは次のとおりです。

    項目 今回のブリーフ
    読者の問い 保存分はあるが、どれを復元候補にすればよいか分からない
    記事の役割 保存対象・保存先を確認し、復元の検討へ進む
    残す情報 元の本文と注意事項、表の日時と対象、画像参照とalt、公式リンク先、コード全文
    足す情報 対象サイト・保存日時・ファイルとデータベースの組合せを確かめ、分からなければ保留する説明
    根拠がないとき 未確認の項目と、次に見る公式資料を書く。ボタン名や成功の保証で埋めない
    扱わないこと 復元方式ごとの手順、プラグインの比較、サービス別の画面操作、検索効果の予測

    足す情報の根拠には、WordPress公式の説明を使いました。通常の復元にはデータベースとファイルの両方が必要で、同じ頃の保存分を一組として扱うこと、データベースを復元すると現在の内容が上書きされることです(バックアップの公式説明、データベースのバックアップ・復元、確認日:2026年10月11日)。

    手順3:依頼文に、ブリーフと元の本文を添えて送る

    ブリーフをもとに、次の依頼文を作りました。この後に、改稿前の本文(before.md)の全文と、確認済みの出典2件の要約を続けて送ります。

    読者は、保存済みのWordPressバックアップから復元候補を選びたい人です。
    既存記事の目的は、保存対象と保存先の確認です。
    
    元の本文を書き換えず、「復元候補を選ぶ」節だけを後ろに追加してください。
    元の注意文、表の日時と対象、画像のaltと参照、リンク先、コード全文を残します。
    
    根拠は添付したWordPress公式資料です。
    対象サイト・保存日時・ファイルとDBの組合せを確認する説明を加えます。
    不明なら保留し、記録や利用サービスの公式資料を確認するよう書いてください。
    
    具体的な画面操作、固定の保存間隔、成功保証、未測定のSEO効果は加えません。
    記録例の日時を推奨値として扱わないでください。
    出力は元の本文全体と追加節です。

    この依頼文を、Claudeの新しいチャットに1回送りました(2026年10月20日)。その前に、この記事を作る作業の中で、同じ禁止事項を箇条書きにした指示書でAIに1回書き足させています(2026年10月11日)。以下の「1回目」「2回目」はこの2回です。

    改稿前の本文は次のとおりです。照合を再現するときは、UTF-8の before.md として保存します。

    # WordPressのバックアップ設定と保存先
    
    WordPressのバックアップでは、データベースとファイルを両方保存します。保存先はサーバー内だけにせず、手元や別の保管先にもコピーを置きます。
    
    ## 保存する対象
    
    記事や設定の多くはデータベースに、アップロード画像やテーマ等はファイルに入ります。片方だけでは同じ状態に戻せない場合があります。
    
    | 記録例の保存日時 | 対象 | 確認するもの |
    | --- | --- | --- |
    | 2026-10-10 09:00 JST | データベース | SQLファイル |
    | 2026-10-10 09:00 JST | ファイル | 画像・テーマ等 |
    
    ![データベースとファイルを一組として確認する図](backup-set.webp)
    
    保存対象は[WordPress公式のバックアップ説明](https://developer.wordpress.org/advanced-administration/security/backup/)も確認してください。
    
    ~~~python
    backup_set = ["database.sql", "files.zip"]
    print(len(backup_set))  # 記録例の対象は2種類
    ~~~
    
    ## 実行前の確認
    
    保存日時、対象サイト、ファイルの有無を記録します。復元すると現在の内容が上書きされる可能性があるため、作業直前の状態も別に保存してください。公開サイトでいきなり試さず、検証環境で確認します。
    
    ## 復元について
    
    復元の手順は利用するホスティングやプラグインで異なります。この記事にはサービス別の操作手順はまだありません。

    手順4:保持対象表とPythonで、元の情報が残ったかを照合する

    改稿案を受け取ったら、まず元の情報が残っているかを照合します。保持対象ごとに、照合のしかたと、照合だけでは分からないことを表にしておきます。

    保持対象 照合のしかた それだけでは分からないこと
    3つの注意文 否定や条件を含め、文全体が一致するか 足された文が注意文と矛盾していないか
    表と記録の日時 見出し・区切り・2行の文字列が一致するか 日時が復元に適した保存分か
    画像 altと参照の行が一致するか 画像ファイルの欠落・差し替え
    出典リンク リンク先のURLが完全に残っているか 今の出典がその主張を支えているか
    コード コードブロック全体が一致するか 新しい環境で動くか

    文字列の照合は、次の check.py で行えます。Python 3の標準ライブラリだけで動き、before.md と改稿後の after.md を同じフォルダに置いて python3 check.py before.md after.md で実行します。

    """Python 3標準ライブラリ。保護した箇所の完全一致を確認する小例。"""
    from pathlib import Path
    import re
    import sys
    
    
    def protected(text):
        warnings = (
            "保存日時、対象サイト、ファイルの有無を記録します。",
            "復元すると現在の内容が上書きされる可能性があるため、作業直前の状態も別に保存してください。",
            "公開サイトでいきなり試さず、検証環境で確認します。",
        )
        if not all(s in text for s in warnings):
            raise ValueError("比較元の注意文が不足しています")
        return {
            "注意文": list(warnings),
            "表": re.findall(r"^\|.*\|$", text, re.M),
            "画像参照": re.findall(r"^!\[.*\]\(.*\)$", text, re.M),
            "リンク先": re.findall(r"(?<!!)\[[^\]]+\]\(([^)]+)\)", text),
            "コード": re.findall(r"~~~[^\n]*\n.*?\n~~~", text, re.S),
        }
    
    
    def check(before, after):
        failed = []
        actual = {
            "注意文": after,
            "表": after.splitlines(),
            "画像参照": after.splitlines(),
            "リンク先": re.findall(r"(?<!!)\[[^\]]+\]\(([^)]+)\)", after),
            "コード": re.findall(r"~~~[^\n]*\n.*?\n~~~", after, re.S),
        }
        for kind, pieces in protected(before).items():
            if not pieces:
                raise ValueError(f"比較元に{kind}がありません")
            if any(piece not in actual[kind] for piece in pieces):
                failed.append(kind)
        return failed
    
    
    if __name__ == "__main__":
        if len(sys.argv) != 3:
            raise SystemExit("使い方: python3 check.py before.md after.md")
        before = Path(sys.argv[1]).read_text(encoding="utf-8")
        after = Path(sys.argv[2]).read_text(encoding="utf-8")
        try:
            failed = check(before, after)
        except ValueError as error:
            raise SystemExit(str(error))
        print("FAIL: " + "、".join(failed) if failed else "PASS: 保持対象の文字列が一致")
        raise SystemExit(1 if failed else 0)

    2回の改稿案は、どちらも次の結果でした(終了コード0)。

    PASS: 保持対象の文字列が一致

    表の日時を1つ変えると FAIL: 表、注意文の「上書きされる可能性」を否定に変えると FAIL: 注意文 になります。一方で、注意文を残したまま「検証環境なら現在の内容は上書きされません。」のような根拠のない一文を足しても、この検査は合格します。この検査が確かめるのは「残すと決めた情報が残っているか」だけで、足された文が正しいかは、次の手順で読みます。

    コードを使わない場合は、WordPressのリビジョン画面で改稿前後を見比べられます。投稿の設定サイドバーで「リビジョン」の横の数字を押すと一覧が開き、WordPress 7.0以降では、追加したブロックが緑、削除したブロックが赤、変更したブロックが黄色で示されます(WordPress公式:リビジョン、確認日:2026年10月12日)。公開済みの記事は「更新」を押した時点で反映されるので、改稿は検証環境で保存してから見比べます。

    手順5:足された文を、採る・削る・出典に戻すに分ける

    保持の照合に合格したら、足された文を1文ずつ読みます。Googleも、生成AIで作った内容に正確さ・品質・関連性を求めています(Googleの生成AIコンテンツの指針、確認日:2026年10月11日)。2回の改稿案で、読み分けが必要だった文は次のとおりです(AIの出力の原文の一部)。

    足された文 何が起きたか 分け方
    1回目「…選ぶべき日時や保存間隔の推奨値ではありません。」(文の後半) 禁止事項(日時を推奨値として扱わない)を、読者向けの断り書きにした 削る:表の見出しが「記録例の保存日時」なので要らない
    1回目「候補がそろっても復元の成功が保証されるわけではありません。」 禁止事項(成功を保証しない)を断り書きにした 削る:次の段落の「検証環境で確かめる」で足りる
    1回目「このページでは、未確認の管理画面の操作や復元の実行順序は指定しません。」 禁止事項(画面操作を加えない)を断り書きにした 削る:読者の判断に使えない
    2回目「そのため、この記事では目安となる時間差を定めません。」 禁止事項(固定の保存間隔を加えない)を断り書きにした 削る
    2回目「前掲の表にある2026-10-10 09:00 JSTは、あくまで記録例として置いた日時です。」「選ぶときの基準や推奨値ではありません。」 禁止事項(日時を推奨値として扱わない)を断り書きにした 削る
    2回目「バックアップのファイル名や保存先の表示が、いま復元しようとしているサイトのものかを確認します。」など 添えた出典にない具体的な確かめ方が足された 出典に戻す:使うサービスの公式資料で確かめてから残す
    2回目「## 確認済みの出典(2026-10-11確認)」の見出しと2行 依頼に添えた出典のメモが、元の本文と追加節のあいだに本文として出力された 削る:依頼の外の混入。保持の検査では見つからない

    禁止事項が断り書きの文になった箇所は、1回目に3文、2回目に3文(2か所)ありました。2回に共通していたのは「記録例の日時を推奨値として扱わない」だけで、ほかは回ごとに違う禁止事項が文になっています。依頼に「〜は加えない」と書くと、AIはそれを守ったうえで、守ったことを読者に向けて書くことがある、と考えておくと読み落としにくくなります。

    分け方の基準は次の3つです。

    • 採る:読者が手順や判断に使え、添えた出典で説明できる文。

    • 削る:依頼の禁止事項を読者に向けて言い直しただけの文、依頼の外から入った文。

    • 出典に戻す:出典に書かれていない具体例や確かめ方。根拠を確かめてから残す。

    1回目の追加節から、削る3文を除くと次のようになります。1回目の出力を読み分けの結果に合わせて直した例です。

    ## 復元候補を選ぶ
    
    まず、不具合が起きる前の保存分を候補にします。その保存分が対象サイトのもので、ファイルとデータベースが同じ頃の一組になっているかを確認してください。
    
    対象サイトや保存日時が分からない場合、または片方が欠けている場合は、復元を始めず、保存記録や利用サービスの公式資料を確認します。
    
    復元すると、保存後に追加・変更した内容が戻らなくなる可能性があります。作業直前の状態を別に保存し、検証環境で必要な記事や画像を確認したうえで、利用するホスティングやプラグインの公式手順に進んでください。

    採用するか、保留するかを決める

    照合と読み分けの結果から、次のように決めます。

    • 採用する:保持の照合に合格し、足された文がすべて「採る」だった。

    • 直してから採用する:保持の照合に合格し、「削る」文を除けば残りがすべて「採る」になる。今回の1回目はこれに当たり、上の直した例が採用案です。

    • 保留する:保持の照合に不合格だった、または「出典に戻す」文の根拠がまだ確かめられない。今回の2回目は、出典にない確かめ方が複数あるので、この基準では保留になります。

    採用したら、変更した日と範囲を記録しておきます。検索での評価は、同じ条件の期間のデータで別に見ます。

    保持対象表は、AIに頼むときだけでなく、人が書き直すときにも使えます。この記事自体を書き直したときも、次の表で前後を照らしました。意図して外したものには理由の欄を付けておくと、後で「なぜ消えたか」を追えます。

    保持対象 書き直し後 外した理由
    check.py と before.md の全文 残した(文字列が一致) —
    WordPress公式3件へのリンクと確認日 残した —
    Googleの生成AIコンテンツの指針へのリンク 残した —
    Googleの有用なコンテンツ・画像の説明へのリンク 外した 書き直し後の手順で使う箇所がなくなった
    検査の10ケースの結果表 外した(実験の記録に残した) 読者の判断には、合格・不合格の例2つで足りる
  • Search Consoleの書き出しを2期間で集計し、直す記事を元の行で確かめて1件選ぶ

    Search Consoleの書き出しを2期間で集計し、直す記事を元の行で確かめて1件選ぶ

    WordPressで小さなサイトを運営していると、Search Consoleでクリック数や表示回数が減った記事が目に入ります。ただ、タイトルを変えるべきか、本文を補うべきか、そもそも直すべきかは、画面の合計値だけでは決まりません。AIに分析を頼んでも、その提案が元のデータのどこに基づいているかを確かめられなければ、判断には使えません。

    運営しているサイトで、記事1件について2つの期間のクエリ表を書き出して集計すると、比べる以前の問題が出ました。前の期間はページ全体で2クリック・4表示あったのに、書き出したクエリ表は0行でした。期間を「比較」にしたまま書き出すと、片方の期間に行がない検索語は、クリック・表示・掲載順位がすべて0で埋まり、実在しない「0位」まで入っていました。書き出したCSVを並べるだけでは検索語ごとの前後比較は成り立たず、今回のデータでは「保留して取り直す」が正しい結論になりました。

    この記事では、ページで絞ったクエリ表を同じ条件の2期間で書き出し、コードで集計し、AIに出させた仮説を元のCSVの行に戻して確かめる手順を示します。読み終えたら、先に確かめる記事1件と最初の小さな作業を書いた改善ブリーフを作るか、データが足りないと判断して期間を延ばして取り直すかを決められます。

    完成形:改善ブリーフ1件か、保留と取り直し

    手順の最後に手元に残るのは、次のどちらかです。

    結論 手元に残るもの 次にすること
    候補を1件選ぶ 対象URL、根拠にした元CSVの行、確かめる仮説、最初の小さな作業、判断を変える条件を書いた改善ブリーフ ブリーフの「次の確認」を済ませてから改稿に進む
    比較を保留する 保留したURLと、その理由(行がない、空欄がある、表示が0など) 期間を延ばすか、データがたまってから同じ条件で書き出し直す
    同条件のデータを集計し、比較できない記事は保留し、比較できた記事はAIの仮説を元の行と本文で確かめて1件選ぶ流れ
    集計値、AIの仮説、改稿するかどうかの判断を分けます。

    作業は、データの書き出し→コードでの集計→AIによる仮説の整理→人による確認、の順です。AIにCSVだけを渡して「SEOを改善して」と頼むと、欠けた行をゼロとして数えたり、CTR(クリック率)の低下をすぐタイトルのせいにしたりする余地が残ります。数値はコードで固定し、AIには仮説と次に確かめることだけを出させます。

    手順1:ページで絞ったクエリ表を、期間ごとに書き出す

    「ページ」の一覧と「クエリ」の一覧を別々に書き出しても、どの検索語がどのURLにつながったかは分かりません。確かめたいページを選び、ページで絞った状態のクエリ表を期間ごとに書き出します。以下は、運営しているサイトのSearch Console(日本語画面)でたどった手順です(2026年10月12日)。

    1. 「検索パフォーマンス」を開き、期間の「詳細」から前の期間の開始日と終了日を入れます。検索タイプ、必要なら国と端末も設定します。

    2. 「ページ」タブで対象URLの行をクリックします。上部に「ページ: (URL)」の条件が付き、表は自動で「クエリ」タブに切り替わります。

    3. 右上の「エクスポート」から「CSVをダウンロード」を選びます。届くのはZIPで、中にクエリ・ページ・国・デバイス・検索での見え方の表と、条件を記した「フィルタ.csv」が入っています。

    4. ページの条件はそのままで期間を後の期間に変え、もう一度書き出します。ファイル名には書き出した日付しか入らないので、保存したらすぐ「前期間」「後期間」が分かる名前に変えます。

    比べる2つの期間は長さをそろえ、対象URL・検索タイプ・国・端末を固定します。検索タイプは、上部の表示が「検索タイプ: ウェブ」でも、開くと「ウェブ」の下の「テキストベース」が選ばれていました。フィルタ.csvには「ウェブ」としか残らないので、画面で選んだ区分は自分で記録します。グラフの最新部分が点線なら暫定のデータなので、確定した期間で比べます。日別のデータは太平洋時間(PT)で集計されるため、日本時間のアクセス解析と日付だけで突き合わせると少しずれます(Google公式:概要と基本設定、データの不一致、確認日:2026年10月17日)。

    期間の「比較」のまま書き出さない

    期間の「比較」は画面で見比べるには便利ですが、書き出しには使いません。比較したまま書き出したZIPでは、前の期間に行がない検索語が、前期間側のクリック数・表示回数・CTR・掲載順位をすべて0にして出力されていました。掲載順位は1位以上の値なので、0位は実在しない値です。フィルタ.csvにも、比較した側の期間は残りませんでした。

    CSVの0が、本当のゼロとは限らない

    Googleの説明では、画面で「~」「-」と表示される項目は、ダウンロードしたデータでは0になります(エクスポートの注意)。CSVだけでは、本当のゼロと値がなかった項目を見分けられないことがあります。書き出す前に画面の表示を記録し、どちらか分からない値は、集計の入力では空欄にします。

    また、クエリ表には匿名化された検索語が出ず、表に出せる行数にも上限があります。取得したクエリ行を合計しても、ページ全体の合計と一致するとは限りません(表とグラフの差)。ページ側の合計と、取得できたクエリ行を別々に記録しておきます。

    運営サイトで書き出した結果:保留

    運営しているサイトの記事1件で、9日間ずつの2期間(2026年9月22〜30日と10月1〜9日)を書き出し、集計まで通しました。サイトを公開して間もないため、期間は短くしています。

    期間 ページ全体(ページ.csv) 書き出したクエリ行
    前期間 2クリック/4表示 0行
    後期間 1クリック/1表示 1行(1クリック/1表示)

    集計の結果は比較の保留で、理由は「観測クエリ集合が不一致」と「before: 行なし」でした。前の期間にもクリックはあったのに、どの検索語だったかは表に出てきません。ページ合計の減少だけでは検索語ごとの原因を判断できないので、私はこの記事を改稿の候補から外し、データがたまってから同じ条件で取り直すことにしました。

    保留の理由ごとに、次にすることは次のとおりです。

    保留の理由 次にすること
    片方の期間に行がない 期間を延ばして取り直す。流入が消えた、新しく表示された、とはこれだけで決めない
    数値に空欄がある 画面で該当の行の値を確かめる。分からなければ空欄のまま保留する
    表示回数が0 CTRと掲載順位は計算できない。0%として扱わない

    手順2:コードで集計する

    2つのZIPを、記事の最後に載せた gsc_to_input.py で共通の列のCSVに写し、aggregate.py で集計します。どちらもPython 3.10以上の標準ライブラリだけで動きます。手元でPythonを動かすのが難しければ、コードを実行できる環境のClaudeなどに実行させ、出てきた値を下の期待値と照らしてから次へ進みます。

    python3 gsc_to_input.py 前期間.zip 後期間.zip > input.csv
    python3 aggregate.py input.csv context.json > result.json

    context.json には、2つの期間の日付と、検索タイプ・国・端末・確定データかどうかを、画面の記録から書きます(形式は最後の付録にあります)。

    集計では、CTRを行ごとの率の平均ではなく、合計クリック数÷合計表示回数で計算し直します。掲載順位も、行ごとの順位を表示回数で重み付けして平均します。

    取得クエリ行のCTR(%)= 合計クリック数 ÷ 合計表示回数 × 100
    取得クエリ行の掲載順位 ≒ Σ(各行の掲載順位 × 表示回数)÷ 合計表示回数

    たとえば「60クリック/1,000表示」と「40クリック/400表示」の2行なら、CTRは100÷1,400×100=7.14%で、6%と10%を平均した8%にはなりません。Search Consoleの平均掲載順位は検索結果に出たときの最上位の位置を平均した値なので、取得した行から計算した順位は、画面のページ単位の順位と一致するとは限らない近似値として扱います。

    コードは入力の状態に応じて、次のように止まるか保留します。

    入力の状態 処理
    同じ期間・URL・検索語が重複 二重に数えないよう、入力エラーで止まる
    期間の長さや、検索タイプ・国・端末が違う/暫定データ 入力エラーで止まる
    片方の期間に行がない/数値が空欄 そのURLを保留にする。欠けた値をゼロで埋めない
    表示回数が0 CTRと掲載順位をnullにし、0%と区別する
    掲載順位が1未満・数値以外 入力エラーで止まる

    result.json では、URLごとに comparable(比較できたか)と hold_reasons(保留の理由)を見ます。source_rows から、検索語ごとの値と元CSVの行番号をたどれます。入力エラーで止まったら、空の出力をAIに渡さず、CSVと条件を直します。

    手順3:比較できた記事を、検索語の内訳で確かめる

    運営サイトでは比較できる記事がなかったので、比較できた場合の読み方を、練習用に用意した18行のCSV(7URL、URLはexample.com)で示します。期間は2026年8月14日〜9月10日と9月11日〜10月8日の28日ずつ、条件は日本・モバイル・ウェブのテキストベースです。比較できたのは7URL中3URLでした。

    対象URLの末尾 クリック(表示) CTR(%) 掲載順位
    /wordpress-backup/ 100→50(1,400→1,400) 7.14→3.57 1.94→3.63
    /wordpress-speed/ 100→120(2,500→3,000) 4.00→4.00 4.40→4.40
    /seo-basics/ 30→50(1,100→3,100) 2.73→1.61 7.41→7.79

    掲載順位は数値が大きいほど下位です。残りの4URLは、片方の期間に行がない、空欄がある、両期間とも表示0、のいずれかで保留になりました。

    合計だけを見ると、/seo-basics/ もCTRと順位が悪化しています。ところが、検索語ごとに見るとどちらも変わっていません。

    検索語 クリック/表示 CTR 掲載順位
    SEO 基本 20/100→20/100 20%のまま 1.5位のまま
    SEO とは 10/1,000→30/3,000 1%のまま 8.0位のまま

    CTRも順位も低い「SEO とは」の表示が3倍に増えたため、合計の値が引っ張られただけで、クリック数は20件増えています。合計CTRが下がったという理由だけで、タイトルを変える必要はありません。一方、/wordpress-backup/ は2つの検索語でそろってCTRが半分になり、「WordPress バックアップ 復元」の順位は1.8位から4.2位に下がっていました。

    手順4:AIに仮説と次の確認を出させ、元の行に戻す

    数値はコードで固定し、AIには集計結果、元のCSV、期間と条件、候補ページの本文を渡します。公開できない検索語やURLを含む場合は、利用するAIサービスに渡してよい範囲を先に決めておきます。

    目的:Search Consoleの2期間比較から、先に確認する記事を1件選ぶ。
    入力:元CSV、条件記録、コードで作った集計JSON、候補ページの本文。
    
    数値は集計JSONを使い、根拠を元CSVの行番号まで戻してください。
    URLごとに「観測した事実/改善仮説/代替説明/次に確認すること/
    限定作業の予定工数」を出し、候補は最大3件にしてください。
    
    行の欠落や空欄は0として扱わないでください。CTRは合計クリック÷合計表示で扱います。
    掲載順位は表示回数で重み付けした値を使い、検索語ごとの順位も見てください。
    CTRや順位が下がったことだけで、タイトル・本文・AI Overviewを原因と決めないでください。
    同じ語に複数URLがあることだけで競合と判断しないでください。
    検索ボリューム、売上、順位上昇幅は、入力にないので作らないでください。
    工数は見積もりと明記し、本文で確認できる不足とCTR低下の原因は分けて書いてください。
    データが足りないURLは保留にし、理由を残してください。

    練習データの集計結果・元CSV・条件と、/wordpress-backup/ の本文の見本を添えて、この依頼をClaudeに1回送りました(2026年10月11日)。Claudeは /wordpress-backup/ を第一候補にし、順位が1.94位から3.63位に下がっているので、CTR低下の一部はそれに伴う可能性があると整理しました。本文の不足として、復元の手順がないことに加えて、タイトルにも「復元」が入っていないことを挙げ、前の期間がお盆と重なることも代替説明に入れていました。

    AIの案は、そのまま採用せず、数値を result.json と、根拠の行番号を元CSVと照らします。照らしたうえで、私は次のように判断しました。

    URL 根拠 判断
    /wordpress-backup/ CSV2〜5行。クリック−50、CTR−3.57ポイント、順位1.94→3.63。2語ともCTRが半分 第一候補にする。原因の特定と全面改稿は、次の確認の後に回す
    /seo-basics/ CSV10〜13行。合計は悪化して見えるが、検索語ごとの値は同じ 改稿を急がず、表示が増えた状態が続くかを見る
    /wordpress-speed/ CSV6〜9行。クリックと表示が同じ割合で増え、CTRも順位も同じ 後回しにする。ほかの語や問い合わせなどの成果は別に見る

    AIが出した作業時間の見積もりは、自分の作業の速さで見積もり直すため、候補表から外しました。

    改善ブリーフを書く

    第一候補には、次の改善ブリーフを書いて、次の作業に渡します。判断の対象にした本文の見本は次のとおりです。

    タイトル:WordPressのバックアップ設定と保存先

    データベースとファイルを両方保存し、サーバー外にもコピーを置きます。保存日時と対象を記録してください。復元は現在の内容を上書きする可能性があるため、作業直前の状態も保存し、検証環境で確認します。復元方法はホスティングやプラグインで異なり、この記事にはサービス別の操作手順はありません。

    項目 内容
    対象・読者の問い /wordpress-backup/。「保存済みのバックアップから、どう安全に戻せるか」
    まず確かめる仮説 復元を目的とする読者に、方法の選び方が足りない。サービス別の手順が要るか、案内のリンクで足りるかを確かめる
    代替説明 検索結果の構成や他サイトの変化、表示タイトルの書き換え、季節(前の期間がお盆と重なる)、取得できていない行の変化
    次の確認 日別の推移、WordPressのリビジョン履歴(期間中に本文やタイトルを変えていないか)、URL検査、実際の検索結果での見え方
    残す情報 データベースとファイルの両方、サーバー外の保存、保存日時、上書きの注意、作業前の別保存、検証環境での確認
    最初の小さな作業 本文を直す前に、復元方法の選択肢と必要な根拠を整理する
    判断を変える条件 期間中に本文やタイトルを変えていた、検索結果全体の変動で説明できる、検索の意図が別、のどれかなら本文の追加を優先しない

    ブリーフの「残す情報」は、改稿で消してはいけない情報の一覧です。この先の、AIに既存記事へ書き足させて照合する手順は、既存記事に1節足す前に残す情報を決める記事で扱っています。

    直したら、変更した日と箇所を記録し、確定した同じ条件の期間で見直します。一度に何か所も直すと、どれが効いたのかを切り分けにくくなります。

    付録:コードと練習データ

    長いコードは折りたたんでいます。4つのファイルを同じフォルダにUTF-8で保存すると、練習データで上の結果を再現できます。Python 3.10と3.13で動作を確かめました。

    入力の列

    列 意味・扱い
    period beforeまたはafter。日付と条件は context.json に書く
    page 絞り込んだページの完全なURL。比較の間は表記を変えない
    query 検索語。同じ期間・URL・検索語は1行だけ
    clicks/impressions 0以上の整数。取得できなかった値は空欄、確かめたゼロは0
    position 1以上の数値。表示回数が0の行や取得できなかった値は空欄

    元のCSVにあるCTR列は使わず、クリック数と表示回数から計算し直します。

    input.csv(練習データ)

    period,page,query,clicks,impressions,position
    before,https://example.com/wordpress-backup/,WordPress バックアップ,60,1000,2.0
    before,https://example.com/wordpress-backup/,WordPress バックアップ 復元,40,400,1.8
    after,https://example.com/wordpress-backup/,WordPress バックアップ,30,1000,3.4
    after,https://example.com/wordpress-backup/,WordPress バックアップ 復元,20,400,4.2
    before,https://example.com/wordpress-speed/,WordPress 高速化,80,2000,4.0
    before,https://example.com/wordpress-speed/,WordPress 画像 軽量化,20,500,6.0
    after,https://example.com/wordpress-speed/,WordPress 高速化,96,2400,4.0
    after,https://example.com/wordpress-speed/,WordPress 画像 軽量化,24,600,6.0
    before,https://example.com/seo-basics/,SEO 基本,20,100,1.5
    before,https://example.com/seo-basics/,SEO とは,10,1000,8.0
    after,https://example.com/seo-basics/,SEO 基本,20,100,1.5
    after,https://example.com/seo-basics/,SEO とは,30,3000,8.0
    before,https://example.com/not-observed/,検索テスト,5,100,9.0
    after,https://example.com/first-observed/,新しいテスト,8,200,12.0
    before,https://example.com/zero/,ゼロのテスト,0,0,
    after,https://example.com/zero/,ゼロのテスト,0,0,
    before,https://example.com/incomplete/,欠測のテスト,4,100,7.0
    after,https://example.com/incomplete/,欠測のテスト,,100,7.5

    context.json

    web-text と query-page はこのコード用の記録名で、前者はウェブのテキストベース、後者はクエリ×ページを表します。finalized は、入力した人が確定データだと確かめた記録です。実データでは、フィルタ.csvと画面の記録に合わせて新しく作り、練習用の日付を残さないでください。

    {
      "before": {"start":"2026-08-14","end":"2026-09-10","search_type":"web-text","country":"jpn","device":"MOBILE","grain":"query-page","finalized":true},
      "after": {"start":"2026-09-11","end":"2026-10-08","search_type":"web-text","country":"jpn","device":"MOBILE","grain":"query-page","finalized":true}
    }

    aggregate.py

    コードを開く
    import csv
    import json
    import math
    import sys
    from collections import defaultdict
    from datetime import date
    
    COLUMNS = ["period", "page", "query", "clicks", "impressions", "position"]
    
    
    def to_count(value, line):
        """空欄は取得できなかった値(None)、確認済みのゼロは0として読む。"""
        if value == "":
            return None
        if value.isascii() and value.isdigit():
            return int(value)
        raise ValueError(f"非負整数または空欄が必要: CSV {line}行")
    
    
    def to_position(value, line):
        """掲載順位は1以上の数値。空欄は取得できなかった値(None)。"""
        if value == "":
            return None
        try:
            position = float(value)
        except ValueError:
            position = math.nan
        if not math.isfinite(position) or position < 1:
            raise ValueError(f"掲載順位は1以上の数値または空欄: CSV {line}行")
        return position
    
    
    def aggregate(csv_path, context_path):
        with open(context_path, encoding="utf-8") as f:
            context = json.load(f)
        if set(context) != {"before", "after"}:
            raise ValueError("期間はbeforeとafterの2つが必要")
        spans = []
        for period in ("before", "after"):
            c = context[period]
            start, end = date.fromisoformat(c["start"]), date.fromisoformat(c["end"])
            if end < start or c["finalized"] is not True:
                raise ValueError("期間の順序または確定状態が不正")
            spans.append((start, end))
        if ((spans[0][1] - spans[0][0]).days !=
                (spans[1][1] - spans[1][0]).days or spans[0][1] >= spans[1][0]):
            raise ValueError("期間の長さが不一致、または重複・逆順")
        for key in ("search_type", "country", "device", "grain"):
            if not context["before"][key] or context["before"][key] != context["after"][key]:
                raise ValueError("比較条件が不一致: " + key)
        if context["before"]["grain"] != "query-page":
            raise ValueError("query-page形式が必要")
    
        grouped = defaultdict(lambda: defaultdict(dict))
        with open(csv_path, encoding="utf-8-sig", newline="") as f:
            reader = csv.DictReader(f)
            if reader.fieldnames != COLUMNS:
                raise ValueError("CSV列が不正")
            for line, row in enumerate(reader, 2):
                if None in row.values():
                    raise ValueError(f"列が不足: CSV {line}行")
                p, url, query = row["period"], row["page"], row["query"]
                if p not in context or not url.startswith("https://") or not query:
                    raise ValueError(f"入力不正: CSV {line}行")
                if query in grouped[url][p]:
                    raise ValueError(f"query-page重複: CSV {line}行")
                clicks = to_count(row["clicks"], line)
                impressions = to_count(row["impressions"], line)
                if clicks is not None and impressions is not None and clicks > impressions:
                    raise ValueError(f"クリック数が表示回数を超過: CSV {line}行")
                grouped[url][p][query] = {"clicks": clicks, "impressions": impressions,
                                         "position": to_position(row["position"], line),
                                         "line": line}
    
        if not grouped:
            raise ValueError("データ行がない")
        output = []
        for url, periods in sorted(grouped.items()):
            reasons, totals = [], {}
            if set(periods.get("before", {})) != set(periods.get("after", {})):
                reasons.append("観測クエリ集合が不一致(欠落はゼロにしない)")
            for p in ("before", "after"):
                rows = list(periods.get(p, {}).values())
                blank = [str(r["line"]) for r in rows
                         if r["clicks"] is None or r["impressions"] is None
                         or (r["impressions"] > 0 and r["position"] is None)]
                if not rows or blank:
                    totals[p] = {"clicks": None, "impressions": None,
                                 "ctr_pct": None, "position": None}
                    reasons.append(p + (": 行なし" if not rows
                                        else ": 空欄あり(CSV " + "・".join(blank) + "行)"))
                    continue
                clicks = sum(r["clicks"] for r in rows)
                impressions = sum(r["impressions"] for r in rows)
                if impressions:
                    ctr = 100 * clicks / impressions
                    # 行ごとの掲載順位を表示回数で重み付けした近似値
                    position = sum(r["position"] * r["impressions"]
                                   for r in rows if r["impressions"]) / impressions
                else:
                    ctr = position = None
                    reasons.append(p + ": 表示回数ゼロでCTR・順位を計算できない")
                totals[p] = {"clicks": clicks, "impressions": impressions,
                             "ctr_pct": ctr, "position": position}
            comparable = not reasons
            delta = {}
            if comparable:
                before, after = totals["before"], totals["after"]
                for k in ("clicks", "impressions"):
                    delta[k] = after[k] - before[k]
                delta["ctr_pp"] = after["ctr_pct"] - before["ctr_pct"]
                delta["position"] = after["position"] - before["position"]  # 正は下位へ
            output.append({"page": url, "totals": totals, "delta": delta,
                           "comparable": comparable, "hold_reasons": reasons,
                           "source_rows": dict(periods)})
        return output
    
    
    if __name__ == "__main__":
        try:
            result = aggregate(sys.argv[1], sys.argv[2])
            print(json.dumps(result, ensure_ascii=False, indent=2))
        except (ValueError, KeyError, IndexError) as e:
            sys.exit("入力を確認してください: " + str(e))

    練習データで実行すると、comparable は3URLが true、4URLが false になり、/wordpress-backup/ の delta は次の値になります。

    {"clicks": -50, "impressions": 0, "ctr_pp": -3.5714285714285716, "position": 1.6857142857142857}

    gsc_to_input.py

    期間を比較せず、ページ1件で絞り込んで書き出した前後2つのZIPを、aggregate.py の入力に写します。比較のまま書き出したZIPは列の形が違うので、受け付けずに止まります。表示回数が0の行は、順位を空欄にします。

    コードを開く
    """Search Consoleの書き出し(ZIP)を、aggregate.pyの入力行に写す。
    
    期間比較なし・ページ1件で絞り込んで「CSVをダウンロード」した前後2つのZIPを使う。
    """
    import csv
    import io
    import sys
    import zipfile
    
    QUERY_COLUMNS = ["上位のクエリ", "クリック数", "表示回数", "CTR", "掲載順位"]
    
    
    def read_csv(z, name):
        with z.open(name) as f:
            return list(csv.reader(io.TextIOWrapper(f, encoding="utf-8-sig", newline="")))
    
    
    def convert(zip_path, period):
        with zipfile.ZipFile(zip_path) as z:
            filters = dict(read_csv(z, "フィルタ.csv")[1:])
            rows = read_csv(z, "クエリ.csv")
        page = filters.get("ページ", "")
        if not page.startswith("https://"):
            raise ValueError(f"{zip_path}: ページ1件で絞り込んだ書き出しが必要")
        if rows[0] != QUERY_COLUMNS:
            raise ValueError(f"{zip_path}: クエリ.csvの列が想定と違う(期間比較の書き出しは使わない)")
        out = []
        for query, clicks, impressions, _ctr, position in rows[1:]:
            if impressions == "0":
                position = ""  # 表示0の行には順位がない
            out.append([period, page, query, clicks, impressions, position])
        return filters, out
    
    
    if __name__ == "__main__":
        if len(sys.argv) != 3:
            sys.exit("使い方: python3 gsc_to_input.py 前期間.zip 後期間.zip > input.csv")
        try:
            (fb, before), (fa, after) = convert(sys.argv[1], "before"), convert(sys.argv[2], "after")
            if {k: v for k, v in fb.items() if k != "日付"} != {k: v for k, v in fa.items() if k != "日付"}:
                raise ValueError("前後で日付以外のフィルタが違う")
        except (ValueError, KeyError, OSError, zipfile.BadZipFile) as e:
            sys.exit("書き出しを確認してください: " + str(e))
        for name, f, rows in (("before", fb, before), ("after", fa, after)):
            print(f"{name}: {f.get('日付')} / 検索タイプ {f.get('検索タイプ')} / クエリ {len(rows)}行", file=sys.stderr)
        writer = csv.writer(sys.stdout, lineterminator="\n")
        writer.writerow(["period", "page", "query", "clicks", "impressions", "position"])
        writer.writerows(before + after)