Claude Media
Claude Codeでヘルプセンター記事を棚卸し — Zendeskの古い記事と旧UI名を洗う

Claude Codeでヘルプセンター記事を棚卸し — Zendeskの古い記事と旧UI名を洗う

ZendeskのHelp Center articles APIで記事を全件書き出し、edited_atの古さと旧画面名・旧プラン名の残りを突き合わせて、直す順に並べる手順です。

製品の画面名やプラン名を変えたあと、ヘルプセンターに旧名が残る。Zendeskのヘルプセンターを運用していると、よく起きる状態です。管理画面で記事一覧を並べ替えても、どの記事の本文に旧名が埋まっているかは見えません。

この記事では、Help Center articles APIで記事を全件書き出し、「最後に編集された日」と「本文に残った旧名」の2軸で、直す順の一覧を作る手順を扱います。突き合わせの計算はPythonスクリプトに任せ、Claude Codeには、そのスクリプトを書かせることと、出てきた候補の直し方の下書きを作らせることを頼みます。

マクロ側の棚卸しはZendeskマクロの棚卸しにあります。問い合わせ履歴から新しいヘルプ記事の種を拾う流れはClaudeでFAQを作成の領分で、ここでは既にある記事の古さを洗います。

古さは何で測るか — updated_atとedited_atの違い

記事のJSONには時刻が3つ入っています。created_at、updated_at、edited_atです。棚卸しで見るべきなのはedited_atです。

  • updated_atは「記事が最後に更新された時刻」
  • edited_atは「表示中のロケールで、記事が最後に編集された時刻」

ラベルの付け替えや並び順の変更のようなメタデータの更新と、本文の書き換えは別物です。本文が何年も触られていない記事を探したいなら、edited_atのほうが目的に合います。sort_byの説明には「edited_atはタイトルか本文を最後に編集した時刻」という趣旨の記述があります。

ここで、公式ページ内の食い違いを一つ押さえておきます。並べ替えの解説はsort_byの値としてposition・title・created_at・updated_at・edited_atを挙げ、edited_atにはロケールの指定が要ると書いています。一方、同じページのパラメータ表は許容値をposition・title・created_at・updated_atの4つだけとしています。edited_atで並べ替えるリクエストが通るかは、自分のアカウントで一度試して確かめるのが確実です。この記事のスクリプトは、並べ替えに頼らず全件を取ってから手元でedited_atを比べる作りにして、この食い違いを避けます。

記事の鮮度に関する項目でもう一つ、outdatedは「非推奨で、常にfalse」と明記されています。翻訳元の記事が常に最新の翻訳として扱われるためです。「古い」の目印として使えるのは、翻訳側のロケールで古いと印を付けたoutdated_localesの配列だけになります。

材料

古さの判定に使う項目

  • edited_at

    表示ロケールで最後に編集された時刻。本文の古さはこれで見ます。

  • outdated_locales

    古いと印が付いたロケールの一覧。多言語運用の記事で、翻訳の遅れを拾えます。

  • draft・promoted・vote_sum

    下書きか、目立つ場所に出しているか、評価の合計。直す順を決めるときの重みに使います。

手順1: 記事を全件書き出す

取得に使うのは、記事一覧のエンドポイントです。ロケールを入れた形が/api/v2/help_center/{locale}/articles.jsonで、管理者・担当者はロケールを省略することもできます。ここでは本文の言語をはっきりさせるため、ロケールを入れます。

ページ送りはカーソル方式を使います。公式はカーソル方式を推奨しており、page[size]を付けるとカーソル方式になります。ほとんどのエンドポイントで1ページの上限は100件です。付けない場合はオフセット方式になり、最初の100ページ(1万件)を超えるリクエストは400エラーになります。記事が数千件規模でも、カーソル方式なら取りこぼしの心配はありません。

