コメントは文字列では返ってこない。木構造のJSONとして返る。
ASTの読み方さえ分かれば、あとは単純な処理です
📋 この記事でわかること
- コメントがAST形式で返る仕組みと読み方
- テキストからASTを組み立てる処理
- 必須ヘッダー X-Note-Client-Code の役割
- 返信スレッドを parent_key で扱う方法
- 未返信のコメントを抽出するスクリプト
- 自動返信がスパム扱いになるライン
記事にコメントが付くのは、反応の中でもっとも確かな指標です。スキは読まずに押されることもありますが、コメントは最後まで読まれた証拠になります。ただ、記事数が増えるとどこにコメントが付いているか、返信し忘れがないかを把握するのが難しくなります。APIを使えば、全記事のコメントを横断して確認できます。この記事では、その実装と、コメント特有のAST形式について解説します。
先に断っておくこと
- この記事では技術的な実装を解説しますが、機械的な自動返信は推奨しません
- コメントは人と人のやり取りであり、定型文の返信は関係を損ないます
- 自動化して価値があるのは「取得と可視化」までです
- 投稿系は、返信忘れを防ぐための補助として使ってください
コメントAPIの構成
使うのは /api/v3/notes/{key}/note_comments です。v1系のエンドポイントは空配列を返すよう変更されているため、v3を使う必要があります。
| メソッド | パス | 内容 |
|---|---|---|
| GET | /api/v3/notes/{key}/note_comments | コメント一覧 |
| POST | /api/v3/notes/{key}/note_comments | コメント投稿 |
| PUT | /api/v3/notes/{key}/note_comments/{comment_key} | コメント編集 |
| DELETE | /api/v3/notes/{key}/note_comments/{comment_key} | コメント削除 |
取得時のパラメータは次のとおりです。
| パラメータ | 値 | 意味 |
|---|---|---|
| page | 数値 | ページ番号 |
| per_page | 数値 | 1ページの件数 |
| order | newest / oldest | 並び順 |
| parent_key | nc形式のキー | 指定すると返信スレッドを取得 |
AST形式とは何か
コメントの本文は、単なる文字列ではなく木構造のJSONとして返ります。ASTは抽象構文木の略で、文書の構造をプログラムが扱いやすい形で表現したものです。
{
"type": "root",
"children": [
{
"type": "element",
"tag_name": "p",
"children": [
{
"type": "text",
"value": "記事を読みました。参考になりました。"
}
]
}
]
}
階層は3種類のノードで構成されます。
- root——最上位のノード。
childrenに段落を持ちます。 - element——タグに相当するノード。
tag_nameがpなら段落です。 - text——実際の文字が入るノード。
valueに文字列があります。
なぜ文字列ではないのか
HTMLをそのまま受け取ると、悪意のあるスクリプトを埋め込まれる危険があります。構造化された形式で受け取れば、許可されたノードだけを処理すればよく、安全性が高まります。手間は増えますが、理にかなった設計です。同様の方式は、他のサービスのリッチテキスト処理でも使われています。
ASTとテキストを相互変換する
実装で最初に必要になるのが、この変換処理です。取得したASTを読める形にし、送るテキストをASTに組み立てます。
"""
noteのコメントAST(抽象構文木)とテキストを相互変換する。
"""
def ast_to_text(node) -> str:
"""
ASTからプレーンテキストを取り出す。
段落ごとに改行を入れる。
"""
if node is None:
return ""
# 文字列がそのまま入っている場合もある
if isinstance(node, str):
return node
if not isinstance(node, dict):
return ""
node_type = node.get("type")
if node_type == "text":
return node.get("value", "")
children = node.get("children") or []
parts = [ast_to_text(child) for child in children]
if node_type == "element":
tag = node.get("tag_name", "")
if tag == "br":
return "\n"
if tag in ("p", "div"):
return "".join(parts) + "\n"
return "".join(parts)
def text_to_ast(text: str) -> dict:
"""
プレーンテキストからASTを組み立てる。
改行で段落を分ける。
"""
if not text or not text.strip():
raise ValueError("本文が空です")
lines = [l.strip() for l in text.split("\n")]
children = []
for line in lines:
if not line:
continue
children.append({
"type": "element",
"tag_name": "p",
"children": [
{"type": "text", "value": line}
],
})
if not children:
raise ValueError("有効な行がありません")
return {"type": "root", "children": children}
def describe_ast(node, depth: int = 0):
"""ASTの構造を確認する(デバッグ用)"""
if not isinstance(node, dict):
print(" " * depth + repr(node)[:60])
return
node_type = node.get("type", "?")
if node_type == "text":
value = node.get("value", "")
print(" " * depth + f"text: {value[:40]}")
return
tag = node.get("tag_name", "")
label = f"{node_type}" + (f"({tag})" if tag else "")
print(" " * depth + label)
for child in node.get("children") or []:
describe_ast(child, depth + 1)
if __name__ == "__main__":
sample_text = "記事を読みました。\n参考になりました。"
ast = text_to_ast(sample_text)
print("=== 組み立てたAST ===")
describe_ast(ast)
print("\n=== 戻したテキスト ===")
print(repr(ast_to_text(ast)))
X-Note-Client-Codeヘッダー
コメントの投稿・編集・削除には、X-Note-Client-Code というヘッダーが必要です。これが無いとリクエストが拒否されます。
"""
X-Note-Client-Code を扱う。
このヘッダーは、ブラウザがコメント投稿時に送っている識別子。
DevToolsのネットワークタブで、実際のコメント投稿リクエストを
確認すると値が分かる。
"""
import os
from dotenv import load_dotenv
load_dotenv()
def get_client_code() -> str:
"""
.env から X-Note-Client-Code の値を読む。
.env の例:
NOTE_CLIENT_CODE=64文字の16進数
"""
code = os.getenv("NOTE_CLIENT_CODE", "").strip()
if not code:
raise ValueError(
"NOTE_CLIENT_CODE が設定されていません。\n"
"ブラウザでコメントを投稿し、DevToolsのネットワークタブから\n"
"リクエストヘッダーの X-Note-Client-Code を確認して\n"
".env に設定してください"
)
if len(code) != 64:
print(f"警告: 想定と長さが違います({len(code)} 文字)")
return code
def comment_headers() -> dict:
"""コメント投稿用のヘッダーを組み立てる"""
return {
"X-Note-Client-Code": get_client_code(),
"Content-Type": "application/json",
}
if __name__ == "__main__":
try:
code = get_client_code()
print(f"取得しました(先頭8文字: {code[:8]}...)")
except ValueError as e:
print(e)
値の取得方法
このヘッダーの値は、ブラウザから取得する必要があります。noteの記事でコメントを1件投稿し、DevToolsのネットワークタブでそのリクエストを開いてください。リクエストヘッダーの中に X-Note-Client-Code があります。Cookieと同様に、この値も外部に出さないよう管理してください。
コメントを扱うクライアント
取得・投稿・削除をまとめたクラスを作ります。
"""
noteのコメント操作をまとめたクライアント。
"""
import logging
from note_auth_client import NoteAuthClient
from ast_utils import ast_to_text, text_to_ast
from client_code import get_client_code
logger = logging.getLogger(__name__)
class CommentClient:
def __init__(self, client: NoteAuthClient,
client_code: str = None):
self.client = client
self._client_code = client_code
def _headers(self) -> dict:
code = self._client_code or get_client_code()
return {"X-Note-Client-Code": code}
# ---- 取得 ----
def list_comments(self, note_key: str, order: str = "newest",
per_page: int = 20,
max_pages: int = 10) -> list:
"""記事のコメント一覧を取得する"""
comments = []
for page in range(1, max_pages + 1):
data = self.client.get_json(
f"/api/v3/notes/{note_key}/note_comments",
params={
"page": page,
"per_page": per_page,
"order": order,
},
)
items = data if isinstance(data, list) else data.get("data", [])
if not items:
break
comments.extend(items)
if len(items) < per_page:
break
return comments
def list_replies(self, note_key: str, parent_key: str) -> list:
"""特定コメントへの返信を取得する"""
data = self.client.get_json(
f"/api/v3/notes/{note_key}/note_comments",
params={"parent_key": parent_key, "per_page": 50},
)
return data if isinstance(data, list) else data.get("data", [])
@staticmethod
def to_readable(comment: dict) -> dict:
"""コメントを扱いやすい形に変換する"""
user = comment.get("user") or {}
return {
"key": comment.get("key", ""),
"text": ast_to_text(comment.get("comment")).strip(),
"user_key": user.get("key", ""),
"nickname": user.get("nickname", ""),
"urlname": user.get("urlname", ""),
"is_root": comment.get("is_root", True),
"created_at": comment.get("created_at",
comment.get("createdAt", "")),
}
# ---- 投稿 ----
def post_comment(self, note_key: str, text: str,
parent_key: str = None,
dry_run: bool = True) -> dict:
"""
コメントを投稿する。
parent_key を指定すると返信になる。
"""
payload = {
"comment": text_to_ast(text),
"acknowledgement": False,
}
if parent_key:
payload["parent_key"] = parent_key
print("=== 投稿内容 ===")
print(" 記事:", note_key)
print(" 種別:", "返信" if parent_key else "新規コメント")
print(" 本文:", text[:80])
if dry_run:
print("\n[確認モード] 実際には投稿していません")
return {}
res = self.client.request(
"POST",
f"/api/v3/notes/{note_key}/note_comments",
json=payload,
headers=self._headers(),
)
if res.status_code not in (200, 201):
logger.error("投稿失敗 HTTP %s: %s",
res.status_code, res.text[:200])
return {}
try:
data = res.json().get("data", {})
except ValueError:
data = {}
logger.info("コメントを投稿しました")
return data
def edit_comment(self, note_key: str, comment_key: str,
text: str, dry_run: bool = True) -> bool:
"""自分のコメントを編集する"""
payload = {"comment": text_to_ast(text)}
print(f"=== 編集: {comment_key} ===")
print(" 新しい本文:", text[:80])
if dry_run:
print("\n[確認モード] 実際には送信していません")
return False
res = self.client.request(
"PUT",
f"/api/v3/notes/{note_key}/note_comments/{comment_key}",
json=payload,
headers=self._headers(),
)
if res.status_code not in (200, 201):
logger.error("編集失敗 HTTP %s", res.status_code)
return False
return True
def delete_comment(self, note_key: str,
comment_key: str) -> bool:
"""コメントを削除する(comment_keyはnc形式)"""
res = self.client.request(
"DELETE",
f"/api/v3/notes/{note_key}/note_comments/{comment_key}",
headers=self._headers(),
)
if res.status_code not in (200, 204):
logger.error("削除失敗 HTTP %s", res.status_code)
return False
logger.info("コメントを削除しました: %s", comment_key)
return True
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
client = NoteAuthClient()
client.verify()
cc = CommentClient(client)
note_key = "nXXXXXXXXXXXX"
comments = cc.list_comments(note_key)
print(f"=== コメント {len(comments)} 件 ===")
for c in comments:
r = cc.to_readable(c)
print(f"\n {r['nickname']}({r['created_at'][:10]})")
print(f" {r['text'][:100]}")
▶ スキやフォローの操作も同じように実装できますが、注意点があります。
自動化の境界線について解説しています。
未返信のコメントを見つける
実用上もっとも価値があるのがこの処理です。全記事を横断して、まだ返信していないコメントを洗い出します。
"""
全記事を横断して、未返信のコメントを抽出する。
返信忘れを防ぐための確認スクリプト。
"""
import time
from note_auth_client import NoteAuthClient, CookieExpiredError
from comment_client import CommentClient
URLNAME = "your_note_id"
SLEEP_SEC = 1.5
MAX_ARTICLES = 100
def fetch_my_notes(client: NoteAuthClient, urlname: str,
max_pages: int = 50) -> list:
items = []
for page in range(1, max_pages + 1):
data = client.get_json(
f"/api/v2/creators/{urlname}/contents",
params={"kind": "note", "page": page},
)
contents = data.get("contents", [])
if not contents:
break
items.extend(contents)
if data.get("isLastPage") or data.get("is_last_page"):
break
time.sleep(1.2)
return items
def has_comments(item: dict) -> bool:
"""コメント数のフィールドで事前に絞り込む"""
for key in ("commentCount", "comment_count"):
if item.get(key):
return True
return False
def find_unreplied(client: NoteAuthClient, my_user_key: str,
notes: list) -> list:
"""自分が返信していないコメントを抽出する"""
cc = CommentClient(client)
unreplied = []
targets = [n for n in notes if has_comments(n)][:MAX_ARTICLES]
print(f"コメントがある記事: {len(targets)} 本\n")
for i, note in enumerate(targets, start=1):
note_key = note.get("key")
title = note.get("name", "")
if not note_key:
continue
comments = cc.list_comments(note_key)
readable = [cc.to_readable(c) for c in comments]
# 自分以外のルートコメントを抽出
others = [
r for r in readable
if r["is_root"] and r["user_key"] != my_user_key
]
for parent in others:
replies = cc.list_replies(note_key, parent["key"])
reply_users = {
cc.to_readable(r)["user_key"] for r in replies
}
if my_user_key not in reply_users:
unreplied.append({
"note_key": note_key,
"title": title,
"comment": parent,
})
time.sleep(SLEEP_SEC)
print(f"[{i}/{len(targets)}] {title[:40]}"
f"(コメント {len(others)} 件)")
time.sleep(SLEEP_SEC)
return unreplied
def main():
try:
client = NoteAuthClient()
user = client.verify()
except CookieExpiredError as e:
print("認証エラー:", e)
return
my_user_key = user.get("key") or str(user.get("id", ""))
notes = fetch_my_notes(client, URLNAME)
print(f"記事 {len(notes)} 本を確認します\n")
unreplied = find_unreplied(client, my_user_key, notes)
print(f"\n=== 未返信のコメント: {len(unreplied)} 件 ===")
for item in unreplied:
c = item["comment"]
print(f"\n[{item['title'][:40]}]")
print(f" {c['nickname']}({c['created_at'][:10]})")
print(f" {c['text'][:120]}")
print(f" https://note.com/notes/{item['note_key']}")
if __name__ == "__main__":
main()
この処理は取得だけを行い、返信は人間が書くという設計です。返信すべきコメントの一覧が出れば、あとは読んで自分の言葉で返すだけになります。これが自動化の適切な使い方です。
▼ RECOMMENDATION ▼
反応をもらう前に、読まれる状態を作る
コメントは、読まれた記事にしか付きません。返信管理を整えるより先に、読む人を増やす段階の人が大半です。アメブロであれば、アメプレスProがいいね・フォロー・アクセス獲得を自動で回し、読者との接点そのものを増やせます。noteと違ってASPアフィリエイトも使えるため、収益化まで含めた設計が可能です。月額2,980円、365日LINEサポート付きです。
※ 当サイトはアフィリエイト広告を含みます。
コメントを集計・分析する
どの記事にコメントが集まるかを把握すると、読者の関心が見えてきます。
"""
コメントを集計して、傾向を把握する。
- どの記事にコメントが集まるか
- 誰が繰り返しコメントしてくれているか
- コメントの長さの分布
"""
import csv
import time
from collections import Counter
from datetime import datetime
from note_auth_client import NoteAuthClient, CookieExpiredError
from comment_client import CommentClient
from find_unreplied import fetch_my_notes, has_comments
URLNAME = "your_note_id"
OUTPUT = "comments.csv"
def collect_all(client: NoteAuthClient, notes: list,
limit: int = 80) -> list:
cc = CommentClient(client)
rows = []
targets = [n for n in notes if has_comments(n)][:limit]
for i, note in enumerate(targets, start=1):
note_key = note.get("key")
title = note.get("name", "")
if not note_key:
continue
for c in cc.list_comments(note_key):
r = cc.to_readable(c)
rows.append({
"note_key": note_key,
"title": title,
"comment_key": r["key"],
"nickname": r["nickname"],
"urlname": r["urlname"],
"text": r["text"].replace("\n", " "),
"length": len(r["text"]),
"created_at": r["created_at"],
})
print(f"[{i}/{len(targets)}] {title[:40]}")
time.sleep(1.5)
return rows
def analyze(rows: list):
if not rows:
print("コメントがありません")
return
print(f"\n=== コメント {len(rows)} 件 ===")
by_article = Counter(r["title"] for r in rows)
print("\n--- コメントが多い記事 ---")
for title, count in by_article.most_common(10):
print(f" {count:>3} 件 {title[:44]}")
by_user = Counter(
r["nickname"] for r in rows if r["nickname"]
)
print("\n--- よくコメントをくれる方 ---")
for name, count in by_user.most_common(10):
print(f" {count:>3} 件 {name[:24]}")
lengths = [r["length"] for r in rows]
avg = sum(lengths) / len(lengths)
print(f"\n--- コメントの長さ ---")
print(f" 平均: {avg:.0f} 文字")
print(f" 最長: {max(lengths)} 文字")
long_comments = [r for r in rows if r["length"] > 100]
print(f" 100文字超: {len(long_comments)} 件")
if long_comments:
print("\n --- 長文コメントの例 ---")
for r in long_comments[:3]:
print(f" [{r['title'][:30]}]")
print(f" {r['text'][:100]}...")
def save_csv(rows: list, path: str = OUTPUT):
if not rows:
return
with open(path, "w", encoding="utf-8-sig", newline="") as f:
writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
writer.writeheader()
writer.writerows(rows)
print(f"\n{path} に保存しました")
def main():
try:
client = NoteAuthClient()
client.verify()
except CookieExpiredError as e:
print("認証エラー:", e)
return
notes = fetch_my_notes(client, URLNAME)
rows = collect_all(client, notes)
analyze(rows)
save_csv(rows)
if __name__ == "__main__":
main()
長いコメントに注目する
100文字を超えるコメントは、読者が時間をかけて書いたものです。そこには記事のどこが響いたかという情報が詰まっています。スキ数を見るより、こうしたコメントを読み返すほうが、次に書くべきことが見えてきます。
自動返信について
技術的には、コメントを検出して自動で返信を投稿することも可能です。しかし推奨しません。理由を整理します。
| やり方 | 結果 | 評価 |
|---|---|---|
| すべてに定型文で返信 | すぐに機械的だと気づかれる | 関係を損なう |
| キーワードで返信を出し分け | 的外れな返信が混ざる | リスクが高い |
| 未返信を検出して通知 | 返信漏れが減る | 推奨 |
| 返信の下書きだけ用意する | 人が確認して修正できる | 条件付きで可 |
| 他人の記事に自動コメント | スパム判定の対象 | してはいけない |
他人の記事への自動コメントは明確にNG
宣伝目的で他人の記事に機械的にコメントを投稿する行為は、スパムとして扱われます。アカウントの制限や、最悪の場合は利用停止の対象になります。また、そうした行為で得られる関心は一時的なもので、長期的には自分の評判を落とします。この記事で解説した投稿系のAPIは、自分の記事への返信に限って使ってください。関連する話題は通報の記事でも扱っています。
つまずきやすい点
| 症状 | 原因 | 対処 |
|---|---|---|
| コメントが空配列で返る | v1のエンドポイントを使っている | v3を使う |
| 投稿で403が返る | X-Note-Client-Codeが無い | ヘッダーを付ける |
| 本文が取り出せない | ASTを文字列として扱っている | ast_to_textを通す |
| 投稿すると本文が壊れる | 文字列をそのまま送っている | text_to_astでAST化する |
| 削除で404 | 数値idを渡している | nc形式のcomment_keyを使う |
| 返信が取得できない | parent_keyを指定していない | 親コメントのkeyを渡す |
| コメント欄が無い | 記事側でコメントを無効化している | 記事の設定を確認する |
まとめ:取得は自動化、返信は人が書く
コメントAPIの技術的な要点はAST形式と必須ヘッダーの2つです。この2つを押さえれば実装は難しくありません。ただし、使い方の判断のほうが重要です。
この記事の要点
- コメントは
/api/v3/notes/{key}/note_comments。v1は空配列を返す - 本文はAST(root / element / text の木構造)で返る
- 投稿・編集・削除には X-Note-Client-Code ヘッダーが必須
- 返信は parent_key で親コメントを指定する
- 削除は数値idではなく nc形式の comment_key
- 未返信の検出は実用的で、リスクもない
- 定型文の自動返信は関係を損なう
- 他人の記事への自動コメントはスパム扱いになる
まずは未返信コメントの抽出だけを動かしてみてください。返信忘れが可視化されるだけでも、運用の質は上がります。
FAQ|コメントAPIについてよくある質問
安全性のためです。HTMLを直接受け取ると、スクリプトの埋め込みなど不正な内容が混入する危険があります。構造化された形式で受け取れば、サーバー側で許可されたノードだけを処理でき、危険な要素を確実に排除できます。扱いは面倒になりますが、設計としては妥当です。
ブラウザで実際にコメントを1件投稿し、DevToolsのネットワークタブでそのリクエストを開いてください。リクエストヘッダーの中に含まれています。64文字の16進数です。この値もCookieと同様に認証に関わる情報なので、.env で管理し、外部に出さないようにしてください。
公開されているコメントは取得できます。ただし、大量に収集して分析するような使い方は、コメントを書いた人の想定を超えています。自分の記事のコメント管理に使うのが本来の用途です。他人の記事のコメントは、読んで参考にする程度に留めてください。
通知件数を返す /api/v3/notice_counts というエンドポイントはありますが、コメントに限定した通知の取得は確認されていません。定期的にコメント一覧を取得して差分を見る、という方式が現実的です。1日1回程度の確認であれば、負荷も小さく済みます。
技術的には可能ですが、おすすめしません。読者はコメントを書くとき、書き手からの反応を期待しています。明らかな定型文が返ってくると、期待を裏切られたと感じます。返信が追いつかないなら、返さないほうがまだ良いくらいです。自動化するなら、返信忘れを検出して通知するところまでに留めてください。
記事の設定でコメントのオンオフを切り替えられます。この設定はnoteプレミアムの機能です。APIから公開処理を行う際は disable_comment フィールドで指定できますが、フルペイロード方式のため他の設定を巻き込まないよう注意してください。
自分の記事に付いたコメントは削除できます。この記事の delete_comment() がその処理です。悪質なものについては、削除に加えて通報も検討してください。ただし、単に批判的というだけのコメントを削除すると、かえって反発を招くこともあります。判断は慎重に行ってください。
ASTの構造上、element ノードのタグ名を変えれば理論的には可能ですが、note側で許可されているタグは限られます。この記事の実装は段落(p)のみを扱う最小構成です。リンクを含めたい場合は、URLをそのままテキストとして書けば、note側で自動的にリンクとして表示されることが多いようです。
記事ごとにコメント一覧を取得し、さらに返信も取得するため、リクエスト数が多くなります。この記事のコードでは、コメント数のフィールドで事前に絞り込むことで、コメントが無い記事へのリクエストを省いています。それでも記事数が多い場合は、対象を最近の記事に限定するか、実行頻度を週1回程度に落としてください。
コメントはスキより敷居が高い行動なので、付かないのが普通です。付きやすくするには、記事の最後に問いかけを入れる、自分の意見を明確に書いて反応の余地を作る、といった工夫があります。ただし、コメントの数を目的にすると本末転倒です。付いたときに丁寧に返すことのほうが、結果的に関係を作ります。