Hãy để Codex tự dựng nó cho bạn. Dán bài viết này vào một phiên Codex và yêu cầu nó tạo skill: "Đọc bài viết này và cài skill x-growth-operator vào dự án này đúng như mô tả." Mọi tệp, kể cả bốn script, đều có đầy đủ bên dưới. Không có gì trong skill tự đăng, tự lên lịch hay tự viết lên X thay bạn.
Bạn sẽ nhận được gì
Một tệp skill, bốn script Python và ba tệp trạng thái nhỏ. Cùng nhau, chúng vận hành vòng lặp của một tài khoản X: quyết định hôm nay đăng gì, viết đúng định dạng mà ô đó yêu cầu, bỏ bản nháp nếu dòng đầu không qua được cổng kiểm, ghi lại những gì đã đăng, rồi kéo số liệu về và điều chỉnh.
Thứ này dành cho một người vận hành một tài khoản, hoặc một nhóm nhỏ vận hành một tài khoản thương hiệu. Kiểu hỏng hóc mà nó ngăn chặn không phải là viết dở. Mà là trôi dạt. Tỉ lệ nội dung trượt dần về phía những gì dễ viết trong tuần này, phần mở đầu bị viết sau cùng và trở nên yếu ớt, và không ai mở analytics cho đến khi một tháng trôi qua và khuôn mẫu đã cố định.
Những gì bạn cần trước khi bắt đầu:
- Một phiên agent có quyền ghi vào thư mục dự án của bạn. Codex là đủ; Claude Code hay bất cứ thứ gì tạo được tệp và chạy được Python cũng đủ.
- Tài khoản X bạn định vận hành, và quyền được làm việc đó.
- Python 3.8 trở lên. Cả bốn script chỉ dùng thư viện chuẩn, nên không có gì phải cài.
- Khoảng 30 phút cho việc thiết lập và vòng lập kế hoạch đầu tiên.
- Một trong hai đường lấy dữ liệu cho bước đánh giá: khóa X API trên gói trả phí, hoặc xuất thủ công từ analytics của X. Không có cái nào là bắt buộc để bắt đầu. Vòng lặp vẫn chạy khi thiếu chúng; bạn chỉ chưa thể khép kín nửa phần phản hồi.
Hoàn thành nghĩa là: thư mục skill tồn tại, x-ops/ có một bản brief đã điền và hai bảng trống, và mix_ledger.py plan trả về một ô được chỉ định kèm ngày khớp với brief.
Vì sao vòng lặp này đáng tự động hóa
Viết là nửa dễ hơn và là nửa mà các mô hình đã làm tốt. Mọi thứ xung quanh mới là thứ mục ruỗng. Một người có thể giữ trong đầu một tỉ lệ kiểu "một nửa số bài đăng phải mang tính thực hành" trong khoảng hai tuần. Sau đó tỉ lệ đó lặng lẽ biến thành "những gì tôi kịp làm hôm nay".
Cả ba thứ thực sự hỏng đều là vấn đề trạng thái:
Tỉ lệ nội dung. Không có bản ghi về những gì bạn đã đăng, bạn không thể biết mình đã lệch khỏi tỉ lệ hay chưa. Trí nhớ không phải là bản ghi, và kiểu hỏng hóc này không nhìn thấy được từ bên trong một tuần.
Phần mở đầu. Dòng đầu là câu quyết định phần còn lại có được đọc hay không, và nó thường được viết sau cùng, khi sự tập trung đã cạn. Tự đánh giá phần mở đầu của mình là không đáng tin theo một cách rất cụ thể: bạn đã biết phần thân nói gì, nên điều hiển nhiên với bạn là đóng kín, kể cả khi với một người đọc chưa từng đọc nó thì nó mở toang.
Đánh giá. Số liệu phải được kéo về, đối chiếu với những gì bạn đã đăng, và đọc theo ô được gán cho từng bài. Đó là hai mươi phút ghi chép mỗi tuần, tức là khối lượng công việc không bao giờ sống sót qua một tuần bận rộn.
Một phiên chat không giữ được thứ nào trong số đó. Một skill thì giữ được, vì skill có tệp, và tệp thì đếm được bằng script. Sổ ghi là trí nhớ, cổng kiểm là kỷ luật, và phần đánh giá là một câu lệnh bạn có thể chạy kể cả trong một tuần tồi tệ.
Tầng chiến lược — đăng chính xác cái gì và vì sao một định dạng bài cụ thể giành được một phản hồi cụ thể — là một vấn đề riêng. Hướng dẫn tăng traffic blog từ tài khoản X đi sâu vào phần phân phối. Bài này nói về tầng thực thi: biến lập luận đó thành một thứ chạy được.
Trước khi bắt đầu
Ba giả định. Hãy kiểm tra chúng, vì skill dựa trên đó.
Bạn có một tài khoản và một bản brief. Skill được thiết kế có chủ đích cho một người dùng. Một thư mục x-ops/ mô tả một tài khoản. Vận hành hai tài khoản nghĩa là hai thư mục với hai sổ ghi. Dùng chung một sổ ghi cho nhiều tài khoản sẽ phá hủy đúng thứ duy nhất mà sổ ghi tồn tại vì nó.
Bạn sẵn sàng tự viết bản nháp và tự bấm nút đăng. Skill tạo văn bản và bản ghi. Nó không bao giờ gọi một endpoint đăng bài. Các script khiến điều đó dễ duy trì: không tệp nào trong bốn tệp chứa hàm đăng bài, nên ranh giới không phải một lời hứa mà là thứ kiểm chứng được.
Bạn lấy được số liệu ở một trong hai định dạng. Hoặc bạn có khóa X API trên gói nhà phát triển trả phí, hoặc bạn xuất được analytics ở cấp bài đăng từ X và ánh xạ vài cột. Chi tiết nằm ở phần số liệu. Nếu hôm nay chưa có cách nào, cứ cài skill và để metrics.tsv trống cho đến khi có.
Cài đặt skill
Sáu tệp, bốn trong số đó là script. Tạo cây thư mục này:
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/Các script cố ý nằm ở hai nơi. Một bản sao trong skill giúp skill di động được; một bản sao dưới x-ops/ giữ câu lệnh ngắn gọn và cho phép di chuyển hay lưu trữ toàn bộ thư mục làm việc như một khối. Nếu không muốn trùng lặp, hãy tạo liên kết tượng trưng cho x-ops/scripts trỏ tới thư mục scripts/ của skill và sửa đường dẫn trong các câu lệnh bên dưới.
Hãy tạo hai bảng dưới dạng tệp chỉ có dòng tiêu đề trước bất cứ việc gì khác:
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.tsvHãy để cả hai chỉ có dòng tiêu đề. Vòng lập kế hoạch đầu tiên sẽ báo lỗi nếu tiêu đề nào sai, và đó là kiểu hỏng hóc tốt hơn nhiều so với việc phát hiện ra sau hai mươi bài.
Các tệp của skill
`SKILL.md` là tệp agent đọc. Nó chứa luật, vòng lặp bốn bước, bảng xử lý hỏng hóc, và các bảng tham chiếu mà các bước viết dựa vào. Giữ những bảng đó trong tệp mà agent luôn đọc là một lựa chọn có chủ đích: tham chiếu nằm trong một tài liệu riêng là tham chiếu bị bỏ qua.
`templates/account-brief.yaml` là tệp duy nhất bạn tự viết. Định vị, trụ nội dung, giới hạn giọng điệu, tỉ lệ mục tiêu và trọng số lập kế hoạch.
`scripts/mix_ledger.py` làm phần số học của tỉ lệ nội dung. Nó đọc khối mix_target từ brief và sổ ghi, rồi trả lời một câu hỏi: ô nào đang tụt lại xa nhất trong cửa sổ trượt.
`scripts/hook_lint.py` là cổng kiểm. Bốn kiểm tra máy móc trên dòng đầu của bản nháp, mỗi lần thất bại cho mã thoát khác không, nên agent có thể rẽ nhánh theo nó.
`scripts/metrics.py` lấy dữ liệu hiệu suất qua một trong hai đường và chuẩn hóa cả hai về cùng một bảng.
`scripts/weekly_review.py` nối hai bảng, tính trung vị theo từng ô và gọi tên bốn khuôn mẫu đáng hành động. Nó cũng thực thi quy tắc 48 giờ ngay trong mã, tức phần mà những người sốt ruột muốn thấy số liệu hay bỏ qua.
SKILL.md

