Codex 스킬로 X 계정 운영을 자동화하는 방법

핵심 요약

X 계정 하나의 운영 루프를 돌리는 Codex 스킬을 만듭니다. 기록된 콘텐츠 배합에서 오늘의 슬롯을 고르고, 그 형태로 초안을 쓰고, 결정적 게이트로 약한 훅을 걸러내고, X API 수집 또는 수동 내보내기로 성과를 리뷰합니다.

Codex가 대신 만들어 줍니다. 이 글을 Codex 세션에 붙여넣고 스킬을 만들어 달라고 요청하세요. "이 글을 읽고 x-growth-operator 스킬을 이 프로젝트에 그대로 작성해 줘." 파일 네 개와 스크립트 네 개가 모두 아래에 전문으로 있습니다. 스킬은 스스로 X에 게시하거나 예약하거나 쓰지 않습니다.

완성하고 나면 손에 남는 것

스킬 파일 하나, 파이썬 스크립트 네 개, 작은 상태 파일 세 개입니다. 이들이 함께 X 계정 한 개의 운영 루프를 돌립니다. 오늘 무엇을 올릴지 정하고, 그 슬롯이 요구하는 형태로 초안을 쓰고, 첫 줄이 게이트를 통과하지 못하면 초안을 버리고, 게시한 내용을 기록한 다음, 숫자를 다시 가져와 조정합니다.

계정 하나를 혼자 운영하는 사람, 또는 브랜드 계정 하나를 운영하는 작은 팀을 위해 만들었습니다. 이 스킬이 막는 실패는 글을 못 쓰는 것이 아닙니다. 흐트러짐입니다. 콘텐츠 배합은 이번 주에 쓰기 쉬운 쪽으로 슬금슬금 기울고, 훅은 가장 마지막에 쓰여 약한 채로 남고, 한 달이 지나 패턴이 굳을 때까지 아무도 분석 화면을 열지 않습니다.

시작하기 전에 필요한 것:

  • 프로젝트 디렉터리에 파일을 쓸 수 있는 에이전트 세션. Codex면 충분하고, Claude Code나 파일 생성과 파이썬 실행이 가능한 다른 도구도 됩니다.
  • 운영하려는 X 계정, 그리고 이를 운영할 권한.
  • 파이썬 3.8 이상. 스크립트 네 개 모두 표준 라이브러리만 쓰므로 설치할 것이 없습니다.
  • 구축과 첫 계획 실행에 약 30분.
  • 리뷰 단계용 데이터 경로 두 가지 중 하나: 유료 등급의 X API 키, 또는 X 분석에서 내보낸 수동 내보내기 파일. 시작할 때 둘 다 없어도 됩니다. 루프는 그대로 돌아가고, 피드백 절반만 아직 닫지 못할 뿐입니다.

완료 기준: 스킬 디렉터리가 존재하고, x-ops/에 작성된 브리프와 빈 표 두 개가 들어 있으며, mix_ledger.py plan이 브리프와 일치하는 날짜가 붙은 슬롯 배정을 돌려줍니다.

루프를 자동화할 가치가 있는 이유

글쓰기는 쉬운 절반이고, 모델이 이미 잘하는 절반입니다. 무너지는 것은 그 주변 전부입니다. "게시물의 절반은 실전형이어야 한다" 같은 비율을 머릿속에 붙들고 있을 수 있는 기간은 약 2주입니다. 그 뒤에는 그 비율이 조용히 "오늘 끝낸 것"이 됩니다.

실제로 깨지는 세 가지는 모두 상태 문제입니다:

배합. 무엇을 이미 게시했는지 기록이 없으면 비율에서 벗어났는지 알 수 없습니다. 기억은 기록이 아니고, 실패는 한 주 안에서는 보이지 않습니다.

훅. 첫 줄은 당신이 쓴 나머지가 읽힐지를 결정하는 문장이고, 동시에 집중력이 사라진 뒤 가장 마지막에 쓰일 가능성이 큰 문장입니다. 자기 훅을 스스로 판정하는 일은 특정한 방식으로 신뢰할 수 없습니다. 본문이 무엇을 말하는지 이미 알고 있으므로, 아직 읽지 않은 독자에게는 활짝 열려 있어도 당신에게는 간극이 닫힌 것처럼 느껴집니다.

