#!/usr/bin/env python3 """youtube_market.py — 企画の入口になる言葉で YouTube を検索し、似た題材の動画が実際に何回見られているかを調べる. 企画会議(kikaku.py)が「どれが伸びるか」を推測だけで決めないための材料。 YouTube Data API の検索(search.list)と、動画・チャンネルの数字(videos.list・channels.list)を使う。 置き場所(動画/_調べ/{調べの名前}/): キーワード.txt 1行に1つ、調べる言葉(# で始まる行は無視) かける.txt 印。議論係の見回り(debate_runner.py)がこれを見て動かす。終わると かけた.txt 結果.md 言葉ごとの数字(本編とショートを分けて、再生数の中央値・上位の動画・登録者の少ないチャンネルで伸びた動画) 結果.json 取ったままの数字 許可は youtube_schedule.py の読み取りの許可(~/.config/pixelstomotors/youtube_token.json)をそのまま使う。 API の1日の枠は10,000。検索1回で100使うので、1回の調べは言葉20個までにする。 手で動かすとき: python3 youtube_market.py {調べのフォルダ} """ import json, os, statistics, sys, urllib.parse from datetime import datetime, timedelta, timezone HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) import youtube_schedule as ys JST = timezone(timedelta(hours=9)) MAX_WORDS = 20 SHORT_SEC = 180 # これ以下はショートとして分ける def api(path, tok, **q): return ys.call(f"{ys.API}/{path}?" + urllib.parse.urlencode(q), headers={"Authorization": "Bearer " + tok}) def search(tok, word, since): ids, page = [], "" for _ in range(1): # 1ページ50本(枠を守るため1ページだけ) q = dict(part="id", q=word, type="video", maxResults=50, regionCode="JP", relevanceLanguage="ja", order="relevance", publishedAfter=since) if page: q["pageToken"] = page r = api("search", tok, **q) ids += [i["id"]["videoId"] for i in r.get("items", []) if i.get("id", {}).get("videoId")] page = r.get("nextPageToken") if not page: break return ids def details(tok, ids): out = [] for i in range(0, len(ids), 50): r = api("videos", tok, part="snippet,statistics,contentDetails", id=",".join(ids[i:i + 50])) out += r.get("items", []) return out def subs(tok, chids): m = {} chids = list(dict.fromkeys(chids)) for i in range(0, len(chids), 50): r = api("channels", tok, part="statistics", id=",".join(chids[i:i + 50])) for c in r.get("items", []): s = c.get("statistics", {}) m[c["id"]] = None if s.get("hiddenSubscriberCount") else int(s.get("subscriberCount", 0)) return m def run(folder): words = [w.strip() for w in open(os.path.join(folder, "キーワード.txt"), encoding="utf-8") if w.strip() and not w.strip().startswith("#")][:MAX_WORDS] tok = ys.access_token() now = datetime.now(timezone.utc) since = (now - timedelta(days=365)).strftime("%Y-%m-%dT%H:%M:%SZ") allres = {} L = [f"# YouTube で調べた結果({datetime.now(JST).strftime('%Y-%m-%d %H:%M')}・直近1年に公開された動画・日本向けの検索)", "", "- 1つの言葉につき、YouTube の検索の上位50本を見た。本編(3分より長い)とショートを分けた", "- 再生数は調べた時点の累計。公開からの日数がばらばらなので、1日あたりの再生数も出した", "- 「小さなチャンネルで伸びた」は、登録者1万人未満のチャンネルで再生数が登録者数を超えた本編。題材そのものの力が強い目安", ""] for w in words: ids = search(tok, w, since) vs = details(tok, ids) sm = subs(tok, [v["snippet"]["channelId"] for v in vs]) rows = [] for v in vs: sec = ys.seconds(v.get("contentDetails", {}).get("duration")) or 0 pub = datetime.fromisoformat(v["snippet"]["publishedAt"].replace("Z", "+00:00")) days = max(1, (now - pub).days) views = int(v.get("statistics", {}).get("viewCount", 0)) rows.append({"id": v["id"], "title": v["snippet"]["title"], "channel": v["snippet"]["channelTitle"], "subs": sm.get(v["snippet"]["channelId"]), "views": views, "days": days, "per_day": round(views / days, 1), "sec": sec, "short": sec <= SHORT_SEC, "published": pub.date().isoformat()}) allres[w] = rows longs = sorted([r for r in rows if not r["short"]], key=lambda r: -r["views"]) shorts = [r for r in rows if r["short"]] L += [f"## {w}", ""] if longs: med = statistics.median(r["views"] for r in longs) n1k = sum(1 for r in longs if r["views"] >= 1000) n10k = sum(1 for r in longs if r["views"] >= 10000) L.append(f"- 本編 {len(longs)} 本:再生数の中央値 {med:,.0f} 回・1,000回以上 {n1k} 本・10,000回以上 {n10k} 本") else: L.append("- 本編は見つからなかった") if shorts: L.append(f"- ショート {len(shorts)} 本:中央値 {statistics.median(r['views'] for r in shorts):,.0f} 回") small = [r for r in longs if r["subs"] is not None and r["subs"] < 10000 and r["views"] > r["subs"]] L += ["", "上位の本編(再生数の順・5本)", "", "| 題名 | チャンネル(登録者) | 再生数 | 1日あたり | 公開日 |", "|---|---|---|---|---|"] for r in longs[:5]: L.append(f"| {r['title'][:50]} | {r['channel'][:20]}({r['subs'] if r['subs'] is not None else '非公開'}) | {r['views']:,} | {r['per_day']:,} | {r['published']} |") if small: L += ["", "小さなチャンネルで伸びた本編", ""] for r in sorted(small, key=lambda r: -r["views"])[:5]: L.append(f"- {r['title'][:60]}({r['channel'][:20]}・登録者 {r['subs']:,}・{r['views']:,} 回・{r['published']})") L.append("") with open(os.path.join(folder, "結果.json"), "w", encoding="utf-8") as f: json.dump(allres, f, ensure_ascii=False, indent=1) with open(os.path.join(folder, "結果.md"), "w", encoding="utf-8") as f: f.write("\n".join(L)) return len(words) if __name__ == "__main__": print(run(sys.argv[1]), "語を調べました")