Lưu thành .codex/skills/x-growth-operator/SKILL.md.
---
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
Lưu thành .codex/skills/x-growth-operator/templates/account-brief.yaml, rồi sao chép vào x-ops/account-brief.yaml và điền. Khối mix_target do script đọc; phần còn lại hướng dẫn việc viết. Không có gì khác trong skill đọc tệp này, nên trường nào bạn để trống là trường agent buộc phải đoán.
# 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 overscripts/mix_ledger.py
Số học của tỉ lệ nội dung. plan chọn ô, log ghi một bài đã đăng, report hiển thị thực tế so với mục tiêu. Nó chỉ đọc khối mix_target từ brief, nên một brief hỏng sẽ gãy ngay tại đây, và đây đúng là chỗ nó nên gãy.
#!/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
Cổng kiểm. Bốn kiểm tra, mã thoát khác không khi thất bại, nên agent có thể rẽ nhánh theo mã thoát thay vì diễn giải văn xuôi. Danh sách BANNED là phần bạn sẽ cần sửa; nó được cung cấp với mười bảy mục và nên lớn dần theo những sáo ngữ của chính dòng nội dung bạn làm.
#!/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
Hai đường lấy dữ liệu, một bảng đầu ra. pull nói chuyện với X API, import đọc bản xuất của bạn, và không cái nào đoán một con số mà nó không nhận được.
#!/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
Phần đánh giá. Nối hai bảng theo mã bài đăng, thực thi việc loại trừ 48 giờ trong mã, báo cáo trung vị chứ không phải trung bình, và chỉ gọi tên những khuôn mẫu mà nó chứng minh được bằng số dòng hiện có.
#!/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())Chạy vòng lặp tối thiểu
Bốn câu lệnh, theo thứ tự, mỗi lần một bài. Đây là hình dạng của một lượt chạy được.
Lập kế hoạch. Yêu cầu một ô. Script đếm cửa sổ trượt và in ra phép tính, chứ không phải một cảm giác.
$ 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 windowKiểm tra: khoảng trống là phép tính bạn có thể tự xác nhận bằng cách đếm sổ ghi. Ô C đang thiếu hai bài so với phần mười lăm phần trăm của nó, và các hướng dẫn thực hành đang vượt phần của chúng bốn bài, và đó chính xác là kiểu trôi dạt mà vòng lặp này tồn tại để bắt.
Nếu hỏng: nguyên nhân phổ biến nhất là sổ ghi chỉ có năm dòng. Cửa sổ quá mỏng và lựa chọn sẽ dao động. Script sẽ nói điều đó, thay vì giả vờ ngược lại. Nó ổn định quanh mốc hai mươi dòng.
Viết bản nháp. Viết bài theo định dạng mà ô yêu cầu. Với ô A là một hướng dẫn, với B là một bài đọc gần, với C là một cái nhìn lại.
Kiểm tra: bạn có thể chỉ ra phần mở đầu, luận điểm, bằng chứng và hành động như bốn phần riêng biệt. Nếu bạn không tìm được bằng chứng, bài đó là một ý kiến khoác áo hướng dẫn.
Nếu hỏng: định dạng đúng mà nội dung yếu. Hãy quay lại với điều cụ thể bạn muốn nói. Skill sẽ không bịa ra chuyên môn của bạn, và yêu cầu nó làm vậy là cách để đăng thứ nghe giống hệt mọi người khác.
Cổng kiểm. Cho dòng đầu qua linter trước khi viết bất cứ thứ gì tiếp theo.
$ 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.Phần mở đầu đó hỏng ba kiểm tra cùng lúc, điều này đáng chú ý. Câu mở là một khuôn mẫu, luận điểm tuyệt đối, và phần thân không nhắc đến một thứ cụ thể nào mà phần mở đầu đã hứa sẽ gọi tên. Một người đọc sẽ cảm nhận được cả ba mà không cần gọi tên chúng.
Đây là cùng cổng kiểm đó trên một dòng đạt yêu cầu:
$ 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: passedKiểm tra: đếm xem cổng kiểm từ chối bao nhiêu lần. Nếu nó không từ chối gì trong mười bản nháp, danh sách BANNED của bạn quá ngắn. Mở hook_lint.py và thêm những câu mở đầu bạn liên tục thấy trong dòng nội dung của mình. Danh sách khởi đầu với mười bảy mục và nên lớn dần theo cách bạn dùng.
Nếu hỏng: bản viết lại tệ hơn bản gốc. Giữ bản gốc và sửa đúng kiểm tra đã thất bại. Cổng kiểm là bốn câu hỏi có/không, không phải một dịch vụ viết lại.
Ghi lại. Sau khi đăng, ghi lại một dòng.
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"Kiểm tra: số dòng trong sổ ghi khớp với những gì bạn thực sự đã đăng. Hãy đếm.
Nếu hỏng: bạn đã đăng thứ gì đó ngoài vòng lặp. Vẫn ghi lại. Một bài ngoài vòng lặp vẫn được tính vào tỉ lệ nội dung, và một sổ ghi chỉ ghi những bài bạn tự hào sẽ nói dối bạn vào lúc đánh giá.
Đó là toàn bộ vòng lặp tối thiểu. Nó khép kín nửa phần lập kế hoạch và chất lượng. Nửa phần phản hồi cần đến số liệu.
Khép kín nửa phần phản hồi
Hai đường, và lựa chọn không phải sở thích mà là một đánh đổi thực sự.
Đường A kéo từ X API. Script đọc mã bài đăng từ sổ ghi, gom thành từng lô, hỏi cả bộ số liệu công khai lẫn riêng tư và chỉ ghi những chỉ số được trả về.
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 pullViệc đọc số bookmark và số hiển thị đòi hỏi xác thực theo ngữ cảnh người dùng và chỉ trả về giá trị cho bài của chính tài khoản đã xác thực, đúng phạm vi mà vòng lặp này cần. Không có token, script thoát kèm thông báo thay vì âm thầm gãy giữa đường; nếu gói không cho phép một trường, trường đó trả về rỗng và dòng ghi lại nó là rỗng.
Tên gói, giá, các trường có sẵn và giới hạn tốc độ trên nền tảng nhà phát triển X thay đổi đủ thường xuyên nên bạn cần đối chiếu với tài liệu trước khi xây bất cứ thứ gì dựa trên chúng. Script xử lý 429 bằng cách lùi và thử lại, và không bao giờ lấp khoảng trống do giới hạn tốc độ bằng phỏng đoán.
Đường B nhập bản xuất của chính bạn. Mười phút mỗi tuần, không phụ thuộc gì, và không có lý do gì để nó phải mang cảm giác là một giải pháp tạm.
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"Script tự nhận diện các tên cột phổ biến và cho biết những cột nào nó không ánh xạ được, nên lần nhập đầu tiên cũng là một lần kiểm tra bản xuất của bạn. Mọi thứ không ánh xạ được ghi thành trường rỗng và được báo là không dùng được, chứ không phải số không.
Cả hai đường ghi vào cùng một bảng, nên bạn có thể bắt đầu bằng B và chuyển sang A sau mà không đụng đến phần đánh giá. Mỗi dòng mang một ngày thu thập, vì số liệu còn nhích sau khi đăng, và một lần đánh giá lẫn lộn số đọc ngày đầu với ngày thứ ba mươi là đang không đo lường gì cả.
Một luật quan trọng hơn những luật còn lại: đừng bao giờ bịa dòng để thử phần đánh giá. Hãy chạy nó trên một bảng trống. Nó phải nói với bạn rằng không có dữ liệu, và nếu nó vẫn tạo ra bản tóm tắt thì có gì đó đã hỏng.
Vòng lặp nâng cao
Ba bổ sung một khi vòng lặp tối thiểu đã chạy.
Vòng trả lời. Trong mô hình lập kế hoạch, một câu trả lời nặng gấp khoảng mười ba lần một lượt thích, điều này khiến việc viết trả lời trở thành một hành động phân phối chứ không phải một phép lịch sự. Lập một danh sách tài khoản bạn đã theo dõi trong phạm vi các trụ nội dung của mình, và với mỗi tài khoản, tạo một câu trả lời thuộc một trong bốn kiểu: thêm một con số mà bài gốc bỏ sót, đưa ra một phản ví dụ từ chính công việc của bạn, đặt câu hỏi nối tiếp mà logic của bài gốc đòi hỏi, hoặc kể điều đã xảy ra khi bạn thử.
Điều nên từ chối quan trọng hơn điều nên chấp nhận. Lời khen suông, tham gia mà không thêm gì, và những câu trả lời lén quảng cáo bài của chính bạn đốt cháy đúng thứ duy nhất khiến một câu trả lời đáng viết: rằng bạn đã nói điều gì đó mà bài gốc không nói.
Cửa sổ 48 giờ. Mô hình lập kế hoạch coi hai ngày đầu sau khi đăng là khoảng thời gian một bài còn có thể tăng tốc. Từ đó ra hai luật. Đừng chạy lại một bài đã một tháng như thể nó mới; nếu nó đáng được hồi sinh, hãy viết lại quanh một góc nhìn mới và đăng như tài liệu mới. Và đừng phán xét một bài trước khi cửa sổ đóng. weekly_review.py tự động loại các dòng còn sớm, và ghi đè thủ công điều đó là cách nhanh nhất để biến phần đánh giá thành kẻ nói dối.
Đánh giá hàng tuần. Một câu lệnh, và nó chỉ in ra những gì nó chứng minh được.
python3 x-ops/scripts/weekly_review.py --window 20Nó nối sổ ghi với số liệu theo mã bài đăng, dùng trung vị chứ không phải trung bình để một bài tốt không định nghĩa cả một ô, và gọi tên bốn khuôn mẫu: hiển thị cao mà ít đăng lại, tức là bạn đã tiếp cận được người ta và không lay chuyển được họ; bookmark cao mà hiển thị thấp, tức là bài đáng lưu và không ai thấy; trả lời thấp ở mọi ô, đây là vấn đề định dạng chứ không phải vấn đề bài viết; và một ô vượt trội về tiếp cận, thứ bạn ghi lại và không hành động theo. Cái cuối cùng chính là tỉ lệ đang làm đúng việc của nó.
Kết quả cũng mang một danh sách khoảng trống theo cả hai hướng. Dòng sổ ghi thiếu số liệu và dòng số liệu thiếu bản ghi sổ, mỗi cái chỉ ra rằng có gì đó hỏng ở phía trước. Một bài đăng ngoài vòng lặp vẫn được tính vào tỉ lệ nội dung, nên hãy ghi lại nó thay vì để nó biến mất.
Khi mọi thứ hỏng
Bạn thấy gì | Thường có nghĩa là | Nên làm gì |
|---|---|---|
Cùng một ô ba ngày liên tiếp | Cửa sổ quá mỏng hoặc bài không được ghi lại | Xác nhận bước ghi lại có chạy. Chấp nhận nhiễu cho đến khi có khoảng hai mươi dòng, và nói rõ điều đó |
Mọi bản nháp đều qua cổng kiểm | Cổng kiểm đang được đọc chứ không được áp dụng | Thêm sáo ngữ của riêng bạn vào danh sách |
Hầu hết dòng số liệu không nối được | Định dạng URL bài khác nhau giữa sổ ghi và bản xuất | Cả hai phía chuẩn hóa về mã bài dạng số; kiểm tra |
Phần đánh giá bị một bài chi phối | Cửa sổ quá nhỏ để trung vị giữ được | Mở rộng |
Tỉ lệ nội dung vẫn trôi | Bài đang được đăng ngoài vòng lặp | Ghi chúng lại. Bài ngoài vòng lặp vẫn là một phần của tỉ lệ |
Bản nháp mỗi tuần đọc bằng một giọng khác | Brief không có danh sách cấm | Thêm những nước đi cụ thể cần tránh, mỗi cái kèm một ví dụ |
| Bản xuất của bạn dùng tên cột ngoài danh sách bí danh | Truyền |
Giữ tỉ lệ nội dung trung thực
Tỉ lệ nội dung là phần duy nhất của vòng lặp này hỏng trong im lặng. Không có gì gãy khi bạn đăng bốn bài hướng dẫn liên tiếp. Tài khoản chỉ thu hẹp lại, và bạn biết điều đó bốn tháng sau, khi những người duy nhất còn đọc chính là những người cũ.

