thth 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. thth/VERSION +1 -0
  2. thth/__init__.py +22 -0
  3. thth/__main__.py +18 -0
  4. thth/account_cli.py +474 -0
  5. thth/account_report.py +740 -0
  6. thth/accounts.example/bluesky.json +25 -0
  7. thth/accounts.example/mastodon.json +25 -0
  8. thth/accounts.example/threads.json +25 -0
  9. thth/accounts.py +418 -0
  10. thth/adapters/__init__.py +71 -0
  11. thth/adapters/base.py +314 -0
  12. thth/adapters/bluesky.py +772 -0
  13. thth/adapters/mastodon.py +705 -0
  14. thth/adapters/threads.py +580 -0
  15. thth/appenv.py +204 -0
  16. thth/approval.py +231 -0
  17. thth/ask.py +527 -0
  18. thth/ask_cli.py +142 -0
  19. thth/bundle.py +428 -0
  20. thth/cli.py +2572 -0
  21. thth/collect.py +897 -0
  22. thth/core.py +814 -0
  23. thth/doctor.py +281 -0
  24. thth/forms.py +255 -0
  25. thth/inflight.py +69 -0
  26. thth/jst.py +43 -0
  27. thth/lint.py +186 -0
  28. thth/lock.py +185 -0
  29. thth/maintain.py +294 -0
  30. thth/mcp_server.py +280 -0
  31. thth/measured.py +455 -0
  32. thth/oauth.py +718 -0
  33. thth/postid.py +74 -0
  34. thth/queuefile.py +199 -0
  35. thth/redact.py +41 -0
  36. thth/replies.py +175 -0
  37. thth/report.py +418 -0
  38. thth/runs.py +61 -0
  39. thth/scopes.py +22 -0
  40. thth/secrets_fs.py +68 -0
  41. thth/select.py +338 -0
  42. thth/selfupdate.py +516 -0
  43. thth/sent.py +129 -0
  44. thth/share.py +666 -0
  45. thth/share_cli.py +140 -0
  46. thth/skills/thth/SKILL.md +117 -0
  47. thth/systemd_gen.py +116 -0
  48. thth/threadrun.py +611 -0
  49. thth/threadshape.py +607 -0
  50. thth/threadthrow.py +553 -0
  51. thth/topic_advice.py +753 -0
  52. thth/topic_cli.py +2267 -0
  53. thth/topic_models.py +2248 -0
  54. thth/topic_store.py +418 -0
  55. thth/topics.py +802 -0
  56. thth/writeback.py +408 -0
  57. thth-2.0.0.dist-info/METADATA +95 -0
  58. thth-2.0.0.dist-info/RECORD +61 -0
  59. thth-2.0.0.dist-info/WHEEL +4 -0
  60. thth-2.0.0.dist-info/entry_points.txt +3 -0
  61. thth-2.0.0.dist-info/licenses/LICENSE +21 -0
