Docs/Skills and code/src/

src/series-analysis/topic-study.js

Code204 lines12.6 KBsrc/

What the file says about itself

Topic study: which subjects (companies, brands, people, countries) draw the most views for a series, not which single video wins.

Mentioned on: page 01, page 04, page 12

// Topic study: which subjects (companies, brands, people, countries) draw the most views for a series,
// not which single video wins. Reads the series-analysis store, attributes each on-topic video to the
// entities its title names, and scores every entity on demand, recency, engagement and headroom.
//
//   node src/series-analysis/topic-study.js --series=fall-of-x [--out=reports/series-analysis]
//
// Needs `search` (and ideally `enrich`) to have run for the series. Likes and exact dates exist only
// for enriched videos; everything else uses the search-page view count and the relative date text.
import fs from "node:fs";
import path from "node:path";
import { openStore } from "./store.js";
import { norm, failureHits, structureMatches, nonReplicableFlags, median, percentileRank, publishedFromText, ageDaysFrom, guessSubject } from "./signals.js";

const args = Object.fromEntries(process.argv.slice(2).filter(a => a.startsWith("--")).map(a => {
  const [k, ...v] = a.slice(2).split("=");
  return [k, v.length ? v.join("=") : true];
}));
if (!args.series) { console.error("usage: node src/series-analysis/topic-study.js --series=<id>"); process.exit(1); }

const spec = JSON.parse(fs.readFileSync(path.resolve("series", `${args.series}.json`), "utf8"));
const S = spec.id;
const db = openStore();
const outDir = path.resolve(args.out ?? "reports/series-analysis");
const STRONG_VIEWS = 100000;
const RECENT_DAYS = 365;