Hai thói quen giữ được nó, và cả hai đều nằm trong script.
Đếm cửa sổ trượt, không đếm lịch. Hai mươi bài là một mẫu ổn định; hai tuần thì không, vì số bài trong hai tuần chính là thứ thay đổi. Khi sổ ghi có dưới hai mươi dòng, plan in ra cảnh báo cửa sổ mỏng thay vì một tỉ lệ sẽ dao động ở bài tiếp theo.
Để khoảng trống tự chọn và tự mất tiếp cận. Khi một ô vượt trội rõ rệt về hiển thị, phản xạ là đăng nhiều hơn từ nó. Tỉ lệ tồn tại chính vì khoảnh khắc đó. Hãy ghi lại quan sát, giữ nguyên kế hoạch và xem lại khi cửa sổ đầy. Nếu sự mất cân bằng kéo dài hai cửa sổ đầy đủ, hãy đổi mục tiêu trong brief. Đổi brief chứ không đổi hành vi, để người tiếp theo đọc sổ ghi thấy được lý do.
Câu hỏi thường gặp
Nó có thể đăng thay tôi không?
Không, và đó là chủ đích. Skill viết bản nháp và bản ghi, và không script nào trong bốn script chứa lệnh gọi đăng bài. Tự động hóa chính hành vi đăng lên X là một quyết định riêng với những hệ quả riêng theo luật nền tảng, và nó thay đổi bản chất của tài khoản. Hãy quyết định điều đó theo điều kiện của chính bạn, không phải như một tính năng tiện lợi.
Tôi có cần X API trả phí không?
Chỉ với đường A. Vòng lặp chạy hoàn toàn không có số liệu; bạn chỉ không thể khép kín phần đánh giá. Nhập thủ công là đường rẻ, và nó không phải giải pháp tạm. Mười phút mỗi tuần với những ô trống trung thực tốt hơn một pipeline lấp khoảng trống bằng phỏng đoán.
Nếu tài khoản của tôi không nói về AI thì sao?
Bốn vai trò, sáu định dạng và tỉ lệ nội dung không phụ thuộc chủ đề. Thứ duy nhất đặc thù cho AI là tài liệu ví dụ trong các bảng. Hãy thay các trụ trong brief bằng trụ của bạn, phần còn lại vẫn đúng.
Bài của tôi có trông như được tạo tự động không?
Nó đẩy theo hướng ngược lại. Cổng kiểm từ chối những câu mở đầu có thể hợp với bất kỳ bài nào trên bất kỳ tài khoản nào, và đó chính là kiểu hỏng hóc đọc lên như được tạo tự động. Phần còn lại là bằng chứng của bạn, vì skill sẽ không bịa nó và bạn không nên yêu cầu nó làm vậy.
Bao lâu thì phần đánh giá nói được điều gì hữu ích?
Khoảng hai mươi bài đã ghi lại. Dưới mức đó, trung vị chỉ là nhiễu. Cho đến lúc đó, hãy chạy vòng lặp vì kỷ luật và bỏ qua các con số.
Tác giả: Rowan Blake, Chuyên viên phân tích tự động hóa nội dung tại Auspia, nơi anh vận hành hơn 100 pipeline xuất bản. Rowan viết về brief tự động, pipeline nội dung và hệ thống sản xuất có AI hỗ trợ.