thth/account_report.py ADDED
@@ -0,0 +1,740 @@
1
+ """`thth account <name>`: 1 アカウントの状態を一枚で述べる(masaru 指摘 2026-09-10)。
2
+
3
+ **なぜ要るか。** 2026-09-10 の本番切り替えのとき、統括は「このアカウントはいま
4
+ 投稿できる状態か」を答えるために、その場限りのスクリプトを書いて ssh で
5
+ つなぎ合わせた——台帳・clone の有無・upstream・sparse-checkout・queue_dir の
6
+ 有無・トークン・timer・`production`・inflight。**その問いに答える口が無かった。**
7
+ masaru に「アカウント別の台帳が欲しそう」と指摘されて作った。
8
+
9
+ **方針は他と同じ「確認できたものだけを通す」。** 「たぶん大丈夫」を言わない。
10
+ 判らないものは **判らないと言う**(例: この機械に systemd が無ければ timer の
11
+ 状態は「判りません」であって「無効」ではない)。最後に **投稿できる状態かどうか
12
+ の 1 行**を出す——これがこの口の存在理由。
13
+
14
+ 秘密は扱わない。トークンは残り日数と状態だけ(`thth.maintain` と同じ判定)。
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import shutil
21
+ import subprocess
22
+ import urllib.error
23
+ import urllib.parse
24
+ import urllib.request
25
+
26
+ from . import accounts as accounts_mod
27
+ from . import adapters as adapters_mod
28
+ from . import postid as postid_mod
29
+ from . import core
30
+ from . import inflight as inflight_mod
31
+ from . import jst
32
+ from . import jst as jst_mod
33
+ from . import maintain as maintain_mod
34
+ from . import queuefile
35
+ from . import topics as topics_mod
36
+ from . import redact as redact_mod
37
+ from . import select as select_mod
38
+ from . import sent as sent_mod
39
+ from . import writeback as writeback_mod
40
+ from .adapters import base as adapter_base
41
+
42
+ # 「投稿できる」と言うために確認できていなければならないこと。**ここに並ぶのは
43
+ # すべて「確認できた」形**(「〜でない」を数え上げない・規約 12)。
44
+ READY_CHECKS = ["ledger", "token", "repo", "queue_dir", "production", "no_inflight"]
45
+
46
+
47
+ LEDGER_SOURCE = "ledger" # 台帳(保存済みの行)
48
+ API_SOURCE = "api_observed" # API(実行時点で叩いた値)
49
+ DRAFT_TOPIC = "draft_front_matter" # 原稿由来の記録値
50
+ API_TOPIC = "api_topic_tag" # API 観測値(**付与の主体は未確認**)
51
+
52
+ # 24 時間の刻みとして扱ってよい実経過の幅。**刻みの名前だけで揃えない。**
53
+ # `thth run` は 10 分ごとなので、刻みを跨いだ直後に採れば 24.0〜24.2h に入る。
54
+ # ここを広く取ると「1 日の値」と「3 日の値」が同じ中央値に混ざる。
55
+ AGE_BAND_HOURS = {24: (24.0, 30.0)}
56
+
57
+
58
+ def _git(repo_dir: str, args: list):
59
+ return subprocess.run(["git", "-C", repo_dir, *args], capture_output=True, text=True)
60
+
61
+
62
+ def _repo_state(account_cfg: dict) -> dict:
63
+ """clone の状態。**存在・git・upstream・一致・queue_dir を別々に述べる。**"""
64
+ repo_dir = account_cfg.get("repo_dir") or ""
65
+ out = {
66
+ "repo_dir": repo_dir,
67
+ "exists": bool(repo_dir) and os.path.isdir(repo_dir),
68
+ "is_git": False,
69
+ "branch": None,
70
+ "upstream": None,
71
+ "synced": False, # HEAD == @{u}(=いま照合先にできる commit がある)
72
+ "head": None,
73
+ "sparse": None,
74
+ "queue_dir": None,
75
+ "queue_dir_exists": False,
76
+ }
77
+ # queue_dir は repo が無くても**どこを見ているか**を述べる(「None がありません」と
78
+ # 言わない。人が直すべき場所を毎回示す)。
79
+ out["queue_dir"] = os.path.join(repo_dir, account_cfg.get("queue_dir") or "")
80
+ out["queue_dir_exists"] = os.path.isdir(out["queue_dir"])
81
+ if not out["exists"]:
82
+ return out
83
+
84
+ if _git(repo_dir, ["rev-parse", "--git-dir"]).returncode != 0:
85
+ return out
86
+ out["is_git"] = True
87
+
88
+ branch = _git(repo_dir, ["rev-parse", "--abbrev-ref", "HEAD"])
89
+ if branch.returncode == 0:
90
+ out["branch"] = branch.stdout.strip()
91
+ upstream = _git(repo_dir, ["rev-parse", "--abbrev-ref", "@{u}"])
92
+ if upstream.returncode == 0:
93
+ out["upstream"] = upstream.stdout.strip()
94
+
95
+ sha = writeback_mod.upstream_sha(repo_dir)
96
+ out["synced"] = sha is not None
97
+ out["head"] = sha or (_git(repo_dir, ["rev-parse", "HEAD"]).stdout.strip() or None)
98
+
99
+ sparse = _git(repo_dir, ["sparse-checkout", "list"])
100
+ if sparse.returncode == 0 and sparse.stdout.strip():
101
+ out["sparse"] = sparse.stdout.split()
102
+ return out
103
+
104
+
105
+ def _timer_state(account_name: str) -> dict:
106
+ """systemd の timer の状態。**この機械に systemd が無ければ「判らない」。**"""
107
+ unit = f"thth@{account_name}.timer"
108
+ out = {"unit": unit, "known": False, "enabled": None, "active": None}
109
+ if shutil.which("systemctl") is None:
110
+ return out
111
+ enabled = subprocess.run(["systemctl", "is-enabled", unit], capture_output=True, text=True)
112
+ active = subprocess.run(["systemctl", "is-active", unit], capture_output=True, text=True)
113
+ # is-enabled は無効・不在でも非ゼロを返すが、標準出力に理由が出る。
114
+ if not enabled.stdout.strip() and not active.stdout.strip():
115
+ return out
116
+ out["known"] = True
117
+ out["enabled"] = enabled.stdout.strip() or None
118
+ out["active"] = active.stdout.strip() or None
119
+ return out
120
+
121
+
122
+ REMOTE_TIMEOUT_SECONDS = 8.0
123
+ REMOTE_LIMIT = 25
124
+
125
+
126
+ def fetch_posts(account_cfg: dict, token: dict | None, *,
127
+ limit: int = REMOTE_LIMIT) -> tuple:
128
+ """その媒体で**実際に出ている**直近の投稿 `(rows, エラー文)`。**切り詰めない。**
129
+
130
+ 1 行の形は境界のもの(`Adapter.recent_posts()` の docstring):
131
+ `post_id`・`timestamp`・`url`・`text`・`topic`。
132
+
133
+ **以前ここが `graph.threads.net` の URL を直に組み立てていた**(設計 v2 §4.2 が
134
+ 数えた「Threads 固有になっている 6 箇所」に入っていなかった 7 つめ)。
135
+ `access_token` を**クエリに載せて**おり、Mastodon の `.token` も鍵が
136
+ `access_token` なので、媒体を見ずに通すと**宛先違いに秘密が出る**。当座は
137
+ `media != "threads"` を名指しで弾いていたが、それは**媒体を足すたびにここを
138
+ 見直す**形——F2(2026-09-13)で `Adapter.recent_posts()` を境界に足し、
139
+ **宛先を媒体が決める**ようにした。ここに残るのは 3 つだけ:
140
+
141
+ 1. **能力で塞ぐ**(媒体名で分岐しない)。持たない媒体には**聞きに行かない**
142
+ ——アダプタを組み立てもしない(`.token` を渡す先を増やさない)。
143
+ 2. **トークンが在るかは媒体が決める**(`TOKEN_KEYS`・Bluesky は
144
+ `identifier` と `app_password`)。
145
+ 3. **「0 件」とは言わない**(規約 12)——引けないことを引けないと言う。
146
+ """
147
+ media = account_cfg.get("media")
148
+ if "recent_posts" not in adapters_mod.capabilities_for(media):
149
+ return None, (f"{media} には直近の投稿を引く口がありません"
150
+ f"(引けるのは recent_posts を持つ媒体だけ)")
151
+ adapter_cls = adapters_mod.adapter_class(media)
152
+ if not adapter_cls.has_token(token):
153
+ return None, "token が無いので引けません"
154
+
155
+ try:
156
+ adapter = adapters_mod.make_adapter(account_cfg, token)
157
+ rows = adapter.recent_posts(limit=limit)
158
+ except Exception as e: # 引けないことは投稿の可否と無関係
159
+ return None, "引けませんでした: " + redact_mod.redact(str(e))
160
+ if not isinstance(rows, list):
161
+ return None, "応答の形が想定と違います"
162
+ return rows, ""
163
+
164
+
165
+ def recent_posts(account_name: str, *, limit: int = REMOTE_LIMIT) -> dict:
166
+ """`thth posts <account>`: 実際の投稿を一覧する(手で出した分も含む)。
167
+
168
+ queue の `post_id` と突き合わせて、1 本ごとに **THTH 経由か外で出したか**を
169
+ 付ける。手で投稿していた時期からの移行で要る(nigamilab セッション指摘)。
170
+
171
+ **突合の相手は queue だけではない**(運用の報告 2026-09-13)。`thth send`
172
+ (同席の様態)で出した 1 本を、この口が「**外で出したもの**」と表示していた
173
+ ——`state/<account>/sent/<post_id>.json` に記録があるのに。同席の様態は
174
+ queue も書き戻しも通らないので、front-matter だけを見ると**自分が出した
175
+ ものを他人が出したものとして数える**。`masaru-threads` のような同席専用の
176
+ アカウントでは、出したものが 1 本残らず「通していないもの」になる。
177
+ """
178
+ try:
179
+ account_cfg = accounts_mod.load_account(account_name)
180
+ except accounts_mod.AccountError as e:
181
+ return {"account": account_name, "error": str(e), "posts": []}
182
+
183
+ tree_sha = writeback_mod.upstream_sha(account_cfg.get("repo_dir"))
184
+ files = core.list_queue_files(account_cfg, tree_sha=tree_sha)
185
+ by_post_id = {}
186
+ for qf in files:
187
+ if qf.malformed:
188
+ continue
189
+ post_id = qf.front_matter.get("post_id")
190
+ if post_id:
191
+ by_post_id[post_id] = os.path.basename(qf.path)
192
+ # 同席の様態(`thth send`)の記録。**queue に無いことは「通していない」の
193
+ # 証明ではない。**
194
+ sent_ids = sent_mod.post_ids(accounts_mod.state_dir_for(account_name))
195
+
196
+ rows, err = fetch_posts(account_cfg, accounts_mod.load_token(account_cfg),
197
+ limit=limit)
198
+ if rows is None:
199
+ return {"account": account_name, "error": err, "posts": []}
200
+
201
+ # **外に出す鍵の綴りは変えない**(`thth posts --json` は masaru のセッションが
202
+ # 読んでいる)。境界の `post_id`・`url` をここで `id`・`permalink` に写す。
203
+ posts = []
204
+ for row in rows:
205
+ post_id = row.get("post_id")
206
+ posts.append({
207
+ "id": post_id,
208
+ "timestamp": row.get("timestamp"),
209
+ "permalink": row.get("url"),
210
+ "text": row.get("text"),
211
+ "topic": row.get("topic"),
212
+ "via_thth": post_id in by_post_id or post_id in sent_ids,
213
+ "file": by_post_id.get(post_id),
214
+ # **どちらの記録で「THTH を通した」と言えるか**(鍵の追加だけ)。
215
+ # `"queue"` は queue の front-matter(不在の様態・`file` が付く)、
216
+ # `"sent"` は `state/<account>/sent/`(同席の様態・`file` は無い)。
217
+ # 通していないものは `None`。
218
+ "source": ("queue" if post_id in by_post_id
219
+ else ("sent" if post_id in sent_ids else None)),
220
+ })
221
+ return {"account": account_name, "error": None, "posts": posts}
222
+
223
+
224
+ def measured_views_by_account() -> dict:
225
+ """実測(24 時間の刻み)を `{account: {トピック: [views, ...]}}` で返す。
226
+
227
+ **アカウントを跨いで合算しない**(設計 §2 の表・§12.3・masaru 指示 2026-09-11)。
228
+
229
+ 以前は全アカウントの views を 1 つの辞書に混ぜていた。**読者も目的も違う
230
+ アカウントの数字を足すと、比較の母集団が壊れる**——nigamilab の `コーヒー` の
231
+ 数字が kopicha の判断材料に混ざっていた。トピックの**観測**は共有できるが、
232
+ **数字は account・投稿の単位で保つ。**
233
+ """
234
+ out: dict = {}
235
+ for name in accounts_mod.list_account_names():
236
+ try:
237
+ account_cfg = accounts_mod.load_account(name)
238
+ except accounts_mod.AccountError:
239
+ continue
240
+ out[name] = _measured_observations_by_topic(
241
+ account_cfg.get("repo_dir") or "", name, account_cfg.get("media"))
242
+ return out
243
+
244
+
245
+ NO_VIEWS_REASON = "媒体に views が無い"
246
+
247
+
248
+ def _views_reason(観測: dict) -> str:
249
+ """`views` が数でないとき、**なぜ**かを言い分ける(設計 v2 §4.2・T-B4)。
250
+
251
+ - **媒体に views が無い**(Bluesky・Mastodon)——採り方の問題ではないので、
252
+ 次に採れば入るものではない。**捨てずに数える**(設計 v1 §3.2.2)。
253
+ - **採れていない**(Threads で views が欠けた行)——次の刻みで入りうる。
254
+
255
+ この 2 つを 1 つの理由にまとめると、`--advise` の `not_compared_reasons` を
256
+ 見た人が「そのうち埋まる」と読む。媒体の能力は `REGISTRY` から引く
257
+ (観測の `medium` は `thth/collect.py` が行に刻む)。
258
+ """
259
+ medium = 観測.get("medium")
260
+ if isinstance(medium, str) and medium in adapters_mod.REGISTRY \
261
+ and "views" not in adapters_mod.capabilities_for(medium):
262
+ return f"{NO_VIEWS_REASON}({medium})"
263
+ return "views が数ではありません"
264
+
265
+
266
+ def comparable_views(観測: list, *, source: str = LEDGER_SOURCE,
267
+ topic_source: str = DRAFT_TOPIC, mark: int = 24) -> tuple:
268
+ """**出所と時間条件が揃った数値だけ**を返す(masaru 裁定 2026-09-12)。
269
+
270
+ 戻り値は `(使った観測, 使わなかった観測)`。**使わなかったものは捨てない**
271
+ ——`理由` を添えて返す。**「数が少ない」と「揃わなかった」を混ぜない。**
272
+
273
+ **名前を分けるだけでは足りない**というのがこの口の理由。別名にしても、
274
+ 集計の手前で混ぜられる。**混ぜられない形にする。**
275
+ """
276
+ lo, hi = AGE_BAND_HOURS.get(mark, (None, None))
277
+ 使う, 使わない = [], []
278
+ for o in 観測 or []:
279
+ if not isinstance(o, dict):
280
+ 使わない.append({"観測": o, "理由": "観測の形ではありません"})
281
+ continue
282
+ age = o.get("age_hours")
283
+ if o.get("source") != source:
284
+ 理由 = f"出所が違います({o.get('source')})"
285
+ elif o.get("topic_source") != topic_source:
286
+ 理由 = f"トピックの由来が違います({o.get('topic_source')})"
287
+ elif o.get("mark") != mark:
288
+ 理由 = f"刻みが違います({o.get('mark')})"
289
+ elif not isinstance(age, (int, float)):
290
+ 理由 = "実経過時間が記録されていません"
291
+ elif lo is not None and not (lo <= age <= hi):
292
+ # **刻みの名前で揃えたつもりにならない。**
293
+ 理由 = f"実経過 {age}h が {mark}h の帯({lo}〜{hi}h)の外です"
294
+ elif not isinstance(o.get("views"), int):
295
+ 理由 = _views_reason(o)
296
+ else:
297
+ 使う.append(o)
298
+ continue
299
+ 使わない.append({**o, "理由": 理由})
300
+ return 使う, 使わない
301
+
302
+
303
+ def topic_plan(account_name: str, *, now=None) -> dict:
304
+ """**これから出す本数が、どのトピックにぶら下がっているか**(masaru 指摘 2026-09-10)。
305
+
306
+ > とにかくトピックが threads 新参者にとってはとても重要だと考えています
307
+
308
+ フォロワーが 0 でも `中学受験` で 202〜574 views 取れている一方、
309
+ トピック無し・弱いトピックは 1 view。**新参者にとってトピックは唯一の入口**
310
+
311
+ **訂正 2026-09-12**: この「1 view」は**経過が数時間の疎通確認投稿**で、同じ投稿が 9/12 時点で **112 views**。**400 倍の大半は経過時間だった。**トピックが効かないという意味ではなく、**この数字では判定できない。**
312
+ なので、「何本がどのトピックに賭かっているか」と「そのトピックを確かめたか」を
313
+ 1 枚で出す。読むだけ。
314
+
315
+ `checked` は `thth.topics`(下調べの記録)から。`views_median` は
316
+ `data/sns/insights/posts/*.ndjson`(実測)から——**同じ経過時間で比べる**ため、
317
+ 24 時間の刻みを満たした行だけを使う。
318
+ """
319
+ now = now if now is not None else jst_mod.now_jst()
320
+ try:
321
+ account_cfg = accounts_mod.load_account(account_name)
322
+ except accounts_mod.AccountError as e:
323
+ return {"account": account_name, "error": str(e), "topics": []}
324
+
325
+ repo_dir = account_cfg.get("repo_dir") or ""
326
+ files = core.list_queue_files(
327
+ account_cfg, tree_sha=writeback_mod.upstream_sha(repo_dir))
328
+
329
+ measured = _measured_observations_by_topic(repo_dir, account_name,
330
+ account_cfg.get("media"))
331
+ rows: dict = {}
332
+ for qf in files:
333
+ if qf.malformed or qf.front_matter.get("account") != account_name:
334
+ continue
335
+ status = qf.front_matter.get("status")
336
+ if status not in ("draft", "approved", "posted"):
337
+ continue
338
+ topic = queuefile.normalize_topic(qf.front_matter.get("topic")) or "(トピック無し)"
339
+ row = rows.setdefault(topic, {"topic": topic, "draft": 0, "approved": 0, "posted": 0})
340
+ row[status] += 1
341
+
342
+ out = []
343
+ for topic, row in rows.items():
344
+ check = topics_mod.latest(topic, account=account_name)
345
+ # **揃ったものだけで数える。揃わなかったものは理由ごと残す。**
346
+ 使う, 使わない = comparable_views(measured.get(topic, []))
347
+ seen = sorted(o["views"] for o in 使う)
348
+ row.update({
349
+ "planned": row["draft"] + row["approved"],
350
+ "verdict": check.get("verdict", "unknown"),
351
+ "audience": check.get("audience") or None,
352
+ # 自 account の判断が無い語では、観測を書いた人(`topics.latest()`)
353
+ "audience_observer": check.get("audience_observer"),
354
+ "checked_at": check.get("checked_at"),
355
+ "checked_by": check.get("by"),
356
+ "views_median_24h": seen[len(seen) // 2] if seen else None,
357
+ "measured_posts": len(seen),
358
+ # **比較に使わなかったもの**(「無かった」と混ぜない)。
359
+ "not_compared": [{"post_id": o.get("post_id"), "理由": o.get("理由")}
360
+ for o in 使わない],
361
+ "comparison_basis": {"source": LEDGER_SOURCE,
362
+ "topic_source": DRAFT_TOPIC,
363
+ "mark": 24,
364
+ "age_band_hours": AGE_BAND_HOURS[24]},
365
+ })
366
+ out.append(row)
367
+ # **賭かっている本数が多く、かつ確かめていないもの**を先頭に置く。
368
+ out.sort(key=lambda r: (r["verdict"] != "unknown", -r["planned"], r["topic"]))
369
+ return {"account": account_name, "error": None, "topics": out}
370
+
371
+
372
+ # **数値は「出所」と「時間条件」を連れて歩く**(設計 §3.2.2・masaru 裁定
373
+ # 2026-09-12)。**`views` だけを配列に積むと、何と比べてよいか分からなくなる。**
374
+ #
375
+ # `24 in marks` は**「24 時間ちょうどに採った」ではない。** 行には実際の
376
+ # `age_hours` があり、`6.16h で 42` のように**刻みの名前と実経過時間はずれる。**
377
+ # 出所が同じでも、**実経過時間が違う値を並べて中央値を出すのは比較になっていない。**
378
+
379
+
380
+ def _measured_observations_by_topic(repo_dir: str, account_name: str,
381
+ default_medium: str | None = None) -> dict:
382
+ """実測を topic ごとに集める。**数値だけでなく、出所と時間条件も返す。**
383
+
384
+ `default_medium` は**行に `medium` が無いとき**(設計 v2 §4.2 より前に
385
+ 採った行)に当てる媒体。台帳の `media` を渡す——**推定ではなく、いま判って
386
+ いる唯一の手がかり**で、views を持つ媒体なら扱いは従来どおり変わらない。
387
+
388
+ **改名した**(`_measured_views_by_topic` → これ・2026-09-12)。返すものが
389
+ `int` の配列から観測の記録に変わったので、**古い読み手を黙って通さない**
390
+ (規約 5)。
391
+
392
+ 戻り値は `{topic: [観測, ...]}`。観測は
393
+ `{views, age_hours, collected_at, mark, source, topic_source, post_id}`。
394
+ """
395
+ out: dict = {}
396
+ 見た投稿: set = set()
397
+ base = os.path.join(repo_dir, "data", "sns", "insights", "posts")
398
+ if not os.path.isdir(base):
399
+ return out
400
+ for name in sorted(os.listdir(base)):
401
+ if not name.endswith(".ndjson"):
402
+ continue
403
+ best = None
404
+ others = set()
405
+ try:
406
+ with open(os.path.join(base, name), encoding="utf-8") as f:
407
+ for line in f:
408
+ line = line.strip()
409
+ if not line:
410
+ continue
411
+ row = json.loads(line)
412
+ # **その行が誰のものかを見る**(独立検収 A・2026-09-12)。
413
+ # `measured.py` は外部レビュー M3/R3 で**まさにこの穴を閉じて
414
+ # いる**のに、同じ台帳を読むここには選別が無かった。
415
+ # **repo を共有した瞬間に、他 account の数が自分の分母に入る。**
416
+ # **`account` の無い古い行**(R3 以前)も、`measured.py` では
417
+ # 「不明」なのにここでは自分のものとして数えていた。
418
+ この行の主 = row.get("account")
419
+ if この行の主 is not None and この行の主 != account_name:
420
+ others.add(この行の主)
421
+ continue
422
+ if この行の主 is None:
423
+ # **不明を自分のものにしない。**
424
+ continue
425
+ if 24 in (row.get("marks") or []):
426
+ best = row
427
+ except (OSError, ValueError):
428
+ continue
429
+ if best is None:
430
+ continue
431
+ views = (best.get("metrics") or {}).get("views")
432
+ medium = best.get("medium") or default_medium
433
+ if not isinstance(views, int):
434
+ # **媒体に views が無いなら、捨てずに数える**(設計 v2 §4.2・T-B4・
435
+ # 設計 v1 §3.2.2)。ここで `continue` すると、Bluesky・Mastodon の
436
+ # 投稿は `--advise` の分母からも `not_compared` からも消え、
437
+ # **「実測がまだ無い」と見分けが付かなくなる。**
438
+ #
439
+ # 一方、views を持つ媒体で欠けているだけの行は従来どおり落とす
440
+ # ——それは「次の刻みで入りうる」話で、比較の理由としては別物。
441
+ if not (isinstance(medium, str) and medium in adapters_mod.REGISTRY
442
+ and "views" not in adapters_mod.capabilities_for(medium)):
443
+ continue
444
+ views = None
445
+ # **同じ投稿を 2 本として数えない**(独立検収 A・2026-09-12)。
446
+ # 台帳は `<post_id>.ndjson` 固定だが、取り込み直しや改名で
447
+ # `p1.ndjson` と `p1-copy.ndjson` が同居すると 1 投稿が 2 本になる。
448
+ pid = best.get("post_id") or postid_mod.from_filename(name[:-len(".ndjson")])
449
+ if pid in 見た投稿:
450
+ continue
451
+ 見た投稿.add(pid)
452
+ out.setdefault(best.get("topic") or "(トピック無し)", []).append({
453
+ "views": views,
454
+ # **どの媒体の数か**(設計 v2 §2.1「媒体をまたいで比較しない」)。
455
+ # 古い行には無いので、そのときは台帳の `media` を当てる。
456
+ "medium": medium,
457
+ # **刻みの名前と、実際にいつ採ったかは別。**
458
+ "mark": 24,
459
+ "age_hours": best.get("age_hours"),
460
+ "collected_at": best.get("collected_at"),
461
+ "post_id": pid,
462
+ "source": LEDGER_SOURCE,
463
+ "topic_source": DRAFT_TOPIC,
464
+ })
465
+ return out
466
+
467
+
468
+ def topic_performance(account_name: str, *, limit: int = REMOTE_LIMIT) -> dict:
469
+ """**トピック別に、実際にどれだけ見られたか**(masaru 指摘 2026-09-10)。
470
+
471
+ > いかに合ったトピックに刺さるポストを落とすか、です。
472
+
473
+ その 2 つを分けて見るための口。**合っているか**=トピックごとの中央値、
474
+ **刺さったか**=同じトピックの中での散らばり(最小〜最大)。
475
+
476
+ 実測の裏付け(2026-09-10): asmon はフォロワー 0 で `中学受験` を付けた投稿が
477
+ 202〜574 views、nigamilab のトピック無しは 1 view(****訂正 2026-09-12**: この「1 view」は**経過が数時間の疎通確認投稿**で、同じ投稿が 9/12 時点で **112 views**。**400 倍の大半は経過時間だった。**トピックが効かないという意味ではなく、**この数字では判定できない。****)。
478
+ **届ける経路はフォロワー
479
+ ではなくトピック**。だからトピックは「付けるかどうか」ではなく
480
+ 「どれを付けるか」の問題になる。
481
+
482
+ 読み取りだけ(`threads_basic` + `threads_manage_insights`)。投稿ごとに
483
+ 1 回ずつ数を読むので、`limit` 本ぶんの呼び出しが走る(手で叩く口なので可)。
484
+ """
485
+ try:
486
+ account_cfg = accounts_mod.load_account(account_name)
487
+ except accounts_mod.AccountError as e:
488
+ return {"account": account_name, "error": str(e), "topics": []}
489
+ token = accounts_mod.load_token(account_cfg)
490
+ rows, err = fetch_posts(account_cfg, token, limit=limit)
491
+ if rows is None:
492
+ return {"account": account_name, "error": err, "topics": []}
493
+
494
+ adapter = core._default_adapter_factory(account_cfg, token)
495
+ by_topic: dict = {}
496
+ for row in rows:
497
+ post_id = row.get("post_id")
498
+ if not post_id:
499
+ continue
500
+ try:
501
+ # `insights()` は `{"metrics", "available"}` を返す(設計 v2 §4.2)。
502
+ metrics, _available = adapter_base.metrics_of(adapter.insights(post_id))
503
+ except Exception as e:
504
+ metrics = {"error": redact_mod.redact(str(e))}
505
+ views = metrics.get("views")
506
+ topic = row.get("topic") or "(トピック無し)"
507
+ by_topic.setdefault(topic, []).append({
508
+ "id": post_id, "timestamp": row.get("timestamp"),
509
+ # **この数は「打った瞬間の値」**(設計 §3.2.2)。台帳の刻みの値では
510
+ # ない。**時点を書かないと、受け取る側が台帳の数字と混ぜる**
511
+ # (2026-09-12 に kopicha セッションが実際に混ぜた)。
512
+ "observed_at": jst.iso(),
513
+ "source": API_SOURCE,
514
+ "topic_source": API_TOPIC,
515
+ "views": views, "likes": metrics.get("likes"),
516
+ "replies": metrics.get("replies"),
517
+ "head": (row.get("text") or "").strip().split("\n", 1)[0][:40],
518
+ })
519
+
520
+ topics = []
521
+ for topic, posts in by_topic.items():
522
+ seen = sorted(p["views"] for p in posts if isinstance(p["views"], int))
523
+ topics.append({
524
+ "topic": topic,
525
+ "posts": len(posts),
526
+ "views_median": seen[len(seen) // 2] if seen else None,
527
+ "views_min": seen[0] if seen else None,
528
+ "views_max": seen[-1] if seen else None,
529
+ "likes_total": sum(p["likes"] for p in posts if isinstance(p["likes"], int)),
530
+ "items": sorted(posts, key=lambda p: (p["views"] is None, -(p["views"] or 0))),
531
+ })
532
+ topics.sort(key=lambda t: (t["views_median"] is None, -(t["views_median"] or 0)))
533
+ return {"account": account_name, "error": None, "topics": topics,
534
+ # **この口が返す数の素性**(設計 §3.2.2)。台帳の数と混ぜない。
535
+ "source": API_SOURCE, "topic_source": API_TOPIC,
536
+ "observed_at": jst.iso()}
537
+
538
+
539
+ def _remote_posts(account_cfg: dict, token: dict | None, known_post_ids: set) -> dict:
540
+ """媒体側の**実際の**直近の投稿を引く(masaru 指摘 2026-09-10)。
541
+
542
+ > thth通してないものもひいてきたら加わると良いですね
543
+
544
+ THTH は自分が出した投稿しか知らない。masaru が携帯の Threads アプリから
545
+ 出した投稿・THTH を作る前の投稿は、queue のどこにも無い。そのため
546
+ 「最後に投稿したのはいつか」も「同じ本文を最近出していないか」も、
547
+ **THTH の中だけを見ていると嘘になる**。ここで実物を引いて突き合わせる。
548
+
549
+ 読み取りだけ(`threads_basic`)。投稿・返信・削除は呼ばない。トークンが無い・
550
+ 網に届かない場合は **「判りません」** を返す(「0 件」と言わない・規約 12)。
551
+ """
552
+ out = {"known": False, "count": None, "latest": None, "outside": [], "message": ""}
553
+ rows, err = fetch_posts(account_cfg, token)
554
+ if rows is None:
555
+ out["message"] = err
556
+ return out
557
+
558
+ out["known"] = True
559
+ out["count"] = len(rows)
560
+ if rows:
561
+ out["latest"] = rows[0].get("timestamp")
562
+ # **THTH の queue に post_id が無いもの=THTH を通していない投稿。**
563
+ out["outside"] = [
564
+ {"id": row.get("post_id"), "timestamp": row.get("timestamp"),
565
+ "permalink": row.get("url")}
566
+ for row in rows
567
+ if row.get("post_id") and row["post_id"] not in known_post_ids
568
+ ]
569
+ return out
570
+
571
+
572
+ def account_detail(account_name: str, *, now=None, remote: bool = True) -> dict:
573
+ """1 アカウントの全体像。`thth account <name> [--json]` が使う。"""
574
+ now = now if now is not None else jst.now_jst()
575
+ try:
576
+ account_cfg = accounts_mod.load_account(account_name)
577
+ except accounts_mod.AccountError as e:
578
+ return {"account": account_name, "error": str(e),
579
+ "ready": False, "blockers": ["ledger: " + str(e)]}
580
+
581
+ repo = _repo_state(account_cfg)
582
+ token = maintain_mod.inspect(account_name, now=now)
583
+ state_dir = accounts_mod.state_dir_for(account_name)
584
+ pending = inflight_mod.read(state_dir)
585
+
586
+ tree_sha = writeback_mod.upstream_sha(account_cfg.get("repo_dir"))
587
+ files = core.list_queue_files(account_cfg, tree_sha=tree_sha)
588
+ counts = {"draft": 0, "approved": 0, "posted": 0, "型外": 0}
589
+ for qf in files:
590
+ if qf.malformed:
591
+ counts["型外"] += 1
592
+ continue
593
+ if qf.front_matter.get("account") != account_name:
594
+ continue
595
+ status = qf.front_matter.get("status")
596
+ if status in counts:
597
+ counts[status] += 1
598
+
599
+ # **queue と `sent/` の両方**(運用の報告 2026-09-13)。同席の様態で出した
600
+ # ものは queue に残らないので、front-matter だけで数えると `thth account` の
601
+ # 「うち THTH を通していないもの N 件」に自分の投稿が並ぶ。
602
+ known_post_ids = {qf.front_matter.get("post_id") for qf in files
603
+ if not qf.malformed and qf.front_matter.get("post_id")}
604
+ known_post_ids |= sent_mod.post_ids(state_dir)
605
+ remote_state = ({"known": False, "count": None, "latest": None, "outside": [],
606
+ "message": "--no-remote が指定されました"} if not remote
607
+ else _remote_posts(account_cfg, accounts_mod.load_token(account_cfg),
608
+ known_post_ids))
609
+
610
+ last_at = core.last_post_at(files, account_name)
611
+ recent = select_mod.recent_posted_texts(
612
+ files, account_name=account_name, media=account_cfg["media"], now=now)
613
+ result = select_mod.select_one(
614
+ files, account_name=account_name, account_cfg=account_cfg, now=now,
615
+ last_post_at=last_at, recent_texts=recent)
616
+
617
+ # **予約投稿を使うアカウントかどうか**(台帳 `scheduled`・既定 true)。
618
+ # `masaru-threads` は同席専用(queue も timer も持たない・設計 §3.7)。
619
+ # これを区別しないと、正常な同席専用アカウントを「投稿できません」と
620
+ # 言ってしまう(作った直後に実際に言った)。**使わない仕組みが無いことを
621
+ # 欠陥として数えない。**
622
+ scheduled = account_cfg.get("scheduled", True)
623
+
624
+ blockers = []
625
+ if token["state"] in maintain_mod.ATTENTION_STATES:
626
+ blockers.append(f"token: {token['state']}({token['message']})")
627
+ if pending is not None:
628
+ blockers.append(f"inflight: {pending.get('file')} が残っています(人が確認するまで止まります)")
629
+ if scheduled:
630
+ if not repo["exists"]:
631
+ blockers.append(f"repo: {repo['repo_dir']} がありません")
632
+ elif not repo["is_git"]:
633
+ blockers.append(f"repo: {repo['repo_dir']} は git repo ではありません")
634
+ elif not repo["synced"]:
635
+ blockers.append("repo: HEAD が upstream と一致していません"
636
+ "(push していない commit があるか、upstream が無い)")
637
+ if not repo["queue_dir_exists"]:
638
+ blockers.append(f"queue: {repo['queue_dir']} がありません(作って push してください)")
639
+ if not account_cfg.get("production"):
640
+ blockers.append("台帳: production: false(リハーサル。出しません)")
641
+
642
+ return {
643
+ "account": account_name,
644
+ "project": account_cfg.get("project"),
645
+ "media": account_cfg.get("media"),
646
+ "handle": account_cfg.get("handle"),
647
+ "production": bool(account_cfg.get("production")),
648
+ "scheduled": bool(scheduled),
649
+ "設定": {
650
+ "publish_at を上書きしうるもの": {
651
+ "min_interval_hours": account_cfg.get("min_interval_hours"),
652
+ "quiet_hours": account_cfg.get("quiet_hours"),
653
+ "max_per_run": account_cfg.get("max_per_run"),
654
+ },
655
+ "tick_minutes": account_cfg.get("tick_minutes"),
656
+ "stale_days": account_cfg.get("stale_days"),
657
+ "hashtags": account_cfg.get("hashtags"),
658
+ "collect_days": account_cfg.get("collect_days"),
659
+ },
660
+ "repo": repo,
661
+ "token": {k: token[k] for k in ("state", "remaining_days", "obtained_at", "message")},
662
+ "timer": _timer_state(account_name),
663
+ "queue": {
664
+ "counts": counts,
665
+ "next_file": os.path.basename(result.chosen.path) if result.chosen else None,
666
+ "needs_review": [os.path.basename(p) for p in result.needs_review],
667
+ "last_post_at": last_at.isoformat() if last_at else None,
668
+ },
669
+ # Threads 側の実物(THTH を通していない投稿を含む)。
670
+ "remote": remote_state,
671
+ "inflight": pending.get("file") if pending else None,
672
+ "ready": not blockers,
673
+ "blockers": blockers,
674
+ }
675
+
676
+
677
+ def render(detail: dict) -> str:
678
+ """人が読む形。**最後の 1 行が結論**(投稿できるか・できないなら何が足りないか)。"""
679
+ if detail.get("error") and "repo" not in detail:
680
+ return f"{detail['account']}: {detail['error']}\n"
681
+
682
+ repo, token, timer, queue = detail["repo"], detail["token"], detail["timer"], detail["queue"]
683
+ over = detail["設定"]["publish_at を上書きしうるもの"]
684
+ head = [
685
+ f"{detail['account']}({detail['project']} / {detail['media']} / @{detail['handle']})",
686
+ f" 本番 : {'はい' if detail['production'] else 'いいえ(リハーサル)'}",
687
+ ]
688
+ if not detail.get("scheduled", True):
689
+ # 同席専用。**使わない仕組みの状態を並べない**(無いことが欠陥に見える)。
690
+ lines = head + [
691
+ " 様態 : 同席専用(queue も timer も持たない・設計 §3.7)",
692
+ f" token : {token['state']}"
693
+ + (f"(残り {token['remaining_days']:.1f} 日)" if token["remaining_days"] is not None else ""),
694
+ f" inflight : {detail['inflight'] or 'なし'}",
695
+ ]
696
+ return _append_remote_and_verdict(lines, detail)
697
+ lines = head + [
698
+ f" repo : {repo['repo_dir']}",
699
+ f" git={repo['is_git']} branch={repo['branch']} upstream={repo['upstream']} "
700
+ f"同期={'済' if repo['synced'] else '未'}",
701
+ f" queue : {repo['queue_dir']}({'あり' if repo['queue_dir_exists'] else 'なし'})",
702
+ f" draft={queue['counts']['draft']} approved={queue['counts']['approved']} "
703
+ f"posted={queue['counts']['posted']} 型外={queue['counts']['型外']}",
704
+ f" 次に出るもの={queue['next_file']} 要確認={queue['needs_review'] or 'なし'}",
705
+ f" token : {token['state']}"
706
+ + (f"(残り {token['remaining_days']:.1f} 日)" if token["remaining_days"] is not None else ""),
707
+ f" timer : {timer['unit']} "
708
+ + (f"enabled={timer['enabled']} active={timer['active']}" if timer["known"]
709
+ else "(この機械では判りません)"),
710
+ f" 指定を上書きしうる設定: min_interval={over['min_interval_hours']}h "
711
+ f"quiet={over['quiet_hours']} max_per_run={over['max_per_run']}",
712
+ f" inflight : {detail['inflight'] or 'なし'}",
713
+ ]
714
+ return _append_remote_and_verdict(lines, detail)
715
+
716
+
717
+ def _append_remote_and_verdict(lines: list, detail: dict) -> str:
718
+ remote = detail.get("remote") or {}
719
+ # **「Threads 側」と書かない**(F2・2026-09-13)。この行は媒体を問わず出るのに
720
+ # 見出しだけ Threads で固定されていたので、Bluesky の画面が「Threads 側 :
721
+ # 判りません」と言っていた——**引けない理由が媒体違いに見える。**
722
+ 見出し = f" {detail.get('media') or '媒体'} 側".ljust(14)
723
+ if remote.get("known"):
724
+ outside = remote["outside"]
725
+ lines.append(f"{見出し}: 直近 {remote['count']} 件(最新 {remote['latest']})")
726
+ if outside:
727
+ lines.append(f"{' ' * 14} うち **THTH を通していないもの {len(outside)} 件**"
728
+ f"(最新 {outside[0]['timestamp']})")
729
+ else:
730
+ lines.append(f"{' ' * 14} すべて THTH 経由です")
731
+ else:
732
+ lines.append(f"{見出し}: 判りません({remote.get('message', '')})")
733
+ if detail["ready"] and not detail.get("scheduled", True):
734
+ lines.append(" → **同席の送信ができます**(このアカウントは予約投稿を使いません)")
735
+ elif detail["ready"]:
736
+ lines.append(" → **投稿できます**")
737
+ else:
738
+ lines.append(" → **投稿できません**:")
739
+ lines.extend(f" - {b}" for b in detail["blockers"])
740
+ return "\n".join(lines) + "\n"