5-Sekunden-Antwort: Wir können nicht beweisen, dass Nagano bei gefährlicher Handlung tatsächlich schneller zeichnet. Prüfen können wir aber ein beobachtbares Muster: In einigen unheimlichen Arcs und Höhepunkten wurden die öffentlichen Abstände zwischen X-Posts deutlich kürzer. Daraus lässt sich ein inoffizieller „Chiikawa-Gefahrenindex“ bauen, der den aktuellen Rhythmus mit der eigenen Historie vergleicht.
Im November 2023, gegen Ende des Siren-Arcs, zeigen Archive Serien vom 5.–9., 13.–18. und 21.–26. November. Zu Beginn von Parallel World im März 2024 erschien vom 1. bis 8. März täglich etwas, am 8. zweimal. Das belegt schnellere Veröffentlichung, nicht schnelleres Zeichnen.
Der Index nutzt Posts der letzten 3 Tage, der letzten 7 Tage und die Serie aufeinanderfolgender Posting-Tage. Daraus werden historische Perzentile mit 35 %, 45 % und 20 % Gewicht. Kurze Wiederholungen wie 🍣🍣 erhalten +5 pro weiterer Wiederholung, maximal +15. Dieser Bonus ist bewusst der Witz „zwei Sushi hintereinander wirken verdächtig“, keine Wissenschaft.
Am 3. September 2026 nennt X Post Read $0.005 pro Post, Counts: All $0.010 pro Request, normalen Content Create $0.015 und Content Create mit URL $0.200. Full-archive Counts wird in 31-Tage-Fenstern paginiert; 2020-01-01 bis 2026-09-03 sind ungefähr 79 Requests bzw. $0.79. Aktuelle Preise prüfen und Spending limit setzen.
Einrichtung ab Null
Benötigt werden Windows oder macOS, ein X-Konto mit Developer App, GitHub und Python. Unter Windows py --version, unter macOS python3 --version prüfen. Ordner chiikawa-danger-bot anlegen und die sieben Dateien aus dem gemeinsamen Anhang exakt benennen.
App unter console.x.com erstellen. Bearer Token dient zum Lesen; OAuth 1.0a benötigt API Key/Secret und Access Token/Secret zum Posten. Permission: Read and write. Nach Änderung von Read only Access Token/Secret neu erzeugen. Echte Schlüssel nur in .env und GitHub Secrets speichern.
Windows:
py -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
mkdir data
copy danger_periods.example.csv data\danger_periods.csv
copy .env.example .env
notepad .env
macOS:
python3 -m venv .venv
./.venv/bin/python -m pip install -r requirements.txt
mkdir -p data
cp danger_periods.example.csv data/danger_periods.csv
cp .env.example .env
nano .env
Zuerst Bearer Token eintragen und history_analysis.py ausführen; dadurch entsteht data/history_features.csv. Danach die vier OAuth-Werte ergänzen, BOT_DRY_RUN=1 lassen und bot.py starten. Der erste Lauf postet niemals, sondern initialisiert nur den Zustand.
Auf GitHub Code und data/history_features.csv hochladen, niemals .env. Fünf Secrets unter Settings → Secrets and variables → Actions anlegen, Workflow aus dem Anhang erstellen und mit BOT_DRY_RUN: "1" testen. Erst danach auf "0" wechseln. POST_MODE: "alert" lässt den BOT nur bei steigender Warnstufe posten.
Profil klar als inoffiziellen Fan-BOT kennzeichnen, Automated-Account-Label verwenden und X-Automationsregeln beachten. Der Index basiert nur auf öffentlichen Zeitpunkten und sagt nichts Sicheres über Gesundheit, echtes Arbeitstempo oder zukünftige Handlung.
Ergebnis: Nagano postet schnell → Python: „Zeitreihenanomalie“ → Leser: „Lauf.“
共通コピペコード / Shared copy-paste code
以下のコードは12言語で共通です。Code is language-independent: save each block with the exact filename shown.
1. requirements.txt
pandas>=2.2,<4
requests>=2.32,<3
requests-oauthlib>=2,<3
python-dotenv>=1.0,<2
2. .env.example
# X Developer Consoleで取得。絶対にGitHubへそのまま公開しない。
X_BEARER_TOKEN=
# BOT投稿を有効にするときだけ必要(OAuth 1.0a User Context)
X_API_KEY=
X_API_SECRET=
X_ACCESS_TOKEN=
X_ACCESS_TOKEN_SECRET=
# 通常はこのままでOK
X_QUERY=from:ngnchiikawa has:images -is:retweet -is:reply
X_HISTORY_START=2020-01-01T00:00:00Z
X_COUNTS_GRANULARITY=hour
BOT_DRY_RUN=1
POST_MODE=alert
INCLUDE_SOURCE_URL=0
3. history_analysis.py
from __future__ import annotations
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pandas as pd
import requests
from dotenv import load_dotenv
load_dotenv()
API_URL = "https://api.x.com/2/tweets/counts/all"
QUERY = os.getenv(
"X_QUERY",
"from:ngnchiikawa has:images -is:retweet -is:reply",
)
BEARER_TOKEN = os.environ["X_BEARER_TOKEN"]
START_TIME = os.getenv("X_HISTORY_START", "2020-01-01T00:00:00Z")
GRANULARITY = os.getenv("X_COUNTS_GRANULARITY", "hour")
OUT_DIR = Path(os.getenv("OUT_DIR", "data"))
OUT_DIR.mkdir(parents=True, exist_ok=True)
def rfc3339_now_minus_30s() -> str:
dt = datetime.now(timezone.utc) - timedelta(seconds=30)
return dt.replace(microsecond=0).isoformat().replace("+00:00", "Z")
def fetch_counts_all() -> list[dict]:
"""Fetch X Post Counts All until next_token disappears."""
params: dict[str, str] = {
"query": QUERY,
"start_time": START_TIME,
"end_time": os.getenv("X_HISTORY_END", rfc3339_now_minus_30s()),
"granularity": GRANULARITY,
}
headers = {"Authorization": f"Bearer {BEARER_TOKEN}"}
rows: list[dict] = []
page = 0
while True:
page += 1
r = requests.get(API_URL, headers=headers, params=params, timeout=30)
if not r.ok:
raise RuntimeError(f"X API error {r.status_code}: {r.text}")
payload = r.json()
rows.extend(payload.get("data", []))
next_token = payload.get("meta", {}).get("next_token")
print(f"page={page} buckets={len(payload.get('data', []))} total_buckets={len(rows)}")
if not next_token:
break
params["next_token"] = next_token
return rows
def build_daily_features(rows: list[dict]) -> pd.DataFrame:
if not rows:
raise RuntimeError("No count data returned. Check query, dates, credits, and API access.")
raw = pd.DataFrame(rows)
raw["start"] = pd.to_datetime(raw["start"], utc=True)
# Current X Post Counts responses use tweet_count.
# Keep post_count as a fallback in case the field is renamed later.
count_col = "tweet_count" if "tweet_count" in raw.columns else "post_count"
if count_col not in raw.columns:
raise RuntimeError(f"Unexpected Counts response columns: {list(raw.columns)}")
raw["post_count"] = pd.to_numeric(raw[count_col], errors="coerce").fillna(0).astype(int)
if GRANULARITY == "hour":
raw["date"] = raw["start"].dt.tz_convert("Asia/Tokyo").dt.tz_localize(None).dt.floor("D")
elif GRANULARITY == "day":
raw["date"] = raw["start"].dt.tz_localize(None).dt.floor("D")
else:
raise ValueError("X_COUNTS_GRANULARITY must be 'hour' or 'day'")
daily = raw.groupby("date", as_index=True)["post_count"].sum().sort_index().to_frame()
full_index = pd.date_range(daily.index.min(), daily.index.max(), freq="D")
daily = daily.reindex(full_index, fill_value=0)
daily.index.name = "date"
daily["posts_3d"] = daily["post_count"].rolling(3, min_periods=1).sum()
daily["posts_7d"] = daily["post_count"].rolling(7, min_periods=1).sum()
active = (daily["post_count"] > 0).astype(int)
reset_group = (active == 0).cumsum()
daily["active_streak"] = active.groupby(reset_group).cumsum().astype(int)
daily["pct_3d"] = daily["posts_3d"].rank(method="average", pct=True)
daily["pct_7d"] = daily["posts_7d"].rank(method="average", pct=True)
daily["pct_streak"] = daily["active_streak"].rank(method="average", pct=True)
daily["danger_index"] = (
100 * (0.35 * daily["pct_3d"] + 0.45 * daily["pct_7d"] + 0.20 * daily["pct_streak"])
).round().clip(0, 100).astype(int)
return daily.reset_index()
def optional_episode_check(features: pd.DataFrame) -> None:
label_path = OUT_DIR / "danger_periods.csv"
if not label_path.exists():
return
periods = pd.read_csv(label_path)
work = features.copy()
work["date"] = pd.to_datetime(work["date"])
work["danger_label"] = 0
work["episode"] = ""
for _, p in periods.iterrows():
start = pd.Timestamp(p["start"])
end = pd.Timestamp(p["end"])
mask = work["date"].between(start, end)
work.loc[mask, "danger_label"] = 1
work.loc[mask, "episode"] = str(p.get("name", "danger"))
danger = work.loc[work["danger_label"] == 1, "danger_index"]
normal = work.loc[work["danger_label"] == 0, "danger_index"]
print("\n--- manual-label check ---")
if len(danger):
print(f"danger mean={danger.mean():.1f} median={danger.median():.1f} n={len(danger)}")
else:
print("danger: no labeled rows")
print(f"normal mean={normal.mean():.1f} median={normal.median():.1f} n={len(normal)}")
for threshold in (60, 70, 80, 90):
tpr = (danger >= threshold).mean() if len(danger) else float("nan")
fpr = (normal >= threshold).mean() if len(normal) else float("nan")
print(f"threshold={threshold}: danger_hit={tpr:.1%}, normal_false_alarm={fpr:.1%}")
work.to_csv(OUT_DIR / "history_labeled.csv", index=False)
def main() -> None:
rows = fetch_counts_all()
features = build_daily_features(rows)
out = OUT_DIR / "history_features.csv"
features.to_csv(out, index=False)
print(f"\nsaved: {out}")
print(features.tail(14).to_string(index=False))
optional_episode_check(features)
if __name__ == "__main__":
main()
4. danger_periods.example.csv
name,start,end
siren_climax,2023-11-05,2023-11-26
parallel_opening,2024-03-01,2024-03-08
5. bot.py
from __future__ import annotations
import json
import os
import re
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pandas as pd
import requests
from dotenv import load_dotenv
from requests_oauthlib import OAuth1
load_dotenv()
SEARCH_URL = "https://api.x.com/2/tweets/search/recent"
POST_URL = "https://api.x.com/2/tweets"
QUERY = os.getenv(
"X_QUERY",
"from:ngnchiikawa has:images -is:retweet -is:reply",
)
BEARER_TOKEN = os.environ["X_BEARER_TOKEN"]
BASELINE_PATH = Path(os.getenv("BASELINE_PATH", "data/history_features.csv"))
STATE_PATH = Path(os.getenv("STATE_PATH", "data/state.json"))
STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
DRY_RUN = os.getenv("BOT_DRY_RUN", "1") != "0"
POST_MODE = os.getenv("POST_MODE", "alert") # alert | every
INCLUDE_SOURCE_URL = os.getenv("INCLUDE_SOURCE_URL", "0") == "1"
def utc_rfc3339(dt: datetime) -> str:
return dt.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def load_state() -> dict:
if not STATE_PATH.exists():
return {"last_seen_id": None, "events": [], "last_score": 0}
return json.loads(STATE_PATH.read_text(encoding="utf-8"))
def save_state(state: dict) -> None:
STATE_PATH.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
def recent_search(*, since_id: str | None = None) -> list[dict]:
"""Fetch only new Posts. First run bootstraps the latest 6d23h."""
headers = {"Authorization": f"Bearer {BEARER_TOKEN}"}
params: dict[str, str | int] = {
"query": QUERY,
"max_results": 100,
"tweet.fields": "created_at",
}
if since_id:
params["since_id"] = since_id
else:
params["start_time"] = utc_rfc3339(
datetime.now(timezone.utc) - timedelta(days=6, hours=23)
)
out: list[dict] = []
while True:
r = requests.get(SEARCH_URL, headers=headers, params=params, timeout=30)
if not r.ok:
raise RuntimeError(f"X API search error {r.status_code}: {r.text}")
payload = r.json()
out.extend(payload.get("data", []))
token = payload.get("meta", {}).get("next_token")
if not token:
break
params["next_token"] = token
return out
def marker(text: str) -> str | None:
t = re.sub(r"https?://\S+", "", text or "").strip()
t = re.sub(r"\s+", " ", t)
return t if 0 < len(t) <= 12 else None
def consecutive_marker_streak(events: list[dict]) -> tuple[str | None, int]:
if not events:
return None, 0
ordered = sorted(events, key=lambda x: x["created_at"], reverse=True)
newest = marker(ordered[0].get("text", ""))
if not newest:
return None, 0
streak = 0
for e in ordered:
if marker(e.get("text", "")) == newest:
streak += 1
else:
break
return newest, streak
def empirical_percentile(value: float, baseline: pd.Series) -> float:
clean = pd.to_numeric(baseline, errors="coerce").dropna()
if clean.empty:
return 0.0
return float((clean <= value).mean())
def current_metrics(events: list[dict]) -> dict:
now_jst = pd.Timestamp.now(tz="Asia/Tokyo")
today = now_jst.tz_localize(None).floor("D")
start = today - pd.Timedelta(days=13)
days = pd.date_range(start, today, freq="D")
if events:
frame = pd.DataFrame(events)
frame["created_at"] = pd.to_datetime(frame["created_at"], utc=True)
frame["date"] = (
frame["created_at"].dt.tz_convert("Asia/Tokyo").dt.tz_localize(None).dt.floor("D")
)
counts = frame.groupby("date").size().reindex(days, fill_value=0)
else:
counts = pd.Series(0, index=days, dtype=int)
posts_3d = int(counts.tail(3).sum())
posts_7d = int(counts.tail(7).sum())
streak = 0
for count in counts.iloc[::-1]:
if int(count) > 0:
streak += 1
else:
break
return {
"posts_3d": posts_3d,
"posts_7d": posts_7d,
"active_streak": streak,
}
def compute_score(events: list[dict]) -> tuple[int, dict]:
baseline = pd.read_csv(BASELINE_PATH)
m = current_metrics(events)
p3 = empirical_percentile(m["posts_3d"], baseline["posts_3d"])
p7 = empirical_percentile(m["posts_7d"], baseline["posts_7d"])
ps = empirical_percentile(m["active_streak"], baseline["active_streak"])
base = 100 * (0.35 * p3 + 0.45 * p7 + 0.20 * ps)
mk, mk_streak = consecutive_marker_streak(events)
marker_bonus = min(15, max(0, mk_streak - 1) * 5)
score = int(round(min(100, base + marker_bonus)))
m.update({
"marker": mk,
"marker_streak": mk_streak,
"marker_bonus": marker_bonus,
"base_score": round(base, 1),
})
return score, m
def band(score: int) -> tuple[int, str]:
if score < 55:
return 0, "🟢 平常"
if score < 70:
return 1, "🟡 加速"
if score < 85:
return 2, "🟠 警戒"
return 3, "🚨 かなり速い"
def format_post(score: int, metrics: dict, newest_id: str) -> str:
_, label = band(score)
lines = [
f"ちいかわ危険指数 {score}/100 {label}",
f"直近3日 {metrics['posts_3d']}投稿 / 7日 {metrics['posts_7d']}投稿 / 連続投稿日 {metrics['active_streak']}日",
]
if metrics.get("marker") and metrics.get("marker_streak", 0) >= 2:
lines.append(
f"同一マーカー「{metrics['marker']}」{metrics['marker_streak']}連投 +{metrics['marker_bonus']}"
)
lines.append("※非公式。更新頻度から作ったネタ指標です。")
if INCLUDE_SOURCE_URL:
lines.append(f"https://x.com/ngnchiikawa/status/{newest_id}")
return "\n".join(lines)
def post_to_x(text: str) -> None:
if DRY_RUN:
print("\n--- DRY RUN ---\n" + text)
return
auth = OAuth1(
os.environ["X_API_KEY"],
os.environ["X_API_SECRET"],
os.environ["X_ACCESS_TOKEN"],
os.environ["X_ACCESS_TOKEN_SECRET"],
)
r = requests.post(POST_URL, auth=auth, json={"text": text}, timeout=30)
if not r.ok:
raise RuntimeError(f"X API post error {r.status_code}: {r.text}")
print("posted:", r.json())
def prune_events(events: list[dict], days: int = 14) -> list[dict]:
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
out = []
seen = set()
for e in events:
if e["id"] in seen:
continue
seen.add(e["id"])
created = datetime.fromisoformat(e["created_at"].replace("Z", "+00:00"))
if created >= cutoff:
out.append(e)
return out
def main() -> None:
if not BASELINE_PATH.exists():
raise RuntimeError(f"Missing baseline: {BASELINE_PATH}. Run history_analysis.py first.")
state = load_state()
first_run = not state.get("last_seen_id")
new_posts = recent_search(since_id=state.get("last_seen_id"))
if first_run:
state["events"] = prune_events(new_posts)
if new_posts:
state["last_seen_id"] = str(max(int(p["id"]) for p in new_posts))
score, metrics = compute_score(state["events"])
state["last_score"] = score
save_state(state)
print("bootstrapped; no X post")
print(format_post(score, metrics, state.get("last_seen_id") or "0"))
return
if not new_posts:
print("no new posts")
return
newest_id = str(max(int(p["id"]) for p in new_posts))
merged = list(state.get("events", [])) + new_posts
state["events"] = prune_events(merged)
previous_score = int(state.get("last_score", 0))
score, metrics = compute_score(state["events"])
prev_band, _ = band(previous_score)
new_band, _ = band(score)
should_post = POST_MODE == "every" or new_band > prev_band
if should_post:
post_to_x(format_post(score, metrics, newest_id))
else:
print(f"score={score}; band unchanged, no bot post")
state["last_seen_id"] = newest_id
state["last_score"] = score
save_state(state)
if __name__ == "__main__":
main()
6. .gitignore
.env
.venv/
__pycache__/
*.pyc
.DS_Store
7. .github/workflows/chiikawa-danger.yml
name: Chiikawa danger bot
on:
schedule:
- cron: "17 * * * *"
workflow_dispatch:
permissions:
contents: write
concurrency:
group: chiikawa-danger-bot
cancel-in-progress: false
jobs:
run:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.13"
- run: python -m pip install -r requirements.txt
- name: Detect and optionally post
env:
X_BEARER_TOKEN: ${{ secrets.X_BEARER_TOKEN }}
X_API_KEY: ${{ secrets.X_API_KEY }}
X_API_SECRET: ${{ secrets.X_API_SECRET }}
X_ACCESS_TOKEN: ${{ secrets.X_ACCESS_TOKEN }}
X_ACCESS_TOKEN_SECRET: ${{ secrets.X_ACCESS_TOKEN_SECRET }}
BOT_DRY_RUN: "1"
POST_MODE: "alert"
INCLUDE_SOURCE_URL: "0"
run: python bot.py
- name: Persist state
run: |
if ! git status --porcelain -- data/state.json | grep -q .; then
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add data/state.json
git commit -m "chore: update chiikawa bot state"
git push
Sources / 参考資料
- X API Pricing: https://docs.x.com/x-api/getting-started/pricing
- X Post Counts: https://docs.x.com/x-api/posts/counts/introduction
- Get count of all Posts: https://docs.x.com/x-api/posts/get-count-of-all-posts
- X Developer Console: https://docs.x.com/fundamentals/developer-portal
- X Developer Apps / permissions: https://docs.x.com/fundamentals/developer-apps
- X API v2 authentication mapping: https://docs.x.com/fundamentals/authentication/guides/v2-authentication-mapping
- X Automation Rules: https://help.x.com/en/rules-and-policies/x-automation?lang=browser
- X Automated account labels: https://help.x.com/en/using-x/automated-account-labels
- GitHub Actions Secrets: https://docs.github.com/en/actions/concepts/security/secrets
- Python Windows: https://docs.python.org/3/using/windows.html
- Python macOS downloads: https://www.python.org/downloads/macos/
- pandas PyPI: https://pypi.org/project/pandas/
- November 2023 Chiikawa public-post archive: https://chiikawa.hatenablog.jp/entry/matome/2023/11
- March 2024 Chiikawa public-post archive: https://chiikawa.hatenablog.jp/entry/matome/2024/03