認証はOAuthのアクセストークンを使います。APIトークンは認証方式の一覧で「deprecated」と表記されています。トークンは環境変数に置き、スクリプトには書き込みません。

次のコードをexport_articles.pyとして保存します。Claude Codeに「記事を全件JSONに書き出して」と頼んだときに目指す形の一例で、meta.has_moreとlinks.nextはページ送りの解説にあるレスポンスに合わせています。

import json, os, time, urllib.request, urllib.error
 
sub = os.environ["ZENDESK_SUBDOMAIN"]
loc = os.environ.get("HC_LOCALE", "ja")
token = os.environ["ZENDESK_OAUTH_TOKEN"]
url = (f"https://{sub}.zendesk.com/api/v2/help_center/{loc}/articles.json"
       "?page[size]=100")
 
articles = []
while url:
    req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
    try:
        with urllib.request.urlopen(req) as res:
            data = json.load(res)
    except urllib.error.HTTPError as e:
        if e.code == 429:
            time.sleep(int(e.headers.get("Retry-After", "30")))
            continue
        raise
    articles += data["articles"]
    url = data["links"]["next"] if data["meta"]["has_more"] else None
 
json.dump(articles, open("articles.json", "w"), ensure_ascii=False)
print(len(articles), "articles")

実行は次のとおりです。

export ZENDESK_SUBDOMAIN=your-subdomain
export ZENDESK_OAUTH_TOKEN=...   # OAuthアクセストークン
export HC_LOCALE=ja
python3 export_articles.py

レート制限は、Help Center APIもSupport APIと同じ水準で、プランごとに1分あたりTeamが200、Professionalが400、Enterpriseが700リクエストです。Help Center APIの呼び出しはSupport APIの枠とは別に数えられます。1ページ100件なので、記事が1,000件あっても10リクエスト前後で済み、棚卸しの頻度なら制限に触れる量ではありません。429が返ったときはRetry-Afterの秒数だけ待つ処理を入れてあります。

差分だけを見たい運用なら、/api/v2/help_center/incremental/articles.json?start_time=<Unixエポック秒>で、指定時刻以降に更新された記事だけを取れます。月次で回すなら、前回の実行時刻をstart_timeに渡す形にすると、取得量が減ります。

手順2: 旧UI名・旧プラン名の辞書を作って突き合わせる

APIは、どの語が「旧名」かを教えてくれません。旧名は自分たちで辞書にします。製品の変更履歴やリリースノートから、改名した画面名・メニュー名・プラン名を拾い、旧名と新名の組をrenames.jsonにします。

{
  "ビジネスプラン": "Teamプラン",
  "設定メニュー": "管理センター"
}

この辞書づくりは、Claude Codeに手伝わせやすい作業です。リリースノートの文章を渡して「改名された名称を旧名と新名の組で列挙して」と頼めば、下書きができます。最終的な採否は人が決めます。辞書に載せた語がそのまま一般語として使われる場合(「設定メニュー」が別の意味で出てくる等)は、ヒットが誤検知になるためです。

記事のbodyはHTMLなので、タグを外した文字列に対して辞書を当てます。次のコードをaudit_articles.pyとして保存します。

import csv, html, json, re, sys
from datetime import datetime, timezone
 
STALE_DAYS = int(sys.argv[1]) if len(sys.argv) > 1 else 365
articles = json.load(open("articles.json"))
renames = json.load(open("renames.json"))  # {"旧名": "新名", ...}
now = datetime.now(timezone.utc)
 
def text(body):
    s = re.sub(r"<[^>]+>", " ", body or "")
    return re.sub(r"\s+", " ", html.unescape(s))
 