// Entities: every subject we searched for, typed, with the title aliases that name it.
const typeOf = new Map();
for (const e of spec.companies.indian) typeOf.set(e, "company (India)");
for (const e of spec.companies.global) typeOf.set(e, "company (global)");
for (const e of spec.entityTypes?.person ?? []) typeOf.set(e, "person");
for (const e of spec.entityTypes?.country ?? []) typeOf.set(e, "country/economy");
const aliasesOf = new Map([...typeOf.keys()].map(e => {
  const extra = spec.topicAliases?.[e] ?? [];
  const base = e.replace(/\s+(app|scam)$/i, "").replace(/'s$/i, "");
  return [e, [...new Set([norm(e), norm(base), ...extra.map(norm)])].filter(a => a.length >= 3)];
}));
// Position of the alias as a whole word in the title, or -1. The title's first-named subject is the video's subject.
const aliasPos = (titleN, alias) => {
  const m = new RegExp(`(^|[^a-z0-9])${alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^a-z0-9]|$)`).exec(titleN);
  return m ? m.index + m[1].length : -1;
};

// One row per video, with best-available views and an age.
const now = Date.now();
const videos = db.prepare("SELECT * FROM series_videos WHERE series_id=?").all(S).map(v => {
  const pub = v.published_at ? { iso: v.published_at } : publishedFromText(v.search_published_text, v.first_seen_at);
  return {
    ...v,
    views: v.views ?? v.search_views ?? null,
    likes: v.likes ?? null,
    ageDays: ageDaysFrom(pub.iso, now),
    enriched: v.info_status === "ok",
    structure: structureMatches(v.title)[0] ?? null,
    failureN: failureHits(v.title, spec.failureTerms).length,
    flags: nonReplicableFlags(v.title)
  };
});

// On-topic: the title frames a collapse AND names the subject. Shorts and fiction clips are not the topic.
const onTopic = videos.filter(v => (v.structure || v.failureN) && !v.flags.some(f => /shorts|fiction/.test(f)) && v.views != null);
// Each video counts once, for the subject its title names first, so a video that mentions two subjects
// (a Mallya clip under a Kingfisher title) does not inflate both.
const byEntity = new Map([...typeOf.keys()].map(e => [e, []]));
for (const v of onTopic) {
  const t = norm(v.title);
  let primary = null;
  for (const [e, aliases] of aliasesOf) {
    for (const a of aliases) {
      const pos = aliasPos(t, a);
      if (pos >= 0 && (!primary || pos < primary.pos || (pos === primary.pos && a.length > primary.len))) primary = { e, pos, len: a.length };
    }
  }
  if (primary) byEntity.get(primary.e).push(v);
}

// Per-entity metrics
const rows = [];
for (const [e, vids] of byEntity) {
  if (!vids.length) continue;
  const views = vids.map(v => v.views);
  const recent = vids.filter(v => v.ageDays != null && v.ageDays <= RECENT_DAYS);
  const liked = vids.filter(v => v.enriched && v.likes != null && v.views > 0);
  const perDay = vids.filter(v => v.ageDays >= 30).map(v => v.views / v.ageDays);
  const top = vids.reduce((a, b) => (b.views > a.views ? b : a));
  rows.push({
    entity: e, type: typeOf.get(e),
    videos: vids.length, channels: new Set(vids.map(v => v.channel_name ?? v.channel_id)).size,
    median_views: median(views), max_views: top.views, top_title: top.title, top_channel: top.channel_name,
    sum_views: views.reduce((a, b) => a + b, 0),
    recent_videos: recent.length, recent_views: recent.reduce((a, v) => a + v.views, 0),
    median_views_per_day: median(perDay),
    strong_videos: vids.filter(v => v.views >= STRONG_VIEWS).length,
    like_ratio: liked.length ? median(liked.map(v => v.likes / v.views)) : null,
    enriched_share: vids.filter(v => v.enriched).length / vids.length
  });
}

// Scoring: percentile ranks within the entities with at least two on-topic videos.
const eligible = rows.filter(r => r.videos >= 2);
const pct = (key, log = true) => {
  const vals = eligible.map(r => r[key]).filter(x => x != null).map(x => (log ? Math.log10(x + 1) : x)).sort((a, b) => a - b);
  return r => { const x = r[key]; return x == null ? null : percentileRank(vals, log ? Math.log10(x + 1) : x); };
};
const P = {
  median: pct("median_views"), max: pct("max_views"), sum: pct("sum_views"), recent: pct("recent_views"),
  perDay: pct("median_views_per_day"), like: pct("like_ratio", false), strong: pct("strong_videos", false)
};
// Weighted mean over the parts that exist; a missing part (no likes, no dates) is re-weighted, not zeroed.
const wmean = parts => {
  const xs = parts.filter(([v]) => v != null);
  const wsum = xs.reduce((a, [, w]) => a + w, 0);
  return wsum ? xs.reduce((a, [v, w]) => a + v * w, 0) / wsum : null;
};
const score = (r, w) => {
  const demand = wmean([[P.median(r), 0.5], [P.max(r), 0.3], [P.sum(r), 0.2]]);
  const recency = wmean([[P.recent(r), 0.6], [P.perDay(r), 0.4]]);
  const engagement = P.like(r);
  const strong = P.strong(r);
  const headroom = wmean([[P.sum(r), 0.5], [strong == null ? null : 1 - strong, 0.5]]);
  return wmean([[demand, w.demand], [recency, w.recency], [engagement, w.engagement], [headroom, w.headroom]]);
};
const BASE = { name: "base", demand: 0.45, recency: 0.30, engagement: 0.10, headroom: 0.15 };
const SENSITIVITY = [
  { name: "demand-heavy", demand: 0.7, recency: 0.15, engagement: 0.05, headroom: 0.1 },
  { name: "recency-heavy", demand: 0.25, recency: 0.6, engagement: 0.05, headroom: 0.1 },
  { name: "headroom-heavy", demand: 0.25, recency: 0.2, engagement: 0.05, headroom: 0.5 },
  { name: "equal", demand: 0.25, recency: 0.25, engagement: 0.25, headroom: 0.25 }
];
for (const r of eligible) {
  r.score = score(r, BASE);
  for (const w of SENSITIVITY) r[`score_${w.name}`] = score(r, w);
}
eligible.sort((a, b) => b.score - a.score);
const top10Under = w => [...eligible].filter(r => r[`score_${w.name}`] != null).sort((a, b) => b[`score_${w.name}`] - a[`score_${w.name}`]).slice(0, 10).map(r => r.entity);

// Packaging: which title frame earns the most per on-topic video.
const frame = {};
for (const v of onTopic) (frame[v.structure ?? "other failure wording"] ??= []).push(v.views);
const frameRows = Object.entries(frame).map(([k, xs]) => ({ frame: k, n: xs.length, median_views: median(xs) })).sort((a, b) => b.median_views - a.median_views);

// Recursion: subjects named in on-topic titles that are not in the list yet.
const known = [...typeOf.keys()];
const unlisted = new Map();
for (const v of onTopic) {
  const g = guessSubject(v.title, known);
  if (g.source === "known" || !g.key || g.key.length < 4) continue;
  const agg = unlisted.get(g.key) ?? { key: g.key, videos: 0, views: 0, example: v.title };
  agg.videos++; agg.views += v.views ?? 0; unlisted.set(g.key, agg);
}
const nextRound = [...unlisted.values()].filter(x => x.videos >= 3).sort((a, b) => b.views - a.views).slice(0, 40);

// Outputs
const fmt = n => (n == null ? "-" : n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(Math.round(n)));
const f2 = n => (n == null ? "-" : n.toFixed(2));
const csvCols = ["rank", "entity", "type", "score", "score_demand-heavy", "score_recency-heavy", "score_headroom-heavy", "score_equal", "videos", "channels", "median_views", "max_views", "top_title", "sum_views", "recent_videos", "recent_views", "median_views_per_day", "strong_videos", "like_ratio", "enriched_share"];
const esc = x => (x == null ? "" : typeof x === "number" ? (Number.isInteger(x) ? String(x) : x.toFixed(4)) : `"${String(x).replace(/"/g, '""')}"`);
const csv = [csvCols.join(","), ...eligible.map((r, i) => csvCols.map(c => esc(c === "rank" ? i + 1 : r[c])).join(","))].join("\n");
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(path.join(outDir, `${S}-topics.csv`), csv + "\n");

const typeSummary = [...new Set(eligible.map(r => r.type))].map(t => {
  const rs = eligible.filter(r => r.type === t);
  return { t, n: rs.length, score: median(rs.map(r => r.score)), median_views: median(rs.map(r => r.median_views)), max: median(rs.map(r => r.max_views)) };
}).sort((a, b) => b.score - a.score);

const lines = [
  `# Topic study: ${spec.name}`, "",
  `_${videos.length} videos searched, ${onTopic.length} on-topic (collapse framing and a named subject), ${eligible.length} subjects with at least two on-topic videos. Views are search-page counts unless the video was enriched. Likes exist only for enriched videos._`, "",
  "## What the score means", "",
  `Score = demand ${BASE.demand} + recency ${BASE.recency} + engagement ${BASE.engagement} + headroom ${BASE.headroom}. Each part is a percentile within the eligible subjects.`,
  "- **Demand**: median views (0.5), best single video (0.3), total views (0.2). How much the subject draws.",
  "- **Recency**: views from the last 12 months (0.6) and median views per day (0.4). Whether interest is current.",
  "- **Engagement**: median likes per view, enriched videos only. Neutral when there are no likes.",
  "- **Headroom**: total demand, plus the share of videos that did not reach 100K views. High demand with few strong videos is a gap.",
  "- **Attribution**: each video counts once, for the subject its title names first.",
  "- **Shares are not measured.** The YouTube data here does not expose shares. Likes are the nearest engagement signal.", "",
  "## Top 25 subjects", "",
  "| # | Subject | Type | Score | Videos | Channels | Median views | Best video | Recent views (12 mo) | Views/day (median) | Like ratio | Strong (100K+) |",
  "|---|---|---|---|---|---|---|---|---|---|---|---|",
  ...eligible.slice(0, 25).map((r, i) => `| ${i + 1} | ${r.entity} | ${r.type} | ${f2(r.score)} | ${r.videos} | ${r.channels} | ${fmt(r.median_views)} | ${fmt(r.max_views)} | ${fmt(r.recent_views)} | ${fmt(r.median_views_per_day)} | ${r.like_ratio == null ? "-" : `${(r.like_ratio * 100).toFixed(2)}%`} | ${r.strong_videos} |`),
  "", "## Bottom 10 subjects", "",
  "| # | Subject | Type | Score | Videos | Median views | Best video |", "|---|---|---|---|---|---|---|",
  ...eligible.slice(-10).map((r, i) => `| ${eligible.length - 9 + i} | ${r.entity} | ${r.type} | ${f2(r.score)} | ${r.videos} | ${fmt(r.median_views)} | ${fmt(r.max_views)} |`),
  "", "## By subject type", "",
  "| Type | Subjects | Median score | Median of subject medians | Median best video |", "|---|---|---|---|---|",
  ...typeSummary.map(t => `| ${t.t} | ${t.n} | ${f2(t.score)} | ${fmt(t.median_views)} | ${fmt(t.max)} |`),
  "", "## Title frame (on-topic videos)", "",
  "| Frame | Videos | Median views |", "|---|---|---|",
  ...frameRows.map(f => `| ${f.frame} | ${f.n} | ${fmt(f.median_views)} |`),
  "", "## Robustness: top 10 under different weights", "",
  "| Weights | Top 10 |", "|---|---|",
  ...SENSITIVITY.map(w => `| ${w.name} (demand ${w.demand}, recency ${w.recency}, engagement ${w.engagement}, headroom ${w.headroom}) | ${top10Under(w).join("; ")} |`),
  "", "## Next round: subjects in titles but not in the list", "",
  "Named in at least three on-topic titles and not yet searched. Add the real ones to the spec and rerun search.", "",
  "| Name guessed from title | On-topic videos | Total views | Example title |", "|---|---|---|---|",
  ...nextRound.map(x => `| ${x.key} | ${x.videos} | ${fmt(x.views)} | ${x.example.replace(/\|/g, "/")} |`),
  "", `_Enriched videos: ${videos.filter(v => v.enriched).length} of ${videos.length}._`
];
const mdPath = path.join(outDir, `${S}-topics.md`);
fs.writeFileSync(mdPath, lines.join("\n") + "\n");
console.log(`topic study: ${eligible.length} subjects scored from ${onTopic.length} on-topic videos`);
console.log(`report: ${mdPath}`);

Shown as it is in the repository at build time (2026-11-03); the home directory appears as <home>. If this page and the file differ, the file is right. Back to the catalogue.