리뷰. 숫자를 가져오고, 게시한 내용과 맞추고, 각 게시물에 배정된 슬롯과 비교해 읽어야 합니다. 주당 20분의 장부 작업이고, 이는 바쁜 한 주를 결코 넘기지 못하는 정확히 그만큼의 노동입니다.

채팅 세션은 이 중 어느 것도 붙들지 못합니다. 스킬은 붙들 수 있습니다. 스킬에는 파일이 있고, 파일은 스크립트로 셀 수 있기 때문입니다. 장부는 기억이고, 게이트는 규율이며, 리뷰는 좋지 않은 주에도 돌릴 수 있는 명령입니다.

전략 계층, 즉 무엇을 게시할지 그리고 특정 게시물 형태가 왜 특정 반응을 얻는지는 별개의 문제입니다. X 계정으로 블로그 트래픽을 늘리는 가이드가 배포 쪽을 자세히 다룹니다. 이 글은 실행 계층입니다. 그 판단을 실제로 돌아가는 것으로 바꾸는 일입니다.

시작하기 전에

세 가지 전제. 스킬이 그 위에 세워지므로 반드시 확인하세요.

계정 하나, 브리프 하나. 이 스킬은 의도적으로 단일 테넌트입니다. x-ops/ 디렉터리 하나가 계정 하나를 기술합니다. 계정 두 개를 운영하면 장부 두 개를 가진 디렉터리 두 개가 필요합니다. 계정 간에 장부를 공유하면 장부의 유일한 존재 이유가 파괴됩니다.

초안을 쓰고 게시 버튼은 직접 누를 의향. 스킬은 텍스트와 기록을 만들어 냅니다. 쓰기 엔드포인트는 절대 호출하지 않습니다. 스크립트가 이 약속을 지키기 쉽게 만듭니다. 네 파일 어디에도 게시 함수가 없으므로, 경계는 약속이 아니라 검증 가능한 것입니다.

지표를 두 가지 형태 중 하나로 만들 수 있을 것. 유료 개발자 등급의 X API 키를 가지고 있거나, X에서 게시물 단위 분석을 내보내 몇 개 열을 매핑할 수 있어야 합니다. 자세한 내용은 지표 섹션에 있습니다. 지금 둘 다 아니라면 그래도 스킬을 만들고, 가능해질 때까지 metrics.tsv를 비워 두세요.

스킬 설치

파일 여섯 개, 그중 네 개가 스크립트입니다. 다음 트리를 만드세요:

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/

스크립트가 두 곳에 있는 것은 의도적입니다. 스킬 안에 사본을 두면 스킬이 이식성을 유지하고, x-ops/ 아래에 사본을 두면 명령이 짧아지고 작업 디렉터리 전체를 한 덩어리로 옮기거나 보관할 수 있습니다. 복제가 싫다면 x-ops/scripts를 스킬의 scripts/에 심볼릭 링크하고 아래 명령의 경로를 조정하세요.

무엇보다 먼저 두 표를 헤더만 있는 파일로 만드세요:

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

두 파일 모두 헤더만 남기세요. 헤더가 잘못되면 첫 계획 실행이 불평합니다. 게시물 스무 개를 올린 뒤에 발견하는 것보다 훨씬 나은 실패입니다.

스킬 파일

`SKILL.md`는 에이전트가 읽는 파일입니다. 규칙, 네 단계 루프, 실패 표, 그리고 초안 작성 단계가 의존하는 참조 표를 담습니다. 그 표들을 에이전트가 항상 읽는 파일 안에 두는 것은 의도적입니다. 별도 문서에 사는 참조는 건너뛰어지는 참조입니다.

`templates/account-brief.yaml`은 직접 작성하는 유일한 파일입니다. 포지셔닝, 필러, 어조 제한, 배합 목표, 계획 가중치.

`scripts/mix_ledger.py`는 배합 계산을 합니다. 브리프의 mix_target 블록과 장부를 읽고 한 가지 질문에 답합니다. 최근 구간에서 어느 슬롯이 가장 뒤처져 있는가.

`scripts/hook_lint.py`는 게이트입니다. 초안 첫 줄에 대한 네 가지 기계적 검사, 하나라도 실패하면 비영(非零) 종료 코드를 반환하므로 에이전트가 분기할 수 있습니다.

`scripts/metrics.py`는 두 경로 중 하나로 성과 데이터를 수집하고 둘 모두를 같은 표로 정규화합니다.