rows = []
for a in articles:
    t = text(a.get("body"))
    hits = []
    for old, new in renames.items():
        for m in re.finditer(re.escape(old), t):
            hits.append((old, new, t[max(m.start() - 15, 0):m.end() + 15]))
        if old in (a.get("title") or ""):
            hits.append((old, new, "(タイトル)"))
    edited = datetime.fromisoformat(a["edited_at"].replace("Z", "+00:00"))
    age = (now - edited).days
    rows.append({
        "id": a["id"], "title": a["title"], "url": a["html_url"],
        "draft": a["draft"], "promoted": a["promoted"],
        "vote_sum": a.get("vote_sum", 0), "edited_at": a["edited_at"],
        "age_days": age, "stale": age >= STALE_DAYS,
        "hit_count": len(hits),
        "hit_terms": sorted({h[0] for h in hits}),
        "samples": [h[2] for h in hits[:3]],
    })
 
# 直す順: 旧名ヒットが多い -> 古い -> 票が多い
rows.sort(key=lambda r: (-r["hit_count"], -r["age_days"], -r["vote_sum"]))
with open("article_report.csv", "w", newline="") as f:
    w = csv.writer(f)
    w.writerow(["id", "title", "draft", "promoted", "edited_at", "age_days",
                "stale", "hit_count", "hit_terms", "samples", "url"])
    for r in rows:
        if r["hit_count"] or r["stale"]:
            w.writerow([r["id"], r["title"], r["draft"], r["promoted"],
                        r["edited_at"], r["age_days"], r["stale"], r["hit_count"],
                        " / ".join(r["hit_terms"]), " | ".join(r["samples"]), r["url"]])
print(sum(1 for r in rows if r["hit_count"] or r["stale"]), "of", len(rows), "flagged")

古さの閾値は引数で変えられ、既定は365日です。

python3 audit_articles.py 365

ダミーの4記事で実行しました。内訳は、旧プラン名と旧メニュー名を両方含む古い記事、旧名はないが2年以上触られていない記事、タイトルに旧プラン名を含む直近の下書き、問題のない新しい記事です。出力は4件中3件の検出で、CSVの先頭は「旧名を2回含む古い記事」、次が「タイトルにだけ旧名がある下書き」、最後が「旧名なしで古いだけの記事」の順でした。問題のない記事はCSVに載りません。

手順3: 直す順を決め、Claude Codeに下書きを作らせる

並べ方は、スクリプトのsortの1行で決まります。ここに載せた「旧名の数、古さ、評価」の順は、筆者の置いた仮の優先度です。ヘルプセンターの閲覧数はこの記事一覧のAPIでは取れないため、閲覧の多い記事を先に直したいなら、promotedを重みに加えるか、別の分析ツールの数字を列に足します。

優先度の考え方は、次のように分けると迷いません。

  1. 旧名が本文にあり、promotedがtrueの記事。目に付く場所で誤った案内が出ています
  2. 旧名が本文にあり、古い記事。手順の画面が今と違う可能性が高く、読者がつまずきます
  3. 旧名はないが古い記事。内容が今も合っているかの確認が中心で、直すとは限りません
  4. 下書き(draftがtrue)。公開前なので、読者への影響はまだありません

候補の絞り込みができたら、article_report.csvの上位だけをClaude Codeに読ませます。全記事の本文を渡す必要はありません。

article_report.csvのhit_countが1以上の行だけを対象にしてください。
articles.jsonから該当IDの本文を引き、旧名ごとに、renames.jsonの新名への
置き換え案を、前後の文脈つきで表にしてください。
置き換えると意味が通らない箇所や、画面の手順そのものが変わっていそうな
箇所には「要確認」と付けてください。
Zendeskへの書き込みはしないでください。

文字列の置き換えだけで済む記事と、手順の画面が変わっていて書き直しが必要な記事は、ここで分かれます。後者は、実際の画面を見ながら人が直す部分で、Claude Codeの下書きは「どの段落が怪しいか」を示す地図として使います。

Claude Codeに触らせる範囲を決める

このスクリプトはGETだけを使います。Claude Codeに作業ディレクトリを渡すときは、書き込みをしないことをプロジェクトのCLAUDE.mdに書いておくと、依頼のたびに前提を言い直さずに済みます。

