Codex スキルで X アカウント運用を自動化する方法

重要なポイント

1 つの X アカウントの運用ループを回す Codex スキルを作ります。記録されたコンテンツ配合から今日の枠を選び、その形で下書きを書き、決定的なゲートで弱いフックを弾き、X API の取得または手動エクスポートから実績をレビューします。

Codex に作らせましょう。 この記事を Codex セッションに貼り付けて、こう依頼してください。「この記事を読み、書かれているとおりに x-growth-operator スキルをこのプロジェクトに構築してください。」すべてのファイルは 4 本のスクリプトを含めて全文を下に掲載しています。スキル内の何ものも、X への投稿・予約・書き込みを単独では行いません。

## 最終的に手に入るもの

スキルファイル 1 つ、Python スクリプト 4 本、小さな状態ファイル 3 つ。これらが 1 つの X アカウントの運用ループを回します。今日何を出すかを決め、その枠が求める形で下書きを書き、最初の一行がゲートを通らなければ下書きを捨て、公開したものを記録し、数字を引き戻して調整する。

これは 1 人で 1 アカウントを運用する人、あるいは小さなチームで 1 つのブランドアカウントを運用するためのものです。これが防ぐのは文章力の失敗ではありません。ドリフトです。コンテンツの配合は今週書きやすいものへと静かに傾き、フックは最後に書かれて弱いまま残り、1 か月が過ぎて型が固まるまで誰もアナリティクスを開きません。

始める前に必要なもの:

  • プロジェクトディレクトリへの書き込み権限を持つエージェントのセッション。Codex で動きます。Claude Code でも、ファイルを作成して Python を実行できるものであれば何でも構いません。
  • 運用しようとしている X アカウントと、それを運用する立場。
  • Python 3.8 以降。4 本のスクリプトはすべて標準ライブラリのみを使うので、インストールするものはありません。
  • 構築と最初のプランニング実行に 30 分ほど。
  • レビュー段階では 2 つのデータ経路のうちどちらか。有料枠の X API キー、または X アナリティクスからの手動エクスポート。どちらも開始時点では必須ではありません。ループはそれらがなくても動きます。ただしフィードバックの半分はまだ閉じられません。

完了の条件: スキルディレクトリが存在し、x-ops/ に記入済みのブリーフと空のテーブル 2 つが入り、mix_ledger.py plan がブリーフと一致する日付付きの枠割り当てを返すことです。

## なぜループこそ自動化する価値があるのか

書くことは簡単な半分であり、モデルがすでに得意としている半分です。劣化するのはその周りのすべてです。「公開するものの半分は実践的な内容であるべきだ」といった比率を、人は 2 週間ほどは頭に保てます。それを過ぎると、比率は静かに「今日書き終えたもの」になります。

実際に壊れる 3 つは、いずれも状態の問題です。

配合。 すでに何を公開したかの記録がなければ、比率から外れているかどうかは分かりません。記憶は記録ではなく、その失敗は 1 週間の内側からは見えません。

フック。 書き出しの一行は、あなたが書いた他のすべてが読まれるかどうかを決める文であり、そして最も最後に、注意力が尽きた時間帯に書かれがちな文でもあります。自分のフックを自分で判定するのは、ある特定の仕方で当てになりません。本文が何を言っているかをすでに知っているので、まだ本文を読んでいない読者にとっては大きく開いているのに、あなたにはその隙間が閉じて感じられるのです。

レビュー。 数字を引き出し、公開したものと突き合わせ、各投稿が割り当てられた枠に対して読む必要があります。これは週 20 分の記帳作業であり、忙しい週を生き延びない作業量そのものです。

チャットセッションはこれらのどれも保持できません。スキルは保持できます。スキルにはファイルがあり、ファイルはスクリプトで数えられるからです。台帳が記憶であり、ゲートが規律であり、レビューは悪い週にも実行できるコマンドです。

戦略の層、つまり何を公開するか、なぜ特定の投稿の形が特定の反応を引き出すのかは、別の問題です。X アカウントからブログへの流入を伸ばすためのガイドが流通側を詳しく扱っています。この記事は実行の層です。その判断を、動くものに変える話です。

## 始める前に

3 つの前提があります。確認してください。スキルはその上に建っているからです。

アカウント 1 つ、ブリーフ 1 つ。 スキルは意図的にシングルテナントです。1 つの x-ops/ ディレクトリが 1 つのアカウントを記述します。2 つのアカウントを運用するなら、台帳を 2 つ持つディレクトリが 2 つです。台帳をアカウント間で共有すると、台帳の存在理由そのものが壊れます。

下書きを書き、公開ボタンを自分で押す意思があること。 スキルはテキストと記録を生み出します。書き込みエンドポイントを呼ぶことは決してありません。スクリプトはそれを守りやすくします。4 つのファイルのどこにも公開用の関数が存在しないので、境界は約束ではなく検証可能なものです。

指標を 2 つの形のどちらかで用意できること。 有料の開発者枠の X API キーを持っているか、X から投稿単位のアナリティクスをエクスポートしていくつかの列を対応付けるかです。詳細は指標のセクションにあります。今日どちらも当てはまらない場合でも、スキルは作ってしまって、当てはまるまで metrics.tsv を空のままにしておいてください。

## スキルをインストールする

6 つのファイル、うち 4 つがスクリプトです。このツリーを作ります:

text
your-project/
├── .codex/
│   └── skills/
│       └── x-growth-operator/
│           ├── SKILL.md
│           ├── templates/
│           │   └── account-brief.yaml
│           └── scripts/
│               ├── mix_ledger.py
│               ├── hook_lint.py
│               ├── metrics.py
│               └── weekly_review.py
└── x-ops/
    ├── account-brief.yaml        ← you fill this in, once
    ├── mix-ledger.tsv            ← header row only
    ├── metrics.tsv               ← header row only
    └── scripts/                  ← copy of the skill's scripts/

スクリプトが 2 か所にあるのは意図的です。スキル内にコピーを置くとスキルが可搬のまま保たれ、x-ops/ の下にコピーを置くとコマンドが短いままで、作業ディレクトリ全体を一まとまりで移動またはアーカイブできます。複製したくない場合は、x-ops/scripts をスキルの scripts/ へのシンボリックリンクにして、以下のコマンドのパスを調整してください。

何よりも先に、2 つのテーブルをヘッダーだけのファイルとして作成します:

bash
printf 'date	slot	shape	target_signal	hook_pattern	topic	post_url
' > x-ops/mix-ledger.tsv
printf 'post_url	collected_at	impressions	likes	replies	reposts	bookmarks	profile_clicks
' > x-ops/metrics.tsv

どちらもヘッダー以外は何も入れないでください。最初のプランニング実行は、ヘッダーが間違っていれば文句を言います。20 投稿を進めた後に気づくより、はるかに良い失敗の仕方です。

## スキルのファイル群

`SKILL.md` はエージェントが読むファイルです。ルール、4 段階のループ、失敗の表、そして下書きの各段階が依存する参照用の表を保持します。それらの表を、エージェントが常に読むファイルの中に置くのは意図的です。別の文書にある参照は、飛ばされる参照になります。

`templates/account-brief.yaml` は手で書く唯一のファイルです。ポジショニング、柱となるテーマ、トーンの制約、配合の目標、そしてプランニングの重み付けです。

`scripts/mix_ledger.py` は配合の計算を行います。ブリーフの mix_target ブロックと台帳を読み、1 つの問いに答えます。直近の窓の中で、どの枠が最も大きく遅れているか。

`scripts/hook_lint.py` はゲートです。下書きの最初の一行に対する 4 つの機械的なチェックで、いずれかが失敗すれば非ゼロで終了するので、エージェントはそれで分岐できます。

`scripts/metrics.py` はどちらの経路でも実績データを取り込み、両方を同じテーブルに正規化します。

`scripts/weekly_review.py` は 2 つのテーブルを結合し、枠ごとの中央値を計算し、対応する価値のある 4 つのパターンを示します。48 時間ルールをコードで強制するのもこのスクリプトで、数字を早く見たい人が飛ばしてしまう部分です。

## SKILL.md

x-growth-operator スキルのファイル構成図。スキル側に SKILL.md と account-brief.yaml、スクリプト側に mix_ledger.py、hook_lint.py、metrics.py、weekly_review.py

.codex/skills/x-growth-operator/SKILL.md として保存します。

markdown
---
name: x-growth-operator
description: Run the operating loop for one X account. Chooses today's content slot from a logged mix, drafts the post in the matching shape, rejects weak hooks with a deterministic gate, records what was published, and reviews performance from an X API pull or a manually imported metrics file. Use when the user asks to plan, draft, gate, or review posts for their own X account.
---