`scripts/weekly_review.py`는 두 표를 결합하고, 슬롯별 중앙값을 계산하며, 조치할 가치가 있는 네 가지 패턴을 지목합니다. 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

게이트. 검사 네 가지, 실패 시 비영 종료. 에이전트가 산문을 해석하는 대신 종료 코드로 분기할 수 있습니다. BANNED 목록은 직접 편집하도록 만들어진 부분입니다. 열일곱 개의 상투적 도입부가 기본으로 들어 있고, 자기 피드의 클리셰가 늘어나는 만큼 함께 늘어나야 합니다.

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

두 데이터 경로, 하나의 출력 표. 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로 결합하고, 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())

최소 루프 돌리기

명령 네 개를 순서대로, 한 번에 게시물 하나씩. 실제로 동작하는 실행은 이렇게 생겼습니다.

계획. 슬롯을 요청합니다. 스크립트는 최근 구간을 세고 느낌이 아니라 산수를 출력합니다.

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퍼센트 몫에 두 개가 모자라고, 실전형 튜토리얼은 몫보다 네 개가 많습니다. 이것이 이 루프 전체가 잡으려는 흐트러짐입니다.

잘못될 때: 가장 흔한 원인은 행이 다섯 개뿐인 장부입니다. 구간이 얇으면 선택이 흔들립니다. 스크립트는 모른 척하지 않고 그렇게 말합니다. 스무 행 근처에서 안정됩니다.

초안. 슬롯이 요구하는 형태로 글을 씁니다. 슬롯 A에는 튜토리얼, B에는 정독, C에는 회고.

확인: 훅, 주장, 근거, 행동을 네 개의 별개 조각으로 지목할 수 있어야 합니다. 근거를 찾을 수 없다면 그 글은 튜토리얼 옷을 입은 의견입니다.

잘못될 때: 형태는 맞는데 내용이 빈약합니다. 말하고 싶었던 구체적인 것 하나를 들고 돌아가세요. 스킬은 당신의 전문성을 발명하지 않습니다. 그걸 요구하는 것이 바로 남들과 똑같이 들리는 글을 게시하게 되는 경로입니다.

게이트. 첫 줄에 다른 어떤 일이 일어나기 전에 린터를 통과시키세요.

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.

그 훅은 세 가지 방식으로 동시에 실패합니다. 눈여겨볼 만합니다. 도입부는 템플릿이고, 주장은 전면적이며, 본문은 훅이 약속한 이름 있는 것 하나를 언급조차 하지 않습니다. 사람 독자는 셋 다 이름 붙이지 못한 채 느낍니다.

통과하는 줄에 같은 게이트를 적용하면 이렇습니다:

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

확인: 게이트가 얼마나 자주 거부하는지 세어 보세요. 초안 열 개 동안 한 번도 거부한 적이 없다면 BANNED 목록이 너무 짧습니다. hook_lint.py를 열고 자기 피드에서 계속 보이는 도입부를 추가하세요. 목록은 열일곱 개로 시작해 쓰면서 늘어납니다.

잘못될 때: 재작성본이 원본보다 나쁩니다. 원본을 유지하고 실패한 검사만 고치세요. 게이트는 예/아니오 질문 네 개이지 재작성 서비스가 아닙니다.

기록. 게시한 뒤 행을 남깁니다.

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"

확인: 장부 행 수가 실제로 게시한 것과 일치합니다. 세어 보세요.

잘못될 때: 루프 밖에서 게시했습니다. 그래도 기록하세요. 루프 밖 게시물도 배합에 포함되며, 자랑스러운 글만 기록하는 장부는 리뷰 시점에 당신에게 거짓말을 합니다.

이것이 최소 루프의 전부입니다. 계획과 품질 쪽 절반을 닫습니다. 피드백 절반에는 숫자가 필요합니다.

피드백 절반 닫기

두 경로가 있고, 이 선택은 선호가 아니라 실제 맞바꿈입니다.

경로 A는 X API에서 가져옵니다. 스크립트는 장부에서 게시물 id를 읽어 백 개씩 묶어 요청하고, 공개 지표 그룹과 비공개 지표 그룹을 모두 요청하며, 돌아온 카운터만 기록합니다.

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"