## ヘルプセンター棚卸しの作業規約
- Zendesk APIは読み取り(GET)だけを使う。PUT・POST・DELETEは実行しない
- 認証情報は環境変数 ZENDESK_OAUTH_TOKEN から読み、ファイルに書き出さない
- 旧名と新名の対応は renames.json だけを正とし、スクリプトに直書きしない
- 出力は article_report.csv に集約し、記事の修正案は別ファイルに分ける

書き込みを人の手に残す理由は、APIの仕様にもあります。記事の更新PUT /api/v2/help_center/articles/{article_id}は、昇格や並び順のようなメタデータの更新用で、タイトル・本文・ロケール・下書きの状態は更新できません。それらは翻訳(Translations)のエンドポイントの担当です。誤って記事のメタデータだけを書き換えた、という事故が起きる余地があります。また、削除のDELETE /api/v2/help_center/articles/{article_id}は記事をアーカイブし、元に戻すにはヘルプセンターの管理画面から復元します。棚卸しの結果で古い記事を片づける場合も、アーカイブは人が画面で確かめてから行う作業に残しておくのが安全です。

見落としやすいところ

落とし穴

画像の中の旧画面名は検出できない

スクリプトが見るのは本文のテキストだけです。スクリーンショットに写った旧メニュー名や旧プラン名は、文字列としては本文に存在しません。画像を多用する記事は、旧名ヒットが0でも、画面キャプチャが古いままの可能性があります。hit_countが0でstaleがtrueの記事を、画像の確認対象に回してください。

見落としやすい点は、ほかにもあります。

  • 多言語の記事: 一覧は指定したロケールの内容を返します。翻訳先のロケールは、その言語ごとに取得して辞書を当てます。翻訳が遅れている記事はoutdated_localesにロケールが入ります
  • コンテンツブロック: Articles APIには、コンテンツブロックを有効にした記事での制限があります。ブロックを使っている記事は、本文の見え方がAPIのbodyと一致しない場合があるため、画面で見て確かめます
  • 本文のHTML: bodyは安全でないタグや属性が除去されることがあるHTMLです。表示される文面とほぼ同じですが、完全に同一とは限りません
  • ラベルでの絞り込み: 特定のラベルの記事だけを洗うときはlabel_namesを使います。複数指定するとすべてのラベルを持つ記事だけが返り(AND)、大文字小文字は区別されます。ラベルは一部のプランでしか使えません

最後の点は、旧名の辞書にも通じます。「Business」と「business」のように、英字の大文字小文字が混ざる旧名は、辞書側に両方を載せるか、re.IGNORECASEを付けて当てます。

棚卸しのあとに続ける作業

一覧ができたら、直した記事の数を月ごとに記録すると、棚卸しが回っているかが分かります。毎月の実行は、start_timeで前回以降の更新を取る差分実行にしておくと軽く済みます。ただし旧名の混入は、更新されていない記事でも起きます。製品側で改名があった月だけは、全件を取り直して辞書を当て直してください。

マクロとヘルプ記事は、同じ旧名が両方に残っていることが多い組み合わせです。同じrenames.jsonをマクロの棚卸しの本文検索にも当てると、案内文の食い違いを一度に潰せます。問い合わせの傾向から足りない記事を探す作業はZendeskチケット分析につなげられます。

まとめ

ヘルプセンターの古さは、edited_atと本文の旧名の2軸で洗えます。取得はカーソル方式のpage[size]=100、判定は手元のスクリプト、直し方の下書きだけをClaude Codeに任せる分担にすると、書き込み事故を避けつつ、直す順の一覧が手に入ります。閲覧数がAPIで取れない点と、画像内の旧名を拾えない点は、この方法の限界です。ここは別の数字と目視で補う前提で組んでください。

この記事を共有:XはてブLinkedIn