# X Growth Operator

One account per `x-ops/` directory. The state files are the memory; read them rather than working from recollection.

```text
x-ops/
├── account-brief.yaml    positioning, pillars, tone, mix target, signal weights
├── mix-ledger.tsv        one row per published post
├── metrics.tsv           one row per post per collection date
└── scripts/
    ├── mix_ledger.py     plan / log / report
    ├── hook_lint.py      the four-check hook gate
    ├── metrics.py        pull (X API) / import (your own export)
    └── weekly_review.py  join, medians, named patterns
```

## 0. Rules that never bend

1. Never post, schedule, or call any X write endpoint. Output is a draft block for the user to copy. Publishing is a human action.
2. Never state a metric that is not in `x-ops/metrics.tsv`. Missing data is reported as missing, never estimated.
3. Never recommend a slot without running `mix_ledger.py plan` first.
4. Never produce engagement pods, bulk follow or unfollow sequences, or the same text repeated across accounts. If asked, refuse and name the platform rule it violates.
5. Never draft a post whose only purpose is to trigger a reaction the content does not support.

## 1. First run

If `x-ops/account-brief.yaml` is missing or still the template, stop and build it with the user before anything else. Ask, do not infer. The `mix_target` block is required by `mix_ledger.py`; the rest guides your drafting.

Copy `scripts/` alongside the skill and create the two state files as header-only tables:

```bash
cp -r <skill-dir>/scripts x-ops/scripts
printf 'date	slot	shape	target_signal	hook_pattern	topic	post_url
' > x-ops/mix-ledger.tsv
printf 'post_url	collected_at	impressions	likes	replies	reposts	bookmarks	profile_clicks
' > x-ops/metrics.tsv
```

Then write `x-ops/account-brief.yaml` from `templates/account-brief.yaml` with the user, filling in every field.

## 2. The daily loop

Four steps, in order. Do not skip the gate because a draft looks good.

### 2.1 Plan

```bash
python3 x-ops/scripts/mix_ledger.py plan --window 20
```

This reads the trailing window, compares the counts per slot against `mix_target`, and prints the slot with the largest deficit plus the arithmetic behind the pick. Report its output. Do not substitute your own judgement about which slot would be more interesting.

When the window holds fewer than 20 rows, the script says so. Pass that warning on rather than hiding it.

### 2.2 Draft

Load the shape for the selected slot from §9 and write the post.

- Single post: hook, the core claim in one or two sentences, the evidence, exactly one call to action.
- Thread: see §3.
- The target signal drives the ending. A post aimed at bookmarks ends with something worth keeping. A post aimed at replies ends with something a reader can disagree with.

### 2.3 Hook gate

```bash
python3 x-ops/scripts/hook_lint.py --hook "<first line>" --body "<full post>"
```

The script runs four mechanical checks and exits non-zero on any failure:

1. Banned openers, meaning openers that would still be true of a different post.
2. Concreteness: the hook needs a number, a named thing, or a scene. Otherwise it is a thesis wearing a hook's clothing.
3. Gap width: a total claim with no figure or boundary is too wide to close.
4. Payoff: every number and name in the hook must appear in the body.

The gate is four questions, not an oracle. Checks 2 and 3 are heuristics and will occasionally reject a deliberately plain hook. When you disagree with a rejection, keep the hook and say why. When you agree, show the rejected line and the rewrite side by side; never present only the fixed version.

A gate that never rejects anything is not running. If ten consecutive drafts all pass, read §9's hook failure table with the user and add their own clichés to the `BANNED` list in `hook_lint.py`.

### 2.4 Log

After the user confirms they published, append the row:

```bash
python3 x-ops/scripts/mix_ledger.py log \
  --slot A --shape tutorial --signal bookmark \
  --hook-pattern information-gap --topic "short topic label" \
  --url "x.com/HANDLE/status/ID"
```

Ask before logging; do not assume publication. A row with no url is unverified and cannot be joined to metrics later.

## 3. Thread mode

Use a thread when the slot is A or B and the material genuinely needs steps. Do not use a thread to say one thing at length.

At a target of five slots:

| Slot | Job | Fails when |
| --- | --- | --- |
| 1 | Promise the outcome, not the topic | It describes the subject instead of the result |
| 2-4 | The two to four points that carry the argument | More than one idea per slot |
| 5-6 | Steps, checklist, or a worked case | It restates slot 1 instead of adding proof |
| Last | Summary plus one call to action | Two calls to action |

Five is a target. Three is fine when the material is thin; seven only if every slot does distinct work.

## 4. Reply engine

Replies carry the second-highest weight in the planning model, which makes writing one a distribution action rather than a courtesy. Run it as its own pass.

Build a target list from accounts the user follows inside their topic pillars, and for each produce a reply in one of four kinds:

1. Add a number the original post left out.
2. Offer a counterexample from the user's own work, without hostility.
3. Ask the follow-up question the post's own logic demands.
4. Describe what happened when the user tried it.

Reject bare praise, agreement with no addition, replies that are really an advertisement for the user's own post, and any reply that drags in an unrelated account for reach. Do not use the mention mechanism on people who have not asked for it.

## 5. The two metrics paths

Both write the same file. The review does not care which one produced a row.

### Path A, the X API

```bash
export X_BEARER_TOKEN="..."          # user-context OAuth 2.0 token
python3 x-ops/scripts/metrics.py pull --dry-run     # inspect the request first
python3 x-ops/scripts/metrics.py pull
```

The script reads post ids from the ledger, batches them 100 at a time, requests both the public and non-public metric groups, and writes only the counters that come back. Bookmark and impression counts require user-context authentication and are returned only for the authenticated account's own posts, which is exactly the scope this loop needs.

Field availability, tier names, prices, and rate limits on the X developer platform change often. Confirm the current ones against the developer documentation before building anything on top, and never answer a rate-limit rejection by guessing the numbers.

### Path B, manual import

```bash
python3 x-ops/scripts/metrics.py import --file export.csv
python3 x-ops/scripts/metrics.py import --file export.csv --map "url=Post link,impressions=Views"
```

The script recognizes common header names automatically and leaves anything it cannot map as an empty field. An empty field is honest and is reported as unavailable. A filled-in guess contaminates every review built on it afterward.

Never write a metrics row to test the review. Run the review against an empty table instead; it should say it has no data rather than produce a summary.

## 6. Weekly review

```bash
python3 x-ops/scripts/weekly_review.py --window 20
```

The script joins the ledger to the metrics on the post id, excludes rows collected inside the 48-hour window, reports medians per slot, and names the patterns it can prove:

- High impressions, low reposts: reached people, did not travel.
- High bookmarks, low impressions: worth keeping, under-distributed. Rewrite around a new angle; do not re-post it.
- Low replies across every slot: a shape problem, not a post problem.
- One slot outperforming the rest on reach: record it and keep the ratio.

Report what it prints, including the gap lists. When the data cannot support a claim, say so instead of summarizing around the hole.

## 7. Failure modes

| Symptom | Likely cause | Recovery |
| --- | --- | --- |
| Plan returns the same slot repeatedly | Window below 20 rows, or posts are not being logged | Confirm the log step runs. Say the window is thin instead of repeating a slot |
| Every draft passes the gate | The gate is being read, not applied | Add the user's own clichés to `BANNED` and re-run |
| Metrics fail to join | Post url formats differ between ledger and export | Normalize both to the numeric post id |
| Review dominated by one post | A mean crept in, or the window is too small | Medians are already used; widen the window and report the outlier |
| Mix drifts anyway | Posts are published outside the loop | Log them. Off-loop posts are still part of the mix |
| Drafts sound different each week | The brief has no banned list | Add specific banned moves, each with an example |

## 8. The 48-hour window

The planning model treats the first two days after publication as the period when a post can still be picked up. Two consequences.

Do not re-run a post from outside the window as though it were fresh. If it deserves reviving, rewrite it around a new angle and publish new material.

Do not judge a post before the window closes. `weekly_review.py` excludes the early rows automatically; do not override that by hand.

## 9. Reference tables

### The four account roles

Pick exactly one for the brief. Everything else assumes a single consistent position.

| Role | The account is | Publishes mostly |
| --- | --- | --- |
| Research translator | Reading primary sources and restating them clearly | Slot B |
| Engineering practitioner | Building things and reporting what broke | Slot A |
| Product and application advocate | Showing what a tool does in real use | Slots A and C |
| Industry observer | Tracking where the field is going and betting on it | Slots B and D |

### Content mix