스크립트는 흔한 헤더 이름을 스스로 알아보고 매핑하지 못한 열을 알려 줍니다. 따라서 첫 가져오기는 내보내기 파일을 점검하는 일이기도 합니다. 매핑되지 않은 항목은 빈 필드로 기록되고 나중에 0이 아니라 사용 불가로 보고됩니다.

두 경로 모두 같은 표에 쓰므로, B로 시작해 나중에 A로 옮겨도 리뷰를 건드릴 필요가 없습니다. 모든 행에는 수집 날짜가 붙습니다. 지표는 게시 후에도 움직이고, 1일차 수치와 30일차 수치를 섞은 리뷰는 아무것도 측정하지 않기 때문입니다.

다른 무엇보다 중요한 규칙 하나: 리뷰를 시험하려고 행을 지어내지 마세요. 대신 빈 표에 리뷰를 돌리세요. 데이터가 없다고 말해야 하고, 그래도 요약을 만들어 낸다면 무언가가 고장 난 것입니다.

고급 루프

최소 루프가 돌아간 뒤의 세 가지 추가 사항.

답글 패스. 계획 모델에서 답글은 좋아요의 약 열세 배 가중치를 가집니다. 따라서 답글을 쓰는 일은 예의가 아니라 배포 행동입니다. 이미 팔로우하는 주제 필러 안의 계정에서 대상 목록을 만들고, 각각에 대해 네 종류 중 하나로 답글을 작성하세요. 원글이 빠뜨린 숫자를 더하거나, 자기 작업에서 반례를 제시하거나, 원글 논리 자체가 요구하는 후속 질문을 던지거나, 직접 해봤을 때 벌어진 일을 설명하는 것입니다.

무엇을 거부할지가 무엇을 받아들일지보다 중요합니다. 빈 칭찬, 보탬 없는 동의, 그리고 자기 글의 몰래 광고인 답글은 답글을 쓸 가치 있게 만드는 유일한 것, 즉 원글이 하지 않은 말을 했다는 사실을 태워 버립니다.

48시간 창. 계획 모델은 게시 후 첫 이틀을 글이 아직 줍힐 수 있는 기간으로 취급합니다. 두 가지 규칙이 따릅니다. 한 달 된 글을 새것인 양 다시 돌리지 마세요. 되살릴 가치가 있다면 새 각도로 다시 쓰고 새 소재로 게시하세요. 그리고 창이 닫히기 전에 글을 판단하지 마세요. weekly_review.py가 이른 행을 자동으로 제외하며, 이를 손으로 덮어쓰는 것이 리뷰를 거짓말하게 만드는 가장 빠른 길입니다.

주간 리뷰. 명령 하나, 그리고 증명할 수 있는 것을 출력합니다.

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

두 표를 게시물 id로 결합하고, 좋은 글 하나가 슬롯 전체를 정의하지 않도록 평균 대신 중앙값을 쓰며, 네 가지 패턴을 지목합니다. 노출은 높고 리포스트는 낮음, 즉 사람에게 닿았지만 퍼지지 않았다는 뜻입니다. 북마크는 높고 노출은 낮음, 즉 간직할 가치가 있었지만 아무도 보지 못했다는 뜻입니다. 모든 슬롯에서 낮은 답글, 이는 글 문제가 아니라 형태 문제입니다. 그리고 도달에서 나머지를 앞서는 슬롯 하나, 이는 기록하고 행동하지 않습니다. 마지막 것은 비율이 제 역할을 하는 중이라는 신호입니다.

출력에는 양방향 빈틈 목록도 함께 붙습니다. 지표가 없는 장부 행, 그리고 장부 항목이 없는 지표 행은 각각 상류의 무언가가 고장 났다는 뜻입니다. 루프 밖에서 게시한 글도 배합에 포함되므로, 사라지게 두지 말고 기록하세요.

깨질 때

보이는 것

보통의 의미

할 일

사흘 연속 같은 슬롯

구간이 얇거나 게시물이 기록되지 않음

기록 단계가 도는지 확인. 약 스무 행이 될 때까지는 잡음이 섞이므로 그렇게 말할 것

모든 초안이 게이트를 통과

게이트를 적용하는 게 아니라 읽고 있음

hook_lint.pyBANNED에 자기 클리셰를 추가하고 재실행

대부분의 지표 행이 결합에 실패

장부와 내보내기 파일의 게시물 url 형식이 다름

양쪽이 숫자 게시물 id로 정규화됨. 장부의 post_url이 채워졌는지 확인

리뷰가 글 하나에 휘둘림

중앙값이 버티기에 구간이 너무 작음

--window를 넓히고 이상치를 따로 보고

그래도 배합이 흐트러짐

루프 밖에서 게시 중

기록하세요. 루프 밖 게시물도 배합의 일부입니다

초안의 목소리가 매주 달라짐

브리프에 금지 목록이 없음

피할 구체적 수법을 예시와 함께 추가

import가 거의 아무것도 매핑하지 못함

내보내기 파일의 헤더 이름이 별칭 목록 밖

--map을 한 번 넘기고, 매핑을 메모에 적어 재사용

배합을 정직하게 유지하기

배합은 이 루프에서 조용히 실패하는 유일한 부분입니다. 튜토리얼을 네 개 연속 게시해도 아무것도 깨지지 않습니다. 계정이 좁아질 뿐이고, 넉 달 뒤 같은 사람들만 읽고 있을 때 알게 됩니다.

최근 기록한 게시물 20개에서 슬롯별 실제 점유율과 목표를 비교한 막대 그래프. 슬롯 A는 목표를 웃돌고 슬롯 C, D, E는 밑돈다

두 가지 습관이 이를 지키고, 둘 다 스크립트 안에 있습니다.

달력이 아니라 최근 구간을 세세요. 게시물 스무 개는 안정적인 표본이고 2주는 아닙니다. 2주에 담기는 게시물 수가 바로 변하는 값이기 때문입니다. 장부가 스무 행에 못 미치면 plan은 다음 게시물에서 흔들릴 비율 대신 얇은 구간 경고를 출력합니다.

부족분이 고르게 하고, 도달은 지게 하세요. 한 슬롯이 노출에서 눈에 띄게 앞설 때 더 많이 게시하고 싶어집니다. 바로 그 순간을 위해 비율이 존재합니다. 관찰을 기록하고 계획을 유지한 뒤 구간이 채워진 다음 다시 보세요. 불균형이 두 개의 온전한 구간에서 유지된다면 브리프의 목표를 바꾸세요. 행동이 아니라 브리프를 바꾸면, 장부를 다음에 읽는 사람이 이유를 알 수 있습니다.

자주 묻는 질문

대신 게시해 주나요?

아니요, 그리고 그것은 의도적입니다. 스킬은 초안과 기록을 만들고, 네 스크립트 어디에도 게시 호출이 없습니다. X의 게시 동작을 자동화하는 것은 자체적인 플랫폼 규칙 결과를 동반하는 별개의 결정이며, 계정이 무엇인지를 바꿉니다. 편의 기능이 아니라 그 자체의 기준으로 판단하세요.

유료 X API가 필요한가요?

경로 A에만 필요합니다. 루프는 지표 없이도 돌아가고, 다만 리뷰를 닫지 못할 뿐입니다. 수동 가져오기는 저렴한 경로이고 임시방편도 아닙니다. 정직한 빈 칸으로 주당 10분을 쓰는 것이 추정치로 빈틈을 채우는 파이프라인보다 낫습니다.

제 계정이 AI 주제가 아니면요?

네 가지 역할, 여섯 가지 형태, 배합 비율은 주제와 무관합니다. AI에 특화된 내용은 표의 예시 소재뿐입니다. 브리프의 필러를 자기 것으로 바꾸면 나머지는 그대로 성립합니다.

글이 생성된 것처럼 들리게 되나요?

오히려 반대로 밀어붙입니다. 게이트는 어떤 계정의 어떤 글이든 들어맞을 도입부를 거부하는데, 그것이 바로 생성된 것처럼 읽히게 하는 특정 실패입니다. 당신 것으로 남는 부분은 근거입니다. 스킬은 근거를 발명하지 않고, 당신도 요구해서는 안 됩니다.

리뷰가 쓸 만한 말을 하기까지 얼마나 걸리나요?

기록한 게시물 약 스무 개. 그 아래에서는 중앙값이 잡음입니다. 그때까지는 규율을 위해 루프를 돌리고 숫자는 무시하세요.

작성자: Rowan Blake, Auspia에서 100개 이상의 퍼블리싱 파이프라인을 담당하는 콘텐츠 자동화 분석가. 자동 브리프, 콘텐츠 파이프라인, AI 지원 제작 시스템에 관해 씁니다.

이 주제 더 보기

같은 성장 주제를 계속 살펴보세요