| Slot | Share | What it is |
| --- | --- | --- |
| A | 50 | Hands-on tutorials, prompts, and worked skill or tool examples |
| B | 20 | Close reads of papers, releases, and trend shifts |
| C | 15 | Project progress and retrospectives |
| D | 10 | Industry opinion with a stated position |
| E | 5 | The flexible slot, for whatever the week hands you |

### The six shapes

| Shape | Opens with | Aimed at |
| --- | --- | --- |
| Discovery | A thing the reader did not know existed | Bookmark, profile click |
| Review | I tested this, here is the verdict | Reply, bookmark |
| Tutorial | Here is how to do the thing | Bookmark |
| Opinion | Here is what I think, and why | Reply |
| Retro | Here is what happened and what I learned | Reply, profile click |
| Announcement | Here is what shipped | Profile click, repost |

### Three hook tension sources

1. **Information gap.** Withhold one specific thing the reader needs, and make the edges of the gap visible. A gap the reader cannot see the shape of reads as vagueness.
2. **Scene resonance.** Open inside a situation the reader has been in. A scene earns the second sentence; an abstraction does not.
3. **Counterintuitive conflict.** State the thing that contradicts expectation, then earn it. Only usable when the body actually earns it.

### Four hooks that fail

| Failure | What it looks like | Fix |
| --- | --- | --- |
| Thesis in a hook's clothing | The main claim, stated flat, with no gap and no scene | Move the claim into the body. Open with the evidence that made you believe it |
| Template that outlived its edits | An opener that fits any post on any account | Delete the first line. Start with the second |
| Gap opened too wide | A total claim with no figure, boundary, or condition | Add the constraint. Say what it does not apply to |
| Gap the body never closes | A sharp hook followed by general advice | Cut the hook, or write the body the hook promised |

### Single post structure

Hook, then the core claim in one or two sentences, then the evidence, then one call to action. Two to three sentences total for the common case.

### The verified output block

Every draft leaves the loop in this shape:

```text
SLOT      A (deficit 2.0 over a 20-row window)
SHAPE     tutorial
SIGNAL    bookmark
HOOK      <the first line>
BODY      <the post>
CTA       <the one action>
GATE      check1 pass | check2 pass | check3 pass | check4 pass
```

If the GATE line is missing, the draft did not go through the gate. Run it again before showing the draft.

## templates/account-brief.yaml

.codex/skills/x-growth-operator/templates/account-brief.yaml として保存し、それを x-ops/account-brief.yaml にコピーして記入します。mix_target ブロックはスクリプトが読み、残りは下書きの指針になります。スキル内でこのファイルを読むものは他にないので、空欄のままにしたフィールドはエージェントが推測するフィールドになります。

yaml
# x-ops/account-brief.yaml
# Fill this in with the user before the first planning run. Every field here is
# read by the skill or by a script. Do not leave a field as a guess.

account:
  handle: ""
  positioning: ""        # exactly one of the four roles in SKILL.md section 9
  audience: ""           # who reads this, in one sentence
  language: ""           # the language post drafts are written in

pillars:                 # 2-4 topics this account is allowed to publish about
  - ""
  - ""

tone:
  allowed: []            # moves that fit this account
  banned: []             # words or moves it never uses, each with a reason

# Read by mix_ledger.py, which parses this block only. Must total 100.
mix_target:
  A: 50
  B: 20
  C: 15
  D: 10
  E: 5

# Planning weights, not measurements. These drive which signal a draft is aimed
# at, not how results are scored. The top three are the relative multipliers
# this skill was tuned around. The middle band is ordinal, meaning "more than a
# like, less than a bookmark", and the numbers are editable placeholders.
# Change any of them and say so in the review.
signal_weights:
  repost: 20
  reply: 13.5
  bookmark: 10
  profile_click: 8
  link_click: 7
  video_completion: 6
  dwell: 6
  like: 1

thread_length: 5         # target slots, usable range 3 to 7
window: 20               # the trailing post count every ratio is computed over

## scripts/mix_ledger.py

配合の計算です。plan が枠を選び、log が公開済みの投稿を記録し、report が実績と目標を並べて表示します。ブリーフの mix_target ブロックだけを読むので、壊れたブリーフはまずここで失敗します。失敗すべき場所としては正しい場所です。

python
#!/usr/bin/env python3
"""Content-mix ledger for the x-growth-operator skill.

One job: never let a slot be chosen from memory. The ledger is the record,
this script is the arithmetic.

Usage:
  mix_ledger.py plan   [--window 20] [--ops DIR]
  mix_ledger.py log    --slot A --shape tutorial --signal bookmark
                       --hook-pattern information-gap --topic "..."
                       [--url URL] [--date YYYY-MM-DD] [--ops DIR]
  mix_ledger.py report [--window 20] [--ops DIR]

Reads the mix target from x-ops/account-brief.yaml (the mix_target block only)
and the record from x-ops/mix-ledger.tsv.
"""

import argparse
import datetime as dt
import os
import re
import sys

HEADER = ["date", "slot", "shape", "target_signal", "hook_pattern", "topic", "post_url"]


def ops_dir(arg):
    return arg or os.environ.get("X_OPS_DIR") or "x-ops"


def read_target(brief_path):
    """Return {slot: share} from the mix_target block, or None if unreadable."""
    if not os.path.exists(brief_path):
        return None
    target = {}
    inside = False
    with open(brief_path, encoding="utf-8") as fh:
        for raw in fh:
            line = raw.rstrip("
")
            if re.match(r"^mix_target\s*:", line):
                inside = True
                continue
            if inside:
                if line.strip() and not line.startswith((" ", "	")):
                    break  # next top-level key
                m = re.match(r"^\s+([A-Za-z])\s*:\s*(\d+(?:\.\d+)?)\s*$", line)
                if m:
                    target[m.group(1).upper()] = float(m.group(2))
    return target or None


def read_rows(path):
    if not os.path.exists(path):
        return []
    rows = []
    with open(path, encoding="utf-8") as fh:
        for n, raw in enumerate(fh):
            line = raw.rstrip("
")
            if not line.strip():
                continue
            parts = line.split("	")
            if n == 0 and parts[0].strip().lower() == "date":
                continue  # header
            if parts[0].strip().lower() == "date":
                continue
            parts += [""] * (len(HEADER) - len(parts))
            rows.append(dict(zip(HEADER, parts)))
    return rows


def window_rows(rows, size):
    return rows[-size:] if size and size > 0 else rows


def deficits(rows, target):
    """Positive number means the slot is behind its share of the window."""
    n = len(rows)
    order = sorted(target.keys())
    counts = {s: 0 for s in order}
    for r in rows:
        s = (r["slot"] or "").strip().upper()
        if s in counts:
            counts[s] += 1
    total = sum(target.values()) or 100.0
    out = {}
    for s in order:
        share = target[s] / total
        out[s] = {
            "count": counts[s],
            "expected": share * n,
            "deficit": share * n - counts[s],
            "share_actual": (counts[s] / n * 100) if n else 0.0,
            "share_target": share * 100,
        }
    return out, counts, n


def last_seen(rows):
    seen = {}
    for i, r in enumerate(rows):
        s = (r["slot"] or "").strip().upper()
        if s:
            seen[s] = (r["date"], i)
    return seen


def cmd_plan(args):
    d = ops_dir(args.ops)
    ledger = os.path.join(d, "mix-ledger.tsv")
    target = read_target(os.path.join(d, "account-brief.yaml"))
    if not target:
        sys.exit("could not read a mix_target block from %s/account-brief.yaml" % d)

    rows = window_rows(read_rows(ledger), args.window)
    info, counts, n = deficits(rows, target)

    if n == 0:
        pick = sorted(target.keys())[0]
        print("window: empty, no rows logged yet")
        print("slot:   %s (default first slot; the ledger has nothing to count)" % pick)
        print("note:   below %d rows the deficit is noise. Publish and log anyway." % args.window)
        return 0

    thin = n < args.window
    seen = last_seen(rows)
    ranked = sorted(
        info.items(),
        key=lambda kv: (-kv[1]["deficit"], seen.get(kv[0], ("", -1))[1]),
    )
    pick, stats = ranked[0]

    print("window: last %d logged posts%s" % (n, " (THIN, fewer than %d)" % args.window if thin else ""))
    print()
    print("slot  count  expected  deficit  actual%   target%")
    for s, st in sorted(info.items()):
        print(
            "  %-4s %5d %9.1f %8.1f %7.1f%% %7.1f%%"
            % (s, st["count"], st["expected"], st["deficit"], st["share_actual"], st["share_target"])
        )
    print()
    print("slot:   %s" % pick)
    print("why:    deficit %.1f over a %d-row window%s" % (stats["deficit"], n, " (tie broken by least recent)" if len(ranked) > 1 and ranked[1][1]["deficit"] == stats["deficit"] else ""))
    if thin:
        print("warning: window below %d rows. Treat the pick as provisional." % args.window)
    return 0


def cmd_log(args):
    d = ops_dir(args.ops)
    os.makedirs(d, exist_ok=True)
    ledger = os.path.join(d, "mix-ledger.tsv")
    new = not os.path.exists(ledger)
    date = args.date or dt.date.today().isoformat()
    row = [date, args.slot.upper(), args.shape, args.signal, args.hook_pattern, args.topic, args.url or ""]
    if "	" in "".join(row):
        sys.exit("fields may not contain tabs")
    with open(ledger, "a", encoding="utf-8") as fh:
        if new:
            fh.write("	".join(HEADER) + "
")
        fh.write("	".join(row) + "
")
    print("logged: %s" % "	".join(row))
    if not args.url:
        print("note:   post_url is empty, so this row is unverified and cannot be joined to metrics")
    return 0


def cmd_report(args):
    d = ops_dir(args.ops)
    target = read_target(os.path.join(d, "account-brief.yaml"))
    if not target:
        sys.exit("could not read a mix_target block from %s/account-brief.yaml" % d)
    rows = window_rows(read_rows(os.path.join(d, "mix-ledger.tsv")), args.window)
    info, counts, n = deficits(rows, target)
    if n == 0:
        print("no rows in the window; nothing to report")
        return 0
    print("%d rows in window" % n)
    print()
    print("slot  count  expected  delta   actual%   target%   status")
    for s, st in sorted(info.items()):
        status = "ok"
        if st["deficit"] > 1.5:
            status = "BEHIND"
        elif st["deficit"] < -1.5:
            status = "over"
        print(
            "  %-4s %5d %9.1f %+7.1f %7.1f%% %7.1f%%  %s"
            % (s, st["count"], st["expected"], st["deficit"], st["share_actual"], st["share_target"], status)
        )
    return 0


def main():
    ap = argparse.ArgumentParser(description="x-growth-operator content-mix ledger")
    sub = ap.add_subparsers(dest="cmd", required=True)

    p = sub.add_parser("plan", help="pick today's slot from the trailing window")
    p.add_argument("--window", type=int, default=20)
    p.add_argument("--ops")
    p.set_defaults(func=cmd_plan)

    p = sub.add_parser("log", help="append a published post to the ledger")
    p.add_argument("--slot", required=True)
    p.add_argument("--shape", required=True)
    p.add_argument("--signal", required=True, help="the signal this post targets")
    p.add_argument("--hook-pattern", required=True, dest="hook_pattern")
    p.add_argument("--topic", required=True)
    p.add_argument("--url", default="")
    p.add_argument("--date")
    p.add_argument("--ops")
    p.set_defaults(func=cmd_log)

    p = sub.add_parser("report", help="actual mix vs target")
    p.add_argument("--window", type=int, default=20)
    p.add_argument("--ops")
    p.set_defaults(func=cmd_report)

    args = ap.parse_args()
    return args.func(args)


if __name__ == "__main__":
    sys.exit(main())

## scripts/hook_lint.py

ゲートです。4 つのチェック、失敗時は非ゼロ終了。エージェントは文章を解釈する代わりに終了コードで分岐できます。BANNED のリストはあなたが編集することを想定した部分です。17 個の書き出しが同梱されており、あなた自身のフィードの常套句とともに増やしていくべきものです。

python
#!/usr/bin/env python3
"""Deterministic hook gate for the x-growth-operator skill.

Four checks on the first line of a draft, all mechanical. Exits 1 if any
check fails so a caller can branch on the exit code.

Usage:
  hook_lint.py --hook "..." --body "..."
  hook_lint.py --hook "..." --body-file post.txt
  hook_lint.py --card x-ops/post-card.md          # reads HOOK/BODY blocks

Checks
  1 banned openers      - openers that fit any post on any account
  2 concreteness        - needs a number, a name, or a scene
  3 gap width           - total claims with no figure or boundary
  4 payoff              - every number and name in the hook appears in the body

Check 2 and 3 are heuristics that catch the two most common failure shapes.
They produce false positives on deliberately plain hooks. When you disagree
with a rejection, keep the hook and say why; the gate is four questions, not
an oracle.
"""

import argparse
import re
import sys

BANNED = [
    r"^in today'?s",
    r"^let'?s dive in",
    r"^let'?s talk about",
    r"^we'?re excited to",
    r"^excited to announce",
    r"^here'?s why",
    r"^here are \d+ (things|ways|tips|reasons)",
    r"^a thread",
    r"^thread",
    r"^hot take",
    r"^unpopular opinion",
    r"^game[- ]?chang(er|ing)",
    r"^the future of \w+ is",
    r"^\w+ is (dead|dying)",
    r"^stop doing",
    r"^nobody talks about",
    r"^most people (get|don'?t)",
]

# Words that signal a total claim the body probably cannot pay off.
WIDE = [
    "everything", "everyone", "nobody", "always", "never", "all of",
    "the only", "completely", "totally", "100%", "forever", "instantly",
]

SCENE_MARKERS = [
    r"i", r"we", r"my", r"our", r"yesterday",
    r"last (week|month|night)", r"this (morning|week|month)",
    r"when i", r"after \d", r"three (weeks|months|days)",
]

# Capitalised words that are not proper nouns.
STOP_CAPS = {
    "The", "This", "That", "These", "Those", "It", "We", "I", "You", "They",
    "He", "She", "A", "An", "And", "But", "Or", "So", "If", "When", "Why",
    "How", "What", "Every", "Most", "Some", "No", "Not", "Do", "Does", "Did",
    "My", "Our", "Your", "Their", "Its", "There", "Here", "Stop", "Start",
    "Never", "Always", "Just", "Only", "One", "Two", "Three", "In", "On",
    "At", "After", "Before", "Because", "Then", "Now", "Today", "Yesterday",
    "Everything", "Nothing", "Everyone", "Nobody", "Anything", "Something",
    "Anyone", "Someone", "Everywhere", "Nothing's", "Its",
}

CAP = re.compile(r"[A-Z][A-Za-z0-9.+#-]{1,}")
SENT_SPLIT = re.compile(r"(?<=[.!?])\s+")


def proper_nouns(text):
    """Capitalised words that are not the first token of a sentence.

    Sentence-initial capitals carry no signal, which is the whole reason this
    is not a one-line regex.
    """
    found = []
    for sentence in SENT_SPLIT.split(text.strip()):
        tokens = sentence.split()
        for tok in tokens[1:]:
            for word in CAP.findall(tok):
                if word not in STOP_CAPS:
                    found.append(word)
    return found


def first_line(hook):
    return hook.strip().splitlines()[0].strip() if hook.strip() else ""


def check_banned(hook):
    low = hook.lower()
    for pat in BANNED:
        if re.search(pat, low):
            return False, "banned opener matching /%s/" % pat
    return True, "no banned opener"


def check_concrete(hook):
    digits = re.findall(r"\d", hook)
    caps = proper_nouns(hook)
    scene = [p for p in SCENE_MARKERS if re.search(p, hook, re.I)]
    if digits:
        return True, "carries a number"
    if caps:
        return True, "names %s" % ", ".join(sorted(set(caps))[:3])
    if scene:
        return True, "opens inside a scene"
    return False, "no number, no name, no scene. This reads as a thesis, not a hook"


def check_width(hook):
    low = hook.lower()
    hits = [w for w in WIDE if re.search(r"%s" % re.escape(w), low)]
    has_number = bool(re.search(r"\d", hook))
    if hits and not has_number:
        return False, "total claim (%s) with no figure or boundary to close it" % ", ".join(hits)
    if hits:
        return True, "total claim present but bounded by a figure"
    return True, "gap width not obviously too wide"


def check_payoff(hook, body):
    tokens = set(re.findall(r"\d+(?:[.,]\d+)?", hook))
    tokens |= set(proper_nouns(hook))
    missing = sorted(t for t in tokens if t not in body)
    if missing:
        return False, "the body never mentions %s" % ", ".join(missing)
    return True, "every number and name in the hook appears in the body"


def run(hook, body):
    line = first_line(hook)
    if not line:
        print("FAIL  the hook is empty")
        return 1
    results = [
        ("1 banned openers", check_banned(line)),
        ("2 concreteness  ", check_concrete(line)),
        ("3 gap width     ", check_width(line)),
        ("4 payoff        ", check_payoff(line, body or "")),
    ]
    print("hook: %s" % line)
    print("chars: %d" % len(line))
    print()
    failed = 0
    for name, (ok, why) in results:
        print("%s  %s  %s" % ("pass" if ok else "FAIL", name, why))
        if not ok:
            failed += 1
    print()
    if failed:
        print("gate: REJECTED on %d check(s). Rewrite the hook, do not soften the body." % failed)
        return 1
    print("gate: passed")
    return 0


def read_card(path):
    text = open(path, encoding="utf-8").read()
    hook = body = ""
    m = re.search(r"^HOOK[ 	]*(.*)$", text, re.M)
    if m:
        hook = m.group(1).strip()
    m = re.search(r"^BODY[ 	]*(.*?)(?=^\w+[ 	]|\Z)", text, re.M | re.S)
    if m:
        body = m.group(1).strip()
    return hook, body


def main():
    ap = argparse.ArgumentParser(description="x-growth-operator hook gate")
    ap.add_argument("--hook")
    ap.add_argument("--body", default="")
    ap.add_argument("--body-file")
    ap.add_argument("--card")
    args = ap.parse_args()

    hook, body = args.hook, args.body
    if args.card:
        hook, body = read_card(args.card)
    if args.body_file:
        body = open(args.body_file, encoding="utf-8").read()
    if not hook:
        sys.exit("no hook supplied; pass --hook or --card")
    return run(hook, body)


if __name__ == "__main__":
    sys.exit(main())

## scripts/metrics.py

両方のデータ経路、1 つの出力テーブル。pull は X API と通信し、import は自分のエクスポートを読み、どちらも受け取っていない数字を推測しません。

python
#!/usr/bin/env python3
"""Metrics ingestion for the x-growth-operator skill.

Two paths, one output file. The review does not care which path produced a row.

  metrics.py pull    read post ids from the ledger, ask the X API, append rows
  metrics.py import  read your own CSV/TSV, map columns, append rows

Both append to x-ops/metrics.tsv:
  post_url  collected_at  impressions  likes  replies  reposts  bookmarks  profile_clicks

An absent counter is written as an empty field and reported as unavailable.
Nothing is ever estimated.

Path A needs a user-context OAuth 2.0 token in $X_BEARER_TOKEN, and an access
tier that permits reading. Field availability differs by tier and auth method,
so this script requests both the public and non-public metric groups and fills
only the counters that come back. Confirm the current tier, fields, and rate
limits against X's developer documentation before relying on them.
"""

import argparse
import csv
import datetime as dt
import json
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

HEADER = ["post_url", "collected_at", "impressions", "likes", "replies", "reposts", "bookmarks", "profile_clicks"]

API = "https://api.x.com/2/tweets"
BATCH = 100

# API field group -> our column
FIELD_MAP = {
    "impression_count": "impressions",
    "like_count": "likes",
    "reply_count": "replies",
    "retweet_count": "reposts",
    "bookmark_count": "bookmarks",
    "user_profile_clicks": "profile_clicks",
}

# Accepted header names per column, for the manual path.
ALIASES = {
    "post_url": ["post_url", "url", "post url", "link", "permalink", "tweet url", "post link"],
    "collected_at": ["collected_at", "date", "collected", "export date"],
    "impressions": ["impressions", "impression_count", "impressions total", "views"],
    "likes": ["likes", "like_count", "favorites", "likes total"],
    "replies": ["replies", "reply_count", "replies total"],
    "reposts": ["reposts", "retweets", "retweet_count", "reposts total"],
    "bookmarks": ["bookmarks", "bookmark_count", "bookmarks total"],
    "profile_clicks": ["profile_clicks", "user_profile_clicks", "profile clicks", "profile visits"],
}


def ops_dir(arg):
    return arg or os.environ.get("X_OPS_DIR") or "x-ops"


def append_rows(path, rows):
    new = not os.path.exists(path)
    with open(path, "a", encoding="utf-8", newline="") as fh:
        w = csv.writer(fh, delimiter="	")
        if new:
            w.writerow(HEADER)
        for r in rows:
            w.writerow([r.get(c, "") for c in HEADER])


def post_ids_from_ledger(path):
    ids, order = [], []
    if not os.path.exists(path):
        sys.exit("no ledger at %s" % path)
    with open(path, encoding="utf-8") as fh:
        for raw in fh:
            parts = raw.rstrip("
").split("	")
            if len(parts) < 7 or parts[0].strip().lower() == "date":
                continue
            url = parts[6].strip()
            if not url or url.startswith("<"):
                continue
            m = re.search(r"/status(?:es)?/(\d+)", url) or re.fullmatch(r"(\d+)", url)
            if m:
                ids.append(m.group(1))
                order.append(url)
    return ids, order


def api_get(url, token, timeout=30):
    req = urllib.request.Request(url, headers={
        "Authorization": "Bearer %s" % token,
        "User-Agent": "x-growth-operator metrics.py",
    })
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        return json.loads(resp.read().decode("utf-8"))


def cmd_pull(args):
    d = ops_dir(args.ops)
    ids, order = post_ids_from_ledger(os.path.join(d, "mix-ledger.tsv"))
    if not ids:
        print("no post ids in the ledger. Log a published post with --url first.")
        return 0

    collected = args.collected or dt.date.today().isoformat()
    fields = "public_metrics,non_public_metrics,created_at"
    batches = [ids[i:i + BATCH] for i in range(0, len(ids), BATCH)]

    if args.dry_run:
        for b in batches:
            print("%s?ids=%s&tweet.fields=%s" % (API, ",".join(b), fields))
        print("
%d ids in %d request(s). Token read from $X_BEARER_TOKEN." % (len(ids), len(batches)))
        return 0

    token = os.environ.get("X_BEARER_TOKEN")
    if not token:
        sys.exit("$X_BEARER_TOKEN is not set. Export a user-context OAuth 2.0 token, or use the import path.")

    rows, missing = [], []
    for b in batches:
        q = urllib.parse.urlencode({"ids": ",".join(b), "tweet.fields": fields})
        url = "%s?%s" % (API, q)
        for attempt in range(4):
            try:
                payload = api_get(url, token)
                break
            except urllib.error.HTTPError as e:
                if e.code == 429 and attempt < 3:
                    wait = int(e.headers.get("x-rate-limit-reset", "0"))
                    delay = max(5, min(60, wait - int(time.time()))) if wait else 15
                    print("rate limited, waiting %ds" % delay, file=sys.stderr)
                    time.sleep(delay)
                    continue
                print("HTTP %s on batch starting %s: %s" % (e.code, b[0], e.read()[:200].decode("utf-8", "replace")), file=sys.stderr)
                payload = None
                break
            except Exception as e:
                print("request failed: %s" % e, file=sys.stderr)
                payload = None
                break
        if not payload:
            missing.extend(b)
            continue

        seen = set()
        for item in payload.get("data") or []:
            seen.add(item.get("id"))
            values = {"post_url": "x.com/i/status/%s" % item["id"], "collected_at": collected}
            groups = [item.get("public_metrics") or {}, item.get("non_public_metrics") or {}]
            for src_col, dest in FIELD_MAP.items():
                for g in groups:
                    if src_col in g:
                        values[dest] = g[src_col]
                        break
            # keep the original url when we can match it back
            for u in order:
                if u.endswith(str(item.get("id"))):
                    values["post_url"] = u
                    break
            rows.append(values)

        if payload.get("errors"):
            for err in payload["errors"]:
                rid = (err.get("value") or err.get("resource_id") or "?")
                missing.append(str(rid))
        missing.extend([i for i in b if i not in seen and i not in missing])

    if rows:
        append_rows(os.path.join(d, "metrics.tsv"), rows)
    print("appended %d row(s) for %s" % (len(rows), collected))
    if missing:
        print("no data returned for %d id(s): %s" % (len(set(missing)), ", ".join(sorted(set(missing))[:8])))
    if not rows:
        print("nothing written. Check that the token is user-context and the tier allows reads.")
    return 0


def detect_delim(sample):
    return "	" if sample.count("	") > sample.count(",") else ","


def cmd_import(args):
    d = ops_dir(args.ops)
    path = args.file
    if path == "-":
        sample = sys.stdin.read()
        fh = sample.splitlines()
    else:
        with open(path, encoding="utf-8") as f:
            sample = f.read()
        fh = sample.splitlines()
    if not fh:
        sys.exit("empty input")

    delim = detect_delim(sample[:4000])
    reader = csv.DictReader(fh, delimiter=delim)
    headers = reader.fieldnames or []

    mapping = {}
    explicit = {}
    if args.map:
        for pair in args.map.split(","):
            if "=" in pair:
                k, v = pair.split("=", 1)
                explicit[k.strip().lower()] = v.strip()

    for col in HEADER:
        if col in explicit:
            src = explicit[col]
            if src and src not in headers:
                sys.exit("--map points %s at %r, which is not in the file. Headers: %s" % (col, src, headers))
            mapping[col] = src
            continue
        lower = {h.lower().strip(): h for h in headers}
        for alias in ALIASES[col]:
            if alias in lower:
                mapping[col] = lower[alias]
                break
        else:
            mapping[col] = None

    unmapped = [c for c in HEADER if not mapping.get(c) and c != "collected_at"]
    if unmapped:
        print("unmapped columns, will be left empty: %s" % ", ".join(unmapped))
        print("use --map \"%s=<your header>\" to fill them" % unmapped[0])

    collected = args.collected or dt.date.today().isoformat()
    rows, blanks = [], {c: 0 for c in HEADER}
    for rec in reader:
        row = {}
        for col in HEADER:
            src = mapping.get(col)
            val = ""
            if src:
                val = (rec.get(src) or "").strip()
            if col == "collected_at" and not val:
                val = collected
            if col == "post_url" and re.fullmatch(r"\d+", val or ""):
                # a bare post id becomes a url; anything else is left alone
                val = "x.com/i/status/%s" % val
            if not val and col != "post_url":
                blanks[col] += 1
            row[col] = val
        if row["post_url"]:
            rows.append(row)

    if not rows:
        sys.exit("no usable rows: every row was missing a post url")

    append_rows(os.path.join(d, "metrics.tsv"), rows)
    print("appended %d row(s) for %s" % (len(rows), collected))
    thin = [c for c, n in blanks.items() if n == len(rows) and c != "post_url"]
    if thin:
        print("always empty, reported as unavailable rather than zero: %s" % ", ".join(thin))
    return 0


def main():
    ap = argparse.ArgumentParser(description="x-growth-operator metrics ingestion")
    sub = ap.add_subparsers(dest="cmd", required=True)

    p = sub.add_parser("pull", help="path A: pull from the X API")
    p.add_argument("--collected")
    p.add_argument("--ops")
    p.add_argument("--dry-run", action="store_true", dest="dry_run")
    p.set_defaults(func=cmd_pull)

    p = sub.add_parser("import", help="path B: import your own export")
    p.add_argument("--file", required=True, help="CSV/TSV path, or - for stdin")
    p.add_argument("--map", help='override column mapping, e.g. "url=Post link,impressions=Views"')
    p.add_argument("--collected")
    p.add_argument("--ops")
    p.set_defaults(func=cmd_import)

    args = ap.parse_args()
    return args.func(args)


if __name__ == "__main__":
    sys.exit(main())

## scripts/weekly_review.py

レビューです。投稿 ID で 2 つのテーブルを結合し、48 時間の除外をコードで強制し、平均ではなく中央値を報告し、手元の行から証明できるパターンだけを示します。

python
#!/usr/bin/env python3
"""Weekly review for the x-growth-operator skill.

Joins the ledger to the metrics, reports per slot letter, and names the four
patterns worth acting on. Medians, not means: one good post should not define
a whole slot.

Rows collected inside the 48-hour window are excluded by default, because a
post at hour six has not finished.

Usage:
  weekly_review.py [--window 20] [--exclude-hours 48] [--min-posts 3] [--ops DIR]
"""

import argparse
import csv
import datetime as dt
import os
import statistics as st
import sys

COUNTERS = ["impressions", "likes", "replies", "reposts", "bookmarks", "profile_clicks"]
LEDGER_HEADER = ["date", "slot", "shape", "target_signal", "hook_pattern", "topic", "post_url"]


def ops_dir(arg):
    return arg or os.environ.get("X_OPS_DIR") or "x-ops"


def read_tsv(path, header):
    if not os.path.exists(path):
        return []
    rows = []
    with open(path, encoding="utf-8") as fh:
        rdr = csv.reader(fh, delimiter="	")
        for i, parts in enumerate(rdr):
            if not parts or not any(p.strip() for p in parts):
                continue
            if i == 0 and parts[0].strip().lower() in ("date", "post_url"):
                continue
            if parts[0].strip().lower() in ("date", "post_url"):
                continue
            parts = parts + [""] * (len(header) - len(parts))
            rows.append(dict(zip(header, parts)))
    return rows


def key(url):
    """Normalise a post url to a bare id so the two files can be joined."""
    url = (url or "").strip().split("?")[0].rstrip("/")
    return url.rsplit("/", 1)[-1].lower() if url else ""


def parse_date(s):
    s = (s or "").strip()
    for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%m/%d/%Y", "%d/%m/%Y"):
        try:
            return dt.datetime.strptime(s, fmt).date()
        except ValueError:
            continue
    return None


def to_num(v):
    v = (v or "").strip().replace(",", "").replace("%", "")
    if not v:
        return None
    try:
        return float(v)
    except ValueError:
        return None


def main():
    ap = argparse.ArgumentParser(description="x-growth-operator weekly review")
    ap.add_argument("--window", type=int, default=20)
    ap.add_argument("--exclude-hours", type=int, default=48, dest="exclude_hours")
    ap.add_argument("--min-posts", type=int, default=3, dest="min_posts",
                    help="minimum posts in a slot before its median is reported")
    ap.add_argument("--ops")
    args = ap.parse_args()

    d = ops_dir(args.ops)
    ledger = read_tsv(os.path.join(d, "mix-ledger.tsv"), LEDGER_HEADER)
    metrics = read_tsv(os.path.join(d, "metrics.tsv"), ["post_url", "collected_at"] + COUNTERS)

    if not ledger:
        print("no ledger rows. Nothing to review.")
        return 0

    ledger = ledger[-args.window:] if args.window > 0 else ledger
    by_id = {}
    for r in metrics:
        k = key(r["post_url"])
        if not k:
            continue
        by_id.setdefault(k, []).append(r)

    joined, no_metrics, early = [], [], 0
    for r in ledger:
        k = key(r["post_url"])
        if not k:
            no_metrics.append(r)
            continue
        candidates = by_id.get(k)
        if not candidates:
            no_metrics.append(r)
            continue
        pub = parse_date(r["date"])
        usable = []
        for c in candidates:
            cd = parse_date(c["collected_at"])
            if pub and cd and (cd - pub).days * 24 < args.exclude_hours:
                early += 1
                continue
            usable.append(c)
        if not usable:
            no_metrics.append(r)
            continue
        latest = max(usable, key=lambda c: parse_date(c["collected_at"]) or dt.date.min)
        joined.append((r, latest))

    orphans = [r for r in metrics if key(r["post_url"]) not in {key(l["post_url"]) for l in ledger}]

    print("# Weekly review")
    print()
    print("%d ledger rows in window, %d joined to metrics, %d without usable metrics."
          % (len(ledger), len(joined), len(no_metrics)))
    if early:
        print("%d metrics row(s) excluded for being inside the %dh window."
              % (early, args.exclude_hours))
    print()

    if not joined:
        print("No joinable data. The review cannot say anything yet, and it will not guess.")
        if no_metrics:
            no_url = [r for r in no_metrics if not (r.get("post_url") or "").strip()]
            waiting = [r for r in no_metrics if (r.get("post_url") or "").strip()]
            print()
            if no_url:
                print("Ledger rows with no url (%d). These can never join:" % len(no_url))
                for r in no_url[:10]:
                    print("  %s  %s  %s" % (r["date"], r["slot"], r["topic"] or "(no topic)"))
            if waiting:
                print("Rows whose metrics are missing or still inside the %dh window (%d):"
                      % (args.exclude_hours, len(waiting)))
                for r in waiting[:10]:
                    print("  %s  %s  %s" % (r["date"], r["slot"], r["topic"] or "(no topic)"))
                print("Ingest again once the window closes. Lowering --exclude-hours to see numbers early is the failure this rule exists to prevent.")
        return 0

    slots = {}
    for l, m in joined:
        slots.setdefault((l["slot"] or "?").upper(), []).append((l, m))

    print("## By slot")
    print()
    print("slot  n  " + "  ".join("%s" % c.rjust(14) for c in COUNTERS))
    for s in sorted(slots):
        items = slots[s]
        cells = []
        for c in COUNTERS:
            vals = [to_num(m.get(c)) for _, m in items]
            vals = [v for v in vals if v is not None]
            if len(vals) < args.min_posts:
                cells.append("n/a".rjust(14))
            else:
                cells.append(("%.0f" % st.median(vals)).rjust(14))
        print("%-5s %d  %s" % (s, len(items), "  ".join(cells)))
    print()
    print("n/a means fewer than %d posts with that counter. It does not mean zero." % args.min_posts)
    print()

    print("## Patterns")
    print()
    flags = 0
    allr = [(l, m) for l, m in joined]
    imp = [(l, to_num(m.get("impressions"))) for l, m in allr if to_num(m.get("impressions")) is not None]
    if len(imp) >= args.min_posts:
        med_imp = st.median([v for _, v in imp])
        rep = [(l, to_num(m.get("reposts"))) for l, m in allr if to_num(m.get("reposts")) is not None]
        bkm = [(l, to_num(m.get("bookmarks"))) for l, m in allr if to_num(m.get("bookmarks")) is not None]
        if rep:
            med_rep = st.median([v for _, v in rep])
            hits = []
            for l, m in allr:
                i, rp = to_num(m.get("impressions")), to_num(m.get("reposts"))
                if i is not None and rp is not None and i > med_imp * 1.3 and rp <= med_rep:
                    hits.append(l)
            if hits:
                flags += 1
                print("**High impressions, low reposts** on %d post(s): reached people, did not travel." % len(hits))
                for l in hits[:5]:
                    print("  %s  %s  %s" % (l["date"], l["slot"], l["topic"]))
                print()
        if bkm:
            med_bk = st.median([v for _, v in bkm])
            hits = []
            for l, m in allr:
                i, bk = to_num(m.get("impressions")), to_num(m.get("bookmarks"))
                if i is not None and bk is not None and bk > med_bk * 1.3 and i <= med_imp:
                    hits.append(l)
            if hits:
                flags += 1
                print("**High bookmarks, low impressions** on %d post(s): worth keeping, nobody saw it." % len(hits))
                print("  Rewrite around a new angle rather than re-posting it.")
                for l in hits[:5]:
                    print("  %s  %s  %s" % (l["date"], l["slot"], l["topic"]))
                print()

    reps = [to_num(m.get("replies")) for _, m in allr]
    reps = [v for v in reps if v is not None]
    if reps:
        med_r = st.median(reps)
        per_slot = {}
        for s, items in slots.items():
            vals = [to_num(m.get("replies")) for _, m in items]
            vals = [v for v in vals if v is not None]
            if vals:
                per_slot[s] = st.median(vals)
        if per_slot and all(v <= med_r for v in per_slot.values()) and med_r < 3:
            flags += 1
            print("**Low replies across every slot** (median %.1f). The account is not inviting" % med_r)
            print("  disagreement anywhere. That is a shape problem, not a post problem.")
            print()

    if len(slots) >= 2:
        med_by_slot = {}
        for s, items in slots.items():
            vals = [to_num(m.get("impressions")) for _, m in items]
            vals = [v for v in vals if v is not None]
            if len(vals) >= args.min_posts:
                med_by_slot[s] = st.median(vals)
        if len(med_by_slot) >= 2 and max(med_by_slot.values()) > 1.6 * min(med_by_slot.values()):
            flags += 1
            best = max(med_by_slot, key=med_by_slot.get)
            print("**One slot outperforming the rest**: %s at %.0f median impressions vs %.0f for %s."
                  % (best, med_by_slot[best], min(med_by_slot.values()), min(med_by_slot, key=med_by_slot.get)))
            print("  Record it and keep the ratio. Reach is not the assignment. Change the brief")
            print("  only if the gap holds across two full windows.")
            print()

    if not flags:
        print("No patterns above threshold this window. That is a valid result, not a bug.")
        print()

    print("## Gaps")
    print()
    if no_metrics:
        print("- %d ledger row(s) with no usable metrics." % len(no_metrics))
        for r in no_metrics[:5]:
            print("    %s  %s  %s" % (r["date"], r["slot"], r["topic"] or "(no topic)"))
    if orphans:
        print("- %d metrics row(s) with no ledger entry. Publish outside the loop still counts." % len(orphans))
        for r in orphans[:5]:
            print("    %s  %s" % (r["collected_at"], r["post_url"]))
    if not no_metrics and not orphans:
        print("- none. Every row joined in both directions.")
    print()
    print("Every number above traces to a row in x-ops/metrics.tsv.")
    return 0


if __name__ == "__main__":
    sys.exit(main())

## 最小構成のループを回す

4 つのコマンドを順番に、1 投稿ずつ。動いている実行とはこういうものです。

プラン。 枠を尋ねます。スクリプトは直近の窓を数え、雰囲気ではなく計算を出力します。

text
$ python3 x-ops/scripts/mix_ledger.py plan --window 20
window: last 20 logged posts

slot  count  expected  deficit  actual%   target%
  A       14      10.0     -4.0    70.0%    50.0%
  B        4       4.0      0.0    20.0%    20.0%
  C        1       3.0      2.0     5.0%    15.0%
  D        1       2.0      1.0     5.0%    10.0%
  E        0       1.0      1.0     0.0%     5.0%

slot:   C
why:    deficit 2.0 over a 20-row window

確認: その不足は、台帳を自分で数えれば検証できる計算です。枠 C は 15 パーセントの取り分に対して 2 投稿足りず、実践的なチュートリアルは取り分より 4 投稿多い。これがこのループ全体が捕まえるために存在するドリフトです。

うまくいかないとき: 最も多い原因は台帳に 5 行しかないことです。窓が薄く、選出は揺れます。スクリプトはごまかさずにそう言います。20 行あたりで落ち着きます。

下書き。 その枠が求める形で投稿を書きます。枠 A にはチュートリアル、B には精読、C には振り返り。

確認: フック、主張、根拠、行動を 4 つの別々の部品として指させるかどうかです。根拠が見つからないなら、その投稿はチュートリアルの衣装を着た意見です。

うまくいかないとき: 形は正しいが中身が薄い。言いたかった具体的なことを持って戻ってください。スキルはあなたの専門性を発明しませんし、それを求めることが、誰にでも似た響きのものを公開してしまう道です。

ゲート。 最初の一行は、他に何かが起こる前にリンターに通します。

text
$ python3 x-ops/scripts/hook_lint.py \
    --hook "In today's fast-paced AI landscape, building an audience is everything." \
    --body "Some general advice about posting."
hook: In today's fast-paced AI landscape, building an audience is everything.
chars: 71

FAIL  1 banned openers  banned opener matching /^in today'?s/
pass  2 concreteness    names AI
FAIL  3 gap width       total claim (everything) with no figure or boundary to close it
FAIL  4 payoff          the body never mentions AI

gate: REJECTED on 3 check(s). Rewrite the hook, do not soften the body.

そのフックは 3 通りの仕方で同時に失敗しています。気に留める価値があります。書き出しはテンプレートであり、主張は全体に向けられており、本文はフックが約束した唯一の固有名詞に一切触れていません。人間の読者は 3 つとも名指しせずに感じ取ります。

こちらは同じゲートを通過する一行です:

text
$ python3 x-ops/scripts/hook_lint.py \
    --hook "I ran 40 posts through the same gate. 31 failed on one check." \
    --body "I ran 40 posts through the same gate last month. 31 failed on one check: the body never paid off the hook."
hook: I ran 40 posts through the same gate. 31 failed on one check.
chars: 61

pass  1 banned openers  no banned opener
pass  2 concreteness    carries a number
pass  3 gap width       gap width not obviously too wide
pass  4 payoff          every number and name in the hook appears in the body

gate: passed

確認: ゲートがどれくらいの頻度で却下するかを数えてください。10 回の下書きで一度も却下していないなら、BANNED のリストが短すぎます。hook_lint.py を開いて、自分のフィードで見かける書き出しを追加してください。リストは 17 個で出荷され、使うほど増えます。

うまくいかないとき: 書き直しが元より悪い。元を残し、失敗した特定のチェックだけを直してください。ゲートは 4 つのイエスかノーかの問いであり、書き直しサービスではありません。

ログ。 公開した後、行を記録します。

bash
python3 x-ops/scripts/mix_ledger.py log \
  --slot C --shape retro --signal reply \
  --hook-pattern scene --topic "gate rejection rate" \
  --url "x.com/yourhandle/status/1234567890"

確認: 台帳の行数が実際に公開した数と一致するかどうかです。数えてください。

うまくいかないとき: ループの外で何かを公開しています。それでも記録してください。ループ外の投稿も配合には数えられますし、自分が誇りに思う投稿だけを記録する台帳は、レビューの時にあなたに嘘をつきます。

これが最小構成のループの全体です。プランニングと品質の半分を閉じます。フィードバックの半分には数字が要ります。

## フィードバックの半分を閉じる

2 つの経路があり、その選択は好みではなく実質的なトレードオフです。

経路 A は X API から引きます。 スクリプトは台帳から投稿 ID を読み、100 件ずつまとめ、公開指標と非公開指標の両方のグループを要求し、返ってきたカウンタだけを書き込みます。

bash
export X_BEARER_TOKEN="your user-context OAuth 2.0 token"
python3 x-ops/scripts/metrics.py pull --dry-run   # prints the request, calls nothing
python3 x-ops/scripts/metrics.py pull

ブックマーク数とインプレッション数を読むにはユーザーコンテキスト認証が必要で、認証されたアカウント自身の投稿に対してのみ値が返ります。これはたまたま、このループが必要とする範囲そのものです。トークンがなければスクリプトは途中で失敗せずメッセージを出して終了します。その枠が特定のフィールドを許可していなければ、そのフィールドは欠けて返り、行はそれを空として記録します。

X 開発者プラットフォームの枠の名称、価格、利用できるフィールド、レート制限は変わりやすいので、その上に何かを建てる前に、現在のものをドキュメントで確認してください。スクリプトは 429 をバックオフして再試行し、レート制限で空いた穴を推測で埋めることは決してありません。

経路 B は自分のエクスポートを取り込みます。 週 10 分、依存なし、暫定的な解決策だと感じる理由はありません。

bash
python3 x-ops/scripts/metrics.py import --file export.csv
python3 x-ops/scripts/metrics.py import --file export.csv --map "url=Post link,impressions=Views"

スクリプトは一般的なヘッダー名を自分で認識し、対応付けできなかった列を教えてくれるので、最初の取り込みはエクスポートのチェックを兼ねます。対応付けられなかったものは空のフィールドとして書かれ、後にゼロではなく利用不可として報告されます。

どちらの経路も同じテーブルを書くので、B から始めて後で A に移っても、レビューには触れずに済みます。すべての行は収集日を持ちます。指標は公開後に動くので、1 日目の読みと 30 日目の読みを混ぜたレビューは何も測っていないからです。

1 つのルールが他より重要です。レビューを試すために行をでっち上げないこと。代わりに空のテーブルに対してレビューを実行してください。データがないと告げるはずで、それでも要約を出すなら、どこかが壊れています。

## 発展的なループ

最小構成のループが回り始めてからの 3 つの追加です。

リプライのパス。 プランニングのモデルでは、リプライはいいねのおよそ 13 倍の重みを持ちます。つまり 1 つ書くことは礼儀ではなく流通の行動です。すでにフォローしている、あなたのテーマの柱の内側にいるアカウントから対象リストを作り、それぞれに対して 4 種類のうち 1 つのリプライを書きます。元の投稿が省いた数字を足す、自分の仕事からの反例を出す、その投稿自身の論理が要求する続きの問いを立てる、あるいは実際に試したときに何が起きたかを述べる。

何を受け入れるかより、何を却下するかの方が重要です。中身のない称賛、追加のない同意、そして自分の投稿の宣伝になっているリプライは、どれもリプライを書く価値にしている唯一のものを燃やします。それは、元の投稿が言わなかったことをあなたが言った、ということです。

48 時間の窓。 プランニングのモデルは、公開後の最初の 2 日間を、投稿がまだ拾われる可能性のある期間として扱います。そこから 2 つのルールが導かれます。1 か月前の投稿を新しいものとして再実行しないこと。復活させる価値があるなら、新しい角度で書き直して新しい素材として公開してください。そして、窓が閉じる前に投稿を判定しないこと。weekly_review.py は早い行を自動的に除外しますし、それを手で上書きするのは、レビューに嘘をつかせる最短の方法です。

週次のレビュー。 コマンド 1 つで、証明できることを出力します。

bash
python3 x-ops/scripts/weekly_review.py --window 20

投稿 ID で台帳と指標を結合し、1 つの当たり投稿が枠全体を定義しないように平均ではなく中央値を使い、4 つのパターンを示します。インプレッションが多くリポストが少ない、つまり人に届いたが広がらなかった。ブックマークが多くインプレッションが少ない、つまり保存する価値はあったが誰も見なかった。すべての枠でリプライが少ない、これは投稿の問題ではなく形の問題です。そして 1 つの枠が到達で他を上回っている、これは記録しておき行動はしないもの。最後のものは、比率が仕事をしている証拠です。

出力は両方向の欠落リストも持ちます。指標のない台帳の行と、台帳の項目のない指標の行は、どちらも上流のどこかが壊れていることを意味します。ループの外で公開された投稿も配合には数えられるので、消えてしまわないように記録してください。

## 壊れたとき

見えるもの

たいていの意味

すること

同じ枠が 3 日続く

窓が薄い、または投稿が記録されていない

ログの段階が動いているか確認する。約 20 行になるまでは、ノイズを想定してそう言う

すべての下書きがゲートを通る

ゲートが読まれているだけで、適用されていない

hook_lint.pyBANNED に自分の常套句を追加して再実行する

ほとんどの指標の行が結合に失敗する

台帳とエクスポートで投稿 URL の形式が違う

どちらも数値の投稿 ID に正規化される。台帳で post_url が埋まっているか確認する

レビューが 1 つの投稿に支配される

中央値が効くには窓が小さすぎる

--window を広げ、外れ値は別途報告する

それでも配合がずれる

投稿がループの外で公開されている

記録してください。ループ外の投稿も配合の一部です

下書きの声が毎週違う

ブリーフに禁止リストがない

避けたい具体的な手口を、それぞれ例付きで追加する

import がほとんど対応付けない

エクスポートが別名リストの外のヘッダー名を使っている

--map を 1 回通し、対応をメモに書いて再利用する

## 配合を正直に保つ

配合は、このループの中で唯一、静かに失敗する部分です。チュートリアルを 4 本続けて公開しても何も壊れません。アカウントが狭くなるだけで、同じ人たちだけが読んでいることに 4 か月後に気づきます。

直近 20 投稿の枠ごとの実績と目標の比較棒グラフ。枠 A は目標を上回り、枠 C、D、E は下回っている

2 つの習慣がそれを支え、どちらもスクリプトに入っています。

カレンダーではなく、直近の窓を数えること。20 投稿は安定した標本ですが、2 週間はそうではありません。2 週間に入る投稿数こそが、まさに変動するものだからです。台帳が 20 行に満たないとき、plan は次の投稿で揺れる比率ではなく、窓が薄いという警告を出力します。

不足に選ばせ、到達には負けさせること。1 つの枠がインプレッションで明らかに他を上回っているとき、それを多く公開したくなります。まさにその瞬間のために比率は存在します。観察を記録し、プランを保ち、窓が埋まってから再検討してください。不均衡が 2 つの窓にわたって続くなら、ブリーフの目標を変えてください。行動ではなくブリーフを変えることで、次に台帳を読む人が理由を見られます。

## よくある質問

私の代わりに公開できますか。

いいえ、そしてそれは意図的です。スキルは下書きと記録を書き、4 本のスクリプトのどれにも公開の呼び出しは含まれていません。X での公開行為の自動化は、それ自体のプラットフォーム規約上の帰結を伴う別の判断であり、アカウントが何であるかを変えてしまいます。それを便利な機能としてではなく、それ自体の条件で判断してください。

有料の X API は必要ですか。

経路 A にのみ必要です。ループは指標がまったくなくても回ります。ただレビューを閉じられないだけです。手動の取り込みは安価な経路であり、暫定的なものではありません。正直な空欄のある週 10 分は、穴を推測で埋めるパイプラインに勝ります。

私のアカウントが AI についてでなくても大丈夫ですか。

4 つの役割、6 つの形、配合の比率はテーマに依存しません。AI に固有の内容は、表の中の例示だけです。ブリーフの柱を自分のものに置き換えれば、残りはそのまま成り立ちます。

これを使うと投稿が生成物っぽく聞こえませんか。

逆に押し返します。ゲートは、どのアカウントのどんな投稿にも当てはまる書き出しを却下します。それが生成物らしく読める特定の失敗です。あなたのものとして残るのは根拠です。スキルはそれを発明しませんし、そう求めるべきでもありません。

レビューが役に立つことを言うまでどれくらいかかりますか。

記録された投稿が約 20 件です。それ未満では中央値はノイズです。それまでは規律のためにループを回し、数字は無視してください。

著者: Rowan Blake、Auspia にて 100 以上の公開パイプラインのコンテンツ自動化アナリスト。自動化されたブリーフ、コンテンツパイプライン、AI 支援の制作システムについて書いています。

このトピックを読む

同じテーマの記事を続けて読む