SPB Git forge
7commits 1branches 0releases
229.0 KBsize
maindefault branch
12 days agolast push
TypeScript 91.8% HTML 3.2% JavaScript 3% SQL 1.4% CSS 0.7%

Research console (Next 16) + control-plane API + mld deployment for www.socialcrawl.co

- apps/dashboard: SocialCrawl console — observatory, sessions console (decisions, page state,
  entities, runtime APIs, media, world-model graph, live events), entity evidence pages,
  runtime API explorer, connectors, missions launcher; passcode gate + authenticated API proxy.
- apps/api: node:http control plane (overview, sessions, entities search, schemas, platforms,
  SSE stream, job runner spawning worker processes), token-protected mutations.
- worker: bounded action execution (60 s), PLAY_VIDEO no longer awaits play(); --job linking.
- storage: jsonb sanitiser, linkJob; util: unicode thousands separators, stopwords.
- deploy/social-runtime-crawler.mld.json: PM2 api 8350 + web 8351, MacLustr Tunnel route.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 12 days ago (Sep 12, 2026) parent 40fba6b

46 changed files +7,607 −105

modified .gitignore +5 −0
@@ -14,3 +14,8 @@ data/platform_model/*/
14 14
15 15 # Compiled (learned) connectors are versioned intentionally; regression fixtures may be large.
16 16 connectors/*/regression_tests/large/
17 +
18 +# Next.js dashboard
19 +apps/dashboard/.next/
20 +apps/dashboard/next-env.d.ts
21 +*.tsbuildinfo
modified apps/api/package.json +5 −1
@@ -5,6 +5,10 @@
5 5 "type": "module",
6 6 "dependencies": {
7 7 "@src/shared": "workspace:*",
8 − "@src/storage": "workspace:*"
8 + "@src/storage": "workspace:*",
9 + "@src/platform-model": "workspace:*"
10 + },
11 + "scripts": {
12 + "start": "tsx src/server.ts"
9 13 }
10 14 }
added apps/api/src/jobs.ts +102 −0
@@ -0,0 +1,102 @@
1 +import { spawn, type ChildProcess } from "node:child_process";
2 +import fs from "node:fs";
3 +import path from "node:path";
4 +import { fileURLToPath } from "node:url";
5 +import { createLogger, isPlatform, newId, type AgentMode, type AppConfig } from "@src/shared";
6 +import type { PostgresStore } from "@src/storage";
7 +
8 +const log = createLogger("jobs");
9 +const here = path.dirname(fileURLToPath(import.meta.url));
10 +const repoRoot = path.resolve(here, "..", "..", "..");
11 +
12 +export interface JobRequest {
13 + platform: string;
14 + goal?: string;
15 + query?: string;
16 + mode?: AgentMode;
17 + seed?: string;
18 + minutes?: number;
19 + actions?: number;
20 + media?: number;
21 + headless?: boolean;
22 + account?: string;
23 +}
24 +
25 +/**
26 + * Job runner (§63 control plane, single-node version): spawns the worker CLI as a child process,
27 + * streams its log to data/jobs/<id>.log and tracks status in crawl_jobs. One browser per job.
28 + */
29 +export class JobRunner {
30 + private running = new Map<string, ChildProcess>();
31 + readonly logsDir: string;
32 + constructor(private readonly store: PostgresStore, private readonly cfg: AppConfig, private readonly maxConcurrent = 2) {
33 + this.logsDir = path.join(cfg.dataDir, "jobs");
34 + fs.mkdirSync(this.logsDir, { recursive: true });
35 + }
36 +
37 + get activeCount() {
38 + return this.running.size;
39 + }
40 +
41 + async start(req: JobRequest): Promise<{ job_id: string }> {
42 + if (!isPlatform(req.platform)) throw new Error("unknown platform");
43 + if (!["youtube", "reddit"].includes(req.platform)) throw new Error(`no adapter for ${req.platform} yet (Phase 1 = youtube, reddit)`);
44 + if (this.running.size >= this.maxConcurrent) throw new Error(`max ${this.maxConcurrent} concurrent jobs`);
45 + const job_id = newId("job");
46 + const mode = req.mode ?? "research";
47 + const minutes = clamp(req.minutes ?? 10, 1, 120);
48 + const actions = clamp(req.actions ?? 60, 1, 1000);
49 + const media = clamp(req.media ?? 1, 0, 4);
50 + const goal = req.goal?.trim() || (req.query ? `Discover public content about “${req.query}”` : `Observe the ${req.platform} feed`);
51 + const args = ["node_modules/tsx/dist/cli.mjs", "apps/worker/src/cli.ts", mode === "learn" ? "learn" : "crawl", req.platform, "--job", job_id, "--goal", goal, "--mode", mode, "--minutes", String(minutes), "--actions", String(actions), "--media", String(media), "--account", req.account?.trim() || "research-01"];
52 + if (req.query) args.push("--query", req.query);
53 + if (req.seed) args.push("--seed", req.seed);
54 + if (req.headless !== false) args.push("--headless");
55 +
56 + await this.store.pool.query(`insert into crawl_jobs(job_id, platform, mode, goal, budget, status) values ($1,$2,$3,$4,$5,'queued')`, [job_id, req.platform, mode, goal, JSON.stringify({ minutes, actions, media, query: req.query ?? null, seed: req.seed ?? null, headless: req.headless !== false, account: req.account ?? "research-01" })]);
57 +
58 + const logFile = path.join(this.logsDir, `${job_id}.log`);
59 + const out = fs.openSync(logFile, "a");
60 + const child = spawn(process.execPath, args, { cwd: repoRoot, env: { ...process.env, SRC_LOG_PRETTY: "1", SRC_LOG_LEVEL: "info" }, stdio: ["ignore", out, out] });
61 + this.running.set(job_id, child);
62 + await this.store.pool.query(`update crawl_jobs set status='running' where job_id=$1`, [job_id]);
63 + log.info("job started", { job_id, platform: req.platform, mode, pid: child.pid });
64 + child.on("exit", async (code, signal) => {
65 + this.running.delete(job_id);
66 + fs.closeSync(out);
67 + const status = signal ? "stopped" : code === 0 ? "done" : "failed";
68 + let result: unknown = null;
69 + try {
70 + const sid = (await this.store.pool.query(`select session_id from crawl_jobs where job_id=$1`, [job_id])).rows[0]?.session_id as string | undefined;
71 + if (sid) {
72 + const f = path.join(this.cfg.sessionsDir, sid, "summary.json");
73 + if (fs.existsSync(f)) result = JSON.parse(fs.readFileSync(f, "utf8"));
74 + }
75 + } catch {
76 + /* ignore */
77 + }
78 + await this.store.pool.query(`update crawl_jobs set status=$2, finished_at=now(), result=$3 where job_id=$1`, [job_id, status, result ? JSON.stringify(result) : null]).catch(() => {});
79 + log.info("job finished", { job_id, status, code, signal });
80 + });
81 + return { job_id };
82 + }
83 +
84 + stop(job_id: string): boolean {
85 + const child = this.running.get(job_id);
86 + if (!child) return false;
87 + child.kill("SIGTERM");
88 + setTimeout(() => child.kill("SIGKILL"), 15_000).unref();
89 + return true;
90 + }
91 +
92 + logTail(job_id: string, lines = 200): string {
93 + const f = path.join(this.logsDir, `${job_id}.log`);
94 + if (!fs.existsSync(f)) return "";
95 + const all = fs.readFileSync(f, "utf8").split("\n");
96 + return all.slice(-lines).join("\n");
97 + }
98 +}
99 +
100 +function clamp(n: number, lo: number, hi: number) {
101 + return Math.max(lo, Math.min(hi, Number.isFinite(n) ? n : lo));
102 +}
added apps/api/src/queries.ts +188 −0
@@ -0,0 +1,188 @@
1 +import fs from "node:fs";
2 +import path from "node:path";
3 +import type { PostgresStore } from "@src/storage";
4 +import { PLATFORMS, type AppConfig, type Platform } from "@src/shared";
5 +import { PlatformModel } from "@src/platform-model";
6 +
7 +/** Read-side queries for the research console. All read PostgreSQL; files (models, manifests, session dirs) complete them. */
8 +export class Queries {
9 + constructor(private readonly store: PostgresStore, private readonly cfg: AppConfig) {}
10 + private get q() {
11 + return this.store.pool;
12 + }
13 +
14 + async overview() {
15 + const [totals, byType, running, recentSessions, series, surfaces, topActions, patterns] = await Promise.all([
16 + this.q.query(`select
17 + (select count(*) from sessions)::int as sessions,
18 + (select count(*) from entities)::int as entities,
19 + (select count(*) from media)::int as media,
20 + (select count(*) from schema_patterns)::int as schemas,
21 + (select count(*) from actions)::int as actions,
22 + (select count(*) from observations)::int as observations,
23 + (select count(*) from relationships)::int as relationships,
24 + (select count(*) from observations where event_type='CONNECTOR_PATTERN_LEARNED')::int as patterns_learned,
25 + (select count(*) from feed_items)::int as feed_items`),
26 + this.q.query(`select platform, entity_type, count(*)::int as n from entities group by 1,2 order by 3 desc`),
27 + this.q.query(`select session_id, platform, account_alias, started_at, health from sessions where ended_at is null and started_at > now() - interval '6 hours' order by started_at desc`),
28 + this.q.query(`select s.session_id, s.platform, s.goal, s.mode, s.started_at, s.ended_at, s.health,
29 + (select count(*) from actions a where a.session_id=s.session_id)::int as actions,
30 + (select count(distinct fingerprint) from entity_observations eo where eo.session_id=s.session_id)::int as entities
31 + from sessions s order by started_at desc limit 8`),
32 + this.q.query(`select date_trunc('hour', ts) + (floor(extract(minute from ts)/10)*10) * interval '1 minute' as bucket,
33 + count(*) filter (where event_type in ('VIDEO_DISCOVERED','POST_DISCOVERED','PROFILE_DISCOVERED','ENTITY_DISCOVERED'))::int as entities,
34 + count(*) filter (where event_type='NETWORK_RESPONSE_OBSERVED')::int as responses,
35 + count(*) filter (where event_type in ('ACTION_EXECUTED','ACTION_FAILED'))::int as actions,
36 + count(*) filter (where event_type='NETWORK_SCHEMA_DISCOVERED')::int as schemas
37 + from observations where ts > now() - interval '48 hours' group by 1 order by 1`),
38 + this.q.query(`select coalesce(sum((payload->>'dom_entities')::int),0)::int as dom, coalesce(sum((payload->>'network_entities')::int),0)::int as network,
39 + coalesce(sum((payload->>'both_surfaces')::int),0)::int as both, coalesce(sum((payload->>'field_agreements')::int),0)::int as agreements,
40 + coalesce(sum(jsonb_array_length(coalesce(payload->'field_conflicts','[]'::jsonb))),0)::int as conflicts
41 + from observations where event_type='PAGE_OPENED' and payload ? 'dom_entities'`),
42 + this.q.query(`select action_type, count(*)::int as n, avg(expected_gain)::float as avg_gain, avg(case when success then 1 else 0 end)::float as success_rate,
43 + avg((after_state->>'new_entities')::float) as avg_new_entities from actions group by 1 order by 2 desc`),
44 + this.q.query(`select platform, count(*)::int as n, avg((payload->>'confidence')::float) as conf from observations where event_type='CONNECTOR_PATTERN_LEARNED' group by 1`),
45 + ]);
46 + return {
47 + totals: totals.rows[0],
48 + by_type: byType.rows,
49 + running: running.rows,
50 + recent_sessions: recentSessions.rows,
51 + series: series.rows,
52 + surfaces: surfaces.rows[0],
53 + actions: topActions.rows,
54 + patterns_by_platform: patterns.rows,
55 + platforms: this.platforms(),
56 + };
57 + }
58 +
59 + platforms() {
60 + return PLATFORMS.map((p) => {
61 + const file = path.join(this.cfg.platformModelDir, p, "platform_model.json");
62 + const manifest = path.join(process.cwd(), "connectors", p, "manifest.json");
63 + if (!fs.existsSync(file)) return { platform: p, learned: false, adapter: p === "youtube" || p === "reddit", confidence: 0, page_types: 0, entity_types: 0, navigation_actions: 0, network_schemas: 0, media_patterns: 0, sessions: 0, has_manifest: fs.existsSync(manifest), profiles: this.profilesFor(p) };
64 + const m = new PlatformModel(p, this.cfg.platformModelDir);
65 + return { learned: true, adapter: p === "youtube" || p === "reddit", has_manifest: fs.existsSync(manifest), profiles: this.profilesFor(p), ...m.summary() };
66 + });
67 + }
68 +
69 + profilesFor(p: Platform) {
70 + if (!fs.existsSync(this.cfg.profilesDir)) return [];
71 + return fs
72 + .readdirSync(this.cfg.profilesDir)
73 + .filter((d) => d.startsWith(`${p}-`))
74 + .map((d) => ({ alias: d.slice(p.length + 1), has_state: fs.existsSync(path.join(this.cfg.profilesDir, d, "Default", "Cookies")) || fs.existsSync(path.join(this.cfg.profilesDir, d, "Default", "Network", "Cookies")) }));
75 + }
76 +
77 + platformDetail(p: Platform) {
78 + const file = path.join(this.cfg.platformModelDir, p, "platform_model.json");
79 + const manifest = path.join(process.cwd(), "connectors", p, "manifest.json");
80 + return {
81 + platform: p,
82 + model: fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : null,
83 + manifest: fs.existsSync(manifest) ? JSON.parse(fs.readFileSync(manifest, "utf8")) : null,
84 + summary: fs.existsSync(file) ? new PlatformModel(p, this.cfg.platformModelDir).summary() : null,
85 + profiles: this.profilesFor(p),
86 + };
87 + }
88 +
89 + async sessions(limit = 100) {
90 + return (
91 + await this.q.query(
92 + `select s.*, (select count(*) from actions a where a.session_id=s.session_id)::int as actions,
93 + (select count(distinct fingerprint) from entity_observations eo where eo.session_id=s.session_id)::int as entities,
94 + (select count(*) from observations o where o.session_id=s.session_id and o.event_type='NETWORK_SCHEMA_DISCOVERED')::int as schemas,
95 + (select count(*) from observations o where o.session_id=s.session_id and o.event_type='CONNECTOR_PATTERN_LEARNED')::int as patterns,
96 + (select payload->>'reason' from observations o where o.session_id=s.session_id and o.event_type='BUDGET_EXHAUSTED' limit 1) as ended_because
97 + from sessions s order by started_at desc limit $1`,
98 + [limit],
99 + )
100 + ).rows;
101 + }
102 +
103 + async session(id: string) {
104 + const row = (await this.q.query(`select * from sessions where session_id=$1`, [id])).rows[0];
105 + if (!row) return null;
106 + const dir = path.join(this.cfg.sessionsDir, id);
107 + const read = (f: string) => {
108 + try {
109 + return JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
110 + } catch {
111 + return null;
112 + }
113 + };
114 + const counts = (await this.q.query(`select event_type, count(*)::int as n from observations where session_id=$1 group by 1`, [id])).rows;
115 + return { ...row, job: read("job.json"), summary: read("summary.json"), event_counts: counts };
116 + }
117 +
118 + async pages(id: string) {
119 + return (await this.q.query(`select step, ts, payload from observations where session_id=$1 and event_type='PAGE_OPENED' and payload ? 'page_type' order by ts`, [id])).rows;
120 + }
121 +
122 + async world(id: string) {
123 + const dir = path.join(this.cfg.sessionsDir, id, "world_model.json");
124 + if (fs.existsSync(dir)) return JSON.parse(fs.readFileSync(dir, "utf8"));
125 + // Reconstruct from the database for sessions crawled on another machine.
126 + const nodes = (await this.q.query(`select e.fingerprint, e.entity_type as type, e.name, e.url, e.platform_id, min(eo.step) as first_seen_step, max(eo.step) as last_seen_step, count(*)::int as seen_count from entities e join entity_observations eo on eo.fingerprint=e.fingerprint where eo.session_id=$1 group by e.fingerprint limit 1500`, [id])).rows;
127 + const edges = (await this.q.query(`select from_fp as "from", to_fp as "to", rel_type as type, step from relationships where session_id=$1 limit 4000`, [id])).rows;
128 + return { nodes, edges };
129 + }
130 +
131 + async searchEntities(o: { q?: string; type?: string; platform?: string; limit: number; offset: number; sort?: string }) {
132 + const params: unknown[] = [];
133 + const where: string[] = [];
134 + if (o.q) {
135 + params.push(`%${o.q}%`);
136 + where.push(`(name ilike $${params.length} or text_excerpt ilike $${params.length} or author ilike $${params.length} or platform_id ilike $${params.length})`);
137 + }
138 + if (o.type) {
139 + params.push(o.type);
140 + where.push(`entity_type = $${params.length}`);
141 + }
142 + if (o.platform) {
143 + params.push(o.platform);
144 + where.push(`platform = $${params.length}`);
145 + }
146 + const w = where.length ? `where ${where.join(" and ")}` : "";
147 + const order = o.sort === "views" ? `(metrics->>'views')::numeric desc nulls last` : o.sort === "seen" ? `seen_count desc` : `last_seen desc`;
148 + params.push(o.limit, o.offset);
149 + const rows = (await this.q.query(`select *, (select count(*) from entity_observations eo where eo.fingerprint=entities.fingerprint)::int as observations from entities ${w} order by ${order} limit $${params.length - 1} offset $${params.length}`, params)).rows;
150 + const total = (await this.q.query(`select count(*)::int as n from entities ${w}`, params.slice(0, -2))).rows[0]?.n ?? 0;
151 + return { rows, total };
152 + }
153 +
154 + async entity(fp: string) {
155 + const e = (await this.q.query(`select * from entities where fingerprint=$1`, [fp])).rows[0];
156 + if (!e) return null;
157 + const observations = (await this.q.query(`select eo.session_id, eo.step, eo.ts, eo.surfaces, eo.snapshot, s.goal from entity_observations eo left join sessions s on s.session_id=eo.session_id where eo.fingerprint=$1 order by eo.ts desc limit 100`, [fp])).rows;
158 + const relations = (await this.q.query(`select r.rel_type, r.from_fp, r.to_fp, r.step, coalesce(a.name, a.platform_id) as from_name, coalesce(b.name, b.platform_id) as to_name, a.entity_type as from_type, b.entity_type as to_type from relationships r left join entities a on a.fingerprint=r.from_fp left join entities b on b.fingerprint=r.to_fp where r.from_fp=$1 or r.to_fp=$1 limit 200`, [fp])).rows;
159 + const media = (await this.q.query(`select * from media where fingerprint=$1`, [fp])).rows[0] ?? null;
160 + const feed = (await this.q.query(`select session_id, step, page_type, feed_position, visible, ts from feed_items where fingerprint=$1 order by ts desc limit 50`, [fp])).rows;
161 + return { entity: e, observations, relations, media, feed };
162 + }
163 +
164 + async schemas(platform?: string) {
165 + const params: unknown[] = [];
166 + let w = "";
167 + if (platform) {
168 + params.push(platform);
169 + w = `where platform=$1`;
170 + }
171 + return (await this.q.query(`select * from schema_patterns ${w} order by observed_count desc limit 300`, params)).rows;
172 + }
173 +
174 + async liveEvents(since: string, session?: string, limit = 200) {
175 + const params: unknown[] = [since];
176 + let w = `ts > $1`;
177 + if (session) {
178 + params.push(session);
179 + w += ` and session_id=$2`;
180 + }
181 + params.push(limit);
182 + return (await this.q.query(`select event_id, session_id, platform, event_type, step, ts, payload, provenance from observations where ${w} order by ts asc limit $${params.length}`, params)).rows;
183 + }
184 +
185 + async jobs(limit = 50) {
186 + return (await this.q.query(`select j.*, s.health, (select count(*) from actions a where a.session_id=j.session_id)::int as actions, (select count(distinct fingerprint) from entity_observations eo where eo.session_id=j.session_id)::int as entities from crawl_jobs j left join sessions s on s.session_id=j.session_id order by created_at desc limit $1`, [limit])).rows;
187 + }
188 +}
modified apps/api/src/server.ts +115 −74
@@ -2,108 +2,149 @@ import http from "node:http";
2 2 import fs from "node:fs";
3 3 import path from "node:path";
4 4 import { fileURLToPath } from "node:url";
5 −import { createLogger, loadConfig } from "@src/shared";
6 −import { JsonlEventLog } from "@src/events";
5 +import { createLogger, isPlatform, loadConfig } from "@src/shared";
7 6 import { PostgresStore } from "@src/storage";
7 +import { Queries } from "./queries.ts";
8 +import { JobRunner } from "./jobs.ts";
8 9
9 10 /**
10 − * Research/Debug dashboard API (§53, §82). Zero framework: node:http + a static page.
11 − * Reads PostgreSQL when configured, else the JSONL session logs.
11 + * Control-plane + research API (§53, §63, §82). Plain node:http, JSON, SSE.
12 + * Consumed by the Next.js console (apps/dashboard) through its authenticated proxy; mutations require SRC_API_TOKEN.
12 13 */
13 −const log = createLogger("dashboard");
14 +const log = createLogger("api");
14 15 const cfg = loadConfig();
15 16 const here = path.dirname(fileURLToPath(import.meta.url));
16 17 const publicDir = path.join(here, "..", "public");
17 −let store: PostgresStore | undefined;
18 −try {
19 − if (cfg.databaseUrl) store = await PostgresStore.connect(cfg.databaseUrl);
20 −} catch (err) {
21 − log.warn("no database — serving JSONL logs", { err: (err as Error).message });
18 +const API_TOKEN = process.env.SRC_API_TOKEN ?? "";
19 +const PORT = Number(process.env.SRC_API_PORT ?? cfg.dashboardPort);
20 +
21 +if (!cfg.databaseUrl) {
22 + console.error("SRC_DATABASE_URL is required for the API");
23 + process.exit(1);
22 24 }
25 +const store = await PostgresStore.connect(cfg.databaseUrl);
26 +await store.migrate();
27 +const queries = new Queries(store, cfg);
28 +const jobs = new JobRunner(store, cfg, Number(process.env.SRC_MAX_JOBS ?? 2));
23 29
24 30 function json(res: http.ServerResponse, data: unknown, status = 200) {
25 − res.writeHead(status, { "content-type": "application/json; charset=utf-8", "access-control-allow-origin": "*" });
31 + res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
26 32 res.end(JSON.stringify(data));
27 33 }
28 −
29 −function sessionDirs(): string[] {
30 − return fs.existsSync(cfg.sessionsDir) ? fs.readdirSync(cfg.sessionsDir).filter((d) => fs.existsSync(path.join(cfg.sessionsDir, d, "events.jsonl"))).sort().reverse() : [];
31 −}
32 −
33 −function jsonlSessions() {
34 − return sessionDirs().map((id) => {
35 − const job = readJson(path.join(cfg.sessionsDir, id, "job.json")) as Record<string, unknown> | undefined;
36 − const summary = readJson(path.join(cfg.sessionsDir, id, "summary.json")) as Record<string, unknown> | undefined;
37 − return { session_id: id, platform: job?.platform, mode: job?.mode, goal: job?.goal, account_alias: job?.account_alias, started_at: fs.statSync(path.join(cfg.sessionsDir, id)).birthtime, health: summary ? "stopped" : "running", actions: summary?.steps ?? null, entities: summary?.entities ?? null, ended_because: summary?.ended_because };
34 +function readBody(req: http.IncomingMessage): Promise<string> {
35 + return new Promise((resolve, reject) => {
36 + let s = "";
37 + req.on("data", (c) => {
38 + s += c;
39 + if (s.length > 1e6) reject(new Error("body too large"));
40 + });
41 + req.on("end", () => resolve(s));
42 + req.on("error", reject);
38 43 });
39 44 }
40 −
41 −function readJson(file: string): unknown {
42 − try {
43 − return JSON.parse(fs.readFileSync(file, "utf8"));
44 − } catch {
45 − return undefined;
46 − }
45 +function authorized(req: http.IncomingMessage): boolean {
46 + if (!API_TOKEN) return true; // dev
47 + return req.headers["x-src-token"] === API_TOKEN;
47 48 }
48 49
49 50 const server = http.createServer(async (req, res) => {
50 51 const url = new URL(req.url ?? "/", "http://localhost");
51 52 const parts = url.pathname.split("/").filter(Boolean);
53 + const method = req.method ?? "GET";
52 54 try {
53 − if (parts[0] === "api") {
54 − if (parts[1] === "sessions" && !parts[2]) return json(res, store ? await store.listSessions() : jsonlSessions());
55 − if (parts[1] === "sessions" && parts[2]) {
56 − const id = parts[2];
57 − const dir = path.join(cfg.sessionsDir, id);
58 − const sub = parts[3];
59 − if (sub === "events") {
60 − const types = url.searchParams.get("types")?.split(",").filter(Boolean);
61 − const limit = Number(url.searchParams.get("limit") ?? 300);
62 − if (store) return json(res, await store.sessionEvents(id, { types, limit }));
63 − const evs = JsonlEventLog.read(dir).filter((e) => !types?.length || types.includes(e.event_type));
64 − return json(res, evs.slice(-limit).reverse());
65 − }
66 − if (sub === "actions") return json(res, store ? await store.sessionActions(id) : JsonlEventLog.read(dir).filter((e) => e.event_type === "ACTION_PLANNED").map((e) => ({ step: e.step, ...(e.payload as Record<string, unknown>) })));
67 − if (sub === "entities") {
68 − if (store) return json(res, await store.sessionEntities(id));
69 − const seen = new Map<string, unknown>();
70 − for (const e of JsonlEventLog.read(dir)) if (/DISCOVERED|ENTITY_OBSERVED/.test(e.event_type) && (e.payload as { entity?: { fingerprint: string } }).entity) seen.set((e.payload as { entity: { fingerprint: string } }).entity.fingerprint, { ...(e.payload as { entity: object }).entity, last_step: e.step, provenance: e.provenance });
71 − return json(res, [...seen.values()].reverse());
72 − }
73 − if (sub === "media") return json(res, store ? await store.sessionMedia(id) : JsonlEventLog.read(dir).filter((e) => e.event_type === "MEDIA_DISCOVERED").map((e) => e.payload));
74 − if (sub === "world") return json(res, readJson(path.join(dir, "world_model.json")) ?? { nodes: [], edges: [] });
75 − if (sub === "summary") return json(res, { job: readJson(path.join(dir, "job.json")), summary: readJson(path.join(dir, "summary.json")) });
76 − if (sub === "schemas") {
77 − const platform = (readJson(path.join(dir, "job.json")) as { platform?: string } | undefined)?.platform;
78 − if (store) return json(res, await store.schemas(platform));
79 − return json(res, JsonlEventLog.read(dir).filter((e) => e.event_type === "NETWORK_SCHEMA_DISCOVERED").map((e) => e.payload));
55 + if (parts[0] !== "api" && parts[0] !== "media") {
56 + // legacy static console (apps/api/public) — kept for local debugging
57 + const file = path.join(publicDir, parts.length ? parts.join("/") : "index.html");
58 + if (!file.startsWith(publicDir) || !fs.existsSync(file)) return json(res, { error: "not found" }, 404);
59 + res.writeHead(200, { "content-type": file.endsWith(".html") ? "text/html; charset=utf-8" : "application/octet-stream" });
60 + return fs.createReadStream(file).pipe(res);
61 + }
62 + if (parts[0] === "media") {
63 + const file = path.join(cfg.mediaDir, ...parts.slice(1).map(decodeURIComponent));
64 + if (!file.startsWith(cfg.mediaDir) || !fs.existsSync(file)) return json(res, { error: "not found" }, 404);
65 + res.writeHead(200, { "content-type": "image/jpeg", "cache-control": "public, max-age=86400" });
66 + return fs.createReadStream(file).pipe(res);
67 + }
68 + if (method !== "GET" && !authorized(req)) return json(res, { error: "unauthorized" }, 401);
69 + const [, r1, r2, r3] = parts;
70 +
71 + if (r1 === "health") return json(res, { ok: true, jobs_running: jobs.activeCount, db: true, ts: new Date().toISOString() });
72 + if (r1 === "overview") return json(res, await queries.overview());
73 + if (r1 === "platforms" && !r2) return json(res, queries.platforms());
74 + if (r1 === "platforms" && r2) return isPlatform(r2) ? json(res, queries.platformDetail(r2)) : json(res, { error: "unknown platform" }, 404);
75 + if (r1 === "sessions" && !r2) return json(res, await queries.sessions(Number(url.searchParams.get("limit") ?? 100)));
76 + if (r1 === "sessions" && r2) {
77 + const id = r2;
78 + if (!r3) {
79 + const s = await queries.session(id);
80 + return s ? json(res, s) : json(res, { error: "not found" }, 404);
81 + }
82 + if (r3 === "events") return json(res, await store.sessionEvents(id, { types: url.searchParams.get("types")?.split(",").filter(Boolean), limit: Number(url.searchParams.get("limit") ?? 300) }));
83 + if (r3 === "actions") return json(res, await store.sessionActions(id));
84 + if (r3 === "entities") return json(res, await store.sessionEntities(id, Number(url.searchParams.get("limit") ?? 500)));
85 + if (r3 === "media") return json(res, await store.sessionMedia(id));
86 + if (r3 === "pages") return json(res, await queries.pages(id));
87 + if (r3 === "world") return json(res, await queries.world(id));
88 + if (r3 === "schemas") {
89 + const s = await queries.session(id);
90 + return json(res, await queries.schemas(s?.platform));
91 + }
92 + if (r3 === "relationships") return json(res, await store.sessionRelationships(id));
93 + }
94 + if (r1 === "entities" && !r2) return json(res, await queries.searchEntities({ q: url.searchParams.get("q") ?? undefined, type: url.searchParams.get("type") ?? undefined, platform: url.searchParams.get("platform") ?? undefined, sort: url.searchParams.get("sort") ?? undefined, limit: Math.min(200, Number(url.searchParams.get("limit") ?? 50)), offset: Number(url.searchParams.get("offset") ?? 0) }));
95 + if (r1 === "entities" && r2) {
96 + const e = await queries.entity(decodeURIComponent(r2));
97 + return e ? json(res, e) : json(res, { error: "not found" }, 404);
98 + }
99 + if (r1 === "schemas") return json(res, await queries.schemas(url.searchParams.get("platform") ?? undefined));
100 + if (r1 === "stats") return json(res, await store.entityStats());
101 +
102 + if (r1 === "stream") {
103 + // Server-Sent Events: poll the observations table and push new rows (§53 live panels).
104 + res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive", "x-accel-buffering": "no" });
105 + let since = url.searchParams.get("since") ?? new Date(Date.now() - 60_000).toISOString();
106 + const session = url.searchParams.get("session") ?? undefined;
107 + const types = url.searchParams.get("types")?.split(",").filter(Boolean);
108 + let alive = true;
109 + req.on("close", () => (alive = false));
110 + res.write(`event: hello\ndata: ${JSON.stringify({ since })}\n\n`);
111 + while (alive) {
112 + try {
113 + const rows = await queries.liveEvents(since, session, 200);
114 + for (const row of rows) {
115 + since = new Date(row.ts).toISOString();
116 + if (types && !types.includes(row.event_type)) continue;
117 + res.write(`event: observation\ndata: ${JSON.stringify(row)}\n\n`);
118 + }
119 + res.write(`: ping ${Date.now()}\n\n`);
120 + } catch (err) {
121 + res.write(`event: error\ndata: ${JSON.stringify({ error: (err as Error).message })}\n\n`);
80 122 }
123 + await new Promise((r) => setTimeout(r, 1500));
81 124 }
82 − if (parts[1] === "platform-model" && parts[2]) {
83 − const f = path.join(cfg.platformModelDir, parts[2], "platform_model.json");
84 − return json(res, readJson(f) ?? { error: "no model yet" });
125 + return res.end();
126 + }
127 +
128 + if (r1 === "jobs" && method === "GET" && !r2) return json(res, await queries.jobs());
129 + if (r1 === "jobs" && method === "POST" && !r2) {
130 + const body = JSON.parse((await readBody(req)) || "{}");
131 + try {
132 + return json(res, await jobs.start(body), 201);
133 + } catch (err) {
134 + return json(res, { error: (err as Error).message }, 400);
85 135 }
86 − if (parts[1] === "entity" && parts[2]) return json(res, store ? await store.entity(decodeURIComponent(parts[2])) : { error: "needs database" });
87 − if (parts[1] === "stats") return json(res, store ? await store.entityStats() : []);
88 − return json(res, { error: "not found" }, 404);
89 136 }
90 − if (parts[0] === "media") {
91 − // serve sampled frames
92 − const file = path.join(cfg.mediaDir, ...parts.slice(1));
93 − if (!file.startsWith(cfg.mediaDir) || !fs.existsSync(file)) return json(res, { error: "not found" }, 404);
94 − res.writeHead(200, { "content-type": "image/jpeg" });
95 − fs.createReadStream(file).pipe(res);
96 − return;
137 + if (r1 === "jobs" && r2 && r3 === "log") {
138 + res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
139 + return res.end(jobs.logTail(r2, Number(url.searchParams.get("lines") ?? 200)));
97 140 }
98 − const file = path.join(publicDir, parts.length ? parts.join("/") : "index.html");
99 − if (!file.startsWith(publicDir) || !fs.existsSync(file)) return json(res, { error: "not found" }, 404);
100 − const ext = path.extname(file);
101 − res.writeHead(200, { "content-type": ext === ".html" ? "text/html; charset=utf-8" : ext === ".js" ? "text/javascript" : ext === ".css" ? "text/css" : "application/octet-stream" });
102 − fs.createReadStream(file).pipe(res);
141 + if (r1 === "jobs" && r2 && r3 === "stop" && method === "POST") return json(res, { stopped: jobs.stop(r2) });
142 +
143 + return json(res, { error: "not found" }, 404);
103 144 } catch (err) {
104 145 log.error("request failed", { url: req.url, err: (err as Error).message });
105 146 json(res, { error: (err as Error).message }, 500);
106 147 }
107 148 });
108 149
109 −server.listen(cfg.dashboardPort, () => log.info(`dashboard → http://localhost:${cfg.dashboardPort} (${store ? "postgres" : "jsonl"})`));
150 +server.listen(PORT, "127.0.0.1", () => log.info(`api → http://127.0.0.1:${PORT} (token ${API_TOKEN ? "on" : "off"})`));
added apps/dashboard/next.config.ts +13 −0
@@ -0,0 +1,13 @@
1 +import type { NextConfig } from "next";
2 +
3 +const nextConfig: NextConfig = {
4 + reactStrictMode: true,
5 + poweredByHeader: false,
6 + images: { unoptimized: true },
7 + experimental: { serverActions: { allowedOrigins: ["www.socialcrawl.co", "socialcrawl.co", "localhost:8351"] } },
8 + async headers() {
9 + return [{ source: "/(.*)", headers: [{ key: "X-Frame-Options", value: "DENY" }, { key: "X-Content-Type-Options", value: "nosniff" }, { key: "Referrer-Policy", value: "same-origin" }] }];
10 + },
11 +};
12 +
13 +export default nextConfig;
added apps/dashboard/package.json +31 −0
@@ -0,0 +1,31 @@
1 +{
2 + "name": "@src/dashboard",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "scripts": {
7 + "dev": "next dev -p 8351",
8 + "build": "next build",
9 + "start": "next start -p 8351 -H 0.0.0.0",
10 + "typecheck": "tsc -p tsconfig.json --noEmit"
11 + },
12 + "dependencies": {
13 + "clsx": "^2.1.1",
14 + "d3-force": "^3.0.0",
15 + "framer-motion": "^12.23.0",
16 + "lucide-react": "^1.0.0",
17 + "next": "16.3.4",
18 + "react": "19.2.8",
19 + "react-dom": "19.2.8",
20 + "recharts": "^3.0.0"
21 + },
22 + "devDependencies": {
23 + "@tailwindcss/postcss": "^4",
24 + "@types/d3-force": "^3.0.10",
25 + "@types/node": "^24.0.0",
26 + "@types/react": "^19",
27 + "@types/react-dom": "^19",
28 + "tailwindcss": "^4",
29 + "typescript": "^5.9.3"
30 + }
31 +}
added apps/dashboard/postcss.config.mjs +2 −0
@@ -0,0 +1,2 @@
1 +const config = { plugins: { "@tailwindcss/postcss": {} } };
2 +export default config;
added apps/dashboard/public/brand/icon.svg +1 −0
@@ -0,0 +1 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="10" fill="#05070b"/><defs><linearGradient id="g" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#38e1ff"/><stop offset="1" stop-color="#9d7bff"/></linearGradient></defs><ellipse cx="24" cy="24" rx="19" ry="10" fill="none" stroke="url(#g)" stroke-width="2.2" transform="rotate(-24 24 24)"/><ellipse cx="24" cy="24" rx="19" ry="10" fill="none" stroke="url(#g)" stroke-width="1.2" opacity=".5" transform="rotate(36 24 24)"/><circle cx="24" cy="24" r="5.5" fill="url(#g)"/><circle cx="40" cy="16" r="2.2" fill="#ffb347"/></svg>
added apps/dashboard/src/app/access/page.tsx +39 −0
@@ -0,0 +1,39 @@
1 +import { cookies } from "next/headers";
2 +import { redirect } from "next/navigation";
3 +import { COOKIE, checkPasscode, gateEnabled, makeToken } from "@/lib/auth";
4 +import { Brand } from "@/components/Brand";
5 +
6 +export const dynamic = "force-dynamic";
7 +
8 +async function login(formData: FormData) {
9 + "use server";
10 + const code = String(formData.get("passcode") ?? "");
11 + const next = String(formData.get("next") ?? "/");
12 + if (!checkPasscode(code)) redirect(`/access?error=1&next=${encodeURIComponent(next)}`);
13 + const jar = await cookies();
14 + jar.set(COOKIE, makeToken(), { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", maxAge: 30 * 24 * 3600 });
15 + redirect(next.startsWith("/") ? next : "/");
16 +}
17 +
18 +export default async function AccessPage({ searchParams }: { searchParams: Promise<{ error?: string; next?: string }> }) {
19 + const sp = await searchParams;
20 + if (!gateEnabled()) redirect("/");
21 + return (
22 + <main className="min-h-screen grid place-items-center grid-bg px-6">
23 + <form action={login} className="panel w-full max-w-sm p-8 space-y-6">
24 + <Brand size="lg" />
25 + <div className="space-y-1">
26 + <h1 className="text-lg font-semibold">Research console</h1>
27 + <p className="text-sm text-fg-2">This console observes live social runtimes and can start browser sessions on the cluster. Enter the access code.</p>
28 + </div>
29 + <input type="hidden" name="next" value={sp.next ?? "/"} />
30 + <label className="block space-y-2">
31 + <span className="text-xs uppercase tracking-widest text-dim">Access code</span>
32 + <input name="passcode" type="password" autoFocus required className="w-full rounded-lg bg-bg-2 border border-line-2 px-3 py-2 mono text-fg outline-none focus:border-cyan/60 focus:glow-cyan" />
33 + </label>
34 + {sp.error && <p className="text-sm text-red">Wrong code.</p>}
35 + <button className="w-full rounded-lg bg-cyan/90 hover:bg-cyan text-bg font-semibold py-2 transition">Enter</button>
36 + </form>
37 + </main>
38 + );
39 +}
added apps/dashboard/src/app/api/[...path]/route.ts +28 −0
@@ -0,0 +1,28 @@
1 +import { cookies } from "next/headers";
2 +import { NextResponse, type NextRequest } from "next/server";
3 +import { COOKIE, verifyToken } from "@/lib/auth";
4 +
5 +export const dynamic = "force-dynamic";
6 +const API_URL = process.env.API_URL ?? "http://127.0.0.1:8350";
7 +const API_TOKEN = process.env.SRC_API_TOKEN ?? "";
8 +
9 +/** Authenticated pass-through to the control-plane API (adds the server token, streams SSE as-is). */
10 +async function forward(req: NextRequest, path: string[]) {
11 + const jar = await cookies();
12 + if (!verifyToken(jar.get(COOKIE)?.value)) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
13 + const target = new URL(`/api/${path.join("/")}`, API_URL);
14 + target.search = req.nextUrl.search;
15 + const headers: Record<string, string> = { "x-src-token": API_TOKEN };
16 + if (req.headers.get("content-type")) headers["content-type"] = req.headers.get("content-type")!;
17 + const upstream = await fetch(target, { method: req.method, headers, body: req.method === "GET" || req.method === "HEAD" ? undefined : await req.text(), cache: "no-store", // @ts-expect-error — undici option for streaming request bodies
18 + duplex: "half" });
19 + const ct = upstream.headers.get("content-type") ?? "application/json";
20 + return new Response(upstream.body, { status: upstream.status, headers: { "content-type": ct, "cache-control": "no-store", ...(ct.includes("event-stream") ? { connection: "keep-alive", "x-accel-buffering": "no" } : {}) } });
21 +}
22 +
23 +export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
24 + return forward(req, (await ctx.params).path);
25 +}
26 +export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
27 + return forward(req, (await ctx.params).path);
28 +}
added apps/dashboard/src/app/entities/[fp]/page.tsx +173 −0
@@ -0,0 +1,173 @@
1 +"use client";
2 +
3 +import { use } from "react";
4 +import Link from "next/link";
5 +import { ExternalLink } from "lucide-react";
6 +import { useApi } from "@/lib/api";
7 +import { fmtDate, fmtDuration, fmtNum, truncate } from "@/lib/format";
8 +import { Empty, Metric, Panel, Skeleton, SurfaceChips, Table, Tag, TypeTag } from "@/components/ui";
9 +import type { EntityRow } from "@/components/EntityTable";
10 +
11 +interface Detail {
12 + entity: EntityRow & { first_seen: string; last_seen: string; seen_count: number; first_session: string };
13 + observations: { session_id: string; step: number; ts: string; surfaces: string[]; snapshot: { name?: string; author?: string; metrics?: Record<string, number>; context?: string; provenance?: { surface: string; confidence: number; detail?: string }[] }; goal: string | null }[];
14 + relations: { rel_type: string; from_fp: string; to_fp: string; from_name: string | null; to_name: string | null; from_type: string; to_type: string; step: number }[];
15 + media: { duration_s: number | null; width: number | null; height: number | null; delivery: { kind: string; hostnames: string[] } | null; frames: string[] | null; thumbnail_url: string | null } | null;
16 + feed: { session_id: string; step: number; page_type: string; feed_position: number; visible: boolean; ts: string }[];
17 +}
18 +
19 +export default function EntityPage({ params }: { params: Promise<{ fp: string }> }) {
20 + const { fp } = use(params);
21 + const { data, loading, error } = useApi<Detail>(`/entities/${encodeURIComponent(decodeURIComponent(fp))}`);
22 + if (loading) return <Skeleton rows={10} />;
23 + if (error || !data) return <Empty>{error ?? "not found"}</Empty>;
24 + const e = data.entity;
25 + const type = e.entity_type ?? e.type ?? "?";
26 + return (
27 + <div className="space-y-4">
28 + <div className="text-xs text-dim mono">
29 + <Link href="/entities" className="hover:text-cyan">entities</Link> / {e.fingerprint}
30 + </div>
31 + <div className="flex flex-wrap items-start gap-4">
32 + <div className="min-w-0">
33 + <div className="flex items-center gap-2">
34 + <TypeTag type={type} />
35 + <Tag>{e.platform}</Tag>
36 + {e.url && (
37 + <a href={e.url} target="_blank" rel="noreferrer" className="text-dim hover:text-cyan flex items-center gap-1 text-xs">
38 + open on platform <ExternalLink className="h-3.5 w-3.5" />
39 + </a>
40 + )}
41 + </div>
42 + <h1 className="text-xl font-semibold tracking-tight mt-2 max-w-3xl">{e.name ?? e.text_excerpt ?? e.platform_id}</h1>
43 + {e.author && <div className="text-sm text-fg-2 mt-1">by {e.author}</div>}
44 + {e.text_excerpt && e.name && <p className="text-sm text-fg-2 mt-2 max-w-3xl">{e.text_excerpt}</p>}
45 + </div>
46 + </div>
47 +
48 + <div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
49 + <Panel title="Evidence per field" sub="every canonical value points back to the surface that produced it (§38)" className="xl:col-span-2" pad={false}>
50 + <div className="p-4">
51 + <Table head={["field", "value", "evidence"]} dense>
52 + {Object.entries(e.fields ?? {}).map(([k, f]) => (
53 + <tr key={k}>
54 + <td className="mono text-fg-2 whitespace-nowrap">{k}</td>
55 + <td className="max-w-[480px] break-words">{typeof f.value === "string" ? f.value : JSON.stringify(f.value)}</td>
56 + <td>
57 + <SurfaceChips provenance={f.provenance} />
58 + {f.provenance?.[0]?.detail && <div className="text-[10px] text-dim mono truncate max-w-[280px]">{f.provenance[0].detail}</div>}
59 + </td>
60 + </tr>
61 + ))}
62 + </Table>
63 + </div>
64 + </Panel>
65 + <div className="space-y-4">
66 + <Panel title="Canonical record">
67 + <Metric label="platform id" value={e.platform_id ?? "—"} />
68 + <Metric label="confidence" value={e.confidence ? Number(e.confidence).toFixed(2) : "—"} />
69 + <Metric label="observed" value={`${e.seen_count}× in ${new Set(data.observations.map((o) => o.session_id)).size} session(s)`} />
70 + <Metric label="first seen" value={fmtDate(e.first_seen)} />
71 + <Metric label="last seen" value={fmtDate(e.last_seen)} />
72 + {e.metrics &&
73 + Object.entries(e.metrics).map(([k, v]) => (
74 + <Metric key={k} label={k} value={fmtNum(v)} />
75 + ))}
76 + {data.media && (
77 + <>
78 + <Metric label="duration" value={fmtDuration(data.media.duration_s)} />
79 + {data.media.width ? <Metric label="resolution" value={`${data.media.width}×${data.media.height}`} /> : null}
80 + {data.media.delivery && <Metric label="delivery" value={`${data.media.delivery.kind} · ${data.media.delivery.hostnames.slice(0, 2).join(", ")}`} />}
81 + </>
82 + )}
83 + </Panel>
84 + {data.media?.frames?.length ? (
85 + <Panel title="Sampled frames" sub="0 / 25 / 50 / 75 / 100 % (LEVEL 2)">
86 + <div className="grid grid-cols-5 gap-1">
87 + {data.media.frames.map((f) => (
88 + // eslint-disable-next-line @next/next/no-img-element
89 + <img key={f} src={`/media/${f.split("/media/")[1] ?? ""}`} alt="" className="aspect-video object-cover rounded" />
90 + ))}
91 + </div>
92 + </Panel>
93 + ) : data.media?.thumbnail_url || e.media?.thumbnail_url ? (
94 + <Panel title="Thumbnail">
95 + {/* eslint-disable-next-line @next/next/no-img-element */}
96 + <img src={data.media?.thumbnail_url ?? e.media?.thumbnail_url ?? ""} alt="" className="rounded w-full" />
97 + </Panel>
98 + ) : null}
99 + </div>
100 + </div>
101 +
102 + <div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
103 + <Panel title="Observations" sub="raw sightings, never overwritten (§37)" pad={false}>
104 + <div className="p-4">
105 + <Table head={["when", "session", "step", "surfaces", "context", "name as seen"]} dense>
106 + {data.observations.map((o, i) => (
107 + <tr key={i}>
108 + <td className="mono text-dim whitespace-nowrap">{fmtDate(o.ts)}</td>
109 + <td>
110 + <Link href={`/sessions/${o.session_id}`} className="mono text-[11px] hover:text-cyan">
111 + {truncate(o.goal ?? o.session_id, 32)}
112 + </Link>
113 + </td>
114 + <td className="mono">#{o.step}</td>
115 + <td>
116 + <SurfaceChips provenance={o.snapshot.provenance ?? o.surfaces.map((s) => ({ surface: s, confidence: 0 }))} />
117 + </td>
118 + <td className="text-dim text-[11px]">{o.snapshot.context}</td>
119 + <td className="text-fg-2 max-w-[260px] truncate">{o.snapshot.name}</td>
120 + </tr>
121 + ))}
122 + </Table>
123 + </div>
124 + </Panel>
125 + <div className="space-y-4">
126 + <Panel title="Relationships" pad={false}>
127 + <div className="p-4">
128 + {data.relations.length ? (
129 + <Table head={["from", "relation", "to"]} dense>
130 + {data.relations.map((r, i) => (
131 + <tr key={i}>
132 + <td className="max-w-[240px] truncate">
133 + <TypeTag type={r.from_type ?? "?"} /> <Link href={`/entities/${encodeURIComponent(r.from_fp)}`} className="hover:text-cyan">{truncate(r.from_name ?? r.from_fp, 40)}</Link>
134 + </td>
135 + <td>
136 + <Tag color="#9d7bff">{r.rel_type}</Tag>
137 + </td>
138 + <td className="max-w-[240px] truncate">
139 + <TypeTag type={r.to_type ?? "?"} /> <Link href={`/entities/${encodeURIComponent(r.to_fp)}`} className="hover:text-cyan">{truncate(r.to_name ?? r.to_fp, 40)}</Link>
140 + </td>
141 + </tr>
142 + ))}
143 + </Table>
144 + ) : (
145 + <Empty>no relationships recorded</Empty>
146 + )}
147 + </div>
148 + </Panel>
149 + <Panel title="Feed exposure" sub="where it ranked when the crawler saw it (§44)" pad={false}>
150 + <div className="p-4">
151 + {data.feed.length ? (
152 + <Table head={["when", "page", "position", "visible"]} dense>
153 + {data.feed.map((f, i) => (
154 + <tr key={i}>
155 + <td className="mono text-dim whitespace-nowrap">{fmtDate(f.ts)}</td>
156 + <td>
157 + <Tag color="#38e1ff">{f.page_type}</Tag>
158 + </td>
159 + <td className="mono">#{f.feed_position + 1}</td>
160 + <td className="mono">{f.visible ? "yes" : "below fold"}</td>
161 + </tr>
162 + ))}
163 + </Table>
164 + ) : (
165 + <Empty>not seen in a feed</Empty>
166 + )}
167 + </div>
168 + </Panel>
169 + </div>
170 + </div>
171 + </div>
172 + );
173 +}
added apps/dashboard/src/app/entities/page.tsx +85 −0
@@ -0,0 +1,85 @@
1 +"use client";
2 +
3 +import { useState } from "react";
4 +import { Search } from "lucide-react";
5 +import { useApi } from "@/lib/api";
6 +import { fmtNum } from "@/lib/format";
7 +import { Empty, Panel, Skeleton } from "@/components/ui";
8 +import { EntityTable, type EntityRow } from "@/components/EntityTable";
9 +
10 +const TYPES = ["", "video", "channel", "profile", "post", "comment", "community", "hashtag", "page"];
11 +const PLATFORMS = ["", "youtube", "reddit", "facebook", "instagram", "tiktok", "x"];
12 +
13 +export default function Entities() {
14 + const [q, setQ] = useState("");
15 + const [type, setType] = useState("");
16 + const [platform, setPlatform] = useState("");
17 + const [sort, setSort] = useState("last_seen");
18 + const [page, setPage] = useState(0);
19 + const limit = 50;
20 + const params = new URLSearchParams({ limit: String(limit), offset: String(page * limit), sort });
21 + if (q) params.set("q", q);
22 + if (type) params.set("type", type);
23 + if (platform) params.set("platform", platform);
24 + const { data, loading } = useApi<{ rows: EntityRow[]; total: number }>(`/entities?${params}`);
25 + const sel = "rounded-lg bg-bg-2 border border-line-2 px-2 py-1.5 text-sm text-fg outline-none focus:border-cyan/60";
26 + return (
27 + <div className="space-y-4">
28 + <div className="flex flex-wrap items-end gap-3">
29 + <div>
30 + <h1 className="text-2xl font-semibold tracking-tight">Entities</h1>
31 + <p className="text-sm text-fg-2">Canonical entities with per-field evidence. {data ? <span className="mono">{fmtNum(data.total)} match</span> : null}</p>
32 + </div>
33 + <div className="ml-auto flex flex-wrap gap-2 items-center">
34 + <label className="relative">
35 + <Search className="h-4 w-4 absolute left-2 top-2 text-dim" />
36 + <input
37 + value={q}
38 + onChange={(e) => {
39 + setQ(e.target.value);
40 + setPage(0);
41 + }}
42 + placeholder="search name, text, author, id…"
43 + className={`${sel} pl-8 w-72 mono`}
44 + />
45 + </label>
46 + <select value={type} onChange={(e) => (setType(e.target.value), setPage(0))} className={sel}>
47 + {TYPES.map((t) => (
48 + <option key={t} value={t}>
49 + {t || "all types"}
50 + </option>
51 + ))}
52 + </select>
53 + <select value={platform} onChange={(e) => (setPlatform(e.target.value), setPage(0))} className={sel}>
54 + {PLATFORMS.map((p) => (
55 + <option key={p} value={p}>
56 + {p || "all platforms"}
57 + </option>
58 + ))}
59 + </select>
60 + <select value={sort} onChange={(e) => setSort(e.target.value)} className={sel}>
61 + <option value="last_seen">recently seen</option>
62 + <option value="seen">most observed</option>
63 + <option value="views">most views</option>
64 + </select>
65 + </div>
66 + </div>
67 + <Panel pad={false}>
68 + <div className="p-4">{loading ? <Skeleton rows={10} /> : data?.rows.length ? <EntityTable rows={data.rows} showPlatform /> : <Empty>Nothing matches.</Empty>}</div>
69 + {data && data.total > limit && (
70 + <div className="flex items-center gap-3 px-4 py-3 border-t border-line text-xs">
71 + <button disabled={page === 0} onClick={() => setPage((p) => p - 1)} className="px-2 py-1 rounded border border-line disabled:opacity-40 hover:border-cyan/50">
72 + ← prev
73 + </button>
74 + <span className="mono text-dim">
75 + {page * limit + 1}–{Math.min(data.total, (page + 1) * limit)} of {data.total}
76 + </span>
77 + <button disabled={(page + 1) * limit >= data.total} onClick={() => setPage((p) => p + 1)} className="px-2 py-1 rounded border border-line disabled:opacity-40 hover:border-cyan/50">
78 + next →
79 + </button>
80 + </div>
81 + )}
82 + </Panel>
83 + </div>
84 + );
85 +}
added apps/dashboard/src/app/globals.css +87 −0
@@ -0,0 +1,87 @@
1 +@import "tailwindcss";
2 +
3 +@theme {
4 + --color-bg: #05070b;
5 + --color-bg-2: #0a0e15;
6 + --color-panel: #0d1219;
7 + --color-panel-2: #111823;
8 + --color-line: #1a2331;
9 + --color-line-2: #243042;
10 + --color-fg: #e6edf5;
11 + --color-fg-2: #a9b6c6;
12 + --color-dim: #6b7a8c;
13 + --color-cyan: #38e1ff;
14 + --color-amber: #ffb347;
15 + --color-green: #43e69a;
16 + --color-red: #ff5c7a;
17 + --color-violet: #9d7bff;
18 + --color-pink: #ff7ad9;
19 + --font-sans: var(--font-sans), ui-sans-serif, system-ui, sans-serif;
20 + --font-mono: var(--font-mono), ui-monospace, "SF Mono", Menlo, monospace;
21 +}
22 +
23 +:root {
24 + color-scheme: dark;
25 +}
26 +
27 +html,
28 +body {
29 + background: var(--color-bg);
30 + color: var(--color-fg);
31 + font-family: var(--font-sans);
32 + -webkit-font-smoothing: antialiased;
33 +}
34 +
35 +body {
36 + background-image:
37 + radial-gradient(1200px 600px at 10% -10%, rgba(56, 225, 255, 0.07), transparent 60%),
38 + radial-gradient(900px 500px at 100% 0%, rgba(157, 123, 255, 0.07), transparent 60%);
39 + background-attachment: fixed;
40 +}
41 +
42 +.grid-bg {
43 + background-image:
44 + linear-gradient(rgba(255, 255, 255, 0.025) 1px, transparent 1px),
45 + linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px);
46 + background-size: 28px 28px;
47 + mask-image: radial-gradient(ellipse at top, black 40%, transparent 85%);
48 +}
49 +
50 +.panel {
51 + background: linear-gradient(180deg, rgba(255, 255, 255, 0.025), rgba(255, 255, 255, 0.005)), var(--color-panel);
52 + border: 1px solid var(--color-line);
53 + border-radius: 14px;
54 + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.03) inset, 0 10px 30px -20px rgba(0, 0, 0, 0.8);
55 +}
56 +
57 +.glow-cyan { box-shadow: 0 0 0 1px rgba(56, 225, 255, 0.25), 0 0 24px -6px rgba(56, 225, 255, 0.5); }
58 +
59 +.mono { font-family: var(--font-mono); font-feature-settings: "tnum" 1, "zero" 1; }
60 +
61 +.scrollbar-thin::-webkit-scrollbar { width: 8px; height: 8px; }
62 +.scrollbar-thin::-webkit-scrollbar-thumb { background: var(--color-line-2); border-radius: 8px; }
63 +.scrollbar-thin::-webkit-scrollbar-track { background: transparent; }
64 +
65 +@keyframes pulse-dot {
66 + 0%, 100% { transform: scale(1); opacity: 1; }
67 + 50% { transform: scale(1.6); opacity: 0.35; }
68 +}
69 +.live-dot::after {
70 + content: "";
71 + position: absolute;
72 + inset: 0;
73 + border-radius: 9999px;
74 + background: currentColor;
75 + animation: pulse-dot 1.6s ease-in-out infinite;
76 +}
77 +
78 +@keyframes shimmer { from { background-position: -200% 0; } to { background-position: 200% 0; } }
79 +.skeleton {
80 + background: linear-gradient(90deg, rgba(255,255,255,0.03) 25%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.03) 75%);
81 + background-size: 200% 100%;
82 + animation: shimmer 1.4s linear infinite;
83 + border-radius: 6px;
84 +}
85 +
86 +a { color: inherit; }
87 +::selection { background: rgba(56, 225, 255, 0.3); }
added apps/dashboard/src/app/jobs/page.tsx +213 −0
@@ -0,0 +1,213 @@
1 +"use client";
2 +
3 +import { useEffect, useState } from "react";
4 +import Link from "next/link";
5 +import { Play, Square } from "lucide-react";
6 +import { api, useApi } from "@/lib/api";
7 +import { ago, truncate } from "@/lib/format";
8 +import { Empty, Panel, Skeleton, StatusPill, Table, Tag } from "@/components/ui";
9 +
10 +interface Job {
11 + job_id: string;
12 + session_id: string | null;
13 + platform: string;
14 + mode: string;
15 + goal: string;
16 + budget: { minutes: number; actions: number; media: number; query?: string | null; headless: boolean; account: string };
17 + status: string;
18 + created_at: string;
19 + finished_at: string | null;
20 + result: { steps?: number; entities?: number; videos?: number; ended_because?: string } | null;
21 + actions: number;
22 + entities: number;
23 +}
24 +
25 +const PRESETS = [
26 + { label: "Topic · AI Québec on YouTube", platform: "youtube", mode: "topic", query: "intelligence artificielle Québec", goal: "Discover public Quebec creators and channels discussing artificial intelligence", minutes: 10, actions: 60, media: 2 },
27 + { label: "Learn · YouTube runtime", platform: "youtube", mode: "learn", query: "", goal: "", minutes: 15, actions: 80, media: 1 },
28 + { label: "Observe · YouTube home feed", platform: "youtube", mode: "observe", query: "", goal: "Observe the recommendation feed without navigating", minutes: 5, actions: 30, media: 0 },
29 + { label: "Research · Reddit r/Quebec AI", platform: "reddit", mode: "research", query: "intelligence artificielle Québec", goal: "Discover public Reddit posts and users discussing AI in Quebec", minutes: 10, actions: 60, media: 1 },
30 +];
31 +
32 +export default function Missions() {
33 + const jobs = useApi<Job[]>("/jobs", { refreshMs: 5000 });
34 + const [form, setForm] = useState({ platform: "youtube", mode: "research", query: "intelligence artificielle Québec", goal: "Discover public Quebec creators discussing AI", minutes: 10, actions: 60, media: 1, headless: true, account: "research-01" });
35 + const [busy, setBusy] = useState(false);
36 + const [msg, setMsg] = useState<string | null>(null);
37 + const [logJob, setLogJob] = useState<string | null>(null);
38 + const input = "w-full rounded-lg bg-bg-2 border border-line-2 px-3 py-2 text-sm outline-none focus:border-cyan/60";
39 +
40 + async function launch() {
41 + setBusy(true);
42 + setMsg(null);
43 + try {
44 + const r = await api<{ job_id: string }>("/jobs", { method: "POST", body: JSON.stringify(form) });
45 + setMsg(`mission ${r.job_id} launched`);
46 + setLogJob(r.job_id);
47 + jobs.refresh();
48 + } catch (e) {
49 + setMsg((e as Error).message);
50 + } finally {
51 + setBusy(false);
52 + }
53 + }
54 + async function stop(id: string) {
55 + await api(`/jobs/${id}/stop`, { method: "POST", body: "{}" });
56 + jobs.refresh();
57 + }
58 +
59 + return (
60 + <div className="space-y-4">
61 + <div>
62 + <h1 className="text-2xl font-semibold tracking-tight">Missions</h1>
63 + <p className="text-sm text-fg-2 max-w-2xl">A mission opens one browser on the cluster node, gives the agent a goal and a budget, and lets it explore. Read-only: the crawler never likes, follows, comments or subscribes.</p>
64 + </div>
65 + <div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
66 + <Panel title="New mission" className="xl:col-span-1">
67 + <div className="space-y-3">
68 + <div className="flex flex-wrap gap-1">
69 + {PRESETS.map((p) => (
70 + <button key={p.label} onClick={() => setForm((f) => ({ ...f, ...p }))} className="text-[11px] rounded-md border border-line px-2 py-1 hover:border-cyan/50 text-fg-2 hover:text-fg">
71 + {p.label}
72 + </button>
73 + ))}
74 + </div>
75 + <div className="grid grid-cols-2 gap-2">
76 + <label className="text-xs text-dim">
77 + platform
78 + <select value={form.platform} onChange={(e) => setForm({ ...form, platform: e.target.value })} className={input}>
79 + <option value="youtube">youtube</option>
80 + <option value="reddit">reddit</option>
81 + </select>
82 + </label>
83 + <label className="text-xs text-dim">
84 + mode
85 + <select value={form.mode} onChange={(e) => setForm({ ...form, mode: e.target.value })} className={input}>
86 + {["research", "topic", "observe", "profile", "learn"].map((m) => (
87 + <option key={m}>{m}</option>
88 + ))}
89 + </select>
90 + </label>
91 + </div>
92 + <label className="text-xs text-dim block">
93 + topic / search query
94 + <input value={form.query} onChange={(e) => setForm({ ...form, query: e.target.value })} className={input} placeholder="e.g. intelligence artificielle Québec" />
95 + </label>
96 + <label className="text-xs text-dim block">
97 + goal (what the agent optimises for)
98 + <textarea value={form.goal} onChange={(e) => setForm({ ...form, goal: e.target.value })} rows={2} className={input} />
99 + </label>
100 + <div className="grid grid-cols-3 gap-2">
101 + <label className="text-xs text-dim">
102 + minutes
103 + <input type="number" min={1} max={120} value={form.minutes} onChange={(e) => setForm({ ...form, minutes: Number(e.target.value) })} className={`${input} mono`} />
104 + </label>
105 + <label className="text-xs text-dim">
106 + actions
107 + <input type="number" min={1} max={1000} value={form.actions} onChange={(e) => setForm({ ...form, actions: Number(e.target.value) })} className={`${input} mono`} />
108 + </label>
109 + <label className="text-xs text-dim">
110 + media level
111 + <select value={form.media} onChange={(e) => setForm({ ...form, media: Number(e.target.value) })} className={input}>
112 + <option value={0}>0 · metadata</option>
113 + <option value={1}>1 · + thumbnail</option>
114 + <option value={2}>2 · + frames</option>
115 + </select>
116 + </label>
117 + </div>
118 + <div className="grid grid-cols-2 gap-2">
119 + <label className="text-xs text-dim">
120 + account profile
121 + <input value={form.account} onChange={(e) => setForm({ ...form, account: e.target.value })} className={`${input} mono`} />
122 + </label>
123 + <label className="text-xs text-dim flex items-end gap-2 pb-2">
124 + <input type="checkbox" checked={form.headless} onChange={(e) => setForm({ ...form, headless: e.target.checked })} /> headless browser
125 + </label>
126 + </div>
127 + <button disabled={busy} onClick={launch} className="w-full rounded-lg bg-cyan/90 hover:bg-cyan disabled:opacity-50 text-bg font-semibold py-2 flex items-center justify-center gap-2">
128 + <Play className="h-4 w-4" /> Launch mission
129 + </button>
130 + {msg && <div className="text-xs text-fg-2 mono">{msg}</div>}
131 + </div>
132 + </Panel>
133 + <Panel title="Missions" className="xl:col-span-2" pad={false} right={jobs.data ? `${jobs.data.filter((j) => j.status === "running").length} running` : ""}>
134 + <div className="p-4">
135 + {jobs.loading ? (
136 + <Skeleton rows={6} />
137 + ) : jobs.data?.length ? (
138 + <Table head={["status", "platform", "goal", "mode", "budget", "steps", "entities", "created", ""]} dense>
139 + {jobs.data.map((j) => (
140 + <tr key={j.job_id}>
141 + <td>
142 + <StatusPill status={j.status} />
143 + </td>
144 + <td>
145 + <Tag>{j.platform}</Tag>
146 + </td>
147 + <td className="max-w-[360px]">
148 + {j.session_id ? (
149 + <Link href={`/sessions/${j.session_id}`} className="hover:text-cyan">
150 + {truncate(j.goal, 80)}
151 + </Link>
152 + ) : (
153 + truncate(j.goal, 80)
154 + )}
155 + {j.budget?.query && <div className="text-[10.5px] text-dim">“{j.budget.query}”</div>}
156 + </td>
157 + <td className="text-fg-2">{j.mode}</td>
158 + <td className="mono text-[11px] text-dim">
159 + {j.budget?.minutes}m · {j.budget?.actions}a · L{j.budget?.media}
160 + </td>
161 + <td className="mono">{j.actions || j.result?.steps || 0}</td>
162 + <td className="mono">{j.entities || j.result?.entities || 0}</td>
163 + <td className="text-dim whitespace-nowrap">{ago(j.created_at)}</td>
164 + <td className="whitespace-nowrap">
165 + <button onClick={() => setLogJob(j.job_id)} className="text-[11px] text-dim hover:text-cyan mr-2">
166 + log
167 + </button>
168 + {j.status === "running" && (
169 + <button onClick={() => stop(j.job_id)} className="text-[11px] text-red hover:text-red/80 inline-flex items-center gap-1">
170 + <Square className="h-3 w-3" /> stop
171 + </button>
172 + )}
173 + </td>
174 + </tr>
175 + ))}
176 + </Table>
177 + ) : (
178 + <Empty>No missions yet — launch one on the left.</Empty>
179 + )}
180 + </div>
181 + </Panel>
182 + </div>
183 + {logJob && <JobLog id={logJob} onClose={() => setLogJob(null)} />}
184 + </div>
185 + );
186 +}
187 +
188 +function JobLog({ id, onClose }: { id: string; onClose: () => void }) {
189 + const [text, setText] = useState("");
190 + useEffect(() => {
191 + let alive = true;
192 + let timer: ReturnType<typeof setTimeout> | undefined;
193 + const tick = async () => {
194 + try {
195 + const r = await fetch(`/api/jobs/${id}/log?lines=300`, { cache: "no-store" });
196 + if (alive) setText(await r.text());
197 + } catch {
198 + /* ignore */
199 + }
200 + if (alive) timer = setTimeout(tick, 3000);
201 + };
202 + tick();
203 + return () => {
204 + alive = false;
205 + if (timer) clearTimeout(timer);
206 + };
207 + }, [id]);
208 + return (
209 + <Panel title={`Worker log · ${id}`} right={<button onClick={onClose} className="hover:text-cyan">close</button>}>
210 + <pre className="mono text-[11px] leading-5 text-fg-2 whitespace-pre-wrap max-h-[420px] overflow-auto scrollbar-thin">{text || "…"}</pre>
211 + </Panel>
212 + );
213 +}
added apps/dashboard/src/app/layout.tsx +24 −0
@@ -0,0 +1,24 @@
1 +import type { Metadata } from "next";
2 +import { Inter, JetBrains_Mono } from "next/font/google";
3 +import "./globals.css";
4 +import { Shell } from "@/components/Shell";
5 +
6 +const sans = Inter({ subsets: ["latin"], variable: "--font-sans", display: "swap" });
7 +const mono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-mono", display: "swap" });
8 +
9 +export const metadata: Metadata = {
10 + title: { default: "SocialCrawl — Social Runtime Mining console", template: "%s · SocialCrawl" },
11 + description: "Research console of the Social Runtime Crawler: live observation of social-media runtimes, information-gain navigation, learned connectors.",
12 + robots: { index: false, follow: false },
13 + icons: { icon: "/brand/icon.svg" },
14 +};
15 +
16 +export default function RootLayout({ children }: { children: React.ReactNode }) {
17 + return (
18 + <html lang="en" className={`${sans.variable} ${mono.variable}`}>
19 + <body className="min-h-screen">
20 + <Shell>{children}</Shell>
21 + </body>
22 + </html>
23 + );
24 +}
added apps/dashboard/src/app/media/[...path]/route.ts +15 −0
@@ -0,0 +1,15 @@
1 +import { cookies } from "next/headers";
2 +import { NextResponse, type NextRequest } from "next/server";
3 +import { COOKIE, verifyToken } from "@/lib/auth";
4 +
5 +export const dynamic = "force-dynamic";
6 +const API_URL = process.env.API_URL ?? "http://127.0.0.1:8350";
7 +
8 +/** Sampled video frames, served through the gate. */
9 +export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
10 + const jar = await cookies();
11 + if (!verifyToken(jar.get(COOKIE)?.value)) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
12 + const { path } = await ctx.params;
13 + const upstream = await fetch(new URL(`/media/${path.map(encodeURIComponent).join("/")}`, API_URL), { cache: "no-store" });
14 + return new Response(upstream.body, { status: upstream.status, headers: { "content-type": upstream.headers.get("content-type") ?? "image/jpeg", "cache-control": "private, max-age=3600" } });
15 +}
added apps/dashboard/src/app/page.tsx +126 −0
@@ -0,0 +1,126 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { ArrowRight } from "lucide-react";
5 +import { useApi } from "@/lib/api";
6 +import { ago, fmtNum, truncate } from "@/lib/format";
7 +import { Bar, Empty, Kpi, Panel, Skeleton, StatusPill, Tag } from "@/components/ui";
8 +import { ActivityChart, SurfaceBars, TypeDonut, YieldBars } from "@/components/charts";
9 +import { LiveFeed } from "@/components/LiveFeed";
10 +
11 +interface Overview {
12 + totals: { sessions: number; entities: number; media: number; schemas: number; actions: number; observations: number; relationships: number; patterns_learned: number; feed_items: number };
13 + by_type: { platform: string; entity_type: string; n: number }[];
14 + running: { session_id: string; platform: string; started_at: string }[];
15 + recent_sessions: { session_id: string; platform: string; goal: string | null; mode: string | null; started_at: string; ended_at: string | null; health: string; actions: number; entities: number }[];
16 + series: { bucket: string; entities: number; responses: number; actions: number; schemas: number }[];
17 + surfaces: { dom: number; network: number; both: number; agreements: number; conflicts: number };
18 + actions: { action_type: string; n: number; avg_gain: number | null; success_rate: number | null; avg_new_entities: number | null }[];
19 + platforms: { platform: string; learned: boolean; adapter: boolean; confidence: number; page_types: number; entity_types: number; network_schemas: number; sessions: number; has_manifest: boolean }[];
20 +}
21 +
22 +export default function Observatory() {
23 + const { data, loading } = useApi<Overview>("/overview", { refreshMs: 8000 });
24 + const t = data?.totals;
25 + const s = data?.surfaces;
26 + const agreement = s && s.agreements + s.conflicts > 0 ? s.agreements / (s.agreements + s.conflicts) : null;
27 + const spark = (k: keyof Overview["series"][number]) => (data?.series ?? []).slice(-30).map((r) => Number(r[k]));
28 + return (
29 + <div className="space-y-6">
30 + <div className="flex flex-wrap items-end gap-4">
31 + <div>
32 + <h1 className="text-2xl font-semibold tracking-tight">Observatory</h1>
33 + <p className="text-sm text-fg-2 mt-1 max-w-2xl">The crawler does not download pages. It watches social applications run inside an authenticated browser, fuses what the network and the DOM reveal, and learns the platform as it goes.</p>
34 + </div>
35 + <div className="ml-auto flex gap-2">
36 + {data?.running.map((r) => (
37 + <Link key={r.session_id} href={`/sessions/${r.session_id}`} className="panel px-3 py-1.5 text-xs flex items-center gap-2 hover:border-amber/50">
38 + <StatusPill status="running" /> {r.platform} <span className="text-dim">{ago(r.started_at)}</span>
39 + </Link>
40 + ))}
41 + <Link href="/jobs" className="rounded-lg bg-cyan/90 hover:bg-cyan text-bg text-sm font-semibold px-3 py-1.5 flex items-center gap-1">
42 + New mission <ArrowRight className="h-4 w-4" />
43 + </Link>
44 + </div>
45 + </div>
46 +
47 + <div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3">
48 + <Kpi label="entities" value={fmtNum(t?.entities)} hint={`${fmtNum(t?.relationships)} relationships`} spark={spark("entities")} />
49 + <Kpi label="media" value={fmtNum(t?.media)} hint="videos & images with metadata" accent="pink" />
50 + <Kpi label="runtime schemas" value={fmtNum(t?.schemas)} hint={`${fmtNum(t?.patterns_learned)} patterns learned`} accent="violet" spark={spark("schemas")} />
51 + <Kpi label="actions" value={fmtNum(t?.actions)} hint={`${fmtNum(t?.sessions)} sessions`} accent="amber" spark={spark("actions")} />
52 + <Kpi label="observations" value={fmtNum(t?.observations)} hint="raw, append-only" accent="fg" spark={spark("responses")} />
53 + <Kpi label="surface agreement" value={agreement === null ? "—" : `${Math.round(agreement * 100)}%`} hint={s ? `${fmtNum(s.both)} entities seen on both surfaces` : ""} accent="green" />
54 + </div>
55 +
56 + <div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
57 + <Panel title="Activity" sub="10-minute buckets, last 48 h" className="xl:col-span-2">
58 + {loading ? <Skeleton rows={6} /> : data?.series.length ? <ActivityChart data={data.series} /> : <Empty>No activity yet — launch a mission.</Empty>}
59 + </Panel>
60 + <Panel title="Entity mix" sub="canonical entities by type">
61 + {data?.by_type.length ? <TypeDonut data={data.by_type} /> : <Empty>—</Empty>}
62 + </Panel>
63 + </div>
64 +
65 + <div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
66 + <Panel title="Live observation stream" sub="every event, as the browser sees it" className="xl:col-span-2" pad={false}>
67 + <div className="p-2">
68 + <LiveFeed max={80} height={380} />
69 + </div>
70 + </Panel>
71 + <div className="space-y-4">
72 + <Panel title="Network vs DOM" sub="where entities were confirmed">
73 + {s ? <SurfaceBars dom={s.dom} network={s.network} both={s.both} /> : <Skeleton />}
74 + <div className="text-xs text-dim mt-2">
75 + field agreements <span className="mono text-fg">{fmtNum(s?.agreements)}</span> · conflicts <span className="mono text-fg">{fmtNum(s?.conflicts)}</span>
76 + </div>
77 + </Panel>
78 + <Panel title="Action yield" sub="average new entities per action type">
79 + {data?.actions.length ? <YieldBars data={data.actions} height={190} /> : <Empty>—</Empty>}
80 + </Panel>
81 + </div>
82 + </div>
83 +
84 + <div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
85 + <Panel title="Recent sessions" right={<Link href="/sessions" className="hover:text-cyan">all →</Link>} pad={false}>
86 + {data?.recent_sessions.length ? (
87 + <ul className="divide-y divide-line/60">
88 + {data.recent_sessions.map((r) => (
89 + <li key={r.session_id}>
90 + <Link href={`/sessions/${r.session_id}`} className="flex items-center gap-3 px-4 py-2.5 hover:bg-white/[0.03]">
91 + <StatusPill status={r.ended_at ? "done" : r.health} />
92 + <Tag>{r.platform}</Tag>
93 + <span className="text-sm truncate">{truncate(r.goal ?? "(no goal)", 70)}</span>
94 + <span className="ml-auto mono text-xs text-dim whitespace-nowrap">
95 + {r.actions} steps · {r.entities} ent · {ago(r.started_at)}
96 + </span>
97 + </Link>
98 + </li>
99 + ))}
100 + </ul>
101 + ) : (
102 + <Empty>No sessions yet.</Empty>
103 + )}
104 + </Panel>
105 + <Panel title="Connectors" sub="learned platform knowledge" right={<Link href="/platforms" className="hover:text-cyan">details →</Link>}>
106 + <div className="space-y-3">
107 + {data?.platforms
108 + .filter((p) => p.adapter || p.learned)
109 + .map((p) => (
110 + <Link key={p.platform} href={`/platforms/${p.platform}`} className="block group">
111 + <div className="flex items-center gap-3 text-sm">
112 + <span className="w-20 capitalize group-hover:text-cyan">{p.platform}</span>
113 + <Bar value={p.confidence / 100} color={p.confidence >= 70 ? "#43e69a" : p.confidence >= 40 ? "#38e1ff" : "#ffb347"} className="flex-1" />
114 + <span className="mono text-xs w-10 text-right">{p.confidence}%</span>
115 + </div>
116 + <div className="text-[10.5px] text-dim mono ml-[92px]">
117 + {p.page_types} page types · {p.entity_types} entity types · {p.network_schemas} schemas · {p.sessions} sessions{p.has_manifest ? " · compiled" : ""}
118 + </div>
119 + </Link>
120 + ))}
121 + </div>
122 + </Panel>
123 + </div>
124 + </div>
125 + );
126 +}
added apps/dashboard/src/app/platforms/[platform]/page.tsx +198 −0
@@ -0,0 +1,198 @@
1 +"use client";
2 +
3 +import { use } from "react";
4 +import Link from "next/link";
5 +import { useApi } from "@/lib/api";
6 +import { fmtDate } from "@/lib/format";
7 +import { Bar, Empty, Metric, Panel, Skeleton, Table, Tag } from "@/components/ui";
8 +
9 +interface Detail {
10 + platform: string;
11 + summary: { confidence: number; page_types: number; entity_types: number; navigation_actions: number; network_schemas: number; media_patterns: number; sessions: number } | null;
12 + model: {
13 + updated_at: string;
14 + response_patterns: Record<string, { shape_hash: string; hostname: string; path_pattern: string; method: string; graphql_operation?: string; likely_entity_types: Record<string, number>; observed_count: number; entity_yield_total: number; confidence: number; triggered_by: Record<string, number>; first_seen: string; last_seen: string }>;
15 + action_patterns: Record<string, { action_type: string; from_page_type: string; observed_count: number; avg_new_entities: number; avg_network_responses: number; to_page_types: Record<string, number>; failures: number }>;
16 + page_types: Record<string, { page_type: string; observed_count: number; url_patterns: Record<string, number>; avg_entities: number; entity_types: Record<string, number>; avg_confidence: number }>;
17 + media_patterns: Record<string, { hostname: string; kind: string; observed_count: number }>;
18 + sessions_learned: string[];
19 + } | null;
20 + manifest: { compiled_at: string; confidence: number; network_patterns: unknown[] } | null;
21 + profiles: { alias: string; has_state: boolean }[];
22 +}
23 +
24 +export default function PlatformPage({ params }: { params: Promise<{ platform: string }> }) {
25 + const { platform } = use(params);
26 + const { data, loading } = useApi<Detail>(`/platforms/${platform}`, { refreshMs: 15_000 });
27 + if (loading) return <Skeleton rows={10} />;
28 + if (!data) return <Empty>unknown platform</Empty>;
29 + const m = data.model;
30 + const rps = m ? Object.values(m.response_patterns).filter((r) => Object.keys(r.likely_entity_types).length).sort((a, b) => b.confidence - a.confidence) : [];
31 + const aps = m ? Object.values(m.action_patterns).filter((a) => a.from_page_type !== "ANY").sort((a, b) => b.observed_count - a.observed_count) : [];
32 + const pts = m ? Object.values(m.page_types).sort((a, b) => b.observed_count - a.observed_count) : [];
33 + return (
34 + <div className="space-y-4">
35 + <div className="text-xs text-dim mono">
36 + <Link href="/platforms" className="hover:text-cyan">connectors</Link> / {platform}
37 + </div>
38 + <div className="flex flex-wrap items-end gap-4">
39 + <h1 className="text-2xl font-semibold tracking-tight capitalize">{platform}</h1>
40 + {data.summary && (
41 + <div className="ml-auto flex items-center gap-3 w-72">
42 + <span className="text-xs text-dim">confidence</span>
43 + <Bar value={data.summary.confidence / 100} className="flex-1" color={data.summary.confidence >= 70 ? "#43e69a" : "#38e1ff"} />
44 + <span className="mono">{data.summary.confidence}%</span>
45 + </div>
46 + )}
47 + </div>
48 + {!m ? (
49 + <Panel>
50 + <Empty>
51 + Nothing learned yet on {platform}. {["youtube", "reddit"].includes(platform) ? "Launch a learning mission from the Missions page." : "No adapter yet — this platform is scheduled for a later phase."}
52 + </Empty>
53 + </Panel>
54 + ) : (
55 + <>
56 + <div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
57 + <Panel title="Learned model">
58 + <Metric label="page types" value={data.summary?.page_types} />
59 + <Metric label="entity types" value={data.summary?.entity_types} />
60 + <Metric label="navigation actions" value={data.summary?.navigation_actions} />
61 + <Metric label="network schemas" value={data.summary?.network_schemas} />
62 + <Metric label="media patterns" value={data.summary?.media_patterns} />
63 + <Metric label="sessions learned from" value={m.sessions_learned.length} />
64 + <Metric label="updated" value={fmtDate(m.updated_at)} />
65 + <Metric label="compiled manifest" value={data.manifest ? `${fmtDate(data.manifest.compiled_at)} · ${data.manifest.network_patterns.length} patterns` : "not yet (needs ≥ 40 %)"} />
66 + <Metric label="browser profiles" value={data.profiles.length ? data.profiles.map((p) => `${p.alias}${p.has_state ? " ✓" : ""}`).join(", ") : "none — run pnpm login"} />
67 + </Panel>
68 + <Panel title="Page types" sub="what the crawler recognises, and what each page usually yields" className="xl:col-span-2" pad={false}>
69 + <div className="p-4">
70 + <Table head={["page type", "seen", "avg entities", "avg conf", "url patterns", "entity mix"]} dense>
71 + {pts.map((p) => (
72 + <tr key={p.page_type}>
73 + <td>
74 + <Tag color="#38e1ff">{p.page_type}</Tag>
75 + </td>
76 + <td className="mono">{p.observed_count}</td>
77 + <td className="mono">{p.avg_entities.toFixed(1)}</td>
78 + <td className="mono">{p.avg_confidence.toFixed(2)}</td>
79 + <td className="mono text-[11px] text-fg-2">
80 + {Object.entries(p.url_patterns)
81 + .sort((a, b) => b[1] - a[1])
82 + .slice(0, 3)
83 + .map(([k, v]) => `${k} (${v})`)
84 + .join(" ")}
85 + </td>
86 + <td className="text-[11px]">
87 + {Object.entries(p.entity_types)
88 + .sort((a, b) => b[1] - a[1])
89 + .slice(0, 4)
90 + .map(([k, v]) => (
91 + <Tag key={k} className="mr-1">
92 + {k} {v}
93 + </Tag>
94 + ))}
95 + </td>
96 + </tr>
97 + ))}
98 + </Table>
99 + </div>
100 + </Panel>
101 + </div>
102 + <Panel title="Runtime API map" sub="action → response shape → entity types (§71) — the learned frontend API of the platform" pad={false}>
103 + <div className="p-4">
104 + {rps.length ? (
105 + <Table head={["endpoint", "entity types", "triggered by", "seen", "yield", "confidence"]} dense>
106 + {rps.map((r) => (
107 + <tr key={r.shape_hash}>
108 + <td className="mono text-[11.5px]">
109 + <Tag color="#9d7bff" className="mr-1">
110 + {r.method}
111 + </Tag>
112 + {r.hostname}
113 + <span className="text-fg-2">{r.path_pattern}</span>
114 + {r.graphql_operation && <Tag color="#ff7ad9" className="ml-1">{r.graphql_operation}</Tag>}
115 + <div className="text-[10px] text-dim">#{r.shape_hash.slice(0, 10)}</div>
116 + </td>
117 + <td>
118 + {Object.entries(r.likely_entity_types).map(([t, n]) => (
119 + <Tag key={t} color="#38e1ff" className="mr-1">
120 + {t} {n}
121 + </Tag>
122 + ))}
123 + </td>
124 + <td>
125 + {Object.entries(r.triggered_by).map(([a, n]) => (
126 + <Tag key={a} color="#ffb347" className="mr-1">
127 + {a.toLowerCase()} {n}
128 + </Tag>
129 + ))}
130 + </td>
131 + <td className="mono">{r.observed_count}</td>
132 + <td className="mono">{(r.entity_yield_total / Math.max(1, r.observed_count)).toFixed(1)} ent/resp</td>
133 + <td className="w-32">
134 + <Bar value={r.confidence} color={r.confidence >= 0.5 ? "#43e69a" : "#ffb347"} />
135 + <div className="mono text-[10px] text-dim">{r.confidence.toFixed(2)}</div>
136 + </td>
137 + </tr>
138 + ))}
139 + </Table>
140 + ) : (
141 + <Empty>no entity-bearing schema learned yet</Empty>
142 + )}
143 + </div>
144 + </Panel>
145 + <div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
146 + <Panel title="Navigation patterns" sub="action from a page type → where it leads and what it yields (§30)" pad={false}>
147 + <div className="p-4">
148 + <Table head={["action", "from", "→ to", "seen", "avg new ent", "avg resp", "fail"]} dense>
149 + {aps.map((a) => (
150 + <tr key={a.action_type + a.from_page_type}>
151 + <td>
152 + <Tag color="#ffb347">{a.action_type.toLowerCase()}</Tag>
153 + </td>
154 + <td>
155 + <Tag color="#38e1ff">{a.from_page_type}</Tag>
156 + </td>
157 + <td className="text-[11px]">
158 + {Object.entries(a.to_page_types)
159 + .sort((x, y) => y[1] - x[1])
160 + .map(([k, v]) => `${k} ${v}`)
161 + .join(", ")}
162 + </td>
163 + <td className="mono">{a.observed_count}</td>
164 + <td className="mono">{a.avg_new_entities.toFixed(1)}</td>
165 + <td className="mono">{a.avg_network_responses.toFixed(1)}</td>
166 + <td className="mono">{a.failures}</td>
167 + </tr>
168 + ))}
169 + </Table>
170 + </div>
171 + </Panel>
172 + <Panel title="Media delivery patterns" pad={false}>
173 + <div className="p-4">
174 + {Object.values(m.media_patterns).length ? (
175 + <Table head={["host", "kind", "seen"]} dense>
176 + {Object.values(m.media_patterns)
177 + .sort((a, b) => b.observed_count - a.observed_count)
178 + .map((mp) => (
179 + <tr key={mp.hostname + mp.kind}>
180 + <td className="mono text-[11.5px]">{mp.hostname}</td>
181 + <td>
182 + <Tag color="#ff7ad9">{mp.kind}</Tag>
183 + </td>
184 + <td className="mono">{mp.observed_count}</td>
185 + </tr>
186 + ))}
187 + </Table>
188 + ) : (
189 + <Empty>none yet</Empty>
190 + )}
191 + </div>
192 + </Panel>
193 + </div>
194 + </>
195 + )}
196 + </div>
197 + );
198 +}
added apps/dashboard/src/app/platforms/page.tsx +82 −0
@@ -0,0 +1,82 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { useApi } from "@/lib/api";
5 +import { Bar, Panel, Skeleton, Tag } from "@/components/ui";
6 +
7 +interface P {
8 + platform: string;
9 + learned: boolean;
10 + adapter: boolean;
11 + confidence: number;
12 + page_types: number;
13 + entity_types: number;
14 + navigation_actions: number;
15 + network_schemas: number;
16 + media_patterns: number;
17 + sessions: number;
18 + has_manifest: boolean;
19 + profiles: { alias: string; has_state: boolean }[];
20 +}
21 +
22 +const PHASE: Record<string, string> = { youtube: "Phase 1", reddit: "Phase 1", facebook: "Phase 2", instagram: "Phase 2", tiktok: "Phase 3", x: "Phase 3", linkedin: "Phase 4", threads: "Phase 4" };
23 +
24 +export default function Platforms() {
25 + const { data, loading } = useApi<P[]>("/platforms", { refreshMs: 15_000 });
26 + return (
27 + <div className="space-y-4">
28 + <div>
29 + <h1 className="text-2xl font-semibold tracking-tight">Connectors</h1>
30 + <p className="text-sm text-fg-2 max-w-2xl">A connector is learned state, not hand-written truth: page types, response schemas, action → observation patterns and media patterns accumulated across sessions, compiled into a manifest once confidence is sufficient.</p>
31 + </div>
32 + {loading ? (
33 + <Skeleton rows={6} />
34 + ) : (
35 + <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
36 + {data?.map((p) => (
37 + <Link key={p.platform} href={`/platforms/${p.platform}`} className="block">
38 + <Panel className="h-full hover:border-cyan/40 transition">
39 + <div className="flex items-center gap-2">
40 + <span className="text-lg font-semibold capitalize">{p.platform}</span>
41 + <span className="ml-auto text-[10px] uppercase tracking-widest text-dim">{PHASE[p.platform]}</span>
42 + </div>
43 + <div className="flex gap-1 mt-2 flex-wrap">
44 + <Tag color={p.adapter ? "#43e69a" : "#6b7a8c"}>{p.adapter ? "adapter" : "no adapter"}</Tag>
45 + <Tag color={p.learned ? "#38e1ff" : "#6b7a8c"}>{p.learned ? "learned" : "unlearned"}</Tag>
46 + {p.has_manifest && <Tag color="#9d7bff">compiled</Tag>}
47 + {p.profiles.map((pr) => (
48 + <Tag key={pr.alias} color={pr.has_state ? "#ffb347" : "#6b7a8c"}>
49 + profile {pr.alias}
50 + </Tag>
51 + ))}
52 + </div>
53 + <div className="mt-4">
54 + <div className="flex justify-between text-xs text-dim mb-1">
55 + <span>connector confidence</span>
56 + <span className="mono text-fg">{p.confidence}%</span>
57 + </div>
58 + <Bar value={p.confidence / 100} color={p.confidence >= 70 ? "#43e69a" : p.confidence >= 40 ? "#38e1ff" : "#ffb347"} />
59 + </div>
60 + <dl className="grid grid-cols-2 gap-x-3 gap-y-1 mt-4 text-xs">
61 + {[
62 + ["page types", p.page_types],
63 + ["entity types", p.entity_types],
64 + ["navigation actions", p.navigation_actions],
65 + ["network schemas", p.network_schemas],
66 + ["media patterns", p.media_patterns],
67 + ["sessions", p.sessions],
68 + ].map(([k, v]) => (
69 + <div key={k as string} className="flex justify-between border-b border-line/60 py-1">
70 + <dt className="text-dim">{k as string}</dt>
71 + <dd className="mono">{v as number}</dd>
72 + </div>
73 + ))}
74 + </dl>
75 + </Panel>
76 + </Link>
77 + ))}
78 + </div>
79 + )}
80 + </div>
81 + );
82 +}
added apps/dashboard/src/app/schemas/page.tsx +97 −0
@@ -0,0 +1,97 @@
1 +"use client";
2 +
3 +import { useState } from "react";
4 +import { useApi } from "@/lib/api";
5 +import { fmtDate } from "@/lib/format";
6 +import { Empty, Panel, Skeleton, Table, Tag } from "@/components/ui";
7 +
8 +interface Schema {
9 + platform: string;
10 + shape_hash: string;
11 + fingerprint: { hostname: string; path_pattern: string; method: string; graphql_operation?: string; observed_entity_types?: string[] };
12 + schema: { candidate_entity_types: { type: string; confidence: number; path: string }[]; fields: { path: string; semantic?: { kind: string; confidence: number }; example?: string; types?: string[] }[]; object_count: number; repeated_object_paths: string[] };
13 + observed_count: number;
14 + sample_url: string;
15 + first_seen: string;
16 + last_seen: string;
17 +}
18 +
19 +export default function Schemas() {
20 + const [platform, setPlatform] = useState("");
21 + const { data, loading } = useApi<Schema[]>(`/schemas${platform ? `?platform=${platform}` : ""}`, { refreshMs: 15_000 });
22 + const platforms = [...new Set((data ?? []).map((s) => s.platform))];
23 + return (
24 + <div className="space-y-4">
25 + <div className="flex flex-wrap items-end gap-3">
26 + <div>
27 + <h1 className="text-2xl font-semibold tracking-tight">Runtime APIs</h1>
28 + <p className="text-sm text-fg-2 max-w-2xl">Response shapes the browser received while a human-like session unfolded. Endpoints are fingerprinted by (host, path pattern, method, structure hash) and their fields are semantically inferred — nothing here was written by hand.</p>
29 + </div>
30 + <select value={platform} onChange={(e) => setPlatform(e.target.value)} className="ml-auto rounded-lg bg-bg-2 border border-line-2 px-2 py-1.5 text-sm">
31 + <option value="">all platforms</option>
32 + {["youtube", "reddit", ...platforms.filter((p) => !["youtube", "reddit"].includes(p))].map((p) => (
33 + <option key={p} value={p}>
34 + {p}
35 + </option>
36 + ))}
37 + </select>
38 + </div>
39 + {loading ? (
40 + <Skeleton rows={8} />
41 + ) : data?.length ? (
42 + <div className="space-y-3">
43 + {data.map((sc) => (
44 + <details key={sc.platform + sc.shape_hash} className="panel open:glow-cyan/30">
45 + <summary className="cursor-pointer px-4 py-3 flex flex-wrap items-center gap-2 text-sm">
46 + <Tag>{sc.platform}</Tag>
47 + <Tag color="#9d7bff">{sc.fingerprint.method}</Tag>
48 + <span className="mono">
49 + {sc.fingerprint.hostname}
50 + <span className="text-fg-2">{sc.fingerprint.path_pattern}</span>
51 + </span>
52 + {sc.fingerprint.graphql_operation && <Tag color="#ff7ad9">{sc.fingerprint.graphql_operation}</Tag>}
53 + <span className="ml-auto flex gap-1 flex-wrap">
54 + {sc.schema.candidate_entity_types.slice(0, 5).map((c) => (
55 + <Tag key={c.type + c.path} color="#38e1ff" title={c.path}>
56 + {c.type} {Number(c.confidence).toFixed(2)}
57 + </Tag>
58 + ))}
59 + </span>
60 + <span className="mono text-xs text-dim whitespace-nowrap">
61 + seen {sc.observed_count}× · {sc.schema.object_count} objects · #{sc.shape_hash.slice(0, 8)}
62 + </span>
63 + </summary>
64 + <div className="px-4 pb-4 space-y-3">
65 + <div className="text-[11px] text-dim mono break-all">
66 + sample {sc.sample_url} · first {fmtDate(sc.first_seen)} · last {fmtDate(sc.last_seen)}
67 + </div>
68 + {sc.schema.repeated_object_paths?.length ? (
69 + <div className="text-xs text-fg-2">
70 + repeated object arrays (entity lists): <span className="mono">{sc.schema.repeated_object_paths.slice(0, 5).join(" · ")}</span>
71 + </div>
72 + ) : null}
73 + <Table head={["field path", "inferred semantic", "conf", "types", "example"]} dense>
74 + {sc.schema.fields.slice(0, 80).map((f) => (
75 + <tr key={f.path}>
76 + <td className="mono text-[11px] text-fg-2 max-w-[560px] truncate" title={f.path}>
77 + {f.path}
78 + </td>
79 + <td>{f.semantic ? <Tag color={f.semantic.kind === "identifier" ? "#ffb347" : f.semantic.kind.includes("url") ? "#ff7ad9" : "#43e69a"}>{f.semantic.kind}</Tag> : "—"}</td>
80 + <td className="mono text-[11px]">{f.semantic ? Number(f.semantic.confidence).toFixed(2) : ""}</td>
81 + <td className="mono text-[11px] text-dim">{f.types?.join("|")}</td>
82 + <td className="mono text-[11px] text-dim max-w-[320px] truncate">{f.example}</td>
83 + </tr>
84 + ))}
85 + </Table>
86 + </div>
87 + </details>
88 + ))}
89 + </div>
90 + ) : (
91 + <Panel>
92 + <Empty>No schemas discovered yet.</Empty>
93 + </Panel>
94 + )}
95 + </div>
96 + );
97 +}
added apps/dashboard/src/app/sessions/[id]/page.tsx +405 −0
@@ -0,0 +1,405 @@
1 +"use client";
2 +
3 +import { use, useMemo, useState } from "react";
4 +import Link from "next/link";
5 +import clsx from "clsx";
6 +import { ExternalLink } from "lucide-react";
7 +import { useApi } from "@/lib/api";
8 +import { ago, eventColor, fmtDate, fmtDuration, fmtTime, truncate } from "@/lib/format";
9 +import { Bar, Empty, Metric, Panel, Skeleton, StatusPill, Tag, Table } from "@/components/ui";
10 +import { GainChart } from "@/components/charts";
11 +import { LiveFeed } from "@/components/LiveFeed";
12 +import { WorldGraph, type GraphEdge, type GraphNode } from "@/components/WorldGraph";
13 +import { EntityTable, type EntityRow } from "@/components/EntityTable";
14 +
15 +interface Session {
16 + session_id: string;
17 + platform: string;
18 + account_alias: string;
19 + mode: string | null;
20 + goal: string | null;
21 + started_at: string;
22 + ended_at: string | null;
23 + health: string;
24 + job: { budget?: Record<string, number>; query?: string; media_level?: number } | null;
25 + summary: { steps: number; entities: number; videos: number; network_responses: number; schemas: number; patterns_learned: number; ended_because: string; world: { nodes: number; edges: number; visited: number; multi_surface: number; by_type: Record<string, number> }; connector: { confidence: number; page_types: number; network_schemas: number } } | null;
26 + event_counts: { event_type: string; n: number }[];
27 +}
28 +interface Action {
29 + step: number;
30 + action_type: string;
31 + label: string;
32 + target_url: string | null;
33 + planner: string;
34 + expected_gain: number;
35 + novelty: number;
36 + relevance: number;
37 + reason: string;
38 + scores: { action_id: string; information_gain: number; penalties: string[] }[];
39 + before_state: { url: string; page_type: string; visible_entities: number } | null;
40 + after_state: { url: string; page_type: string; visible_entities: number; new_entities: number; network_responses: number; dom_nodes_added: number } | null;
41 + success: boolean;
42 + error: string | null;
43 + duration_ms: number;
44 + ts: string;
45 +}
46 +interface PageRow {
47 + step: number;
48 + ts: string;
49 + payload: { url: string; title: string; page_type: string; confidence: number; signals: string[]; entities: number; dom_entities: number; network_entities: number; both_surfaces: number; field_agreements: number; field_conflicts: { field: string; network: unknown; dom: unknown }[]; videos: number; summary: string };
50 +}
51 +interface Media {
52 + fingerprint: string;
53 + media_type: string;
54 + platform_media_id: string | null;
55 + title: string | null;
56 + author: string | null;
57 + duration_s: number | null;
58 + width: number | null;
59 + height: number | null;
60 + thumbnail_url: string | null;
61 + page_url: string | null;
62 + delivery: { kind: string; hostnames: string[]; manifest_url?: string } | null;
63 + frames: string[] | null;
64 +}
65 +interface Schema {
66 + shape_hash: string;
67 + fingerprint: { hostname: string; path_pattern: string; method: string; graphql_operation?: string };
68 + schema: { candidate_entity_types: { type: string; confidence: number; path: string }[]; fields: { path: string; semantic?: { kind: string; confidence: number }; example?: string }[]; object_count: number };
69 + observed_count: number;
70 + sample_url: string;
71 +}
72 +
73 +const TABS = ["decisions", "pages", "entities", "runtime apis", "media", "world model", "events"] as const;
74 +
75 +export default function SessionConsole({ params }: { params: Promise<{ id: string }> }) {
76 + const { id } = use(params);
77 + const [tab, setTab] = useState<(typeof TABS)[number]>("decisions");
78 + const s = useApi<Session>(`/sessions/${id}`, { refreshMs: 10_000 });
79 + const live = !s.data?.ended_at;
80 + const actions = useApi<Action[]>(`/sessions/${id}/actions`, { refreshMs: live ? 5000 : undefined });
81 + const pages = useApi<PageRow[]>(`/sessions/${id}/pages`, { refreshMs: live ? 5000 : undefined });
82 + const entities = useApi<EntityRow[]>(tab === "entities" ? `/sessions/${id}/entities?limit=500` : null, { refreshMs: live ? 8000 : undefined });
83 + const media = useApi<Media[]>(tab === "media" ? `/sessions/${id}/media` : null);
84 + const schemas = useApi<Schema[]>(tab === "runtime apis" ? `/sessions/${id}/schemas` : null);
85 + const world = useApi<{ nodes: GraphNode[]; edges: GraphEdge[] }>(tab === "world model" ? `/sessions/${id}/world` : null, { refreshMs: live ? 10_000 : undefined });
86 + const [selStep, setSelStep] = useState<number | null>(null);
87 +
88 + const gainSeries = useMemo(() => (actions.data ?? []).map((a) => ({ step: a.step, expected_gain: Number(a.expected_gain), novelty: Number(a.novelty), relevance: Number(a.relevance), action_type: a.action_type, new_entities: a.after_state?.new_entities })), [actions.data]);
89 + const sess = s.data;
90 + const sum = sess?.summary;
91 + const lastPage = pages.data?.[pages.data.length - 1];
92 + const selPage = selStep !== null ? pages.data?.find((p) => p.step === selStep) : lastPage;
93 +
94 + return (
95 + <div className="space-y-4">
96 + <div className="flex flex-wrap items-start gap-3">
97 + <div className="min-w-0">
98 + <div className="flex items-center gap-2 text-xs text-dim mono">
99 + <Link href="/sessions" className="hover:text-cyan">sessions</Link> / {id}
100 + </div>
101 + <h1 className="text-xl font-semibold tracking-tight mt-1 truncate max-w-3xl">{sess?.goal ?? "…"}</h1>
102 + <div className="flex flex-wrap items-center gap-2 mt-2 text-xs">
103 + {sess && <StatusPill status={sess.ended_at ? (sess.health === "auth_required" ? "auth_required" : "done") : sess.health} />}
104 + {sess && <Tag>{sess.platform}</Tag>}
105 + {sess?.mode && <Tag color="#ffb347">{sess.mode}</Tag>}
106 + {sess?.job?.query && <Tag color="#9d7bff">“{sess.job.query}”</Tag>}
107 + {sess && <span className="text-dim">started {fmtDate(sess.started_at)} · {sess.ended_at ? `ended ${ago(sess.ended_at)}` : "running"}</span>}
108 + {sum && <span className="text-dim">· ended because <span className="text-fg-2">{sum.ended_because}</span></span>}
109 + </div>
110 + </div>
111 + <div className="ml-auto grid grid-cols-3 sm:grid-cols-6 gap-2 text-center">
112 + {[
113 + ["steps", actions.data?.length ?? sum?.steps],
114 + ["entities", sum?.entities ?? sess?.event_counts.filter((c) => /DISCOVERED/.test(c.event_type)).reduce((a, c) => a + c.n, 0)],
115 + ["videos", sum?.videos],
116 + ["responses", sum?.network_responses ?? sess?.event_counts.find((c) => c.event_type === "NETWORK_RESPONSE_OBSERVED")?.n],
117 + ["schemas", sum?.schemas ?? sess?.event_counts.find((c) => c.event_type === "NETWORK_SCHEMA_DISCOVERED")?.n],
118 + ["learned", sum?.patterns_learned ?? sess?.event_counts.find((c) => c.event_type === "CONNECTOR_PATTERN_LEARNED")?.n],
119 + ].map(([k, v]) => (
120 + <div key={k as string} className="panel px-3 py-2">
121 + <div className="mono text-lg text-cyan">{v ?? "—"}</div>
122 + <div className="text-[10px] uppercase tracking-widest text-dim">{k as string}</div>
123 + </div>
124 + ))}
125 + </div>
126 + </div>
127 +
128 + <div className="flex gap-1 border-b border-line text-sm">
129 + {TABS.map((t) => (
130 + <button key={t} onClick={() => setTab(t)} className={clsx("px-3 py-2 -mb-px border-b-2 capitalize transition", tab === t ? "border-cyan text-cyan" : "border-transparent text-fg-2 hover:text-fg")}>
131 + {t}
132 + </button>
133 + ))}
134 + </div>
135 +
136 + {tab === "decisions" && (
137 + <div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
138 + <div className="xl:col-span-2 space-y-4">
139 + <Panel title="Information gain per step" sub="what the agent expected of each move">
140 + {gainSeries.length ? <GainChart data={gainSeries} /> : <Empty>no decisions yet</Empty>}
141 + </Panel>
142 + <Panel title="Agent decisions" sub="why did the crawler do that? — click a step to see its page state" pad={false}>
143 + {actions.loading ? (
144 + <div className="p-4">
145 + <Skeleton rows={6} />
146 + </div>
147 + ) : (
148 + <ul className="divide-y divide-line/60 max-h-[720px] overflow-auto scrollbar-thin">
149 + {(actions.data ?? [])
150 + .slice()
151 + .reverse()
152 + .map((a) => (
153 + <li key={a.step} onClick={() => setSelStep(a.step)} className={clsx("px-4 py-2.5 cursor-pointer hover:bg-white/[0.03]", selStep === a.step && "bg-cyan/5")}>
154 + <div className="flex items-center gap-2 text-sm">
155 + <span className="mono text-dim w-8">#{a.step}</span>
156 + <Tag color={a.success ? "#43e69a" : "#ff5c7a"}>{a.action_type.replace(/_/g, " ").toLowerCase()}</Tag>
157 + <span className="truncate text-fg">{truncate(a.label.replace(/^Open \w+ E\d+: /, ""), 80)}</span>
158 + <span className="ml-auto mono text-xs text-amber whitespace-nowrap">gain {Number(a.expected_gain).toFixed(3)}</span>
159 + </div>
160 + <div className="flex flex-wrap gap-x-4 text-[11px] text-dim mono mt-1 ml-10">
161 + <span>nov {Number(a.novelty).toFixed(2)}</span>
162 + <span>rel {Number(a.relevance).toFixed(2)}</span>
163 + <span>{a.planner}</span>
164 + {a.after_state && (
165 + <span>
166 + → {a.after_state.page_type} · +{a.after_state.new_entities} ent · {a.after_state.network_responses} resp · {a.duration_ms} ms
167 + </span>
168 + )}
169 + {a.error && <span className="text-red">{truncate(a.error, 60)}</span>}
170 + </div>
171 + <div className="text-xs text-fg-2 mt-1 ml-10">{a.reason}</div>
172 + </li>
173 + ))}
174 + </ul>
175 + )}
176 + </Panel>
177 + </div>
178 + <div className="space-y-4">
179 + <Panel title={selStep !== null ? `Page state after step ${selStep}` : "Current page state"} sub="semantic DOM representation given to the planner">
180 + {selPage ? (
181 + <div className="space-y-2">
182 + <div className="flex items-center gap-2 text-xs">
183 + <Tag color="#38e1ff">{selPage.payload.page_type}</Tag>
184 + <span className="mono text-dim">conf {Number(selPage.payload.confidence).toFixed(2)}</span>
185 + <a href={selPage.payload.url} target="_blank" rel="noreferrer" className="ml-auto text-dim hover:text-cyan">
186 + <ExternalLink className="h-3.5 w-3.5" />
187 + </a>
188 + </div>
189 + <div className="text-[11px] text-dim break-all">{selPage.payload.url}</div>
190 + <div className="grid grid-cols-4 gap-2 text-center text-[10.5px] mono">
191 + {[
192 + ["entities", selPage.payload.entities],
193 + ["dom", selPage.payload.dom_entities],
194 + ["network", selPage.payload.network_entities],
195 + ["both", selPage.payload.both_surfaces],
196 + ].map(([k, v]) => (
197 + <div key={k as string} className="rounded bg-bg-2 py-1">
198 + <div className="text-fg">{v as number}</div>
199 + <div className="text-dim">{k as string}</div>
200 + </div>
201 + ))}
202 + </div>
203 + {selPage.payload.field_conflicts?.length > 0 && (
204 + <div className="text-[11px] text-amber">
205 + {selPage.payload.field_conflicts.length} field conflict(s): {selPage.payload.field_conflicts.map((c) => c.field).join(", ")}
206 + </div>
207 + )}
208 + <pre className="mono text-[11px] leading-5 text-fg-2 whitespace-pre-wrap max-h-[520px] overflow-auto scrollbar-thin bg-bg-2 rounded p-3">{selPage.payload.summary}</pre>
209 + </div>
210 + ) : (
211 + <Empty>no page state yet</Empty>
212 + )}
213 + </Panel>
214 + <Panel title="Budget">
215 + {sess?.job?.budget ? (
216 + <div>
217 + <Metric label="max minutes" value={sess.job.budget.max_minutes} />
218 + <Metric label="max actions" value={sess.job.budget.max_actions} />
219 + <Metric label="max profiles opened" value={sess.job.budget.max_profiles} />
220 + <Metric label="max videos opened" value={sess.job.budget.max_videos} />
221 + <Metric label="media level" value={sess.job.media_level ?? 1} />
222 + <div className="mt-3 text-[10.5px] text-dim">steps used</div>
223 + <Bar value={(actions.data?.length ?? 0) / Math.max(1, sess.job.budget.max_actions)} color="#ffb347" />
224 + </div>
225 + ) : (
226 + <Skeleton rows={3} />
227 + )}
228 + </Panel>
229 + </div>
230 + </div>
231 + )}
232 +
233 + {tab === "pages" && (
234 + <Panel title="Navigation path" sub="each page the browser reached, classified" pad={false}>
235 + <div className="p-4">
236 + {pages.data?.length ? (
237 + <Table head={["step", "time", "page type", "conf", "url", "entities (dom / net / both)", "agreements", "videos"]} dense>
238 + {pages.data.map((p) => (
239 + <tr key={`${p.step}-${p.ts}`} onClick={() => setSelStep(p.step)} className="cursor-pointer">
240 + <td className="mono">#{p.step}</td>
241 + <td className="mono text-dim">{fmtTime(p.ts)}</td>
242 + <td>
243 + <Tag color="#38e1ff">{p.payload.page_type}</Tag>
244 + </td>
245 + <td className="mono">{Number(p.payload.confidence).toFixed(2)}</td>
246 + <td className="max-w-[420px] truncate text-fg-2">
247 + <a href={p.payload.url} target="_blank" rel="noreferrer" className="hover:text-cyan">
248 + {truncate(p.payload.url, 80)}
249 + </a>
250 + </td>
251 + <td className="mono">
252 + {p.payload.entities} <span className="text-dim">({p.payload.dom_entities} / {p.payload.network_entities} / {p.payload.both_surfaces})</span>
253 + </td>
254 + <td className="mono">
255 + {p.payload.field_agreements}
256 + {p.payload.field_conflicts?.length ? <span className="text-amber"> / {p.payload.field_conflicts.length}✗</span> : ""}
257 + </td>
258 + <td className="mono">{p.payload.videos}</td>
259 + </tr>
260 + ))}
261 + </Table>
262 + ) : (
263 + <Empty>no pages yet</Empty>
264 + )}
265 + </div>
266 + </Panel>
267 + )}
268 +
269 + {tab === "entities" && (
270 + <Panel title="Entities observed in this session" sub="every field keeps its provenance (network / dom) and confidence" pad={false}>
271 + <div className="p-4">{entities.loading ? <Skeleton rows={8} /> : entities.data?.length ? <EntityTable rows={entities.data} /> : <Empty>no entities yet</Empty>}</div>
272 + </Panel>
273 + )}
274 +
275 + {tab === "runtime apis" && (
276 + <Panel title="Runtime API discovery" sub="response shapes fingerprinted on this platform — no endpoint was hardcoded" pad={false}>
277 + <div className="p-4 space-y-3">
278 + {schemas.loading ? (
279 + <Skeleton rows={6} />
280 + ) : (
281 + (schemas.data ?? []).map((sc) => (
282 + <details key={sc.shape_hash} className="rounded-lg border border-line bg-bg-2/40 open:bg-bg-2/70">
283 + <summary className="cursor-pointer px-3 py-2 flex flex-wrap items-center gap-2 text-sm">
284 + <Tag color="#9d7bff">{sc.fingerprint.method}</Tag>
285 + <span className="mono text-fg">
286 + {sc.fingerprint.hostname}
287 + <span className="text-fg-2">{sc.fingerprint.path_pattern}</span>
288 + </span>
289 + {sc.fingerprint.graphql_operation && <Tag color="#ff7ad9">{sc.fingerprint.graphql_operation}</Tag>}
290 + <span className="ml-auto flex gap-1">
291 + {sc.schema.candidate_entity_types.slice(0, 4).map((c) => (
292 + <Tag key={c.type + c.path} color="#38e1ff">
293 + {c.type} {Number(c.confidence).toFixed(2)}
294 + </Tag>
295 + ))}
296 + </span>
297 + <span className="mono text-xs text-dim">seen {sc.observed_count}× · {sc.schema.object_count} objects · #{sc.shape_hash.slice(0, 8)}</span>
298 + </summary>
299 + <div className="px-3 pb-3">
300 + <div className="text-[11px] text-dim mono mb-2 break-all">sample: {sc.sample_url}</div>
301 + <Table head={["field path", "inferred semantic", "conf", "example"]} dense>
302 + {sc.schema.fields.slice(0, 60).map((f) => (
303 + <tr key={f.path}>
304 + <td className="mono text-[11px] text-fg-2 max-w-[520px] truncate">{f.path}</td>
305 + <td>{f.semantic ? <Tag color="#43e69a">{f.semantic.kind}</Tag> : "—"}</td>
306 + <td className="mono text-[11px]">{f.semantic ? Number(f.semantic.confidence).toFixed(2) : ""}</td>
307 + <td className="mono text-[11px] text-dim max-w-[300px] truncate">{f.example}</td>
308 + </tr>
309 + ))}
310 + </Table>
311 + </div>
312 + </details>
313 + ))
314 + )}
315 + </div>
316 + </Panel>
317 + )}
318 +
319 + {tab === "media" && (
320 + <Panel title="Media" sub="videos detected on DOM, network and entity surfaces" pad={false}>
321 + <div className="p-4 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
322 + {media.loading ? (
323 + <Skeleton rows={6} />
324 + ) : media.data?.length ? (
325 + media.data.map((m) => (
326 + <div key={m.fingerprint} className="rounded-lg border border-line bg-bg-2/40 overflow-hidden">
327 + {m.frames?.length ? (
328 + <div className="grid grid-cols-5 gap-px bg-line">
329 + {m.frames.map((f) => (
330 + // eslint-disable-next-line @next/next/no-img-element
331 + <img key={f} src={`/media/${f.split("/media/")[1] ?? ""}`} alt="" className="aspect-video object-cover w-full" />
332 + ))}
333 + </div>
334 + ) : m.thumbnail_url ? (
335 + // eslint-disable-next-line @next/next/no-img-element
336 + <img src={m.thumbnail_url} alt="" className="aspect-video object-cover w-full" />
337 + ) : (
338 + <div className="aspect-video grid place-items-center text-dim text-xs">no frame</div>
339 + )}
340 + <div className="p-3 space-y-1">
341 + <div className="text-sm truncate">{m.title ?? m.platform_media_id}</div>
342 + <div className="text-[11px] text-dim mono flex flex-wrap gap-x-3">
343 + <span>{m.author ?? ""}</span>
344 + <span>{fmtDuration(m.duration_s)}</span>
345 + {m.width ? <span>{m.width}×{m.height}</span> : null}
346 + {m.delivery?.kind && <span>{m.delivery.kind}</span>}
347 + {m.delivery?.hostnames?.length ? <span>{m.delivery.hostnames.slice(0, 2).join(", ")}</span> : null}
348 + </div>
349 + <Link href={`/entities/${encodeURIComponent(m.fingerprint)}`} className="text-[11px] text-cyan">evidence →</Link>
350 + </div>
351 + </div>
352 + ))
353 + ) : (
354 + <Empty>no media yet</Empty>
355 + )}
356 + </div>
357 + </Panel>
358 + )}
359 +
360 + {tab === "world model" && (
361 + <Panel title="Social world model" sub="graph of entities and relationships discovered in this session — orange ring = visited by the crawler" pad={false}>
362 + <div className="p-2">{world.data ? <WorldGraph nodes={world.data.nodes} edges={world.data.edges} height={620} /> : <Skeleton rows={8} />}</div>
363 + </Panel>
364 + )}
365 +
366 + {tab === "events" && (
367 + <div className="grid grid-cols-1 xl:grid-cols-4 gap-4">
368 + <Panel title="Event mix">
369 + <ul className="space-y-1 text-xs mono">
370 + {(sess?.event_counts ?? [])
371 + .slice()
372 + .sort((a, b) => b.n - a.n)
373 + .map((c) => (
374 + <li key={c.event_type} className="flex justify-between gap-2">
375 + <Tag color={eventColor(c.event_type)}>{c.event_type.toLowerCase()}</Tag>
376 + <span>{c.n}</span>
377 + </li>
378 + ))}
379 + </ul>
380 + </Panel>
381 + <Panel title="Raw observation stream" sub={live ? "live" : "session ended — showing the tail"} className="xl:col-span-3" pad={false}>
382 + <div className="p-2">{live ? <LiveFeed session={id} max={200} height={640} /> : <EventsTail id={id} />}</div>
383 + </Panel>
384 + </div>
385 + )}
386 + </div>
387 + );
388 +}
389 +
390 +function EventsTail({ id }: { id: string }) {
391 + const { data } = useApi<{ event_id: string; event_type: string; step: number; ts: string; payload: Record<string, unknown> }[]>(`/sessions/${id}/events?limit=400`);
392 + if (!data) return <Skeleton rows={8} />;
393 + return (
394 + <ul className="mono text-[11.5px] space-y-0.5 max-h-[640px] overflow-auto scrollbar-thin">
395 + {data.map((e) => (
396 + <li key={e.event_id} className="flex gap-2 leading-5 px-1">
397 + <span className="text-dim">{fmtTime(e.ts)}</span>
398 + <span className="text-dim w-7 text-right">#{e.step ?? 0}</span>
399 + <Tag color={eventColor(e.event_type)}>{e.event_type.replace(/_/g, " ").toLowerCase()}</Tag>
400 + <span className="text-fg-2 truncate">{truncate(JSON.stringify(e.payload), 160)}</span>
401 + </li>
402 + ))}
403 + </ul>
404 + );
405 +}
added apps/dashboard/src/app/sessions/page.tsx +69 −0
@@ -0,0 +1,69 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { useApi } from "@/lib/api";
5 +import { ago, fmtDate, truncate } from "@/lib/format";
6 +import { Empty, Panel, Skeleton, StatusPill, Table, Tag } from "@/components/ui";
7 +
8 +interface Row {
9 + session_id: string;
10 + platform: string;
11 + account_alias: string;
12 + mode: string | null;
13 + goal: string | null;
14 + started_at: string;
15 + ended_at: string | null;
16 + health: string;
17 + actions: number;
18 + entities: number;
19 + schemas: number;
20 + patterns: number;
21 + ended_because: string | null;
22 +}
23 +
24 +export default function Sessions() {
25 + const { data, loading } = useApi<Row[]>("/sessions?limit=200", { refreshMs: 10_000 });
26 + return (
27 + <div className="space-y-4">
28 + <h1 className="text-2xl font-semibold tracking-tight">Sessions</h1>
29 + <Panel pad={false}>
30 + <div className="p-4">
31 + {loading ? (
32 + <Skeleton rows={8} />
33 + ) : data?.length ? (
34 + <Table head={["status", "platform", "goal", "mode", "steps", "entities", "schemas", "learned", "started", "ended"]}>
35 + {data.map((r) => (
36 + <tr key={r.session_id}>
37 + <td>
38 + <StatusPill status={r.ended_at ? (r.health === "auth_required" ? "auth_required" : "done") : r.health} />
39 + </td>
40 + <td>
41 + <Tag>{r.platform}</Tag>
42 + <div className="text-[10px] text-dim mono">{r.account_alias}</div>
43 + </td>
44 + <td className="max-w-[420px]">
45 + <Link href={`/sessions/${r.session_id}`} className="hover:text-cyan">
46 + {truncate(r.goal ?? "(no goal)", 90)}
47 + </Link>
48 + <div className="text-[10px] text-dim mono">{r.session_id}</div>
49 + </td>
50 + <td className="text-fg-2">{r.mode ?? "—"}</td>
51 + <td className="mono">{r.actions}</td>
52 + <td className="mono">{r.entities}</td>
53 + <td className="mono">{r.schemas}</td>
54 + <td className="mono">{r.patterns}</td>
55 + <td className="text-dim whitespace-nowrap" title={fmtDate(r.started_at)}>
56 + {ago(r.started_at)}
57 + </td>
58 + <td className="text-dim whitespace-nowrap">{r.ended_because ?? (r.ended_at ? "—" : "running")}</td>
59 + </tr>
60 + ))}
61 + </Table>
62 + ) : (
63 + <Empty>No sessions yet.</Empty>
64 + )}
65 + </div>
66 + </Panel>
67 + </div>
68 + );
69 +}
added apps/dashboard/src/components/Brand.tsx +33 −0
@@ -0,0 +1,33 @@
1 +import clsx from "clsx";
2 +
3 +/** Social Runtime Crawler mark: an eye-like orbit — the crawler *watches* a runtime rather than fetching pages. */
4 +export function Mark({ className }: { className?: string }) {
5 + return (
6 + <svg viewBox="0 0 48 48" className={className} aria-hidden>
7 + <defs>
8 + <linearGradient id="srcg" x1="0" x2="1" y1="0" y2="1">
9 + <stop offset="0" stopColor="#38e1ff" />
10 + <stop offset="1" stopColor="#9d7bff" />
11 + </linearGradient>
12 + </defs>
13 + <ellipse cx="24" cy="24" rx="20" ry="11" fill="none" stroke="url(#srcg)" strokeWidth="2.2" transform="rotate(-24 24 24)" />
14 + <ellipse cx="24" cy="24" rx="20" ry="11" fill="none" stroke="url(#srcg)" strokeWidth="1.2" opacity=".5" transform="rotate(36 24 24)" />
15 + <circle cx="24" cy="24" r="5.5" fill="url(#srcg)" />
16 + <circle cx="41" cy="16" r="2.2" fill="#ffb347" />
17 + </svg>
18 + );
19 +}
20 +
21 +export function Brand({ size = "md" }: { size?: "sm" | "md" | "lg" }) {
22 + return (
23 + <div className={clsx("flex items-center gap-3", size === "lg" && "gap-4")}>
24 + <Mark className={clsx(size === "sm" ? "h-6 w-6" : size === "lg" ? "h-12 w-12" : "h-8 w-8")} />
25 + <div className="leading-tight">
26 + <div className={clsx("font-semibold tracking-tight", size === "lg" ? "text-xl" : "text-sm")}>
27 + Social<span className="text-cyan">Crawl</span>
28 + </div>
29 + <div className={clsx("text-dim uppercase tracking-[0.2em]", size === "lg" ? "text-[11px]" : "text-[9px]")}>Social Runtime Mining</div>
30 + </div>
31 + </div>
32 + );
33 +}
added apps/dashboard/src/components/EntityTable.tsx +79 −0
@@ -0,0 +1,79 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { ExternalLink } from "lucide-react";
5 +import { fmtDuration, fmtNum, truncate } from "@/lib/format";
6 +import { SurfaceChips, Table, TypeTag } from "./ui";
7 +
8 +export interface EntityRow {
9 + fingerprint: string;
10 + entity_type?: string;
11 + type?: string;
12 + platform: string;
13 + platform_id?: string | null;
14 + url?: string | null;
15 + name?: string | null;
16 + text_excerpt?: string | null;
17 + text?: string | null;
18 + author?: string | null;
19 + metrics?: Record<string, number> | null;
20 + media?: { has_video?: boolean; duration_s?: number; thumbnail_url?: string } | null;
21 + fields?: Record<string, { value: unknown; provenance: { surface: string; confidence: number; detail?: string }[] }> | null;
22 + provenance?: { surface: string; confidence: number; detail?: string }[] | null;
23 + confidence?: number | null;
24 + seen_count?: number;
25 + last_seen?: string;
26 + last_step?: number;
27 +}
28 +
29 +export function provenanceOf(e: EntityRow) {
30 + if (e.provenance?.length) return e.provenance;
31 + if (e.fields) return Object.values(e.fields).flatMap((f) => f.provenance ?? []);
32 + return [];
33 +}
34 +
35 +export function EntityTable({ rows, showPlatform = false, dense = true }: { rows: EntityRow[]; showPlatform?: boolean; dense?: boolean }) {
36 + return (
37 + <Table head={["type", "entity", "author", "metrics", "evidence", "conf", ""]} dense={dense}>
38 + {rows.map((e) => {
39 + const prov = provenanceOf(e);
40 + const conf = e.confidence ?? Math.max(0, ...prov.map((p) => p.confidence));
41 + const type = e.entity_type ?? e.type ?? "?";
42 + return (
43 + <tr key={e.fingerprint}>
44 + <td>
45 + <TypeTag type={type} />
46 + {showPlatform && <div className="text-[10px] text-dim mono mt-0.5">{e.platform}</div>}
47 + </td>
48 + <td className="max-w-[420px]">
49 + <Link href={`/entities/${encodeURIComponent(e.fingerprint)}`} className="text-fg hover:text-cyan">
50 + {truncate(e.name ?? e.text ?? e.text_excerpt ?? e.platform_id ?? e.fingerprint, 90)}
51 + </Link>
52 + {e.media?.has_video && e.media.duration_s ? <span className="ml-2 text-[10.5px] mono text-dim">{fmtDuration(e.media.duration_s)}</span> : null}
53 + <div className="text-[10.5px] mono text-dim truncate">{e.platform_id}</div>
54 + </td>
55 + <td className="text-fg-2 whitespace-nowrap">{truncate(e.author ?? "", 28)}</td>
56 + <td className="mono text-[11px] text-fg-2 whitespace-nowrap">
57 + {e.metrics && Object.keys(e.metrics).length
58 + ? Object.entries(e.metrics)
59 + .map(([k, v]) => `${fmtNum(v)} ${k}`)
60 + .join(" · ")
61 + : "—"}
62 + </td>
63 + <td>
64 + <SurfaceChips provenance={prov} />
65 + </td>
66 + <td className="mono text-[11px]">{conf ? conf.toFixed(2) : "—"}</td>
67 + <td>
68 + {e.url && (
69 + <a href={e.url} target="_blank" rel="noreferrer" className="text-dim hover:text-cyan" title="open on platform">
70 + <ExternalLink className="h-3.5 w-3.5" />
71 + </a>
72 + )}
73 + </td>
74 + </tr>
75 + );
76 + })}
77 + </Table>
78 + );
79 +}
added apps/dashboard/src/components/LiveFeed.tsx +78 −0
@@ -0,0 +1,78 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { AnimatePresence, motion } from "framer-motion";
5 +import { useLive, type LiveEvent } from "@/lib/api";
6 +import { eventColor, fmtTime, truncate } from "@/lib/format";
7 +import { Tag } from "./ui";
8 +
9 +export function describeEvent(e: LiveEvent): string {
10 + const p = e.payload ?? {};
11 + switch (e.event_type) {
12 + case "ACTION_PLANNED": {
13 + const a = p.action as { type?: string; label?: string } | undefined;
14 + return `${a?.type ?? ""} — ${truncate(a?.label, 80)} · gain ${Number(p.expected_information_gain ?? 0).toFixed(3)} · ${truncate(String(p.reason ?? ""), 90)}`;
15 + }
16 + case "ACTION_EXECUTED":
17 + case "ACTION_FAILED": {
18 + const a = p.action as { type?: string } | undefined;
19 + const after = p.after as { new_entities?: number; network_responses?: number } | undefined;
20 + return `${a?.type ?? ""} ${p.ok ? "ok" : "failed"}${p.error ? ` (${truncate(String(p.error), 60)})` : ""} · +${after?.new_entities ?? 0} entities · ${after?.network_responses ?? 0} responses · ${p.duration_ms} ms`;
21 + }
22 + case "PAGE_OPENED":
23 + return p.page_type ? `${p.page_type} (${Number(p.confidence ?? 0).toFixed(2)}) ${truncate(String(p.url ?? ""), 70)} · dom ${p.dom_entities} · net ${p.network_entities} · both ${p.both_surfaces}` : `${truncate(String(p.url ?? ""), 80)}`;
24 + case "NETWORK_RESPONSE_OBSERVED":
25 + return `${p.kind} ${p.method} ${p.hostname}${p.path_pattern}${p.graphql_operation ? ` [${p.graphql_operation}]` : ""} · ${p.entity_count} entities · ${p.body_size} B`;
26 + case "NETWORK_SCHEMA_DISCOVERED": {
27 + const fp = p.fingerprint as { hostname?: string; path_pattern?: string } | undefined;
28 + const sc = p.schema as { candidate_entity_types?: { type: string; confidence: number }[] } | undefined;
29 + return `new shape ${fp?.hostname}${fp?.path_pattern} → ${(sc?.candidate_entity_types ?? []).map((c) => `${c.type} ${c.confidence.toFixed(2)}`).join(", ") || "?"}`;
30 + }
31 + case "CONNECTOR_PATTERN_LEARNED":
32 + return `learned ${p.hostname}${p.path_pattern} → ${Object.keys((p.likely_entity_types as object) ?? {}).join(",")} · conf ${Number(p.confidence ?? 0).toFixed(2)} · seen ${p.observed_count}×`;
33 + case "DOM_CHANGED":
34 + return `+${p.added} −${p.removed} nodes · ${Object.entries((p.regions as Record<string, number>) ?? {}).map(([k, v]) => `${k} ${v}`).join(" ")}${p.modal_opened ? " · modal" : ""}${Number(p.new_videos) ? ` · ${p.new_videos} video` : ""}`;
35 + case "MEDIA_DISCOVERED":
36 + return `${p.media_type} ${truncate(String(p.title ?? p.platform_media_id ?? ""), 60)} · ${p.duration_s ? Math.round(Number(p.duration_s)) + "s" : ""} ${p.width ? `${p.width}×${p.height}` : ""} ${(p.delivery as { kind?: string } | undefined)?.kind ?? ""}`;
37 + case "LOOP_DETECTED":
38 + return `loop ${p.pattern} at ${truncate(String(p.url ?? ""), 60)} — breaking out`;
39 + case "CONNECTOR_DEGRADED":
40 + return `expected ${p.expected} entities on ${p.page_type}, observed ${p.observed} — ${p.action}`;
41 + case "BUDGET_EXHAUSTED":
42 + return `session ended: ${p.reason}`;
43 + default: {
44 + const ent = p.entity as { type?: string; name?: string; platform_id?: string; author?: string } | undefined;
45 + if (ent) return `${ent.type}: ${truncate(ent.name ?? ent.platform_id ?? "", 80)}${ent.author ? ` — ${truncate(ent.author, 30)}` : ""}`;
46 + return truncate(JSON.stringify(p), 120);
47 + }
48 + }
49 +}
50 +
51 +export function LiveFeed({ session, types, max = 60, height = 420 }: { session?: string; types?: string[]; max?: number; height?: number }) {
52 + const { events, connected } = useLive({ session, types, max });
53 + return (
54 + <div className="overflow-hidden" style={{ height }}>
55 + {!connected && events.length === 0 && <div className="text-xs text-dim p-2">connecting to the observation stream…</div>}
56 + {connected && events.length === 0 && <div className="text-xs text-dim p-2">stream connected — waiting for observations (start a mission)</div>}
57 + <ul className="space-y-0.5 mono text-[11.5px]">
58 + <AnimatePresence initial={false}>
59 + {events.map((e) => (
60 + <motion.li key={e.event_id} initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} transition={{ duration: 0.18 }} className="flex gap-2 items-start leading-5 rounded px-1 hover:bg-white/[0.03]">
61 + <span className="text-dim shrink-0">{fmtTime(e.ts)}</span>
62 + <span className="text-dim shrink-0 w-7 text-right">#{e.step ?? 0}</span>
63 + <Tag color={eventColor(e.event_type)} className="shrink-0">
64 + {e.event_type.replace(/_/g, " ").toLowerCase()}
65 + </Tag>
66 + <span className="text-fg-2 truncate">{describeEvent(e)}</span>
67 + {!session && (
68 + <Link href={`/sessions/${e.session_id}`} className="ml-auto shrink-0 text-dim hover:text-cyan">
69 + {e.platform}
70 + </Link>
71 + )}
72 + </motion.li>
73 + ))}
74 + </AnimatePresence>
75 + </ul>
76 + </div>
77 + );
78 +}
added apps/dashboard/src/components/Shell.tsx +78 −0
@@ -0,0 +1,78 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { usePathname } from "next/navigation";
5 +import clsx from "clsx";
6 +import { Activity, Braces, Database, Eye, Layers, Network, PlayCircle, Radar } from "lucide-react";
7 +import { Brand } from "./Brand";
8 +import { useApi, useLive } from "@/lib/api";
9 +
10 +const NAV = [
11 + { href: "/", label: "Observatory", icon: Radar },
12 + { href: "/sessions", label: "Sessions", icon: Eye },
13 + { href: "/entities", label: "Entities", icon: Database },
14 + { href: "/schemas", label: "Runtime APIs", icon: Braces },
15 + { href: "/platforms", label: "Connectors", icon: Layers },
16 + { href: "/jobs", label: "Missions", icon: PlayCircle },
17 +];
18 +
19 +export function Shell({ children }: { children: React.ReactNode }) {
20 + const path = usePathname();
21 + if (path.startsWith("/access")) return <>{children}</>;
22 + return (
23 + <div className="flex min-h-screen">
24 + <aside className="hidden md:flex w-60 shrink-0 flex-col border-r border-line bg-bg-2/60 backdrop-blur sticky top-0 h-screen">
25 + <div className="px-5 py-5 border-b border-line">
26 + <Link href="/">
27 + <Brand />
28 + </Link>
29 + </div>
30 + <nav className="p-3 space-y-1">
31 + {NAV.map((n) => {
32 + const active = n.href === "/" ? path === "/" : path.startsWith(n.href);
33 + return (
34 + <Link key={n.href} href={n.href} className={clsx("flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition", active ? "bg-cyan/10 text-cyan" : "text-fg-2 hover:bg-white/5 hover:text-fg")}>
35 + <n.icon className="h-4 w-4" />
36 + {n.label}
37 + </Link>
38 + );
39 + })}
40 + </nav>
41 + <div className="mt-auto p-4 text-[11px] text-dim leading-relaxed border-t border-line">
42 + <div className="flex items-center gap-2 mb-2">
43 + <Network className="h-3.5 w-3.5" /> MacLustr · read-only crawler
44 + </div>
45 + Observes only what the authenticated account can legitimately see. No likes, follows, comments.
46 + </div>
47 + </aside>
48 + <div className="flex-1 min-w-0 flex flex-col">
49 + <TopBar />
50 + <main className="flex-1 p-4 md:p-6 space-y-6">{children}</main>
51 + </div>
52 + </div>
53 + );
54 +}
55 +
56 +function TopBar() {
57 + const { data } = useApi<{ jobs_running: number; ts: string }>("/health", { refreshMs: 10_000 });
58 + const { connected, rate } = useLive({ max: 1 });
59 + return (
60 + <header className="sticky top-0 z-20 flex items-center gap-4 px-4 md:px-6 h-14 border-b border-line bg-bg/70 backdrop-blur">
61 + <div className="md:hidden">
62 + <Brand size="sm" />
63 + </div>
64 + <div className="ml-auto flex items-center gap-5 text-xs">
65 + <span className="flex items-center gap-2 text-fg-2">
66 + <span className={clsx("relative inline-block h-2 w-2 rounded-full", connected ? "bg-green text-green live-dot" : "bg-dim")} />
67 + {connected ? "live" : "offline"}
68 + <span className="mono text-dim">{rate}/min</span>
69 + </span>
70 + <span className="flex items-center gap-2 text-fg-2">
71 + <Activity className="h-3.5 w-3.5 text-amber" />
72 + <span className="mono">{data?.jobs_running ?? 0}</span> running
73 + </span>
74 + <span className="mono text-dim hidden sm:inline">{data?.ts ? new Date(data.ts).toLocaleTimeString("en-CA", { hour12: false }) : ""}</span>
75 + </div>
76 + </header>
77 + );
78 +}
added apps/dashboard/src/components/WorldGraph.tsx +237 −0
@@ -0,0 +1,237 @@
1 +"use client";
2 +
3 +import { useEffect, useMemo, useRef, useState } from "react";
4 +import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation, forceX, forceY, type SimulationLinkDatum, type SimulationNodeDatum } from "d3-force";
5 +import { TYPE_COLORS, truncate } from "@/lib/format";
6 +
7 +export interface GraphNode {
8 + fingerprint: string;
9 + type: string;
10 + name?: string | null;
11 + url?: string | null;
12 + visited?: boolean;
13 + seen_count?: number;
14 + surfaces?: string[];
15 +}
16 +export interface GraphEdge {
17 + from: string;
18 + to: string;
19 + type: string;
20 +}
21 +
22 +interface SimNode extends SimulationNodeDatum, GraphNode {
23 + r: number;
24 +}
25 +type SimLink = SimulationLinkDatum<SimNode> & { type: string };
26 +
27 +/**
28 + * Social World Model (§23) rendered on a canvas with a d3-force layout.
29 + * Node size = how often the entity was observed; ring = visited; colour = entity type.
30 + */
31 +export function WorldGraph({ nodes, edges, height = 520, onSelect }: { nodes: GraphNode[]; edges: GraphEdge[]; height?: number; onSelect?: (n: GraphNode | null) => void }) {
32 + const canvasRef = useRef<HTMLCanvasElement>(null);
33 + const [hover, setHover] = useState<SimNode | null>(null);
34 + const [selected, setSelected] = useState<SimNode | null>(null);
35 + const simRef = useRef<ReturnType<typeof forceSimulation<SimNode>> | null>(null);
36 + const transform = useRef({ x: 0, y: 0, k: 1 });
37 +
38 + const data = useMemo(() => {
39 + const limit = 600;
40 + const ns: SimNode[] = nodes.slice(0, limit).map((n) => ({ ...n, r: 3 + Math.min(9, Math.sqrt(n.seen_count ?? 1) * 1.8) + (n.visited ? 2 : 0) }));
41 + const ids = new Set(ns.map((n) => n.fingerprint));
42 + const ls: SimLink[] = edges.filter((e) => ids.has(e.from) && ids.has(e.to)).map((e) => ({ source: e.from, target: e.to, type: e.type }));
43 + return { ns, ls };
44 + }, [nodes, edges]);
45 +
46 + useEffect(() => {
47 + const canvas = canvasRef.current;
48 + if (!canvas) return;
49 + const parent = canvas.parentElement!;
50 + const dpr = window.devicePixelRatio || 1;
51 + const W = parent.clientWidth;
52 + const H = height;
53 + canvas.width = W * dpr;
54 + canvas.height = H * dpr;
55 + canvas.style.width = `${W}px`;
56 + canvas.style.height = `${H}px`;
57 + const ctx = canvas.getContext("2d")!;
58 + ctx.scale(dpr, dpr);
59 +
60 + const sim = forceSimulation<SimNode>(data.ns)
61 + .force("link", forceLink<SimNode, SimLink>(data.ls).id((d) => d.fingerprint).distance(38).strength(0.4))
62 + .force("charge", forceManyBody().strength(-45))
63 + .force("center", forceCenter(W / 2, H / 2))
64 + .force("x", forceX(W / 2).strength(0.03))
65 + .force("y", forceY(H / 2).strength(0.03))
66 + .force("collide", forceCollide<SimNode>((d) => d.r + 2))
67 + .alphaDecay(0.03);
68 + simRef.current = sim;
69 +
70 + const draw = () => {
71 + const { x: tx, y: ty, k } = transform.current;
72 + ctx.clearRect(0, 0, W, H);
73 + ctx.save();
74 + ctx.translate(tx, ty);
75 + ctx.scale(k, k);
76 + ctx.lineWidth = 0.6 / k;
77 + for (const l of data.ls) {
78 + const s = l.source as SimNode;
79 + const t = l.target as SimNode;
80 + if (s.x === undefined || t.x === undefined) continue;
81 + ctx.strokeStyle = l.type === "AUTHORED" ? "rgba(157,123,255,0.35)" : "rgba(255,255,255,0.08)";
82 + ctx.beginPath();
83 + ctx.moveTo(s.x!, s.y!);
84 + ctx.lineTo(t.x!, t.y!);
85 + ctx.stroke();
86 + }
87 + for (const n of data.ns) {
88 + if (n.x === undefined) continue;
89 + const color = TYPE_COLORS[n.type] ?? "#6b7a8c";
90 + const isSel = selected?.fingerprint === n.fingerprint || hover?.fingerprint === n.fingerprint;
91 + ctx.beginPath();
92 + ctx.arc(n.x!, n.y!, n.r, 0, Math.PI * 2);
93 + ctx.fillStyle = isSel ? color : `${color}cc`;
94 + ctx.fill();
95 + if (n.visited) {
96 + ctx.beginPath();
97 + ctx.arc(n.x!, n.y!, n.r + 3, 0, Math.PI * 2);
98 + ctx.strokeStyle = "#ffb347";
99 + ctx.lineWidth = 1.2 / k;
100 + ctx.stroke();
101 + }
102 + if (isSel || (n.seen_count ?? 0) > 4 || n.visited) {
103 + ctx.fillStyle = isSel ? "#e6edf5" : "rgba(230,237,245,0.7)";
104 + ctx.font = `${11 / k}px ui-monospace, monospace`;
105 + ctx.fillText(truncate(n.name ?? n.fingerprint.split(":").pop() ?? "", 34), n.x! + n.r + 4, n.y! + 3);
106 + }
107 + }
108 + ctx.restore();
109 + };
110 + sim.on("tick", draw);
111 + draw();
112 +
113 + const pick = (ev: MouseEvent): SimNode | null => {
114 + const rect = canvas.getBoundingClientRect();
115 + const { x: tx, y: ty, k } = transform.current;
116 + const mx = (ev.clientX - rect.left - tx) / k;
117 + const my = (ev.clientY - rect.top - ty) / k;
118 + let best: SimNode | null = null;
119 + let bd = Infinity;
120 + for (const n of data.ns) {
121 + if (n.x === undefined) continue;
122 + const d = Math.hypot(n.x! - mx, n.y! - my);
123 + if (d < n.r + 4 && d < bd) {
124 + bd = d;
125 + best = n;
126 + }
127 + }
128 + return best;
129 + };
130 + let dragging: SimNode | null = null;
131 + let panning = false;
132 + let last = { x: 0, y: 0 };
133 + const onMove = (ev: MouseEvent) => {
134 + if (dragging) {
135 + const rect = canvas.getBoundingClientRect();
136 + const { x: tx, y: ty, k } = transform.current;
137 + dragging.fx = (ev.clientX - rect.left - tx) / k;
138 + dragging.fy = (ev.clientY - rect.top - ty) / k;
139 + sim.alpha(0.3).restart();
140 + return;
141 + }
142 + if (panning) {
143 + transform.current.x += ev.clientX - last.x;
144 + transform.current.y += ev.clientY - last.y;
145 + last = { x: ev.clientX, y: ev.clientY };
146 + draw();
147 + return;
148 + }
149 + const h = pick(ev);
150 + setHover(h);
151 + canvas.style.cursor = h ? "pointer" : "grab";
152 + draw();
153 + };
154 + const onDown = (ev: MouseEvent) => {
155 + const h = pick(ev);
156 + if (h) {
157 + dragging = h;
158 + } else {
159 + panning = true;
160 + last = { x: ev.clientX, y: ev.clientY };
161 + }
162 + };
163 + const onUp = (ev: MouseEvent) => {
164 + if (dragging) {
165 + dragging.fx = null;
166 + dragging.fy = null;
167 + setSelected(dragging);
168 + onSelect?.(dragging);
169 + dragging = null;
170 + } else if (panning) {
171 + panning = false;
172 + if (Math.hypot(ev.clientX - last.x, ev.clientY - last.y) < 3) {
173 + setSelected(null);
174 + onSelect?.(null);
175 + }
176 + }
177 + };
178 + const onWheel = (ev: WheelEvent) => {
179 + ev.preventDefault();
180 + const rect = canvas.getBoundingClientRect();
181 + const mx = ev.clientX - rect.left;
182 + const my = ev.clientY - rect.top;
183 + const k0 = transform.current.k;
184 + const k1 = Math.max(0.3, Math.min(4, k0 * (ev.deltaY < 0 ? 1.1 : 0.9)));
185 + transform.current.x = mx - ((mx - transform.current.x) * k1) / k0;
186 + transform.current.y = my - ((my - transform.current.y) * k1) / k0;
187 + transform.current.k = k1;
188 + draw();
189 + };
190 + canvas.addEventListener("mousemove", onMove);
191 + canvas.addEventListener("mousedown", onDown);
192 + window.addEventListener("mouseup", onUp);
193 + canvas.addEventListener("wheel", onWheel, { passive: false });
194 + return () => {
195 + sim.stop();
196 + canvas.removeEventListener("mousemove", onMove);
197 + canvas.removeEventListener("mousedown", onDown);
198 + window.removeEventListener("mouseup", onUp);
199 + canvas.removeEventListener("wheel", onWheel);
200 + };
201 + // eslint-disable-next-line react-hooks/exhaustive-deps
202 + }, [data, height]);
203 +
204 + const legend = useMemo(() => {
205 + const c: Record<string, number> = {};
206 + for (const n of nodes) c[n.type] = (c[n.type] ?? 0) + 1;
207 + return Object.entries(c).sort((a, b) => b[1] - a[1]);
208 + }, [nodes]);
209 +
210 + return (
211 + <div className="relative">
212 + <canvas ref={canvasRef} className="w-full rounded-lg bg-bg-2/60" style={{ height }} />
213 + <div className="absolute left-3 top-3 flex flex-wrap gap-2 text-[10.5px] mono">
214 + {legend.map(([t, n]) => (
215 + <span key={t} className="inline-flex items-center gap-1 rounded bg-bg/70 px-1.5 py-0.5 border border-line">
216 + <span className="h-2 w-2 rounded-full" style={{ background: TYPE_COLORS[t] ?? "#6b7a8c" }} /> {t} {n}
217 + </span>
218 + ))}
219 + <span className="inline-flex items-center gap-1 rounded bg-bg/70 px-1.5 py-0.5 border border-line">
220 + <span className="h-2 w-2 rounded-full border border-amber" /> visited
221 + </span>
222 + </div>
223 + {(hover || selected) && (
224 + <div className="absolute right-3 bottom-3 max-w-xs panel p-3 text-xs space-y-1">
225 + <div className="text-fg font-medium truncate">{(hover ?? selected)!.name ?? (hover ?? selected)!.fingerprint}</div>
226 + <div className="text-dim mono">{(hover ?? selected)!.type} · seen {(hover ?? selected)!.seen_count ?? 1}× · {(hover ?? selected)!.surfaces?.join("+") ?? ""}</div>
227 + {(hover ?? selected)!.url && (
228 + <a className="text-cyan truncate block" href={(hover ?? selected)!.url!} target="_blank" rel="noreferrer">
229 + {(hover ?? selected)!.url}
230 + </a>
231 + )}
232 + </div>
233 + )}
234 + <div className="absolute right-3 top-3 text-[10.5px] text-dim mono">{nodes.length} nodes · {edges.length} edges · scroll to zoom · drag to move</div>
235 + </div>
236 + );
237 +}
added apps/dashboard/src/components/charts.tsx +110 −0
@@ -0,0 +1,110 @@
1 +"use client";
2 +
3 +import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Legend, Line, LineChart, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
4 +import { TYPE_COLORS, fmtNum } from "@/lib/format";
5 +
6 +const AXIS = { stroke: "#6b7a8c", fontSize: 10.5, fontFamily: "var(--font-mono)" } as const;
7 +const TT = { contentStyle: { background: "#0d1219", border: "1px solid #243042", borderRadius: 8, fontSize: 12, fontFamily: "var(--font-mono)" }, labelStyle: { color: "#a9b6c6" }, itemStyle: { color: "#e6edf5" } } as const;
8 +
9 +export function ActivityChart({ data, height = 220 }: { data: { bucket: string; entities: number; responses: number; actions: number; schemas: number }[]; height?: number }) {
10 + const rows = data.map((d) => ({ ...d, t: new Date(d.bucket).toLocaleTimeString("en-CA", { hour12: false, hour: "2-digit", minute: "2-digit" }) }));
11 + return (
12 + <ResponsiveContainer width="100%" height={height}>
13 + <AreaChart data={rows} margin={{ top: 8, right: 8, left: -14, bottom: 0 }}>
14 + <defs>
15 + <linearGradient id="gEnt" x1="0" x2="0" y1="0" y2="1">
16 + <stop offset="0" stopColor="#38e1ff" stopOpacity={0.5} />
17 + <stop offset="1" stopColor="#38e1ff" stopOpacity={0} />
18 + </linearGradient>
19 + <linearGradient id="gResp" x1="0" x2="0" y1="0" y2="1">
20 + <stop offset="0" stopColor="#9d7bff" stopOpacity={0.4} />
21 + <stop offset="1" stopColor="#9d7bff" stopOpacity={0} />
22 + </linearGradient>
23 + </defs>
24 + <CartesianGrid stroke="#1a2331" vertical={false} />
25 + <XAxis dataKey="t" tick={AXIS} tickLine={false} axisLine={false} minTickGap={40} />
26 + <YAxis tick={AXIS} tickLine={false} axisLine={false} tickFormatter={(v) => fmtNum(v)} />
27 + <Tooltip {...TT} />
28 + <Area type="monotone" dataKey="responses" name="network responses" stroke="#9d7bff" fill="url(#gResp)" strokeWidth={1.5} />
29 + <Area type="monotone" dataKey="entities" name="entities discovered" stroke="#38e1ff" fill="url(#gEnt)" strokeWidth={1.8} />
30 + <Line type="monotone" dataKey="actions" name="actions" stroke="#ffb347" strokeWidth={1.2} dot={false} />
31 + </AreaChart>
32 + </ResponsiveContainer>
33 + );
34 +}
35 +
36 +export function TypeDonut({ data, height = 200 }: { data: { entity_type: string; n: number }[]; height?: number }) {
37 + const agg = new Map<string, number>();
38 + for (const d of data) agg.set(d.entity_type, (agg.get(d.entity_type) ?? 0) + Number(d.n));
39 + const rows = [...agg.entries()].map(([name, value]) => ({ name, value })).sort((a, b) => b.value - a.value);
40 + return (
41 + <ResponsiveContainer width="100%" height={height}>
42 + <PieChart>
43 + <Pie data={rows} dataKey="value" nameKey="name" innerRadius="58%" outerRadius="85%" paddingAngle={2} stroke="none">
44 + {rows.map((r) => (
45 + <Cell key={r.name} fill={TYPE_COLORS[r.name] ?? "#6b7a8c"} />
46 + ))}
47 + </Pie>
48 + <Tooltip {...TT} />
49 + <Legend iconType="circle" iconSize={7} wrapperStyle={{ fontSize: 11, fontFamily: "var(--font-mono)", color: "#a9b6c6" }} />
50 + </PieChart>
51 + </ResponsiveContainer>
52 + );
53 +}
54 +
55 +export function GainChart({ data, height = 200 }: { data: { step: number; expected_gain: number; novelty: number; relevance: number; action_type: string; new_entities?: number }[]; height?: number }) {
56 + return (
57 + <ResponsiveContainer width="100%" height={height}>
58 + <LineChart data={data} margin={{ top: 8, right: 8, left: -18, bottom: 0 }}>
59 + <CartesianGrid stroke="#1a2331" vertical={false} />
60 + <XAxis dataKey="step" tick={AXIS} tickLine={false} axisLine={false} />
61 + <YAxis tick={AXIS} tickLine={false} axisLine={false} domain={[0, 1]} />
62 + <Tooltip {...TT} labelFormatter={(l) => `step ${l}`} />
63 + <Line type="monotone" dataKey="expected_gain" name="information gain" stroke="#ffb347" strokeWidth={2} dot={{ r: 2 }} />
64 + <Line type="monotone" dataKey="novelty" name="novelty" stroke="#38e1ff" strokeWidth={1.2} dot={false} />
65 + <Line type="monotone" dataKey="relevance" name="relevance" stroke="#9d7bff" strokeWidth={1.2} dot={false} />
66 + </LineChart>
67 + </ResponsiveContainer>
68 + );
69 +}
70 +
71 +export function YieldBars({ data, height = 200 }: { data: { action_type: string; n: number; avg_new_entities: number | null; success_rate: number | null }[]; height?: number }) {
72 + const rows = data.filter((d) => d.n > 0).map((d) => ({ ...d, avg_new_entities: Number(d.avg_new_entities ?? 0), label: d.action_type.replace(/_/g, " ").toLowerCase() }));
73 + return (
74 + <ResponsiveContainer width="100%" height={height}>
75 + <BarChart data={rows} layout="vertical" margin={{ top: 4, right: 12, left: 10, bottom: 0 }}>
76 + <CartesianGrid stroke="#1a2331" horizontal={false} />
77 + <XAxis type="number" tick={AXIS} tickLine={false} axisLine={false} />
78 + <YAxis type="category" dataKey="label" tick={AXIS} tickLine={false} axisLine={false} width={110} />
79 + <Tooltip {...TT} />
80 + <Bar dataKey="avg_new_entities" name="avg new entities / action" radius={[0, 4, 4, 0]}>
81 + {rows.map((r) => (
82 + <Cell key={r.action_type} fill={r.action_type.startsWith("OPEN") ? "#38e1ff" : r.action_type === "SEARCH" ? "#ffb347" : "#9d7bff"} />
83 + ))}
84 + </Bar>
85 + </BarChart>
86 + </ResponsiveContainer>
87 + );
88 +}
89 +
90 +export function SurfaceBars({ dom, network, both, height = 120 }: { dom: number; network: number; both: number; height?: number }) {
91 + const rows = [
92 + { name: "DOM only", value: Math.max(0, dom - both), fill: "#43e69a" },
93 + { name: "Both surfaces", value: both, fill: "#38e1ff" },
94 + { name: "Network only", value: Math.max(0, network - both), fill: "#9d7bff" },
95 + ];
96 + return (
97 + <ResponsiveContainer width="100%" height={height}>
98 + <BarChart data={rows} margin={{ top: 4, right: 8, left: -18, bottom: 0 }}>
99 + <XAxis dataKey="name" tick={AXIS} tickLine={false} axisLine={false} />
100 + <YAxis tick={AXIS} tickLine={false} axisLine={false} tickFormatter={(v) => fmtNum(v)} />
101 + <Tooltip {...TT} />
102 + <Bar dataKey="value" radius={[4, 4, 0, 0]}>
103 + {rows.map((r) => (
104 + <Cell key={r.name} fill={r.fill} />
105 + ))}
106 + </Bar>
107 + </BarChart>
108 + </ResponsiveContainer>
109 + );
110 +}
added apps/dashboard/src/components/ui.tsx +135 −0
@@ -0,0 +1,135 @@
1 +"use client";
2 +
3 +import clsx from "clsx";
4 +import type { ReactNode } from "react";
5 +import { TYPE_COLORS } from "@/lib/format";
6 +
7 +export function Panel({ title, sub, right, children, className, pad = true }: { title?: ReactNode; sub?: ReactNode; right?: ReactNode; children: ReactNode; className?: string; pad?: boolean }) {
8 + return (
9 + <section className={clsx("panel flex flex-col min-w-0", className)}>
10 + {(title || right) && (
11 + <header className="flex items-center gap-3 px-4 py-3 border-b border-line">
12 + <div className="min-w-0">
13 + {title && <h2 className="text-[11px] uppercase tracking-[0.18em] text-fg-2 font-medium">{title}</h2>}
14 + {sub && <p className="text-xs text-dim truncate">{sub}</p>}
15 + </div>
16 + {right && <div className="ml-auto shrink-0 text-xs text-dim">{right}</div>}
17 + </header>
18 + )}
19 + <div className={clsx("min-w-0 flex-1", pad && "p-4")}>{children}</div>
20 + </section>
21 + );
22 +}
23 +
24 +export function Kpi({ label, value, hint, accent = "cyan", spark }: { label: string; value: ReactNode; hint?: ReactNode; accent?: "cyan" | "amber" | "green" | "violet" | "pink" | "red" | "fg"; spark?: number[] }) {
25 + const color = { cyan: "text-cyan", amber: "text-amber", green: "text-green", violet: "text-violet", pink: "text-pink", red: "text-red", fg: "text-fg" }[accent];
26 + return (
27 + <div className="panel p-4 relative overflow-hidden">
28 + <div className="text-[11px] uppercase tracking-[0.18em] text-dim">{label}</div>
29 + <div className={clsx("mono text-3xl font-semibold mt-1 tabular-nums", color)}>{value}</div>
30 + {hint && <div className="text-xs text-fg-2 mt-1">{hint}</div>}
31 + {spark && spark.length > 1 && <Spark values={spark} className="absolute right-3 bottom-3 h-8 w-24 opacity-70" />}
32 + </div>
33 + );
34 +}
35 +
36 +export function Spark({ values, className, color = "currentColor" }: { values: number[]; className?: string; color?: string }) {
37 + const max = Math.max(1, ...values);
38 + const w = 100;
39 + const h = 30;
40 + const pts = values.map((v, i) => `${(i / Math.max(1, values.length - 1)) * w},${h - (v / max) * (h - 2) - 1}`).join(" ");
41 + return (
42 + <svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" className={className}>
43 + <polyline points={pts} fill="none" stroke={color} strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
44 + </svg>
45 + );
46 +}
47 +
48 +export function Tag({ children, color, className, title }: { children: ReactNode; color?: string; className?: string; title?: string }) {
49 + const c = color ?? "#6b7a8c";
50 + return (
51 + <span title={title} className={clsx("inline-flex items-center rounded-md px-1.5 py-0.5 text-[10.5px] font-medium mono leading-4 whitespace-nowrap", className)} style={{ color: c, background: `${c}1f`, border: `1px solid ${c}33` }}>
52 + {children}
53 + </span>
54 + );
55 +}
56 +
57 +export function TypeTag({ type }: { type: string }) {
58 + return <Tag color={TYPE_COLORS[type] ?? "#6b7a8c"}>{type}</Tag>;
59 +}
60 +
61 +export function SurfaceChips({ provenance }: { provenance?: { surface: string; confidence: number; detail?: string }[] | null }) {
62 + if (!provenance?.length) return <span className="text-dim">—</span>;
63 + const best = new Map<string, { confidence: number; detail?: string }>();
64 + for (const p of provenance) if (!best.has(p.surface) || best.get(p.surface)!.confidence < p.confidence) best.set(p.surface, p);
65 + const colors: Record<string, string> = { network: "#38e1ff", dom: "#43e69a", visual: "#ff7ad9", media: "#9d7bff", accessibility: "#ffb347", navigation: "#6b7a8c" };
66 + return (
67 + <span className="inline-flex gap-1 flex-wrap">
68 + {[...best.entries()].map(([s, p]) => (
69 + <Tag key={s} color={colors[s] ?? "#6b7a8c"} title={p.detail}>
70 + {s} {Math.round(p.confidence * 100)}
71 + </Tag>
72 + ))}
73 + </span>
74 + );
75 +}
76 +
77 +export function Bar({ value, color = "#38e1ff", className }: { value: number; color?: string; className?: string }) {
78 + return (
79 + <div className={clsx("h-1.5 rounded-full bg-white/5 overflow-hidden", className)}>
80 + <div className="h-full rounded-full transition-all" style={{ width: `${Math.max(0, Math.min(100, value * 100))}%`, background: color }} />
81 + </div>
82 + );
83 +}
84 +
85 +export function Empty({ children }: { children: ReactNode }) {
86 + return <div className="text-sm text-dim py-10 text-center">{children}</div>;
87 +}
88 +
89 +export function Skeleton({ rows = 4 }: { rows?: number }) {
90 + return (
91 + <div className="space-y-2">
92 + {Array.from({ length: rows }).map((_, i) => (
93 + <div key={i} className="skeleton h-4" style={{ width: `${70 + ((i * 13) % 30)}%` }} />
94 + ))}
95 + </div>
96 + );
97 +}
98 +
99 +export function Table({ head, children, dense }: { head: ReactNode[]; children: ReactNode; dense?: boolean }) {
100 + return (
101 + <div className="overflow-auto scrollbar-thin -mx-4 px-4">
102 + <table className={clsx("w-full text-sm", dense ? "text-[12.5px]" : "")}>
103 + <thead>
104 + <tr className="text-left text-[10.5px] uppercase tracking-widest text-dim">
105 + {head.map((h, i) => (
106 + <th key={i} className="px-2 py-2 font-medium border-b border-line whitespace-nowrap">
107 + {h}
108 + </th>
109 + ))}
110 + </tr>
111 + </thead>
112 + <tbody className="[&>tr]:border-b [&>tr]:border-line/60 [&>tr:hover]:bg-white/[0.025] [&>tr>td]:px-2 [&>tr>td]:py-1.5 [&>tr>td]:align-top">{children}</tbody>
113 + </table>
114 + </div>
115 + );
116 +}
117 +
118 +export function Metric({ label, value }: { label: string; value: ReactNode }) {
119 + return (
120 + <div className="flex items-baseline justify-between gap-3 text-sm border-b border-line/60 py-1.5">
121 + <span className="text-dim">{label}</span>
122 + <span className="mono text-fg">{value}</span>
123 + </div>
124 + );
125 +}
126 +
127 +export function StatusPill({ status }: { status: string }) {
128 + const map: Record<string, string> = { running: "#ffb347", healthy: "#ffb347", queued: "#6b7a8c", done: "#43e69a", stopped: "#6b7a8c", failed: "#ff5c7a", auth_required: "#ff5c7a", crashed: "#ff5c7a", degraded: "#ff5c7a" };
129 + return (
130 + <Tag color={map[status] ?? "#6b7a8c"}>
131 + {status === "running" || status === "healthy" ? <span className="relative inline-block h-1.5 w-1.5 rounded-full bg-amber text-amber live-dot mr-1" /> : null}
132 + {status}
133 + </Tag>
134 + );
135 +}
added apps/dashboard/src/lib/api.ts +94 −0
@@ -0,0 +1,94 @@
1 +"use client";
2 +
3 +import { useCallback, useEffect, useRef, useState } from "react";
4 +
5 +/** Client-side data layer: every call goes through the Next proxy (/api/*) which adds the API token. */
6 +export async function api<T = unknown>(path: string, init?: RequestInit): Promise<T> {
7 + const res = await fetch(`/api${path}`, { ...init, headers: { "content-type": "application/json", ...(init?.headers ?? {}) }, cache: "no-store" });
8 + if (!res.ok) {
9 + let msg = `${res.status}`;
10 + try {
11 + msg = ((await res.json()) as { error?: string }).error ?? msg;
12 + } catch {
13 + /* ignore */
14 + }
15 + throw new Error(msg);
16 + }
17 + return (await res.json()) as T;
18 +}
19 +
20 +export function useApi<T>(path: string | null, opts: { refreshMs?: number; deps?: unknown[] } = {}) {
21 + const [data, setData] = useState<T | null>(null);
22 + const [error, setError] = useState<string | null>(null);
23 + const [loading, setLoading] = useState(!!path);
24 + const [tick, setTick] = useState(0);
25 + const refresh = useCallback(() => setTick((t) => t + 1), []);
26 + useEffect(() => {
27 + if (!path) return;
28 + let alive = true;
29 + api<T>(path)
30 + .then((d) => {
31 + if (!alive) return;
32 + setData(d);
33 + setError(null);
34 + })
35 + .catch((e: Error) => alive && setError(e.message))
36 + .finally(() => alive && setLoading(false));
37 + const id = opts.refreshMs ? setInterval(() => setTick((t) => t + 1), opts.refreshMs) : undefined;
38 + return () => {
39 + alive = false;
40 + if (id) clearInterval(id);
41 + };
42 + // eslint-disable-next-line react-hooks/exhaustive-deps
43 + }, [path, tick, ...(opts.deps ?? [])]);
44 + return { data, error, loading, refresh };
45 +}
46 +
47 +export interface LiveEvent {
48 + event_id: string;
49 + session_id: string;
50 + platform: string;
51 + event_type: string;
52 + step: number | null;
53 + ts: string;
54 + payload: Record<string, unknown>;
55 + provenance?: { surface: string; confidence: number }[] | null;
56 +}
57 +
58 +/** Server-Sent Events subscription (live feed). Keeps the last `max` events. */
59 +export function useLive(opts: { session?: string; types?: string[]; max?: number; enabled?: boolean } = {}) {
60 + const [events, setEvents] = useState<LiveEvent[]>([]);
61 + const [connected, setConnected] = useState(false);
62 + const [rate, setRate] = useState(0); // events per minute (rolling)
63 + const stamps = useRef<number[]>([]);
64 + const max = opts.max ?? 250;
65 + const enabled = opts.enabled !== false;
66 + useEffect(() => {
67 + if (!enabled) return;
68 + const params = new URLSearchParams();
69 + if (opts.session) params.set("session", opts.session);
70 + if (opts.types?.length) params.set("types", opts.types.join(","));
71 + const es = new EventSource(`/api/stream?${params}`);
72 + es.onopen = () => setConnected(true);
73 + es.onerror = () => setConnected(false);
74 + es.addEventListener("observation", (m) => {
75 + const ev = JSON.parse((m as MessageEvent).data) as LiveEvent;
76 + const now = Date.now();
77 + stamps.current.push(now);
78 + stamps.current = stamps.current.filter((t) => now - t < 60_000);
79 + setRate(stamps.current.length);
80 + setEvents((prev) => [ev, ...prev].slice(0, max));
81 + });
82 + const id = setInterval(() => {
83 + const now = Date.now();
84 + stamps.current = stamps.current.filter((t) => now - t < 60_000);
85 + setRate(stamps.current.length);
86 + }, 5000);
87 + return () => {
88 + es.close();
89 + clearInterval(id);
90 + };
91 + // eslint-disable-next-line react-hooks/exhaustive-deps
92 + }, [opts.session, opts.types?.join(","), enabled, max]);
93 + return { events, connected, rate };
94 +}
added apps/dashboard/src/lib/auth.ts +36 −0
@@ -0,0 +1,36 @@
1 +import { createHmac, timingSafeEqual } from "node:crypto";
2 +
3 +/**
4 + * Passcode gate. The console is public on the Internet (www.socialcrawl.co) and can launch browsers
5 + * on a cluster node, so every page and API call requires a signed cookie obtained with SRC_DASHBOARD_PASSCODE.
6 + */
7 +export const COOKIE = "src_access";
8 +const SECRET = process.env.SRC_DASHBOARD_SECRET ?? process.env.SRC_API_TOKEN ?? "dev-secret";
9 +const PASSCODE = process.env.SRC_DASHBOARD_PASSCODE ?? "";
10 +
11 +export function gateEnabled(): boolean {
12 + return PASSCODE.length > 0;
13 +}
14 +
15 +export function checkPasscode(input: string): boolean {
16 + if (!PASSCODE) return true;
17 + const a = Buffer.from(input);
18 + const b = Buffer.from(PASSCODE);
19 + return a.length === b.length && timingSafeEqual(a, b);
20 +}
21 +
22 +export function makeToken(): string {
23 + const exp = Date.now() + 30 * 24 * 3600 * 1000;
24 + const body = `${exp}`;
25 + return `${body}.${createHmac("sha256", SECRET).update(body).digest("hex")}`;
26 +}
27 +
28 +export function verifyToken(token: string | undefined): boolean {
29 + if (!PASSCODE) return true;
30 + if (!token) return false;
31 + const [body, sig] = token.split(".");
32 + if (!body || !sig) return false;
33 + const expect = createHmac("sha256", SECRET).update(body).digest("hex");
34 + if (expect.length !== sig.length || !timingSafeEqual(Buffer.from(expect), Buffer.from(sig))) return false;
35 + return Number(body) > Date.now();
36 +}
added apps/dashboard/src/lib/format.ts +84 −0
@@ -0,0 +1,84 @@
1 +export function fmtNum(n: number | string | null | undefined, digits = 0): string {
2 + if (n === null || n === undefined || n === "") return "—";
3 + const v = Number(n);
4 + if (Number.isNaN(v)) return String(n);
5 + if (Math.abs(v) >= 1e9) return (v / 1e9).toFixed(1) + "B";
6 + if (Math.abs(v) >= 1e6) return (v / 1e6).toFixed(1) + "M";
7 + if (Math.abs(v) >= 1e4) return (v / 1e3).toFixed(1) + "k";
8 + return v.toLocaleString("en-US", { maximumFractionDigits: digits });
9 +}
10 +
11 +export function fmtPct(n: number | null | undefined, digits = 0): string {
12 + if (n === null || n === undefined || Number.isNaN(Number(n))) return "—";
13 + return `${(Number(n) * 100).toFixed(digits)}%`;
14 +}
15 +
16 +export function fmtDuration(s: number | null | undefined): string {
17 + if (!s && s !== 0) return "—";
18 + const v = Math.round(Number(s));
19 + const h = Math.floor(v / 3600);
20 + const m = Math.floor((v % 3600) / 60);
21 + const sec = v % 60;
22 + return h ? `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}` : `${m}:${String(sec).padStart(2, "0")}`;
23 +}
24 +
25 +export function fmtTime(ts: string | Date | null | undefined): string {
26 + if (!ts) return "—";
27 + const d = new Date(ts);
28 + return d.toLocaleTimeString("en-CA", { hour12: false });
29 +}
30 +
31 +export function fmtDate(ts: string | Date | null | undefined): string {
32 + if (!ts) return "—";
33 + const d = new Date(ts);
34 + return d.toLocaleString("en-CA", { hour12: false, month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit" });
35 +}
36 +
37 +export function ago(ts: string | Date | null | undefined): string {
38 + if (!ts) return "—";
39 + const diff = (Date.now() - new Date(ts).getTime()) / 1000;
40 + if (diff < 60) return `${Math.max(0, Math.round(diff))}s ago`;
41 + if (diff < 3600) return `${Math.round(diff / 60)}m ago`;
42 + if (diff < 86400) return `${Math.round(diff / 3600)}h ago`;
43 + return `${Math.round(diff / 86400)}d ago`;
44 +}
45 +
46 +export function truncate(s: string | null | undefined, n: number): string {
47 + if (!s) return "";
48 + return s.length > n ? s.slice(0, n - 1) + "…" : s;
49 +}
50 +
51 +export const TYPE_COLORS: Record<string, string> = {
52 + video: "#38e1ff",
53 + channel: "#9d7bff",
54 + profile: "#9d7bff",
55 + person: "#9d7bff",
56 + post: "#ffb347",
57 + comment: "#ff7ad9",
58 + community: "#43e69a",
59 + page: "#43e69a",
60 + hashtag: "#6b7a8c",
61 + image: "#38e1ff",
62 + organization: "#43e69a",
63 +};
64 +
65 +export const EVENT_COLORS: Record<string, string> = {
66 + ACTION_PLANNED: "#ffb347",
67 + ACTION_EXECUTED: "#43e69a",
68 + ACTION_FAILED: "#ff5c7a",
69 + PAGE_OPENED: "#38e1ff",
70 + NETWORK_SCHEMA_DISCOVERED: "#9d7bff",
71 + CONNECTOR_PATTERN_LEARNED: "#9d7bff",
72 + CONNECTOR_DEGRADED: "#ff5c7a",
73 + CONNECTOR_REPAIRED: "#43e69a",
74 + LOOP_DETECTED: "#ff5c7a",
75 + AUTH_REQUIRED: "#ff5c7a",
76 + MEDIA_DISCOVERED: "#ff7ad9",
77 + VIDEO_DISCOVERED: "#38e1ff",
78 + POST_DISCOVERED: "#ffb347",
79 + PROFILE_DISCOVERED: "#9d7bff",
80 +};
81 +
82 +export function eventColor(t: string): string {
83 + return EVENT_COLORS[t] ?? "#6b7a8c";
84 +}
added apps/dashboard/src/proxy.ts +24 −0
@@ -0,0 +1,24 @@
1 +import { NextResponse, type NextRequest } from "next/server";
2 +
3 +/**
4 + * Edge gate: redirect unauthenticated visitors to /access. Token verification (HMAC) happens in the
5 + * Node route handlers; here we only check presence + expiry to keep the edge runtime crypto-free.
6 + */
7 +export function proxy(req: NextRequest) {
8 + const { pathname } = req.nextUrl;
9 + if (pathname.startsWith("/access") || pathname.startsWith("/_next") || pathname === "/favicon.ico" || pathname.startsWith("/brand")) return NextResponse.next();
10 + const gate = process.env.SRC_DASHBOARD_PASSCODE;
11 + if (!gate) return NextResponse.next();
12 + const token = req.cookies.get("src_access")?.value;
13 + const exp = token ? Number(token.split(".")[0]) : 0;
14 + if (!token || !(exp > Date.now())) {
15 + if (pathname.startsWith("/api")) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
16 + const url = req.nextUrl.clone();
17 + url.pathname = "/access";
18 + url.searchParams.set("next", pathname);
19 + return NextResponse.redirect(url);
20 + }
21 + return NextResponse.next();
22 +}
23 +
24 +export const config = { matcher: ["/((?!_next/static|_next/image).*)"] };
added apps/dashboard/tsconfig.json +41 −0
@@ -0,0 +1,41 @@
1 +{
2 + "compilerOptions": {
3 + "target": "ES2022",
4 + "lib": [
5 + "dom",
6 + "dom.iterable",
7 + "esnext"
8 + ],
9 + "allowJs": true,
10 + "skipLibCheck": true,
11 + "strict": true,
12 + "noEmit": true,
13 + "esModuleInterop": true,
14 + "module": "esnext",
15 + "moduleResolution": "bundler",
16 + "resolveJsonModule": true,
17 + "isolatedModules": true,
18 + "jsx": "react-jsx",
19 + "incremental": true,
20 + "plugins": [
21 + {
22 + "name": "next"
23 + }
24 + ],
25 + "paths": {
26 + "@/*": [
27 + "./src/*"
28 + ]
29 + }
30 + },
31 + "include": [
32 + "next-env.d.ts",
33 + "**/*.ts",
34 + "**/*.tsx",
35 + ".next/types/**/*.ts",
36 + ".next/dev/types/**/*.ts"
37 + ],
38 + "exclude": [
39 + "node_modules"
40 + ]
41 +}
added apps/worker/qa/screenshots.mjs +12 −0
@@ -0,0 +1,12 @@
1 +import { chromium } from "playwright";
2 +const b = await chromium.launch(); const p = await b.newPage({ viewport: { width: 1500, height: 1000 } });
3 +await p.goto("http://localhost:8351/access"); await p.fill('input[name=passcode]', "test1234"); await p.click("button"); await p.waitForURL("http://localhost:8351/");
4 +await p.waitForTimeout(3500); await p.screenshot({ path: "/tmp/shot-home.png", fullPage: true });
5 +const sid = (await (await fetch("http://127.0.0.1:8350/api/sessions?limit=1")).json())[0].session_id;
6 +await p.goto(`http://localhost:8351/sessions/${sid}`); await p.waitForTimeout(3000); await p.screenshot({ path: "/tmp/shot-session.png", fullPage: true });
7 +await p.click("text=world model"); await p.waitForTimeout(3500); await p.screenshot({ path: "/tmp/shot-world.png" });
8 +await p.goto(`http://localhost:8351/platforms/youtube`); await p.waitForTimeout(2500); await p.screenshot({ path: "/tmp/shot-platform.png", fullPage: true });
9 +await p.goto(`http://localhost:8351/jobs`); await p.waitForTimeout(2500); await p.screenshot({ path: "/tmp/shot-jobs.png" });
10 +const errs = []; p.on("console", m => { if (m.type()==="error") errs.push(m.text()); });
11 +await p.goto(`http://localhost:8351/entities`); await p.waitForTimeout(2500); await p.screenshot({ path: "/tmp/shot-entities.png" });
12 +console.log("errors:", errs.slice(0,5)); await b.close();
modified apps/worker/src/cli.ts +1 −1
@@ -69,7 +69,7 @@ async function main() {
69 69 const goal = str("goal") ?? (isLearn ? `Learn how ${platform} surfaces content: page types, entity structures, response schemas, navigation` : query ? `Discover public content about “${query}”` : `Observe the ${platform} feed`);
70 70 if (args.flags.headless) cfg.headless = true;
71 71 const job: CrawlJob = {
72 − job_id: newId("job"),
72 + job_id: str("job") ?? newId("job"),
73 73 platform,
74 74 account_alias: str("account", "research-01")!,
75 75 mode,
modified apps/worker/src/engine.ts +1 −0
@@ -76,6 +76,7 @@ export class CrawlEngine {
76 76 this.store = await openStore(cfg.databaseUrl);
77 77 this.store?.attach(this.bus);
78 78 fs.writeFileSync(path.join(this.sessionDir, "job.json"), JSON.stringify({ ...job, session_id: this.session.id }, null, 2));
79 + await this.store?.linkJob(job, this.session.id).catch(() => {});
79 80
80 81 if (!this.session.hasProfile()) log.warn(`No saved browser profile for ${job.platform}/${job.account_alias}. Run: pnpm login ${job.platform} --account ${job.account_alias}`);
81 82
modified apps/worker/src/executor.ts +0 −0

Binary file not shown.

modified connectors/youtube/manifest.json +2993 −14
@@ -1,14 +1,15 @@
1 1 {
2 2 "platform": "youtube",
3 − "compiled_at": "2026-09-11T21:32:58.338Z",
4 − "learned_from_sessions": 4,
3 + "compiled_at": "2026-09-12T02:35:03.398Z",
4 + "learned_from_sessions": 5,
5 5 "confidence": 45,
6 6 "page_types": [
7 7 {
8 8 "page_type": "UNKNOWN",
9 9 "url_patterns": [
10 10 "/",
11 − "/results"
11 + "/results",
12 + "/watch"
12 13 ],
13 14 "avg_entities": 0
14 15 },
@@ -29,12 +30,2971 @@
29 30 {
30 31 "page_type": "VIDEO_DETAIL",
31 32 "url_patterns": [
32 − "/watch"
33 + "/watch",
34 + "/shorts/*"
33 35 ],
34 − "avg_entities": 39
36 + "avg_entities": 34.1
37 + }
38 + ],
39 + "network_patterns": [
40 + {
41 + "shape_hash": "e716c418dfe5cbd3",
42 + "status": "provisional",
43 + "hostname": "www.youtube.com",
44 + "path_pattern": "/youtubei/v1/search",
45 + "method": "POST",
46 + "likely_entity_types": {
47 + "video": 2,
48 + "post": 2,
49 + "channel": 2
50 + },
51 + "confidence": 0.39,
52 + "triggered_by": {
53 + "SEARCH": 2
54 + },
55 + "key_fields": [
56 + {
57 + "path": "responseContext.mainAppWebResponseContext.loggedOut",
58 + "semantic": {
59 + "kind": "boolean",
60 + "confidence": 0.95
61 + },
62 + "example": "true"
63 + },
64 + {
65 + "path": "responseContext.responseId",
66 + "semantic": {
67 + "kind": "identifier",
68 + "confidence": 0.95
69 + },
70 + "example": "IhMI486x5bznlgMVYhaRAR0OOzWR"
71 + },
72 + {
73 + "path": "responseContext.webResponseContextExtensionData.hasDecorated",
74 + "semantic": {
75 + "kind": "boolean",
76 + "confidence": 0.95
77 + },
78 + "example": "true"
79 + },
80 + {
81 + "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.videoId",
82 + "semantic": {
83 + "kind": "identifier",
84 + "confidence": 0.95
85 + },
86 + "example": "1RqbM2LW3wE"
87 + },
88 + {
89 + "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.thumbnail.thumbnails.[].url",
90 + "semantic": {
91 + "kind": "thumbnail_url",
92 + "confidence": 0.92
93 + },
94 + "example": "https://i.ytimg.com/vi/1RqbM2LW3wE/hq720.jpg?sqp=-oaymwEnCOgCEMoBSFryq4qpAxkIARU"
95 + },
96 + {
97 + "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.title.runs.[].text",
98 + "semantic": {
99 + "kind": "text",
100 + "confidence": 0.85
101 + },
102 + "example": "Intelligence artificielle: que nous réserve 2026?"
103 + },
104 + {
105 + "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.longBylineText.runs.[].text",
106 + "semantic": {
107 + "kind": "text",
108 + "confidence": 0.85
109 + },
110 + "example": "TVA Nouvelles"
111 + },
112 + {
113 + "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.longBylineText.runs.[].navigationEndpoint.browseEndpoint.browseId",
114 + "semantic": {
115 + "kind": "identifier",
116 + "confidence": 0.95
117 + },
118 + "example": "UCgKzdfWnTie2OD4bfaZKaQw"
119 + },
120 + {
121 + "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.publishedTimeText.simpleText",
122 + "semantic": {
123 + "kind": "text",
124 + "confidence": 0.85
125 + },
126 + "example": "il y a 8 mois"
127 + },
128 + {
129 + "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.lengthText.simpleText",
130 + "semantic": {
131 + "kind": "duration",
132 + "confidence": 0.9
133 + },
134 + "example": "9:18"
135 + },
136 + {
137 + "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.viewCountText.simpleText",
138 + "semantic": {
139 + "kind": "text",
140 + "confidence": 0.85
141 + },
142 + "example": "17 686 visionnements"
143 + },
144 + {
145 + "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.navigationEndpoint.watchEndpoint.videoId",
146 + "semantic": {
147 + "kind": "identifier",
148 + "confidence": 0.95
149 + },
150 + "example": "1RqbM2LW3wE"
151 + },
152 + {
153 + "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.navigationEndpoint.watchEndpoint.watchEndpointSupportedOnesieConfig.html5PlaybackOnesieConfig.commonConfig.url",
154 + "semantic": {
155 + "kind": "url",
156 + "confidence": 0.9
157 + },
158 + "example": "https://rr1---sn-cxaaj5o5q5-t0ay.googlevideo.com/initplayback?source=youtube&oei"
159 + },
160 + {
161 + "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.ownerText.runs.[].text",
162 + "semantic": {
163 + "kind": "text",
164 + "confidence": 0.85
165 + },
166 + "example": "TVA Nouvelles"
167 + },
168 + {
169 + "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.ownerText.runs.[].navigationEndpoint.browseEndpoint.browseId",
170 + "semantic": {
171 + "kind": "identifier",
172 + "confidence": 0.95
173 + },
174 + "example": "UCgKzdfWnTie2OD4bfaZKaQw"
175 + },
176 + {
177 + "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.shortBylineText.runs.[].text",
178 + "semantic": {
179 + "kind": "text",
180 + "confidence": 0.85
181 + },
182 + "example": "TVA Nouvelles"
183 + },
184 + {
185 + "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.shortBylineText.runs.[].navigationEndpoint.browseEndpoint.browseId",
186 + "semantic": {
187 + "kind": "identifier",
188 + "confidence": 0.95
189 + },
190 + "example": "UCgKzdfWnTie2OD4bfaZKaQw"
191 + },
192 + {
193 + "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.showActionMenu",
194 + "semantic": {
195 + "kind": "boolean",
196 + "confidence": 0.95
197 + },
198 + "example": "false"
199 + },
200 + {
201 + "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.shortViewCountText.simpleText",
202 + "semantic": {
203 + "kind": "text",
204 + "confidence": 0.85
205 + },
206 + "example": "17 k visionnements"
207 + },
208 + {
209 + "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.menu.menuRenderer.items.[].menuServiceItemRenderer.text.runs.[].text",
210 + "semantic": {
211 + "kind": "text",
212 + "confidence": 0.85
213 + },
214 + "example": "Ajouter à la file d'attente"
215 + },
216 + {
217 + "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.menu.menuRenderer.items.[].menuServiceItemRenderer.serviceEndpoint.commandMetadata.webCommandMetadata.sendPost",
218 + "semantic": {
219 + "kind": "boolean",
220 + "confidence": 0.95
221 + },
222 + "example": "true"
223 + },
224 + {
225 + "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.menu.menuRenderer.items.[].menuServiceItemRenderer.serviceEndpoint.signalServiceEndpoint.actions.[].addToPlaylistCommand.openMiniplayer",
226 + "semantic": {
227 + "kind": "boolean",
228 + "confidence": 0.95
229 + },
230 + "example": "true"
231 + },
232 + {
233 + "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.menu.menuRenderer.items.[].menuServiceItemRenderer.serviceEndpoint.signalServiceEndpoint.actions.[].addToPlaylistCommand.videoId",
234 + "semantic": {
235 + "kind": "identifier",
236 + "confidence": 0.95
237 + },
238 + "example": "1RqbM2LW3wE"
239 + },
240 + {
241 + "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.menu.menuRenderer.items.[].menuServiceItemRenderer.serviceEndpoint.signalServiceEndpoint.actions.[].addToPlaylistCommand.onCreateListCommand.commandMetadata.webCommandMetadata.sendPost",
242 + "semantic": {
243 + "kind": "boolean",
244 + "confidence": 0.95
245 + },
246 + "example": "true"
247 + }
248 + ]
249 + },
250 + {
251 + "shape_hash": "ad92d388ecab3c2a",
252 + "status": "provisional",
253 + "hostname": "www.youtube.com",
254 + "path_pattern": "/youtubei/v1/get_watch",
255 + "method": "POST",
256 + "likely_entity_types": {
257 + "video": 2,
258 + "channel": 2,
259 + "post": 2
260 + },
261 + "confidence": 0.39,
262 + "triggered_by": {
263 + "OPEN_VIDEO": 2
264 + },
265 + "key_fields": [
266 + {
267 + "path": "[].responseContext.responseId",
268 + "semantic": {
269 + "kind": "identifier",
270 + "confidence": 0.95
271 + },
272 + "example": "IhMIlbyp5rznlgMV4NSUCR0s4BSn"
273 + },
274 + {
275 + "path": "[].playerResponse.responseContext.mainAppWebResponseContext.loggedOut",
276 + "semantic": {
277 + "kind": "boolean",
278 + "confidence": 0.95
279 + },
280 + "example": "true"
281 + },
282 + {
283 + "path": "[].playerResponse.responseContext.responseId",
284 + "semantic": {
285 + "kind": "identifier",
286 + "confidence": 0.95
287 + },
288 + "example": "IhMIlbyp5rznlgMV4NSUCR0s4BSn"
289 + },
290 + {
291 + "path": "[].playerResponse.responseContext.webResponseContextExtensionData.hasDecorated",
292 + "semantic": {
293 + "kind": "boolean",
294 + "confidence": 0.95
295 + },
296 + "example": "true"
297 + },
298 + {
299 + "path": "[].playerResponse.playabilityStatus.playableInEmbed",
300 + "semantic": {
301 + "kind": "boolean",
302 + "confidence": 0.95
303 + },
304 + "example": "true"
305 + },
306 + {
307 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].contentLength",
308 + "semantic": {
309 + "kind": "duration",
310 + "confidence": 0.75
311 + },
312 + "example": "46763300"
313 + },
314 + {
315 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].approxDurationMs",
316 + "semantic": {
317 + "kind": "duration",
318 + "confidence": 0.75
319 + },
320 + "example": "358591"
321 + },
322 + {
323 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].highReplication",
324 + "semantic": {
325 + "kind": "boolean",
326 + "confidence": 0.95
327 + },
328 + "example": "true"
329 + },
330 + {
331 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].isDrc",
332 + "semantic": {
333 + "kind": "boolean",
334 + "confidence": 0.95
335 + },
336 + "example": "true"
337 + },
338 + {
339 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].isVb",
340 + "semantic": {
341 + "kind": "boolean",
342 + "confidence": 0.95
343 + },
344 + "example": "true"
345 + },
346 + {
347 + "path": "[].playerResponse.streamingData.serverAbrStreamingUrl",
348 + "semantic": {
349 + "kind": "media_url",
350 + "confidence": 0.93
351 + },
352 + "example": "https://rr2---sn-cxaaj5o5q5-t0ay.googlevideo.com/videoplayback?expire=1789183857"
353 + },
354 + {
355 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.playerAdParams.showContentThumbnail",
356 + "semantic": {
357 + "kind": "boolean",
358 + "confidence": 0.95
359 + },
360 + "example": "true"
361 + },
362 + {
363 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showCompanion",
364 + "semantic": {
365 + "kind": "boolean",
366 + "confidence": 0.95
367 + },
368 + "example": "true"
369 + },
370 + {
371 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showInstream",
372 + "semantic": {
373 + "kind": "boolean",
374 + "confidence": 0.95
375 + },
376 + "example": "true"
377 + },
378 + {
379 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.useGut",
380 + "semantic": {
381 + "kind": "boolean",
382 + "confidence": 0.95
383 + },
384 + "example": "true"
385 + },
386 + {
387 + "path": "[].playerResponse.playbackTracking.videostatsPlaybackUrl.baseUrl",
388 + "semantic": {
389 + "kind": "url",
390 + "confidence": 0.9
391 + },
392 + "example": "https://s.youtube.com/api/stats/playback?cl=974780265&docid=TtU427br-sk&ei=EXOka"
393 + },
394 + {
395 + "path": "[].playerResponse.playbackTracking.videostatsDelayplayUrl.baseUrl",
396 + "semantic": {
397 + "kind": "url",
398 + "confidence": 0.9
399 + },
400 + "example": "https://s.youtube.com/api/stats/delayplay?cl=974780265&docid=TtU427br-sk&ei=EXOk"
401 + },
402 + {
403 + "path": "[].playerResponse.playbackTracking.videostatsWatchtimeUrl.baseUrl",
404 + "semantic": {
405 + "kind": "url",
406 + "confidence": 0.9
407 + },
408 + "example": "https://s.youtube.com/api/stats/watchtime?cl=974780265&docid=TtU427br-sk&ei=EXOk"
409 + },
410 + {
411 + "path": "[].playerResponse.playbackTracking.ptrackingUrl.baseUrl",
412 + "semantic": {
413 + "kind": "url",
414 + "confidence": 0.9
415 + },
416 + "example": "https://www.youtube.com/ptracking?ei=EXOkatXvEOCp0_wPrMDTuAo&oid=m-Wz5fxH9AWP_fn"
417 + },
418 + {
419 + "path": "[].playerResponse.playbackTracking.qoeUrl.baseUrl",
420 + "semantic": {
421 + "kind": "url",
422 + "confidence": 0.9
423 + },
424 + "example": "https://s.youtube.com/api/stats/qoe?cl=974780265&docid=TtU427br-sk&ei=EXOkatXvEO"
425 + },
426 + {
427 + "path": "[].playerResponse.playbackTracking.atrUrl.baseUrl",
428 + "semantic": {
429 + "kind": "url",
430 + "confidence": 0.9
431 + },
432 + "example": "https://s.youtube.com/api/stats/atr?c=WEB&docid=TtU427br-sk&ei=EXOkatXvEOCp0_wPr"
433 + },
434 + {
435 + "path": "[].playerResponse.playbackTracking.atrUrl.elapsedMediaTimeSeconds",
436 + "semantic": {
437 + "kind": "timestamp",
438 + "confidence": 0.7
439 + },
440 + "example": "5"
441 + },
442 + {
443 + "path": "[].playerResponse.playbackTracking.youtubeRemarketingUrl.baseUrl",
444 + "semantic": {
445 + "kind": "url",
446 + "confidence": 0.9
447 + },
448 + "example": "https://www.youtube.com/pagead/viewthroughconversion/962985656/?backend=innertub"
449 + },
450 + {
451 + "path": "[].playerResponse.playbackTracking.youtubeRemarketingUrl.elapsedMediaTimeSeconds",
452 + "semantic": {
453 + "kind": "timestamp",
454 + "confidence": 0.7
455 + },
456 + "example": "0"
457 + },
458 + {
459 + "path": "[].playerResponse.playbackTracking.googleRemarketingUrl.baseUrl",
460 + "semantic": {
461 + "kind": "url",
462 + "confidence": 0.9
463 + },
464 + "example": "https://www.google.com/pagead/1p-user-list/962985656/?backend=innertube&cname=1&"
465 + }
466 + ]
467 + },
468 + {
469 + "shape_hash": "b8cf160ca9b65b8a",
470 + "status": "provisional",
471 + "hostname": "www.youtube.com",
472 + "path_pattern": "/youtubei/v1/next",
473 + "method": "POST",
474 + "likely_entity_types": {
475 + "post": 2,
476 + "comment": 2,
477 + "channel": 2,
478 + "video": 2
479 + },
480 + "confidence": 0.39,
481 + "triggered_by": {
482 + "OPEN_VIDEO": 2
483 + },
484 + "key_fields": [
485 + {
486 + "path": "responseContext.mainAppWebResponseContext.loggedOut",
487 + "semantic": {
488 + "kind": "boolean",
489 + "confidence": 0.95
490 + },
491 + "example": "true"
492 + },
493 + {
494 + "path": "responseContext.responseId",
495 + "semantic": {
496 + "kind": "identifier",
497 + "confidence": 0.95
498 + },
499 + "example": "IhMI3--M57znlgMVqRHLBB0OIjPf"
500 + },
501 + {
502 + "path": "responseContext.webResponseContextExtensionData.hasDecorated",
503 + "semantic": {
504 + "kind": "boolean",
505 + "confidence": 0.95
506 + },
507 + "example": "true"
508 + },
509 + {
510 + "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.targetId",
511 + "semantic": {
512 + "kind": "identifier",
513 + "confidence": 0.95
514 + },
515 + "example": "comments-section"
516 + },
517 + {
518 + "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.authorThumbnail.thumbnails.[].url",
519 + "semantic": {
520 + "kind": "url",
521 + "confidence": 0.9
522 + },
523 + "example": "https://yt3.ggpht.com/a/default-user=s48-c-k-c0x00ffffff-no-rj"
524 + },
525 + {
526 + "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.placeholderText.runs.[].text",
527 + "semantic": {
528 + "kind": "text",
529 + "confidence": 0.85
530 + },
531 + "example": "Ajouter un commentaire…"
532 + },
533 + {
534 + "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.prepareAccountEndpoint.commandMetadata.webCommandMetadata.ignoreNavigation",
535 + "semantic": {
536 + "kind": "boolean",
537 + "confidence": 0.95
538 + },
539 + "example": "true"
540 + },
541 + {
542 + "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.prepareAccountEndpoint.modalEndpoint.modal.modalWithTitleAndButtonRenderer.title.runs.[].text",
543 + "semantic": {
544 + "kind": "text",
545 + "confidence": 0.85
546 + },
547 + "example": "Vous voulez vous joindre à la conversation?"
548 + },
549 + {
550 + "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.prepareAccountEndpoint.modalEndpoint.modal.modalWithTitleAndButtonRenderer.content.runs.[].text",
551 + "semantic": {
552 + "kind": "text",
553 + "confidence": 0.85
554 + },
555 + "example": "Connectez-vous pour continuer"
556 + },
557 + {
558 + "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.prepareAccountEndpoint.modalEndpoint.modal.modalWithTitleAndButtonRenderer.button.buttonRenderer.isDisabled",
559 + "semantic": {
560 + "kind": "boolean",
561 + "confidence": 0.95
562 + },
563 + "example": "false"
564 + },
565 + {
566 + "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.prepareAccountEndpoint.modalEndpoint.modal.modalWithTitleAndButtonRenderer.button.buttonRenderer.navigationEndpoint.commandMetadata.webCommandMetadata.url",
567 + "semantic": {
568 + "kind": "url",
569 + "confidence": 0.9
570 + },
571 + "example": "https://accounts.google.com/ServiceLogin?service=youtube&uilel=3&passive=true&co"
572 + },
573 + {
574 + "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.prepareAccountEndpoint.modalEndpoint.modal.modalWithTitleAndButtonRenderer.button.buttonRenderer.navigationEndpoint.signInEndpoint.hack",
575 + "semantic": {
576 + "kind": "boolean",
577 + "confidence": 0.95
578 + },
579 + "example": "true"
580 + },
581 + {
582 + "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.emojiPicker.emojiPickerRenderer.categories.[].emojiPickerCategoryRenderer.imageLoadingLazy",
583 + "semantic": {
584 + "kind": "boolean",
585 + "confidence": 0.95
586 + },
587 + "example": "true"
588 + },
589 + {
590 + "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.emojiPicker.emojiPickerRenderer.categoryButtons.[].emojiPickerCategoryButtonRenderer.targetId",
591 + "semantic": {
592 + "kind": "identifier",
593 + "confidence": 0.95
594 + },
595 + "example": "emoji-picker-category-button-people"
596 + },
597 + {
598 + "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.emojiPicker.emojiPickerRenderer.searchPlaceholderText.runs.[].text",
599 + "semantic": {
600 + "kind": "text",
601 + "confidence": 0.85
602 + },
603 + "example": "Rechercher un emoji"
604 + },
605 + {
606 + "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.emojiPicker.emojiPickerRenderer.searchNoResultsText.runs.[].text",
607 + "semantic": {
608 + "kind": "text",
609 + "confidence": 0.85
610 + },
611 + "example": "Aucun émoji trouvé"
612 + },
613 + {
614 + "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.emojiPicker.emojiPickerRenderer.pickSkinToneText.runs.[].text",
615 + "semantic": {
616 + "kind": "text",
617 + "confidence": 0.85
618 + },
619 + "example": "Sélectionner le teint de l'emoji"
620 + },
621 + {
622 + "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.disabledText",
623 + "semantic": {
624 + "kind": "text",
625 + "confidence": 0.85
626 + },
627 + "example": "Les commentaires sont désactivés."
628 + },
629 + {
630 + "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.disabledTextUrl",
631 + "semantic": {
632 + "kind": "url",
633 + "confidence": 0.9
634 + },
635 + "example": "https://support.google.com/youtube/answer/9706180"
636 + },
637 + {
638 + "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.sortMenu.sortFilterSubMenuRenderer.subMenuItems.[].title",
639 + "semantic": {
640 + "kind": "title",
641 + "confidence": 0.85
642 + },
643 + "example": "Principaux"
644 + },
645 + {
646 + "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.sortMenu.sortFilterSubMenuRenderer.subMenuItems.[].selected",
647 + "semantic": {
648 + "kind": "boolean",
649 + "confidence": 0.95
650 + },
651 + "example": "true"
652 + },
653 + {
654 + "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.sortMenu.sortFilterSubMenuRenderer.subMenuItems.[].serviceEndpoint.commandMetadata.webCommandMetadata.sendPost",
655 + "semantic": {
656 + "kind": "boolean",
657 + "confidence": 0.95
658 + },
659 + "example": "true"
660 + },
661 + {
662 + "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.sortMenu.sortFilterSubMenuRenderer.subMenuItems.[].serviceEndpoint.continuationCommand.command.showReloadUiCommand.targetId",
663 + "semantic": {
664 + "kind": "identifier",
665 + "confidence": 0.95
666 + },
667 + "example": "comments-section"
668 + },
669 + {
670 + "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.sortMenu.sortFilterSubMenuRenderer.subMenuItems.[].subtitle",
671 + "semantic": {
672 + "kind": "title",
673 + "confidence": 0.85
674 + },
675 + "example": "Affiche les commentaires en vedette"
676 + },
677 + {
678 + "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.sortMenu.sortFilterSubMenuRenderer.title",
679 + "semantic": {
680 + "kind": "title",
681 + "confidence": 0.85
682 + },
683 + "example": "Trier par"
684 + }
685 + ]
686 + },
687 + {
688 + "shape_hash": "094367e8f9ba7873",
689 + "status": "provisional",
690 + "hostname": "www.youtube.com",
691 + "path_pattern": "/youtubei/v1/get_watch",
692 + "method": "POST",
693 + "likely_entity_types": {
694 + "video": 2,
695 + "channel": 2,
696 + "post": 2
697 + },
698 + "confidence": 0.39,
699 + "triggered_by": {
700 + "OPEN_VIDEO": 2
701 + },
702 + "key_fields": [
703 + {
704 + "path": "[].responseContext.responseId",
705 + "semantic": {
706 + "kind": "identifier",
707 + "confidence": 0.95
708 + },
709 + "example": "IhMIvqLNhb3nlgMVloTkBh0Cth8C"
710 + },
711 + {
712 + "path": "[].playerResponse.responseContext.mainAppWebResponseContext.loggedOut",
713 + "semantic": {
714 + "kind": "boolean",
715 + "confidence": 0.95
716 + },
717 + "example": "true"
718 + },
719 + {
720 + "path": "[].playerResponse.responseContext.responseId",
721 + "semantic": {
722 + "kind": "identifier",
723 + "confidence": 0.95
724 + },
725 + "example": "IhMIvqLNhb3nlgMVloTkBh0Cth8C"
726 + },
727 + {
728 + "path": "[].playerResponse.responseContext.webResponseContextExtensionData.hasDecorated",
729 + "semantic": {
730 + "kind": "boolean",
731 + "confidence": 0.95
732 + },
733 + "example": "true"
734 + },
735 + {
736 + "path": "[].playerResponse.playabilityStatus.playableInEmbed",
737 + "semantic": {
738 + "kind": "boolean",
739 + "confidence": 0.95
740 + },
741 + "example": "true"
742 + },
743 + {
744 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].contentLength",
745 + "semantic": {
746 + "kind": "duration",
747 + "confidence": 0.75
748 + },
749 + "example": "54128576"
750 + },
751 + {
752 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].approxDurationMs",
753 + "semantic": {
754 + "kind": "duration",
755 + "confidence": 0.75
756 + },
757 + "example": "184050"
758 + },
759 + {
760 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].highReplication",
761 + "semantic": {
762 + "kind": "boolean",
763 + "confidence": 0.95
764 + },
765 + "example": "true"
766 + },
767 + {
768 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].isDrc",
769 + "semantic": {
770 + "kind": "boolean",
771 + "confidence": 0.95
772 + },
773 + "example": "true"
774 + },
775 + {
776 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].isVb",
777 + "semantic": {
778 + "kind": "boolean",
779 + "confidence": 0.95
780 + },
781 + "example": "true"
782 + },
783 + {
784 + "path": "[].playerResponse.streamingData.serverAbrStreamingUrl",
785 + "semantic": {
786 + "kind": "media_url",
787 + "confidence": 0.93
788 + },
789 + "example": "https://rr1---sn-cxaaj5o5q5-t0ar.googlevideo.com/videoplayback?expire=1789183922"
790 + },
791 + {
792 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.playerAdParams.showContentThumbnail",
793 + "semantic": {
794 + "kind": "boolean",
795 + "confidence": 0.95
796 + },
797 + "example": "true"
798 + },
799 + {
800 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showCompanion",
801 + "semantic": {
802 + "kind": "boolean",
803 + "confidence": 0.95
804 + },
805 + "example": "true"
806 + },
807 + {
808 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showInstream",
809 + "semantic": {
810 + "kind": "boolean",
811 + "confidence": 0.95
812 + },
813 + "example": "true"
814 + },
815 + {
816 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.useGut",
817 + "semantic": {
818 + "kind": "boolean",
819 + "confidence": 0.95
820 + },
821 + "example": "true"
822 + },
823 + {
824 + "path": "[].playerResponse.playbackTracking.videostatsPlaybackUrl.baseUrl",
825 + "semantic": {
826 + "kind": "url",
827 + "confidence": 0.9
828 + },
829 + "example": "https://s.youtube.com/api/stats/playback?cl=974780265&docid=hmG52cE7S5U&ei=UnOka"
830 + },
831 + {
832 + "path": "[].playerResponse.playbackTracking.videostatsDelayplayUrl.baseUrl",
833 + "semantic": {
834 + "kind": "url",
835 + "confidence": 0.9
836 + },
837 + "example": "https://s.youtube.com/api/stats/delayplay?cl=974780265&docid=hmG52cE7S5U&ei=UnOk"
838 + },
839 + {
840 + "path": "[].playerResponse.playbackTracking.videostatsWatchtimeUrl.baseUrl",
841 + "semantic": {
842 + "kind": "url",
843 + "confidence": 0.9
844 + },
845 + "example": "https://s.youtube.com/api/stats/watchtime?cl=974780265&docid=hmG52cE7S5U&ei=UnOk"
846 + },
847 + {
848 + "path": "[].playerResponse.playbackTracking.ptrackingUrl.baseUrl",
849 + "semantic": {
850 + "kind": "url",
851 + "confidence": 0.9
852 + },
853 + "example": "https://www.youtube.com/ptracking?ei=UnOkar6xNZaJkucPguz-EA&oid=WLeG0gE_kkpZY0oK"
854 + },
855 + {
856 + "path": "[].playerResponse.playbackTracking.qoeUrl.baseUrl",
857 + "semantic": {
858 + "kind": "url",
859 + "confidence": 0.9
860 + },
861 + "example": "https://s.youtube.com/api/stats/qoe?cl=974780265&docid=hmG52cE7S5U&ei=UnOkar6xNZ"
862 + },
863 + {
864 + "path": "[].playerResponse.playbackTracking.atrUrl.baseUrl",
865 + "semantic": {
866 + "kind": "url",
867 + "confidence": 0.9
868 + },
869 + "example": "https://s.youtube.com/api/stats/atr?c=WEB&docid=hmG52cE7S5U&ei=UnOkar6xNZaJkucPg"
870 + },
871 + {
872 + "path": "[].playerResponse.playbackTracking.atrUrl.elapsedMediaTimeSeconds",
873 + "semantic": {
874 + "kind": "timestamp",
875 + "confidence": 0.7
876 + },
877 + "example": "5"
878 + },
879 + {
880 + "path": "[].playerResponse.playbackTracking.youtubeRemarketingUrl.baseUrl",
881 + "semantic": {
882 + "kind": "url",
883 + "confidence": 0.9
884 + },
885 + "example": "https://www.youtube.com/pagead/viewthroughconversion/962985656/?backend=innertub"
886 + },
887 + {
888 + "path": "[].playerResponse.playbackTracking.youtubeRemarketingUrl.elapsedMediaTimeSeconds",
889 + "semantic": {
890 + "kind": "timestamp",
891 + "confidence": 0.7
892 + },
893 + "example": "0"
894 + },
895 + {
896 + "path": "[].playerResponse.playbackTracking.googleRemarketingUrl.baseUrl",
897 + "semantic": {
898 + "kind": "url",
899 + "confidence": 0.9
900 + },
901 + "example": "https://www.google.com/pagead/1p-user-list/962985656/?backend=innertube&cname=1&"
902 + }
903 + ]
904 + },
905 + {
906 + "shape_hash": "ffec634da7d6e380",
907 + "status": "provisional",
908 + "hostname": "www.youtube.com",
909 + "path_pattern": "/youtubei/v1/get_watch",
910 + "method": "POST",
911 + "likely_entity_types": {
912 + "video": 2,
913 + "channel": 2,
914 + "post": 2
915 + },
916 + "confidence": 0.39,
917 + "triggered_by": {
918 + "OPEN_VIDEO": 2
919 + },
920 + "key_fields": [
921 + {
922 + "path": "[].responseContext.responseId",
923 + "semantic": {
924 + "kind": "identifier",
925 + "confidence": 0.95
926 + },
927 + "example": "IhMI2by7kr3nlgMVNYHkBh23ayjA"
928 + },
929 + {
930 + "path": "[].playerResponse.responseContext.mainAppWebResponseContext.loggedOut",
931 + "semantic": {
932 + "kind": "boolean",
933 + "confidence": 0.95
934 + },
935 + "example": "true"
936 + },
937 + {
938 + "path": "[].playerResponse.responseContext.responseId",
939 + "semantic": {
940 + "kind": "identifier",
941 + "confidence": 0.95
942 + },
943 + "example": "IhMI2by7kr3nlgMVNYHkBh23ayjA"
944 + },
945 + {
946 + "path": "[].playerResponse.responseContext.webResponseContextExtensionData.hasDecorated",
947 + "semantic": {
948 + "kind": "boolean",
949 + "confidence": 0.95
950 + },
951 + "example": "true"
952 + },
953 + {
954 + "path": "[].playerResponse.playabilityStatus.playableInEmbed",
955 + "semantic": {
956 + "kind": "boolean",
957 + "confidence": 0.95
958 + },
959 + "example": "true"
960 + },
961 + {
962 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].contentLength",
963 + "semantic": {
964 + "kind": "duration",
965 + "confidence": 0.75
966 + },
967 + "example": "523368691"
968 + },
969 + {
970 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].approxDurationMs",
971 + "semantic": {
972 + "kind": "duration",
973 + "confidence": 0.75
974 + },
975 + "example": "2348466"
976 + },
977 + {
978 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].highReplication",
979 + "semantic": {
980 + "kind": "boolean",
981 + "confidence": 0.95
982 + },
983 + "example": "true"
984 + },
985 + {
986 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].isDrc",
987 + "semantic": {
988 + "kind": "boolean",
989 + "confidence": 0.95
990 + },
991 + "example": "true"
992 + },
993 + {
994 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].isVb",
995 + "semantic": {
996 + "kind": "boolean",
997 + "confidence": 0.95
998 + },
999 + "example": "true"
1000 + },
1001 + {
1002 + "path": "[].playerResponse.streamingData.serverAbrStreamingUrl",
1003 + "semantic": {
1004 + "kind": "media_url",
1005 + "confidence": 0.93
1006 + },
1007 + "example": "https://rr4---sn-cxaaj5o5q5-t0ay.googlevideo.com/videoplayback?expire=1789183949"
1008 + },
1009 + {
1010 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.playerAdParams.showContentThumbnail",
1011 + "semantic": {
1012 + "kind": "boolean",
1013 + "confidence": 0.95
1014 + },
1015 + "example": "true"
1016 + },
1017 + {
1018 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showCompanion",
1019 + "semantic": {
1020 + "kind": "boolean",
1021 + "confidence": 0.95
1022 + },
1023 + "example": "true"
1024 + },
1025 + {
1026 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showInstream",
1027 + "semantic": {
1028 + "kind": "boolean",
1029 + "confidence": 0.95
1030 + },
1031 + "example": "true"
1032 + },
1033 + {
1034 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.useGut",
1035 + "semantic": {
1036 + "kind": "boolean",
1037 + "confidence": 0.95
1038 + },
1039 + "example": "true"
1040 + },
1041 + {
1042 + "path": "[].playerResponse.playbackTracking.videostatsPlaybackUrl.baseUrl",
1043 + "semantic": {
1044 + "kind": "url",
1045 + "confidence": 0.9
1046 + },
1047 + "example": "https://s.youtube.com/api/stats/playback?cl=974780265&docid=QYRiiWEsTeI&ei=bXOka"
1048 + },
1049 + {
1050 + "path": "[].playerResponse.playbackTracking.videostatsDelayplayUrl.baseUrl",
1051 + "semantic": {
1052 + "kind": "url",
1053 + "confidence": 0.9
1054 + },
1055 + "example": "https://s.youtube.com/api/stats/delayplay?cl=974780265&docid=QYRiiWEsTeI&ei=bXOk"
1056 + },
1057 + {
1058 + "path": "[].playerResponse.playbackTracking.videostatsWatchtimeUrl.baseUrl",
1059 + "semantic": {
1060 + "kind": "url",
1061 + "confidence": 0.9
1062 + },
1063 + "example": "https://s.youtube.com/api/stats/watchtime?cl=974780265&docid=QYRiiWEsTeI&ei=bXOk"
1064 + },
1065 + {
1066 + "path": "[].playerResponse.playbackTracking.ptrackingUrl.baseUrl",
1067 + "semantic": {
1068 + "kind": "url",
1069 + "confidence": 0.9
1070 + },
1071 + "example": "https://www.youtube.com/ptracking?ei=bXOkapnSM7WCkucPt9ehgQw&oid=XG4oyHHzHGAFkGU"
1072 + },
1073 + {
1074 + "path": "[].playerResponse.playbackTracking.qoeUrl.baseUrl",
1075 + "semantic": {
1076 + "kind": "url",
1077 + "confidence": 0.9
1078 + },
1079 + "example": "https://s.youtube.com/api/stats/qoe?cl=974780265&docid=QYRiiWEsTeI&ei=bXOkapnSM7"
1080 + },
1081 + {
1082 + "path": "[].playerResponse.playbackTracking.atrUrl.baseUrl",
1083 + "semantic": {
1084 + "kind": "url",
1085 + "confidence": 0.9
1086 + },
1087 + "example": "https://s.youtube.com/api/stats/atr?c=WEB&docid=QYRiiWEsTeI&ei=bXOkapnSM7WCkucPt"
1088 + },
1089 + {
1090 + "path": "[].playerResponse.playbackTracking.atrUrl.elapsedMediaTimeSeconds",
1091 + "semantic": {
1092 + "kind": "timestamp",
1093 + "confidence": 0.7
1094 + },
1095 + "example": "5"
1096 + },
1097 + {
1098 + "path": "[].playerResponse.playbackTracking.youtubeRemarketingUrl.baseUrl",
1099 + "semantic": {
1100 + "kind": "url",
1101 + "confidence": 0.9
1102 + },
1103 + "example": "https://www.youtube.com/pagead/viewthroughconversion/962985656/?backend=innertub"
1104 + },
1105 + {
1106 + "path": "[].playerResponse.playbackTracking.youtubeRemarketingUrl.elapsedMediaTimeSeconds",
1107 + "semantic": {
1108 + "kind": "timestamp",
1109 + "confidence": 0.7
1110 + },
1111 + "example": "0"
1112 + },
1113 + {
1114 + "path": "[].playerResponse.captions.playerCaptionsTracklistRenderer.captionTracks.[].baseUrl",
1115 + "semantic": {
1116 + "kind": "url",
1117 + "confidence": 0.9
1118 + },
1119 + "example": "https://www.youtube.com/api/timedtext?v=QYRiiWEsTeI&ei=bXOkapnSM7WCkucPt9ehgQw&c"
1120 + }
1121 + ]
1122 + },
1123 + {
1124 + "shape_hash": "9a96a965bd5dc746",
1125 + "status": "provisional",
1126 + "hostname": "www.youtube.com",
1127 + "path_pattern": "/youtubei/v1/guide",
1128 + "method": "POST",
1129 + "likely_entity_types": {
1130 + "video": 3,
1131 + "post": 3
1132 + },
1133 + "confidence": 0.29,
1134 + "triggered_by": {
1135 + "RETURN_TO_FEED": 2,
1136 + "OPEN_CHANNEL": 1
1137 + },
1138 + "key_fields": [
1139 + {
1140 + "path": "responseContext.mainAppWebResponseContext.loggedOut",
1141 + "semantic": {
1142 + "kind": "boolean",
1143 + "confidence": 0.95
1144 + },
1145 + "example": "true"
1146 + },
1147 + {
1148 + "path": "responseContext.responseId",
1149 + "semantic": {
1150 + "kind": "identifier",
1151 + "confidence": 0.95
1152 + },
1153 + "example": "IhMI2IilqbznlgMVio_lBx1nLSES"
1154 + },
1155 + {
1156 + "path": "responseContext.webResponseContextExtensionData.hasDecorated",
1157 + "semantic": {
1158 + "kind": "boolean",
1159 + "confidence": 0.95
1160 + },
1161 + "example": "true"
1162 + },
1163 + {
1164 + "path": "items.[].guideSectionRenderer.items.[].guideEntryRenderer.navigationEndpoint.browseEndpoint.browseId",
1165 + "semantic": {
1166 + "kind": "identifier",
1167 + "confidence": 0.95
1168 + },
1169 + "example": "FEwhat_to_watch"
1170 + },
1171 + {
1172 + "path": "items.[].guideSectionRenderer.items.[].guideEntryRenderer.isPrimary",
1173 + "semantic": {
1174 + "kind": "boolean",
1175 + "confidence": 0.95
1176 + },
1177 + "example": "true"
1178 + },
1179 + {
1180 + "path": "items.[].guideSectionRenderer.items.[].guideEntryRenderer.serviceEndpoint.reelWatchEndpoint.updateKey",
1181 + "semantic": {
1182 + "kind": "timestamp",
1183 + "confidence": 0.7
1184 + },
1185 + "example": "EhhTSE9SVFNfU0VFRExFU1NfRU5EUE9JTlQg5gEoAQ%3D%3D"
1186 + },
1187 + {
1188 + "path": "items.[].guideSectionRenderer.items.[].guideEntryRenderer.entryData.guideEntryData.guideEntryId",
1189 + "semantic": {
1190 + "kind": "identifier",
1191 + "confidence": 0.95
1192 + },
1193 + "example": "subscriptions-channels-header"
1194 + },
1195 + {
1196 + "path": "items.[].guideSectionRenderer.items.[].guideEntryRenderer.targetId",
1197 + "semantic": {
1198 + "kind": "identifier",
1199 + "confidence": 0.95
1200 + },
1201 + "example": "subscriptions-guide-item"
1202 + },
1203 + {
1204 + "path": "items.[].guideSigninPromoRenderer.descriptiveText.simpleText",
1205 + "semantic": {
1206 + "kind": "text",
1207 + "confidence": 0.85
1208 + },
1209 + "example": "Connectez-vous pour aimer des vidéos, publier des commentaires et vous abonner."
1210 + },
1211 + {
1212 + "path": "items.[].guideSigninPromoRenderer.signInButton.buttonRenderer.isDisabled",
1213 + "semantic": {
1214 + "kind": "boolean",
1215 + "confidence": 0.95
1216 + },
1217 + "example": "false"
1218 + },
1219 + {
1220 + "path": "items.[].guideSigninPromoRenderer.signInButton.buttonRenderer.navigationEndpoint.commandMetadata.webCommandMetadata.url",
1221 + "semantic": {
1222 + "kind": "url",
1223 + "confidence": 0.9
1224 + },
1225 + "example": "https://accounts.google.com/ServiceLogin?service=youtube&uilel=3&passive=true&co"
1226 + },
1227 + {
1228 + "path": "items.[].guideSigninPromoRenderer.signInButton.buttonRenderer.navigationEndpoint.signInEndpoint.hack",
1229 + "semantic": {
1230 + "kind": "boolean",
1231 + "confidence": 0.95
1232 + },
1233 + "example": "true"
1234 + },
1235 + {
1236 + "path": "items.[].guideSectionRenderer.items.[].guideCollapsibleEntryRenderer.expandableItems.[].guideEntryRenderer.navigationEndpoint.browseEndpoint.browseId",
1237 + "semantic": {
1238 + "kind": "identifier",
1239 + "confidence": 0.95
1240 + },
1241 + "example": "UCYfdidRxbB8Qhf0Nx7ioOYw"
1242 + },
1243 + {
1244 + "path": "items.[].guideSectionRenderer.formattedTitle.simpleText",
1245 + "semantic": {
1246 + "kind": "text",
1247 + "confidence": 0.85
1248 + },
1249 + "example": "Explorer"
1250 + },
1251 + {
1252 + "path": "items.[].guideSectionRenderer.items.[].guideEntryRenderer.navigationEndpoint.urlEndpoint.url",
1253 + "semantic": {
1254 + "kind": "url",
1255 + "confidence": 0.9
1256 + },
1257 + "example": "https://music.youtube.com"
1258 + }
1259 + ]
1260 + },
1261 + {
1262 + "shape_hash": "3c89ecaf0eb838d0",
1263 + "status": "provisional",
1264 + "hostname": "www.youtube.com",
1265 + "path_pattern": "/youtubei/v1/get_watch",
1266 + "method": "POST",
1267 + "likely_entity_types": {
1268 + "video": 1,
1269 + "channel": 1,
1270 + "post": 1
1271 + },
1272 + "confidence": 0.22,
1273 + "triggered_by": {
1274 + "OPEN_VIDEO": 1
1275 + },
1276 + "key_fields": [
1277 + {
1278 + "path": "[].responseContext.responseId",
1279 + "semantic": {
1280 + "kind": "identifier",
1281 + "confidence": 0.95
1282 + },
1283 + "example": "IhMI89fHi73nlgMVDprkBh0vlBK2"
1284 + },
1285 + {
1286 + "path": "[].playerResponse.responseContext.mainAppWebResponseContext.loggedOut",
1287 + "semantic": {
1288 + "kind": "boolean",
1289 + "confidence": 0.95
1290 + },
1291 + "example": "true"
1292 + },
1293 + {
1294 + "path": "[].playerResponse.responseContext.responseId",
1295 + "semantic": {
1296 + "kind": "identifier",
1297 + "confidence": 0.95
1298 + },
1299 + "example": "IhMI89fHi73nlgMVDprkBh0vlBK2"
1300 + },
1301 + {
1302 + "path": "[].playerResponse.responseContext.webResponseContextExtensionData.hasDecorated",
1303 + "semantic": {
1304 + "kind": "boolean",
1305 + "confidence": 0.95
1306 + },
1307 + "example": "true"
1308 + },
1309 + {
1310 + "path": "[].playerResponse.playabilityStatus.playableInEmbed",
1311 + "semantic": {
1312 + "kind": "boolean",
1313 + "confidence": 0.95
1314 + },
1315 + "example": "true"
1316 + },
1317 + {
1318 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].contentLength",
1319 + "semantic": {
1320 + "kind": "duration",
1321 + "confidence": 0.75
1322 + },
1323 + "example": "3008965991"
1324 + },
1325 + {
1326 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].approxDurationMs",
1327 + "semantic": {
1328 + "kind": "duration",
1329 + "confidence": 0.75
1330 + },
1331 + "example": "1952742"
1332 + },
1333 + {
1334 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].highReplication",
1335 + "semantic": {
1336 + "kind": "boolean",
1337 + "confidence": 0.95
1338 + },
1339 + "example": "true"
1340 + },
1341 + {
1342 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].audioTrack.displayName",
1343 + "semantic": {
1344 + "kind": "display_name",
1345 + "confidence": 0.7
1346 + },
1347 + "example": "Anglais (US)"
1348 + },
1349 + {
1350 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].audioTrack.audioIsDefault",
1351 + "semantic": {
1352 + "kind": "boolean",
1353 + "confidence": 0.95
1354 + },
1355 + "example": "false"
1356 + },
1357 + {
1358 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].audioTrack.isAutoDubbed",
1359 + "semantic": {
1360 + "kind": "boolean",
1361 + "confidence": 0.95
1362 + },
1363 + "example": "true"
1364 + },
1365 + {
1366 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].isDrc",
1367 + "semantic": {
1368 + "kind": "boolean",
1369 + "confidence": 0.95
1370 + },
1371 + "example": "true"
1372 + },
1373 + {
1374 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].isVb",
1375 + "semantic": {
1376 + "kind": "boolean",
1377 + "confidence": 0.95
1378 + },
1379 + "example": "true"
1380 + },
1381 + {
1382 + "path": "[].playerResponse.streamingData.serverAbrStreamingUrl",
1383 + "semantic": {
1384 + "kind": "media_url",
1385 + "confidence": 0.93
1386 + },
1387 + "example": "https://rr1---sn-cxaaj5o5q5-t0ar.googlevideo.com/videoplayback?expire=1789183935"
1388 + },
1389 + {
1390 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.playerAdParams.showContentThumbnail",
1391 + "semantic": {
1392 + "kind": "boolean",
1393 + "confidence": 0.95
1394 + },
1395 + "example": "true"
1396 + },
1397 + {
1398 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showCompanion",
1399 + "semantic": {
1400 + "kind": "boolean",
1401 + "confidence": 0.95
1402 + },
1403 + "example": "true"
1404 + },
1405 + {
1406 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showInstream",
1407 + "semantic": {
1408 + "kind": "boolean",
1409 + "confidence": 0.95
1410 + },
1411 + "example": "true"
1412 + },
1413 + {
1414 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.useGut",
1415 + "semantic": {
1416 + "kind": "boolean",
1417 + "confidence": 0.95
1418 + },
1419 + "example": "true"
1420 + },
1421 + {
1422 + "path": "[].playerResponse.playbackTracking.videostatsPlaybackUrl.baseUrl",
1423 + "semantic": {
1424 + "kind": "url",
1425 + "confidence": 0.9
1426 + },
1427 + "example": "https://s.youtube.com/api/stats/playback?cl=974780265&docid=lVmAzyP67oE&ei=X3Oka"
1428 + },
1429 + {
1430 + "path": "[].playerResponse.playbackTracking.videostatsDelayplayUrl.baseUrl",
1431 + "semantic": {
1432 + "kind": "url",
1433 + "confidence": 0.9
1434 + },
1435 + "example": "https://s.youtube.com/api/stats/delayplay?cl=974780265&docid=lVmAzyP67oE&ei=X3Ok"
1436 + },
1437 + {
1438 + "path": "[].playerResponse.playbackTracking.videostatsWatchtimeUrl.baseUrl",
1439 + "semantic": {
1440 + "kind": "url",
1441 + "confidence": 0.9
1442 + },
1443 + "example": "https://s.youtube.com/api/stats/watchtime?cl=974780265&docid=lVmAzyP67oE&ei=X3Ok"
1444 + },
1445 + {
1446 + "path": "[].playerResponse.playbackTracking.ptrackingUrl.baseUrl",
1447 + "semantic": {
1448 + "kind": "url",
1449 + "confidence": 0.9
1450 + },
1451 + "example": "https://www.youtube.com/ptracking?ei=X3OkarOsFo60kucPr6jKsAs&oid=ef6_Stpd9GrV7UO"
1452 + },
1453 + {
1454 + "path": "[].playerResponse.playbackTracking.qoeUrl.baseUrl",
1455 + "semantic": {
1456 + "kind": "url",
1457 + "confidence": 0.9
1458 + },
1459 + "example": "https://s.youtube.com/api/stats/qoe?cat=mta&cl=974780265&docid=lVmAzyP67oE&ei=X3"
1460 + },
1461 + {
1462 + "path": "[].playerResponse.playbackTracking.atrUrl.baseUrl",
1463 + "semantic": {
1464 + "kind": "url",
1465 + "confidence": 0.9
1466 + },
1467 + "example": "https://s.youtube.com/api/stats/atr?c=WEB&docid=lVmAzyP67oE&ei=X3OkarOsFo60kucPr"
1468 + },
1469 + {
1470 + "path": "[].playerResponse.playbackTracking.atrUrl.elapsedMediaTimeSeconds",
1471 + "semantic": {
1472 + "kind": "timestamp",
1473 + "confidence": 0.7
1474 + },
1475 + "example": "5"
1476 + }
1477 + ]
1478 + },
1479 + {
1480 + "shape_hash": "7602103111a512bc",
1481 + "status": "provisional",
1482 + "hostname": "www.youtube.com",
1483 + "path_pattern": "/youtubei/v1/get_watch",
1484 + "method": "POST",
1485 + "likely_entity_types": {
1486 + "video": 1,
1487 + "channel": 1,
1488 + "post": 1
1489 + },
1490 + "confidence": 0.22,
1491 + "triggered_by": {
1492 + "OPEN_VIDEO": 1
1493 + },
1494 + "key_fields": [
1495 + {
1496 + "path": "[].responseContext.responseId",
1497 + "semantic": {
1498 + "kind": "identifier",
1499 + "confidence": 0.95
1500 + },
1501 + "example": "IhMI-_r6jb3nlgMVDYXkBh3m1w07"
1502 + },
1503 + {
1504 + "path": "[].playerResponse.responseContext.mainAppWebResponseContext.loggedOut",
1505 + "semantic": {
1506 + "kind": "boolean",
1507 + "confidence": 0.95
1508 + },
1509 + "example": "true"
1510 + },
1511 + {
1512 + "path": "[].playerResponse.responseContext.responseId",
1513 + "semantic": {
1514 + "kind": "identifier",
1515 + "confidence": 0.95
1516 + },
1517 + "example": "IhMI-_r6jb3nlgMVDYXkBh3m1w07"
1518 + },
1519 + {
1520 + "path": "[].playerResponse.responseContext.webResponseContextExtensionData.hasDecorated",
1521 + "semantic": {
1522 + "kind": "boolean",
1523 + "confidence": 0.95
1524 + },
1525 + "example": "true"
1526 + },
1527 + {
1528 + "path": "[].playerResponse.playabilityStatus.playableInEmbed",
1529 + "semantic": {
1530 + "kind": "boolean",
1531 + "confidence": 0.95
1532 + },
1533 + "example": "true"
1534 + },
1535 + {
1536 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].contentLength",
1537 + "semantic": {
1538 + "kind": "duration",
1539 + "confidence": 0.75
1540 + },
1541 + "example": "923323161"
1542 + },
1543 + {
1544 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].approxDurationMs",
1545 + "semantic": {
1546 + "kind": "duration",
1547 + "confidence": 0.75
1548 + },
1549 + "example": "3068880"
1550 + },
1551 + {
1552 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].highReplication",
1553 + "semantic": {
1554 + "kind": "boolean",
1555 + "confidence": 0.95
1556 + },
1557 + "example": "true"
1558 + },
1559 + {
1560 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].audioTrack.displayName",
1561 + "semantic": {
1562 + "kind": "display_name",
1563 + "confidence": 0.7
1564 + },
1565 + "example": "Anglais"
1566 + },
1567 + {
1568 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].audioTrack.audioIsDefault",
1569 + "semantic": {
1570 + "kind": "boolean",
1571 + "confidence": 0.95
1572 + },
1573 + "example": "false"
1574 + },
1575 + {
1576 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].isDrc",
1577 + "semantic": {
1578 + "kind": "boolean",
1579 + "confidence": 0.95
1580 + },
1581 + "example": "true"
1582 + },
1583 + {
1584 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].isVb",
1585 + "semantic": {
1586 + "kind": "boolean",
1587 + "confidence": 0.95
1588 + },
1589 + "example": "true"
1590 + },
1591 + {
1592 + "path": "[].playerResponse.streamingData.serverAbrStreamingUrl",
1593 + "semantic": {
1594 + "kind": "media_url",
1595 + "confidence": 0.93
1596 + },
1597 + "example": "https://rr1---sn-cxaaj5o5q5-t0a6.googlevideo.com/videoplayback?expire=1789183940"
1598 + },
1599 + {
1600 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.playerAdParams.showContentThumbnail",
1601 + "semantic": {
1602 + "kind": "boolean",
1603 + "confidence": 0.95
1604 + },
1605 + "example": "true"
1606 + },
1607 + {
1608 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showCompanion",
1609 + "semantic": {
1610 + "kind": "boolean",
1611 + "confidence": 0.95
1612 + },
1613 + "example": "true"
1614 + },
1615 + {
1616 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showInstream",
1617 + "semantic": {
1618 + "kind": "boolean",
1619 + "confidence": 0.95
1620 + },
1621 + "example": "true"
1622 + },
1623 + {
1624 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.useGut",
1625 + "semantic": {
1626 + "kind": "boolean",
1627 + "confidence": 0.95
1628 + },
1629 + "example": "true"
1630 + },
1631 + {
1632 + "path": "[].playerResponse.playbackTracking.videostatsPlaybackUrl.baseUrl",
1633 + "semantic": {
1634 + "kind": "url",
1635 + "confidence": 0.9
1636 + },
1637 + "example": "https://s.youtube.com/api/stats/playback?cl=974780265&docid=XZWRcP5LWoM&ei=ZHOka"
1638 + },
1639 + {
1640 + "path": "[].playerResponse.playbackTracking.videostatsDelayplayUrl.baseUrl",
1641 + "semantic": {
1642 + "kind": "url",
1643 + "confidence": 0.9
1644 + },
1645 + "example": "https://s.youtube.com/api/stats/delayplay?cl=974780265&docid=XZWRcP5LWoM&ei=ZHOk"
1646 + },
1647 + {
1648 + "path": "[].playerResponse.playbackTracking.videostatsWatchtimeUrl.baseUrl",
1649 + "semantic": {
1650 + "kind": "url",
1651 + "confidence": 0.9
1652 + },
1653 + "example": "https://s.youtube.com/api/stats/watchtime?cl=974780265&docid=XZWRcP5LWoM&ei=ZHOk"
1654 + },
1655 + {
1656 + "path": "[].playerResponse.playbackTracking.ptrackingUrl.baseUrl",
1657 + "semantic": {
1658 + "kind": "url",
1659 + "confidence": 0.9
1660 + },
1661 + "example": "https://www.youtube.com/ptracking?ei=ZHOkavu4GI2KkucP5q-32AM&oid=AkXOB7W-CcJiy62"
1662 + },
1663 + {
1664 + "path": "[].playerResponse.playbackTracking.qoeUrl.baseUrl",
1665 + "semantic": {
1666 + "kind": "url",
1667 + "confidence": 0.9
1668 + },
1669 + "example": "https://s.youtube.com/api/stats/qoe?cat=mta&cl=974780265&docid=XZWRcP5LWoM&ei=ZH"
1670 + },
1671 + {
1672 + "path": "[].playerResponse.playbackTracking.atrUrl.baseUrl",
1673 + "semantic": {
1674 + "kind": "url",
1675 + "confidence": 0.9
1676 + },
1677 + "example": "https://s.youtube.com/api/stats/atr?c=WEB&docid=XZWRcP5LWoM&ei=ZHOkavu4GI2KkucP5"
1678 + },
1679 + {
1680 + "path": "[].playerResponse.playbackTracking.atrUrl.elapsedMediaTimeSeconds",
1681 + "semantic": {
1682 + "kind": "timestamp",
1683 + "confidence": 0.7
1684 + },
1685 + "example": "5"
1686 + },
1687 + {
1688 + "path": "[].playerResponse.playbackTracking.youtubeRemarketingUrl.baseUrl",
1689 + "semantic": {
1690 + "kind": "url",
1691 + "confidence": 0.9
1692 + },
1693 + "example": "https://www.youtube.com/pagead/viewthroughconversion/962985656/?backend=innertub"
1694 + }
1695 + ]
1696 + },
1697 + {
1698 + "shape_hash": "68e05e1c3c83f379",
1699 + "status": "provisional",
1700 + "hostname": "www.youtube.com",
1701 + "path_pattern": "/youtubei/v1/next",
1702 + "method": "POST",
1703 + "likely_entity_types": {
1704 + "comment": 1,
1705 + "channel": 1,
1706 + "video": 1
1707 + },
1708 + "confidence": 0.22,
1709 + "triggered_by": {
1710 + "OPEN_VIDEO": 1
1711 + },
1712 + "key_fields": [
1713 + {
1714 + "path": "responseContext.mainAppWebResponseContext.loggedOut",
1715 + "semantic": {
1716 + "kind": "boolean",
1717 + "confidence": 0.95
1718 + },
1719 + "example": "true"
1720 + },
1721 + {
1722 + "path": "responseContext.responseId",
1723 + "semantic": {
1724 + "kind": "identifier",
1725 + "confidence": 0.95
1726 + },
1727 + "example": "IhMI186jkL3nlgMVORXLBB2u3zne"
1728 + },
1729 + {
1730 + "path": "responseContext.webResponseContextExtensionData.hasDecorated",
1731 + "semantic": {
1732 + "kind": "boolean",
1733 + "confidence": 0.95
1734 + },
1735 + "example": "true"
1736 + },
1737 + {
1738 + "path": "onResponseReceivedEndpoints.[].appendContinuationItemsAction.continuationItems.[].commentThreadRenderer.isModeratedElqComment",
1739 + "semantic": {
1740 + "kind": "boolean",
1741 + "confidence": 0.95
1742 + },
1743 + "example": "false"
1744 + },
1745 + {
1746 + "path": "onResponseReceivedEndpoints.[].appendContinuationItemsAction.continuationItems.[].commentThreadRenderer.commentViewModel.commentViewModel.commentId",
1747 + "semantic": {
1748 + "kind": "identifier",
1749 + "confidence": 0.95
1750 + },
1751 + "example": "UgwXrux_QAEMRaAD0w54AaABAg"
1752 + },
1753 + {
1754 + "path": "onResponseReceivedEndpoints.[].appendContinuationItemsAction.continuationItems.[].commentThreadRenderer.replies.commentRepliesRenderer.hideReplies.buttonRenderer.text.runs.[].text",
1755 + "semantic": {
1756 + "kind": "text",
1757 + "confidence": 0.85
1758 + },
1759 + "example": "Masquer les réponses"
1760 + },
1761 + {
1762 + "path": "onResponseReceivedEndpoints.[].appendContinuationItemsAction.continuationItems.[].commentThreadRenderer.replies.commentRepliesRenderer.targetId",
1763 + "semantic": {
1764 + "kind": "identifier",
1765 + "confidence": 0.95
1766 + },
1767 + "example": "comment-replies-item-Ugzg0kiZiRZUEQ1l0RB4AaABAg"
1768 + },
1769 + {
1770 + "path": "onResponseReceivedEndpoints.[].appendContinuationItemsAction.continuationItems.[].commentThreadRenderer.replies.commentRepliesRenderer.subThreads.[].continuationItemRenderer.continuationEndpoint.commandMetadata.webCommandMetadata.sendPost",
1771 + "semantic": {
1772 + "kind": "boolean",
1773 + "confidence": 0.95
1774 + },
1775 + "example": "true"
1776 + },
1777 + {
1778 + "path": "onResponseReceivedEndpoints.[].appendContinuationItemsAction.continuationItems.[].continuationItemRenderer.continuationEndpoint.commandMetadata.webCommandMetadata.sendPost",
1779 + "semantic": {
1780 + "kind": "boolean",
1781 + "confidence": 0.95
1782 + },
1783 + "example": "true"
1784 + },
1785 + {
1786 + "path": "onResponseReceivedEndpoints.[].appendContinuationItemsAction.targetId",
1787 + "semantic": {
1788 + "kind": "identifier",
1789 + "confidence": 0.95
1790 + },
1791 + "example": "comments-section"
1792 + },
1793 + {
1794 + "path": "frameworkUpdates.entityBatchUpdate.mutations.[].payload.commentEntityPayload.properties.commentId",
1795 + "semantic": {
1796 + "kind": "identifier",
1797 + "confidence": 0.95
1798 + },
1799 + "example": "UgwXrux_QAEMRaAD0w54AaABAg"
1800 + },
1801 + {
1802 + "path": "frameworkUpdates.entityBatchUpdate.mutations.[].payload.commentEntityPayload.properties.content.content",
1803 + "semantic": {
1804 + "kind": "text",
1805 + "confidence": 0.85
1806 + },
1807 + "example": "Adorei o seu vidio."
1808 + },
1809 + {
1810 + "path": "frameworkUpdates.entityBatchUpdate.mutations.[].payload.commentEntityPayload.properties.publishedTime",
1811 + "semantic": {
1812 + "kind": "timestamp",
1813 + "confidence": 0.7
1814 + },
1815 + "example": "il y a 3 mois"
1816 + },
1817 + {
1818 + "path": "frameworkUpdates.entityBatchUpdate.mutations.[].payload.commentEntityPayload.author.channelId",
1819 + "semantic": {
1820 + "kind": "identifier",
1821 + "confidence": 0.95
1822 + },
1823 + "example": "UCI0tDQv_Tt-jnPAuK6FPmfA"
1824 + },
1825 + {
1826 + "path": "frameworkUpdates.entityBatchUpdate.mutations.[].payload.commentEntityPayload.author.displayName",
1827 + "semantic": {
1828 + "kind": "username",
1829 + "confidence": 0.7
1830 + },
1831 + "example": "@AdrianConceição-p8t"
1832 + },
1833 + {
1834 + "path": "frameworkUpdates.entityBatchUpdate.mutations.[].payload.commentEntityPayload.author.avatarThumbnailUrl",
1835 + "semantic": {
1836 + "kind": "url",
1837 + "confidence": 0.9
1838 + },
1839 + "example": "https://yt3.ggpht.com/3y6bu5PDGsPKf4sfHi6k2GhKeU5QWX1VjlbuEymaj7vJlmov4Wye-gws0N"
1840 + },
1841 + {
1842 + "path": "frameworkUpdates.entityBatchUpdate.mutations.[].payload.commentEntityPayload.author.isVerified",
1843 + "semantic": {
1844 + "kind": "boolean",
1845 + "confidence": 0.95
1846 + },
1847 + "example": "false"
1848 + },
1849 + {
1850 + "path": "frameworkUpdates.entityBatchUpdate.mutations.[].payload.commentEntityPayload.author.isCurrentUser",
1851 + "semantic": {
1852 + "kind": "boolean",
1853 + "confidence": 0.95
1854 + },
1855 + "example": "false"
1856 + },
1857 + {
1858 + "path": "frameworkUpdates.entityBatchUpdate.mutations.[].payload.commentEntityPayload.author.isCreator",
1859 + "semantic": {
1860 + "kind": "boolean",
1861 + "confidence": 0.95
1862 + },
1863 + "example": "false"
1864 + },
1865 + {
1866 + "path": "frameworkUpdates.entityBatchUpdate.mutations.[].payload.commentEntityPayload.author.channelCommand.innertubeCommand.browseEndpoint.browseId",
1867 + "semantic": {
1868 + "kind": "identifier",
1869 + "confidence": 0.95
1870 + },
1871 + "example": "UCI0tDQv_Tt-jnPAuK6FPmfA"
1872 + },
1873 + {
1874 + "path": "frameworkUpdates.entityBatchUpdate.mutations.[].payload.commentEntityPayload.author.isArtist",
1875 + "semantic": {
1876 + "kind": "boolean",
1877 + "confidence": 0.95
1878 + },
1879 + "example": "false"
1880 + },
1881 + {
1882 + "path": "frameworkUpdates.entityBatchUpdate.mutations.[].payload.commentEntityPayload.toolbar.likeCountLiked",
1883 + "semantic": {
1884 + "kind": "count",
1885 + "confidence": 0.9
1886 + },
1887 + "example": "3"
1888 + },
1889 + {
1890 + "path": "frameworkUpdates.entityBatchUpdate.mutations.[].payload.commentEntityPayload.toolbar.likeCountNotliked",
1891 + "semantic": {
1892 + "kind": "count",
1893 + "confidence": 0.9
1894 + },
1895 + "example": "2"
1896 + },
1897 + {
1898 + "path": "frameworkUpdates.entityBatchUpdate.mutations.[].payload.commentEntityPayload.toolbar.likeCountA11y",
1899 + "semantic": {
1900 + "kind": "count",
1901 + "confidence": 0.9
1902 + },
1903 + "example": "2 mentions J'aime"
1904 + }
1905 + ]
1906 + },
1907 + {
1908 + "shape_hash": "1a02f0ac8f8d4371",
1909 + "status": "provisional",
1910 + "hostname": "www.youtube.com",
1911 + "path_pattern": "/youtubei/v1/get_watch",
1912 + "method": "POST",
1913 + "likely_entity_types": {
1914 + "video": 1,
1915 + "post": 1,
1916 + "channel": 1
1917 + },
1918 + "confidence": 0.22,
1919 + "triggered_by": {
1920 + "OPEN_VIDEO": 1
1921 + },
1922 + "key_fields": [
1923 + {
1924 + "path": "[].responseContext.responseId",
1925 + "semantic": {
1926 + "kind": "identifier",
1927 + "confidence": 0.95
1928 + },
1929 + "example": "IhMIxI2kkL3nlgMVn4HkBh1GThmw"
1930 + },
1931 + {
1932 + "path": "[].playerResponse.responseContext.mainAppWebResponseContext.loggedOut",
1933 + "semantic": {
1934 + "kind": "boolean",
1935 + "confidence": 0.95
1936 + },
1937 + "example": "true"
1938 + },
1939 + {
1940 + "path": "[].playerResponse.responseContext.responseId",
1941 + "semantic": {
1942 + "kind": "identifier",
1943 + "confidence": 0.95
1944 + },
1945 + "example": "IhMIxI2kkL3nlgMVn4HkBh1GThmw"
1946 + },
1947 + {
1948 + "path": "[].playerResponse.responseContext.webResponseContextExtensionData.hasDecorated",
1949 + "semantic": {
1950 + "kind": "boolean",
1951 + "confidence": 0.95
1952 + },
1953 + "example": "true"
1954 + },
1955 + {
1956 + "path": "[].playerResponse.playabilityStatus.playableInEmbed",
1957 + "semantic": {
1958 + "kind": "boolean",
1959 + "confidence": 0.95
1960 + },
1961 + "example": "true"
1962 + },
1963 + {
1964 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].contentLength",
1965 + "semantic": {
1966 + "kind": "duration",
1967 + "confidence": 0.75
1968 + },
1969 + "example": "176692547"
1970 + },
1971 + {
1972 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].approxDurationMs",
1973 + "semantic": {
1974 + "kind": "duration",
1975 + "confidence": 0.75
1976 + },
1977 + "example": "3509572"
1978 + },
1979 + {
1980 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].highReplication",
1981 + "semantic": {
1982 + "kind": "boolean",
1983 + "confidence": 0.95
1984 + },
1985 + "example": "true"
1986 + },
1987 + {
1988 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].isDrc",
1989 + "semantic": {
1990 + "kind": "boolean",
1991 + "confidence": 0.95
1992 + },
1993 + "example": "true"
1994 + },
1995 + {
1996 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].isVb",
1997 + "semantic": {
1998 + "kind": "boolean",
1999 + "confidence": 0.95
2000 + },
2001 + "example": "true"
2002 + },
2003 + {
2004 + "path": "[].playerResponse.streamingData.serverAbrStreamingUrl",
2005 + "semantic": {
2006 + "kind": "media_url",
2007 + "confidence": 0.93
2008 + },
2009 + "example": "https://rr5---sn-cxaaj5o5q5-t0ay.googlevideo.com/videoplayback?expire=1789183945"
2010 + },
2011 + {
2012 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.playerAdParams.showContentThumbnail",
2013 + "semantic": {
2014 + "kind": "boolean",
2015 + "confidence": 0.95
2016 + },
2017 + "example": "true"
2018 + },
2019 + {
2020 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showCompanion",
2021 + "semantic": {
2022 + "kind": "boolean",
2023 + "confidence": 0.95
2024 + },
2025 + "example": "true"
2026 + },
2027 + {
2028 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showInstream",
2029 + "semantic": {
2030 + "kind": "boolean",
2031 + "confidence": 0.95
2032 + },
2033 + "example": "true"
2034 + },
2035 + {
2036 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.useGut",
2037 + "semantic": {
2038 + "kind": "boolean",
2039 + "confidence": 0.95
2040 + },
2041 + "example": "true"
2042 + },
2043 + {
2044 + "path": "[].playerResponse.playbackTracking.videostatsPlaybackUrl.baseUrl",
2045 + "semantic": {
2046 + "kind": "url",
2047 + "confidence": 0.9
2048 + },
2049 + "example": "https://s.youtube.com/api/stats/playback?cl=974780265&docid=jL4ANKq2MHQ&ei=aXOka"
2050 + },
2051 + {
2052 + "path": "[].playerResponse.playbackTracking.videostatsDelayplayUrl.baseUrl",
2053 + "semantic": {
2054 + "kind": "url",
2055 + "confidence": 0.9
2056 + },
2057 + "example": "https://s.youtube.com/api/stats/delayplay?cl=974780265&docid=jL4ANKq2MHQ&ei=aXOk"
2058 + },
2059 + {
2060 + "path": "[].playerResponse.playbackTracking.videostatsWatchtimeUrl.baseUrl",
2061 + "semantic": {
2062 + "kind": "url",
2063 + "confidence": 0.9
2064 + },
2065 + "example": "https://s.youtube.com/api/stats/watchtime?cl=974780265&docid=jL4ANKq2MHQ&ei=aXOk"
2066 + },
2067 + {
2068 + "path": "[].playerResponse.playbackTracking.ptrackingUrl.baseUrl",
2069 + "semantic": {
2070 + "kind": "url",
2071 + "confidence": 0.9
2072 + },
2073 + "example": "https://www.youtube.com/ptracking?ei=aXOkaoS1EJ-DkucPxpzlgAs&oid=jMMLR90nQCMutT8"
2074 + },
2075 + {
2076 + "path": "[].playerResponse.playbackTracking.qoeUrl.baseUrl",
2077 + "semantic": {
2078 + "kind": "url",
2079 + "confidence": 0.9
2080 + },
2081 + "example": "https://s.youtube.com/api/stats/qoe?cl=974780265&docid=jL4ANKq2MHQ&ei=aXOkaoS1EJ"
2082 + },
2083 + {
2084 + "path": "[].playerResponse.playbackTracking.atrUrl.baseUrl",
2085 + "semantic": {
2086 + "kind": "url",
2087 + "confidence": 0.9
2088 + },
2089 + "example": "https://s.youtube.com/api/stats/atr?c=WEB&docid=jL4ANKq2MHQ&ei=aXOkaoS1EJ-DkucPx"
2090 + },
2091 + {
2092 + "path": "[].playerResponse.playbackTracking.atrUrl.elapsedMediaTimeSeconds",
2093 + "semantic": {
2094 + "kind": "timestamp",
2095 + "confidence": 0.7
2096 + },
2097 + "example": "5"
2098 + },
2099 + {
2100 + "path": "[].playerResponse.playbackTracking.youtubeRemarketingUrl.baseUrl",
2101 + "semantic": {
2102 + "kind": "url",
2103 + "confidence": 0.9
2104 + },
2105 + "example": "https://www.youtube.com/pagead/viewthroughconversion/962985656/?backend=innertub"
2106 + },
2107 + {
2108 + "path": "[].playerResponse.playbackTracking.youtubeRemarketingUrl.elapsedMediaTimeSeconds",
2109 + "semantic": {
2110 + "kind": "timestamp",
2111 + "confidence": 0.7
2112 + },
2113 + "example": "0"
2114 + },
2115 + {
2116 + "path": "[].playerResponse.playbackTracking.googleRemarketingUrl.baseUrl",
2117 + "semantic": {
2118 + "kind": "url",
2119 + "confidence": 0.9
2120 + },
2121 + "example": "https://www.google.com/pagead/1p-user-list/962985656/?backend=innertube&cname=1&"
2122 + }
2123 + ]
2124 + },
2125 + {
2126 + "shape_hash": "ae99380e3e3aef0c",
2127 + "status": "provisional",
2128 + "hostname": "www.youtube.com",
2129 + "path_pattern": "/youtubei/v1/get_watch",
2130 + "method": "POST",
2131 + "likely_entity_types": {
2132 + "video": 1,
2133 + "channel": 1,
2134 + "post": 1
2135 + },
2136 + "confidence": 0.22,
2137 + "triggered_by": {
2138 + "OPEN_VIDEO": 1
2139 + },
2140 + "key_fields": [
2141 + {
2142 + "path": "[].responseContext.responseId",
2143 + "semantic": {
2144 + "kind": "identifier",
2145 + "confidence": 0.95
2146 + },
2147 + "example": "IhMI9uTVlL3nlgMVE4auBR3LsgFe"
2148 + },
2149 + {
2150 + "path": "[].playerResponse.responseContext.mainAppWebResponseContext.loggedOut",
2151 + "semantic": {
2152 + "kind": "boolean",
2153 + "confidence": 0.95
2154 + },
2155 + "example": "true"
2156 + },
2157 + {
2158 + "path": "[].playerResponse.responseContext.responseId",
2159 + "semantic": {
2160 + "kind": "identifier",
2161 + "confidence": 0.95
2162 + },
2163 + "example": "IhMI9uTVlL3nlgMVE4auBR3LsgFe"
2164 + },
2165 + {
2166 + "path": "[].playerResponse.responseContext.webResponseContextExtensionData.hasDecorated",
2167 + "semantic": {
2168 + "kind": "boolean",
2169 + "confidence": 0.95
2170 + },
2171 + "example": "true"
2172 + },
2173 + {
2174 + "path": "[].playerResponse.playabilityStatus.playableInEmbed",
2175 + "semantic": {
2176 + "kind": "boolean",
2177 + "confidence": 0.95
2178 + },
2179 + "example": "true"
2180 + },
2181 + {
2182 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].contentLength",
2183 + "semantic": {
2184 + "kind": "duration",
2185 + "confidence": 0.75
2186 + },
2187 + "example": "734584581"
2188 + },
2189 + {
2190 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].approxDurationMs",
2191 + "semantic": {
2192 + "kind": "duration",
2193 + "confidence": 0.75
2194 + },
2195 + "example": "7197823"
2196 + },
2197 + {
2198 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].highReplication",
2199 + "semantic": {
2200 + "kind": "boolean",
2201 + "confidence": 0.95
2202 + },
2203 + "example": "true"
2204 + },
2205 + {
2206 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].isVb",
2207 + "semantic": {
2208 + "kind": "boolean",
2209 + "confidence": 0.95
2210 + },
2211 + "example": "true"
2212 + },
2213 + {
2214 + "path": "[].playerResponse.streamingData.serverAbrStreamingUrl",
2215 + "semantic": {
2216 + "kind": "media_url",
2217 + "confidence": 0.93
2218 + },
2219 + "example": "https://rr1---sn-cxaaj5o5q5-t0ay.googlevideo.com/videoplayback?expire=1789183954"
2220 + },
2221 + {
2222 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.playerAdParams.showContentThumbnail",
2223 + "semantic": {
2224 + "kind": "boolean",
2225 + "confidence": 0.95
2226 + },
2227 + "example": "true"
2228 + },
2229 + {
2230 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showCompanion",
2231 + "semantic": {
2232 + "kind": "boolean",
2233 + "confidence": 0.95
2234 + },
2235 + "example": "true"
2236 + },
2237 + {
2238 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showInstream",
2239 + "semantic": {
2240 + "kind": "boolean",
2241 + "confidence": 0.95
2242 + },
2243 + "example": "true"
2244 + },
2245 + {
2246 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.useGut",
2247 + "semantic": {
2248 + "kind": "boolean",
2249 + "confidence": 0.95
2250 + },
2251 + "example": "true"
2252 + },
2253 + {
2254 + "path": "[].playerResponse.playbackTracking.videostatsPlaybackUrl.baseUrl",
2255 + "semantic": {
2256 + "kind": "url",
2257 + "confidence": 0.9
2258 + },
2259 + "example": "https://s.youtube.com/api/stats/playback?cl=974780265&docid=Fa2sDP_RYsA&ei=cnOka"
2260 + },
2261 + {
2262 + "path": "[].playerResponse.playbackTracking.videostatsDelayplayUrl.baseUrl",
2263 + "semantic": {
2264 + "kind": "url",
2265 + "confidence": 0.9
2266 + },
2267 + "example": "https://s.youtube.com/api/stats/delayplay?cl=974780265&docid=Fa2sDP_RYsA&ei=cnOk"
2268 + },
2269 + {
2270 + "path": "[].playerResponse.playbackTracking.videostatsWatchtimeUrl.baseUrl",
2271 + "semantic": {
2272 + "kind": "url",
2273 + "confidence": 0.9
2274 + },
2275 + "example": "https://s.youtube.com/api/stats/watchtime?cl=974780265&docid=Fa2sDP_RYsA&ei=cnOk"
2276 + },
2277 + {
2278 + "path": "[].playerResponse.playbackTracking.ptrackingUrl.baseUrl",
2279 + "semantic": {
2280 + "kind": "url",
2281 + "confidence": 0.9
2282 + },
2283 + "example": "https://www.youtube.com/ptracking?ei=cnOkavbjHJOMut0Py-WG8AU&oid=WLeG0gE_kkpZY0o"
2284 + },
2285 + {
2286 + "path": "[].playerResponse.playbackTracking.qoeUrl.baseUrl",
2287 + "semantic": {
2288 + "kind": "url",
2289 + "confidence": 0.9
2290 + },
2291 + "example": "https://s.youtube.com/api/stats/qoe?cl=974780265&docid=Fa2sDP_RYsA&ei=cnOkavbjHJ"
2292 + },
2293 + {
2294 + "path": "[].playerResponse.playbackTracking.atrUrl.baseUrl",
2295 + "semantic": {
2296 + "kind": "url",
2297 + "confidence": 0.9
2298 + },
2299 + "example": "https://s.youtube.com/api/stats/atr?c=WEB&docid=Fa2sDP_RYsA&ei=cnOkavbjHJOMut0Py"
2300 + },
2301 + {
2302 + "path": "[].playerResponse.playbackTracking.atrUrl.elapsedMediaTimeSeconds",
2303 + "semantic": {
2304 + "kind": "timestamp",
2305 + "confidence": 0.7
2306 + },
2307 + "example": "5"
2308 + },
2309 + {
2310 + "path": "[].playerResponse.playbackTracking.youtubeRemarketingUrl.baseUrl",
2311 + "semantic": {
2312 + "kind": "url",
2313 + "confidence": 0.9
2314 + },
2315 + "example": "https://www.youtube.com/pagead/viewthroughconversion/962985656/?backend=innertub"
2316 + },
2317 + {
2318 + "path": "[].playerResponse.playbackTracking.youtubeRemarketingUrl.elapsedMediaTimeSeconds",
2319 + "semantic": {
2320 + "kind": "timestamp",
2321 + "confidence": 0.7
2322 + },
2323 + "example": "0"
2324 + },
2325 + {
2326 + "path": "[].playerResponse.captions.playerCaptionsTracklistRenderer.captionTracks.[].baseUrl",
2327 + "semantic": {
2328 + "kind": "url",
2329 + "confidence": 0.9
2330 + },
2331 + "example": "https://www.youtube.com/api/timedtext?v=Fa2sDP_RYsA&ei=cnOkavbjHJOMut0Py-WG8AU&c"
2332 + },
2333 + {
2334 + "path": "[].playerResponse.captions.playerCaptionsTracklistRenderer.captionTracks.[].name.simpleText",
2335 + "semantic": {
2336 + "kind": "text",
2337 + "confidence": 0.85
2338 + },
2339 + "example": "Français (générés automatiquement)"
2340 + }
2341 + ]
2342 + },
2343 + {
2344 + "shape_hash": "5d481f850a3bd8ee",
2345 + "status": "provisional",
2346 + "hostname": "www.youtube.com",
2347 + "path_pattern": "/youtubei/v1/get_watch",
2348 + "method": "POST",
2349 + "likely_entity_types": {
2350 + "video": 1,
2351 + "channel": 1,
2352 + "post": 1
2353 + },
2354 + "confidence": 0.22,
2355 + "triggered_by": {
2356 + "OPEN_VIDEO": 1
2357 + },
2358 + "key_fields": [
2359 + {
2360 + "path": "[].responseContext.responseId",
2361 + "semantic": {
2362 + "kind": "identifier",
2363 + "confidence": 0.95
2364 + },
2365 + "example": "IhMI7I_0lr3nlgMVPKHkBh3E-zwt"
2366 + },
2367 + {
2368 + "path": "[].playerResponse.responseContext.mainAppWebResponseContext.loggedOut",
2369 + "semantic": {
2370 + "kind": "boolean",
2371 + "confidence": 0.95
2372 + },
2373 + "example": "true"
2374 + },
2375 + {
2376 + "path": "[].playerResponse.responseContext.responseId",
2377 + "semantic": {
2378 + "kind": "identifier",
2379 + "confidence": 0.95
2380 + },
2381 + "example": "IhMI7I_0lr3nlgMVPKHkBh3E-zwt"
2382 + },
2383 + {
2384 + "path": "[].playerResponse.responseContext.webResponseContextExtensionData.hasDecorated",
2385 + "semantic": {
2386 + "kind": "boolean",
2387 + "confidence": 0.95
2388 + },
2389 + "example": "true"
2390 + },
2391 + {
2392 + "path": "[].playerResponse.playabilityStatus.playableInEmbed",
2393 + "semantic": {
2394 + "kind": "boolean",
2395 + "confidence": 0.95
2396 + },
2397 + "example": "true"
2398 + },
2399 + {
2400 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].contentLength",
2401 + "semantic": {
2402 + "kind": "duration",
2403 + "confidence": 0.75
2404 + },
2405 + "example": "33822999"
2406 + },
2407 + {
2408 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].approxDurationMs",
2409 + "semantic": {
2410 + "kind": "duration",
2411 + "confidence": 0.75
2412 + },
2413 + "example": "1560025"
2414 + },
2415 + {
2416 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].highReplication",
2417 + "semantic": {
2418 + "kind": "boolean",
2419 + "confidence": 0.95
2420 + },
2421 + "example": "true"
2422 + },
2423 + {
2424 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].isDrc",
2425 + "semantic": {
2426 + "kind": "boolean",
2427 + "confidence": 0.95
2428 + },
2429 + "example": "true"
2430 + },
2431 + {
2432 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].isVb",
2433 + "semantic": {
2434 + "kind": "boolean",
2435 + "confidence": 0.95
2436 + },
2437 + "example": "true"
2438 + },
2439 + {
2440 + "path": "[].playerResponse.streamingData.serverAbrStreamingUrl",
2441 + "semantic": {
2442 + "kind": "media_url",
2443 + "confidence": 0.93
2444 + },
2445 + "example": "https://rr1---sn-cxaaj5o5q5-t0ar.googlevideo.com/videoplayback?expire=1789183959"
2446 + },
2447 + {
2448 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.playerAdParams.showContentThumbnail",
2449 + "semantic": {
2450 + "kind": "boolean",
2451 + "confidence": 0.95
2452 + },
2453 + "example": "true"
2454 + },
2455 + {
2456 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showCompanion",
2457 + "semantic": {
2458 + "kind": "boolean",
2459 + "confidence": 0.95
2460 + },
2461 + "example": "true"
2462 + },
2463 + {
2464 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showInstream",
2465 + "semantic": {
2466 + "kind": "boolean",
2467 + "confidence": 0.95
2468 + },
2469 + "example": "true"
2470 + },
2471 + {
2472 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.useGut",
2473 + "semantic": {
2474 + "kind": "boolean",
2475 + "confidence": 0.95
2476 + },
2477 + "example": "true"
2478 + },
2479 + {
2480 + "path": "[].playerResponse.playbackTracking.videostatsPlaybackUrl.baseUrl",
2481 + "semantic": {
2482 + "kind": "url",
2483 + "confidence": 0.9
2484 + },
2485 + "example": "https://s.youtube.com/api/stats/playback?cl=974780265&docid=upq306ci98I&ei=d3Oka"
2486 + },
2487 + {
2488 + "path": "[].playerResponse.playbackTracking.videostatsDelayplayUrl.baseUrl",
2489 + "semantic": {
2490 + "kind": "url",
2491 + "confidence": 0.9
2492 + },
2493 + "example": "https://s.youtube.com/api/stats/delayplay?cl=974780265&docid=upq306ci98I&ei=d3Ok"
2494 + },
2495 + {
2496 + "path": "[].playerResponse.playbackTracking.videostatsWatchtimeUrl.baseUrl",
2497 + "semantic": {
2498 + "kind": "url",
2499 + "confidence": 0.9
2500 + },
2501 + "example": "https://s.youtube.com/api/stats/watchtime?cl=974780265&docid=upq306ci98I&ei=d3Ok"
2502 + },
2503 + {
2504 + "path": "[].playerResponse.playbackTracking.ptrackingUrl.baseUrl",
2505 + "semantic": {
2506 + "kind": "url",
2507 + "confidence": 0.9
2508 + },
2509 + "example": "https://www.youtube.com/ptracking?ei=d3Okaqz4CbzCkucPxPfz6QI&oid=EwVG-FSZ-fJALhv"
2510 + },
2511 + {
2512 + "path": "[].playerResponse.playbackTracking.qoeUrl.baseUrl",
2513 + "semantic": {
2514 + "kind": "url",
2515 + "confidence": 0.9
2516 + },
2517 + "example": "https://s.youtube.com/api/stats/qoe?cl=974780265&docid=upq306ci98I&ei=d3Okaqz4Cb"
2518 + },
2519 + {
2520 + "path": "[].playerResponse.playbackTracking.atrUrl.baseUrl",
2521 + "semantic": {
2522 + "kind": "url",
2523 + "confidence": 0.9
2524 + },
2525 + "example": "https://s.youtube.com/api/stats/atr?c=WEB&docid=upq306ci98I&ei=d3Okaqz4CbzCkucPx"
2526 + },
2527 + {
2528 + "path": "[].playerResponse.playbackTracking.atrUrl.elapsedMediaTimeSeconds",
2529 + "semantic": {
2530 + "kind": "timestamp",
2531 + "confidence": 0.7
2532 + },
2533 + "example": "5"
2534 + },
2535 + {
2536 + "path": "[].playerResponse.playbackTracking.youtubeRemarketingUrl.baseUrl",
2537 + "semantic": {
2538 + "kind": "url",
2539 + "confidence": 0.9
2540 + },
2541 + "example": "https://www.youtube.com/pagead/viewthroughconversion/962985656/?backend=innertub"
2542 + },
2543 + {
2544 + "path": "[].playerResponse.playbackTracking.youtubeRemarketingUrl.elapsedMediaTimeSeconds",
2545 + "semantic": {
2546 + "kind": "timestamp",
2547 + "confidence": 0.7
2548 + },
2549 + "example": "0"
2550 + },
2551 + {
2552 + "path": "[].playerResponse.playbackTracking.googleRemarketingUrl.baseUrl",
2553 + "semantic": {
2554 + "kind": "url",
2555 + "confidence": 0.9
2556 + },
2557 + "example": "https://www.google.com/pagead/1p-user-list/962985656/?backend=innertube&cname=1&"
2558 + }
2559 + ]
2560 + },
2561 + {
2562 + "shape_hash": "ec24830f0e0f4c6a",
2563 + "status": "provisional",
2564 + "hostname": "www.youtube.com",
2565 + "path_pattern": "/youtubei/v1/get_watch",
2566 + "method": "POST",
2567 + "likely_entity_types": {
2568 + "video": 1,
2569 + "post": 1,
2570 + "channel": 1
2571 + },
2572 + "confidence": 0.22,
2573 + "triggered_by": {
2574 + "OPEN_VIDEO": 1
2575 + },
2576 + "key_fields": [
2577 + {
2578 + "path": "[].responseContext.responseId",
2579 + "semantic": {
2580 + "kind": "identifier",
2581 + "confidence": 0.95
2582 + },
2583 + "example": "IhMIsti_m73nlgMVbazkBh0L1Sz8"
2584 + },
2585 + {
2586 + "path": "[].playerResponse.responseContext.mainAppWebResponseContext.loggedOut",
2587 + "semantic": {
2588 + "kind": "boolean",
2589 + "confidence": 0.95
2590 + },
2591 + "example": "true"
2592 + },
2593 + {
2594 + "path": "[].playerResponse.responseContext.responseId",
2595 + "semantic": {
2596 + "kind": "identifier",
2597 + "confidence": 0.95
2598 + },
2599 + "example": "IhMIsti_m73nlgMVbazkBh0L1Sz8"
2600 + },
2601 + {
2602 + "path": "[].playerResponse.responseContext.webResponseContextExtensionData.hasDecorated",
2603 + "semantic": {
2604 + "kind": "boolean",
2605 + "confidence": 0.95
2606 + },
2607 + "example": "true"
2608 + },
2609 + {
2610 + "path": "[].playerResponse.playabilityStatus.playableInEmbed",
2611 + "semantic": {
2612 + "kind": "boolean",
2613 + "confidence": 0.95
2614 + },
2615 + "example": "true"
2616 + },
2617 + {
2618 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].contentLength",
2619 + "semantic": {
2620 + "kind": "duration",
2621 + "confidence": 0.75
2622 + },
2623 + "example": "2139831769"
2624 + },
2625 + {
2626 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].approxDurationMs",
2627 + "semantic": {
2628 + "kind": "duration",
2629 + "confidence": 0.75
2630 + },
2631 + "example": "1125191"
2632 + },
2633 + {
2634 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].highReplication",
2635 + "semantic": {
2636 + "kind": "boolean",
2637 + "confidence": 0.95
2638 + },
2639 + "example": "true"
2640 + },
2641 + {
2642 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].isDrc",
2643 + "semantic": {
2644 + "kind": "boolean",
2645 + "confidence": 0.95
2646 + },
2647 + "example": "true"
2648 + },
2649 + {
2650 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].isVb",
2651 + "semantic": {
2652 + "kind": "boolean",
2653 + "confidence": 0.95
2654 + },
2655 + "example": "true"
2656 + },
2657 + {
2658 + "path": "[].playerResponse.streamingData.serverAbrStreamingUrl",
2659 + "semantic": {
2660 + "kind": "media_url",
2661 + "confidence": 0.93
2662 + },
2663 + "example": "https://rr1---sn-cxaaj5o5q5-t0ar.googlevideo.com/videoplayback?expire=1789183968"
2664 + },
2665 + {
2666 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.playerAdParams.showContentThumbnail",
2667 + "semantic": {
2668 + "kind": "boolean",
2669 + "confidence": 0.95
2670 + },
2671 + "example": "true"
2672 + },
2673 + {
2674 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showCompanion",
2675 + "semantic": {
2676 + "kind": "boolean",
2677 + "confidence": 0.95
2678 + },
2679 + "example": "true"
2680 + },
2681 + {
2682 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showInstream",
2683 + "semantic": {
2684 + "kind": "boolean",
2685 + "confidence": 0.95
2686 + },
2687 + "example": "true"
2688 + },
2689 + {
2690 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.useGut",
2691 + "semantic": {
2692 + "kind": "boolean",
2693 + "confidence": 0.95
2694 + },
2695 + "example": "true"
2696 + },
2697 + {
2698 + "path": "[].playerResponse.playbackTracking.videostatsPlaybackUrl.baseUrl",
2699 + "semantic": {
2700 + "kind": "url",
2701 + "confidence": 0.9
2702 + },
2703 + "example": "https://s.youtube.com/api/stats/playback?cl=974780265&docid=AOyfVBVEIt4&ei=gHOka"
2704 + },
2705 + {
2706 + "path": "[].playerResponse.playbackTracking.videostatsDelayplayUrl.baseUrl",
2707 + "semantic": {
2708 + "kind": "url",
2709 + "confidence": 0.9
2710 + },
2711 + "example": "https://s.youtube.com/api/stats/delayplay?cl=974780265&docid=AOyfVBVEIt4&ei=gHOk"
2712 + },
2713 + {
2714 + "path": "[].playerResponse.playbackTracking.videostatsWatchtimeUrl.baseUrl",
2715 + "semantic": {
2716 + "kind": "url",
2717 + "confidence": 0.9
2718 + },
2719 + "example": "https://s.youtube.com/api/stats/watchtime?cl=974780265&docid=AOyfVBVEIt4&ei=gHOk"
2720 + },
2721 + {
2722 + "path": "[].playerResponse.playbackTracking.ptrackingUrl.baseUrl",
2723 + "semantic": {
2724 + "kind": "url",
2725 + "confidence": 0.9
2726 + },
2727 + "example": "https://www.youtube.com/ptracking?ei=gHOkarKYMO3YkucPi6qz4Q8&oid=S9JN2l7skMZq-F8"
2728 + },
2729 + {
2730 + "path": "[].playerResponse.playbackTracking.qoeUrl.baseUrl",
2731 + "semantic": {
2732 + "kind": "url",
2733 + "confidence": 0.9
2734 + },
2735 + "example": "https://s.youtube.com/api/stats/qoe?cl=974780265&docid=AOyfVBVEIt4&ei=gHOkarKYMO"
2736 + },
2737 + {
2738 + "path": "[].playerResponse.playbackTracking.atrUrl.baseUrl",
2739 + "semantic": {
2740 + "kind": "url",
2741 + "confidence": 0.9
2742 + },
2743 + "example": "https://s.youtube.com/api/stats/atr?c=WEB&docid=AOyfVBVEIt4&ei=gHOkarKYMO3YkucPi"
2744 + },
2745 + {
2746 + "path": "[].playerResponse.playbackTracking.atrUrl.elapsedMediaTimeSeconds",
2747 + "semantic": {
2748 + "kind": "timestamp",
2749 + "confidence": 0.7
2750 + },
2751 + "example": "5"
2752 + },
2753 + {
2754 + "path": "[].playerResponse.captions.playerCaptionsTracklistRenderer.captionTracks.[].baseUrl",
2755 + "semantic": {
2756 + "kind": "url",
2757 + "confidence": 0.9
2758 + },
2759 + "example": "https://www.youtube.com/api/timedtext?v=AOyfVBVEIt4&ei=gHOkarKYMO3YkucPi6qz4Q8&c"
2760 + },
2761 + {
2762 + "path": "[].playerResponse.captions.playerCaptionsTracklistRenderer.captionTracks.[].name.simpleText",
2763 + "semantic": {
2764 + "kind": "text",
2765 + "confidence": 0.85
2766 + },
2767 + "example": "Anglais (générés automatiquement)"
2768 + },
2769 + {
2770 + "path": "[].playerResponse.captions.playerCaptionsTracklistRenderer.captionTracks.[].isTranslatable",
2771 + "semantic": {
2772 + "kind": "boolean",
2773 + "confidence": 0.95
2774 + },
2775 + "example": "true"
2776 + }
2777 + ]
2778 + },
2779 + {
2780 + "shape_hash": "ef8c8cb030c1e3ed",
2781 + "status": "provisional",
2782 + "hostname": "www.youtube.com",
2783 + "path_pattern": "/youtubei/v1/get_watch",
2784 + "method": "POST",
2785 + "likely_entity_types": {
2786 + "video": 1,
2787 + "channel": 1,
2788 + "post": 1
2789 + },
2790 + "confidence": 0.22,
2791 + "triggered_by": {
2792 + "OPEN_VIDEO": 1
2793 + },
2794 + "key_fields": [
2795 + {
2796 + "path": "[].responseContext.responseId",
2797 + "semantic": {
2798 + "kind": "identifier",
2799 + "confidence": 0.95
2800 + },
2801 + "example": "IhMIk9Hynb3nlgMV8YDkBh3UOQxb"
2802 + },
2803 + {
2804 + "path": "[].playerResponse.responseContext.mainAppWebResponseContext.loggedOut",
2805 + "semantic": {
2806 + "kind": "boolean",
2807 + "confidence": 0.95
2808 + },
2809 + "example": "true"
2810 + },
2811 + {
2812 + "path": "[].playerResponse.responseContext.responseId",
2813 + "semantic": {
2814 + "kind": "identifier",
2815 + "confidence": 0.95
2816 + },
2817 + "example": "IhMIk9Hynb3nlgMV8YDkBh3UOQxb"
2818 + },
2819 + {
2820 + "path": "[].playerResponse.responseContext.webResponseContextExtensionData.hasDecorated",
2821 + "semantic": {
2822 + "kind": "boolean",
2823 + "confidence": 0.95
2824 + },
2825 + "example": "true"
2826 + },
2827 + {
2828 + "path": "[].playerResponse.playabilityStatus.playableInEmbed",
2829 + "semantic": {
2830 + "kind": "boolean",
2831 + "confidence": 0.95
2832 + },
2833 + "example": "true"
2834 + },
2835 + {
2836 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].contentLength",
2837 + "semantic": {
2838 + "kind": "duration",
2839 + "confidence": 0.75
2840 + },
2841 + "example": "1450852620"
2842 + },
2843 + {
2844 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].approxDurationMs",
2845 + "semantic": {
2846 + "kind": "duration",
2847 + "confidence": 0.75
2848 + },
2849 + "example": "806806"
2850 + },
2851 + {
2852 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].highReplication",
2853 + "semantic": {
2854 + "kind": "boolean",
2855 + "confidence": 0.95
2856 + },
2857 + "example": "true"
2858 + },
2859 + {
2860 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].isDrc",
2861 + "semantic": {
2862 + "kind": "boolean",
2863 + "confidence": 0.95
2864 + },
2865 + "example": "true"
2866 + },
2867 + {
2868 + "path": "[].playerResponse.streamingData.adaptiveFormats.[].isVb",
2869 + "semantic": {
2870 + "kind": "boolean",
2871 + "confidence": 0.95
2872 + },
2873 + "example": "true"
2874 + },
2875 + {
2876 + "path": "[].playerResponse.streamingData.serverAbrStreamingUrl",
2877 + "semantic": {
2878 + "kind": "media_url",
2879 + "confidence": 0.93
2880 + },
2881 + "example": "https://rr1---sn-cxaaj5o5q5-t0ad.googlevideo.com/videoplayback?expire=1789183973"
2882 + },
2883 + {
2884 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.playerAdParams.showContentThumbnail",
2885 + "semantic": {
2886 + "kind": "boolean",
2887 + "confidence": 0.95
2888 + },
2889 + "example": "true"
2890 + },
2891 + {
2892 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showCompanion",
2893 + "semantic": {
2894 + "kind": "boolean",
2895 + "confidence": 0.95
2896 + },
2897 + "example": "true"
2898 + },
2899 + {
2900 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showInstream",
2901 + "semantic": {
2902 + "kind": "boolean",
2903 + "confidence": 0.95
2904 + },
2905 + "example": "true"
2906 + },
2907 + {
2908 + "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.useGut",
2909 + "semantic": {
2910 + "kind": "boolean",
2911 + "confidence": 0.95
2912 + },
2913 + "example": "true"
2914 + },
2915 + {
2916 + "path": "[].playerResponse.playbackTracking.videostatsPlaybackUrl.baseUrl",
2917 + "semantic": {
2918 + "kind": "url",
2919 + "confidence": 0.9
2920 + },
2921 + "example": "https://s.youtube.com/api/stats/playback?cl=974780265&docid=64fmQxx7hiU&ei=hXOka"
2922 + },
2923 + {
2924 + "path": "[].playerResponse.playbackTracking.videostatsDelayplayUrl.baseUrl",
2925 + "semantic": {
2926 + "kind": "url",
2927 + "confidence": 0.9
2928 + },
2929 + "example": "https://s.youtube.com/api/stats/delayplay?cl=974780265&docid=64fmQxx7hiU&ei=hXOk"
2930 + },
2931 + {
2932 + "path": "[].playerResponse.playbackTracking.videostatsWatchtimeUrl.baseUrl",
2933 + "semantic": {
2934 + "kind": "url",
2935 + "confidence": 0.9
2936 + },
2937 + "example": "https://s.youtube.com/api/stats/watchtime?cl=974780265&docid=64fmQxx7hiU&ei=hXOk"
2938 + },
2939 + {
2940 + "path": "[].playerResponse.playbackTracking.ptrackingUrl.baseUrl",
2941 + "semantic": {
2942 + "kind": "url",
2943 + "confidence": 0.9
2944 + },
2945 + "example": "https://www.youtube.com/ptracking?ei=hXOkatP6MfGBkucP1POw2AU&oid=1krK__BKXwDOY6x"
2946 + },
2947 + {
2948 + "path": "[].playerResponse.playbackTracking.qoeUrl.baseUrl",
2949 + "semantic": {
2950 + "kind": "url",
2951 + "confidence": 0.9
2952 + },
2953 + "example": "https://s.youtube.com/api/stats/qoe?cl=974780265&docid=64fmQxx7hiU&ei=hXOkatP6Mf"
2954 + },
2955 + {
2956 + "path": "[].playerResponse.playbackTracking.atrUrl.baseUrl",
2957 + "semantic": {
2958 + "kind": "url",
2959 + "confidence": 0.9
2960 + },
2961 + "example": "https://s.youtube.com/api/stats/atr?c=WEB&docid=64fmQxx7hiU&ei=hXOkatP6MfGBkucP1"
2962 + },
2963 + {
2964 + "path": "[].playerResponse.playbackTracking.atrUrl.elapsedMediaTimeSeconds",
2965 + "semantic": {
2966 + "kind": "timestamp",
2967 + "confidence": 0.7
2968 + },
2969 + "example": "5"
2970 + },
2971 + {
2972 + "path": "[].playerResponse.captions.playerCaptionsTracklistRenderer.captionTracks.[].baseUrl",
2973 + "semantic": {
2974 + "kind": "url",
2975 + "confidence": 0.9
2976 + },
2977 + "example": "https://www.youtube.com/api/timedtext?v=64fmQxx7hiU&ei=hXOkatP6MfGBkucP1POw2AU&c"
2978 + },
2979 + {
2980 + "path": "[].playerResponse.captions.playerCaptionsTracklistRenderer.captionTracks.[].name.simpleText",
2981 + "semantic": {
2982 + "kind": "text",
2983 + "confidence": 0.85
2984 + },
2985 + "example": "Anglais (générés automatiquement)"
2986 + },
2987 + {
2988 + "path": "[].playerResponse.captions.playerCaptionsTracklistRenderer.captionTracks.[].isTranslatable",
2989 + "semantic": {
2990 + "kind": "boolean",
2991 + "confidence": 0.95
2992 + },
2993 + "example": "true"
2994 + }
2995 + ]
35 2996 }
36 2997 ],
37 − "network_patterns": [],
38 2998 "navigation": [
39 2999 {
40 3000 "action": "WAIT_FOR_CONTENT",
@@ -76,19 +3036,19 @@
76 3036 "action": "OPEN_VIDEO",
77 3037 "from": "SEARCH_RESULTS",
78 3038 "to": {
79 − "VIDEO_DETAIL": 3
3039 + "VIDEO_DETAIL": 4
80 3040 },
81 − "avg_new_entities": 25.7,
82 − "observed": 3
3041 + "avg_new_entities": 19.8,
3042 + "observed": 4
83 3043 },
84 3044 {
85 3045 "action": "OPEN_VIDEO",
86 3046 "from": "VIDEO_DETAIL",
87 3047 "to": {
88 − "VIDEO_DETAIL": 10
3048 + "VIDEO_DETAIL": 11
89 3049 },
90 − "avg_new_entities": 29.3,
91 − "observed": 10
3050 + "avg_new_entities": 26.6,
3051 + "observed": 11
92 3052 },
93 3053 {
94 3054 "action": "SEARCH",
@@ -98,13 +3058,22 @@
98 3058 },
99 3059 "avg_new_entities": 1,
100 3060 "observed": 1
3061 + },
3062 + {
3063 + "action": "PLAY_VIDEO",
3064 + "from": "VIDEO_DETAIL",
3065 + "to": {
3066 + "UNKNOWN": 1
3067 + },
3068 + "avg_new_entities": 0,
3069 + "observed": 1
101 3070 }
102 3071 ],
103 3072 "media": [
104 3073 {
105 3074 "hostname": "www.youtube.com",
106 3075 "kind": "media_segment",
107 − "observed_count": 12
3076 + "observed_count": 16
108 3077 },
109 3078 {
110 3079 "hostname": "rr4---sn-q4fl6nde.googlevideo.com",
@@ -114,7 +3083,7 @@
114 3083 {
115 3084 "hostname": "i.ytimg.com",
116 3085 "kind": "image",
117 − "observed_count": 172
3086 + "observed_count": 181
118 3087 },
119 3088 {
120 3089 "hostname": "rr5---sn-q4fl6n6z.googlevideo.com",
@@ -160,6 +3129,16 @@
160 3129 "hostname": "rr1---sn-cxaaj5o5q5-t0ad.googlevideo.com",
161 3130 "kind": "media_segment",
162 3131 "observed_count": 1
3132 + },
3133 + {
3134 + "hostname": "rr1---sn-cxaaj5o5q5-t9cs.googlevideo.com",
3135 + "kind": "media_segment",
3136 + "observed_count": 2
3137 + },
3138 + {
3139 + "hostname": "rr2---sn-ntqe6n7k.googlevideo.com",
3140 + "kind": "media_segment",
3141 + "observed_count": 6
163 3142 }
164 3143 ]
165 3144 }
\ No newline at end of file
added deploy/social-runtime-crawler.mld.json +102 −0
@@ -0,0 +1,102 @@
1 +{
2 + "app": "social-runtime-crawler",
3 + "label": "SocialCrawl — Social Runtime Crawler (research console + control plane + browser worker)",
4 + "domain": "www.socialcrawl.co",
5 + "port": 8351,
6 + "health_path": "/access",
7 + "dir": "~/apps/social-runtime-crawler",
8 + "extra_paths": [],
9 + "sync_excludes": [
10 + "node_modules/",
11 + ".git/",
12 + ".env",
13 + ".env.*",
14 + "!.env.example",
15 + "*.tsbuildinfo",
16 + ".DS_Store",
17 + ".claude/",
18 + "data/",
19 + "apps/dashboard/.next/",
20 + "apps/dashboard/next-env.d.ts",
21 + "deploy/"
22 + ],
23 + "requires": {
24 + "runtimes": ["pm2", "node", "pnpm", "postgresql@17"],
25 + "ram_gb": 8,
26 + "ports": [8350, 8351]
27 + },
28 + "ram_mb_observed": 1500,
29 + "size_mb": 40,
30 + "placement": {
31 + "pin": null,
32 + "prefer": "M3U96a",
33 + "avoid": ["M1M32", "M3U96b", "M2U64", "M1M64", "m2m16b", "m1m16", "m2m16c", "m4me", "m4mf", "m4mg", "m4mh", "m1m16b", "m4mi", "m4mj", "m4mk", "m2m8a", "m2m8b"],
34 + "reason": "Chromium headless par mission (≈1–2 Go chacun) + Postgres 17 local ; nœud LAN avec session graphique pour les logins humains (Partage d'écran) ; M2U64 sans session graphique évité"
35 + },
36 + "processes": [
37 + {
38 + "name": "socialcrawl-api",
39 + "manager": "pm2",
40 + "script": "/opt/homebrew/bin/node",
41 + "args": ["node_modules/tsx/dist/cli.mjs", "apps/api/src/server.ts"],
42 + "interpreter": null,
43 + "cwd": "{{HOME}}/apps/social-runtime-crawler",
44 + "env": {
45 + "NODE_ENV": "production",
46 + "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin",
47 + "SRC_DATABASE_URL": "postgres://localhost:5432/social_runtime",
48 + "SRC_DATA_DIR": "{{HOME}}/apps/social-runtime-crawler/data",
49 + "SRC_API_PORT": "8350",
50 + "SRC_API_TOKEN": "e9618b61c36567d8f01beb39218ab3108e8042c438d31ee4",
51 + "SRC_MAX_JOBS": "2",
52 + "SRC_HEADLESS": "true",
53 + "SRC_BROWSER_CHANNEL": "chromium",
54 + "SRC_LLM_PROVIDER": "none",
55 + "SRC_LOG_LEVEL": "info",
56 + "SRC_LOG_PRETTY": "1"
57 + },
58 + "cron_restart": null,
59 + "autorestart": true,
60 + "max_memory_restart": "1G"
61 + },
62 + {
63 + "name": "socialcrawl-web",
64 + "manager": "pm2",
65 + "script": "/opt/homebrew/bin/node",
66 + "args": ["node_modules/next/dist/bin/next", "start", "-p", "8351", "-H", "0.0.0.0"],
67 + "interpreter": null,
68 + "cwd": "{{HOME}}/apps/social-runtime-crawler/apps/dashboard",
69 + "env": {
70 + "NODE_ENV": "production",
71 + "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin",
72 + "API_URL": "http://127.0.0.1:8350",
73 + "SRC_API_TOKEN": "e9618b61c36567d8f01beb39218ab3108e8042c438d31ee4",
74 + "SRC_DASHBOARD_PASSCODE": "OZVCyj4m0Gepql",
75 + "SRC_DASHBOARD_SECRET": "2b150a27dcbcf0cfdfd275cdfc85587bb15e9a29b864845f2a59e424e3e0e332",
76 + "NEXT_TELEMETRY_DISABLED": "1"
77 + },
78 + "cron_restart": null,
79 + "autorestart": true,
80 + "max_memory_restart": "1G"
81 + }
82 + ],
83 + "tunnel": {
84 + "domain": "www.socialcrawl.co",
85 + "gateway": "BHS64",
86 + "redirects": ["socialcrawl.co"],
87 + "websocket": false,
88 + "note": "SSE (/api/stream) passe par Caddy sans config spéciale ; apex socialcrawl.co n'a pas encore d'enregistrement A (à ajouter chez GoDaddy pour la redirection)"
89 + },
90 + "launchd": [],
91 + "env_overrides": {},
92 + "hooks": {
93 + "post_sync": [
94 + "export PATH=\"/opt/homebrew/bin:$PATH\"; pnpm install --frozen-lockfile --silent && echo ' deps ok'",
95 + "export PATH=\"/opt/homebrew/bin:$PATH\"; (psql -d postgres -Atc \"select 1 from pg_database where datname='social_runtime'\" | grep -q 1 || createdb social_runtime) && SRC_DATABASE_URL=postgres://localhost:5432/social_runtime pnpm db:migrate 2>&1 | tail -1 && echo ' db ok'",
96 + "export PATH=\"/opt/homebrew/bin:$PATH\"; pnpm --filter @src/worker exec playwright install chromium 2>&1 | tail -1; echo ' chromium ok'",
97 + "export PATH=\"/opt/homebrew/bin:$PATH\"; mkdir -p data/browser_profiles data/sessions data/platform_model data/media data/jobs; export NEXT_TELEMETRY_DISABLED=1 API_URL=http://127.0.0.1:8350; pnpm --filter @src/dashboard build 2>&1 | tail -3 && echo ' web build ok'"
98 + ],
99 + "post_start": []
100 + },
101 + "notes": "v0.1 (2026-09-12) : console de recherche Next 16 (gate par code d'accès SRC_DASHBOARD_PASSCODE, proxy /api → API loopback 8350 avec SRC_API_TOKEN), API de contrôle node:http (missions = processus worker Playwright enfants, SSE /api/stream), Postgres 17 local `social_runtime`. Les profils navigateur authentifiés vivent dans data/browser_profiles sur le nœud : login humain via Partage d'écran + `pnpm login <platform>`. Adaptateurs YouTube + Reddit. Lecture seule."
102 +}
modified package.json +5 −2
@@ -14,11 +14,14 @@
14 14 "crawl": "tsx apps/worker/src/cli.ts crawl",
15 15 "learn": "tsx apps/worker/src/cli.ts learn",
16 16 "replay": "tsx apps/worker/src/cli.ts replay",
17 − "dashboard": "tsx apps/api/src/server.ts",
18 17 "db:migrate": "tsx packages/storage/src/migrate.ts",
19 18 "typecheck": "tsc -p tsconfig.json --noEmit",
20 19 "test": "vitest run",
21 − "test:watch": "vitest"
20 + "test:watch": "vitest",
21 + "api": "tsx apps/api/src/server.ts",
22 + "dashboard:dev": "pnpm --filter @src/dashboard dev",
23 + "dashboard:build": "pnpm --filter @src/dashboard build",
24 + "dashboard:start": "pnpm --filter @src/dashboard start"
22 25 },
23 26 "devDependencies": {
24 27 "@types/node": "^24.0.0",
modified packages/media/src/index.ts +2 −0
@@ -97,7 +97,9 @@ export async function sampleVideoFrames(page: Page, media: ObservedMedia, mediaD
97 97 const duration = media.duration_s ?? (await video.evaluate((v: HTMLVideoElement) => (Number.isFinite(v.duration) ? v.duration : 0)).catch(() => 0));
98 98 const positions = [0, 0.25, 0.5, 0.75, 0.98].slice(0, maxFrames);
99 99 const files: string[] = [];
100 + const deadline = Date.now() + 20_000; // frame sampling is best-effort and bounded
100 101 for (const p of positions) {
102 + if (Date.now() > deadline) break;
101 103 try {
102 104 if (duration > 2) {
103 105 await video.evaluate((v: HTMLVideoElement, t: number) => {
modified packages/storage/src/index.ts +0 −0

Binary file not shown.

modified pnpm-lock.yaml +1358 −12
@@ -19,10 +19,13 @@ importers:
19 19 version: 5.9.3
20 20 vitest:
21 21 specifier: ^3.2.0
22 − version: 3.2.7(@types/node@24.13.4)(tsx@4.23.13)
22 + version: 3.2.7(@types/node@24.13.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)
23 23
24 24 apps/api:
25 25 dependencies:
26 + '@src/platform-model':
27 + specifier: workspace:*
28 + version: link:../../packages/platform-model
26 29 '@src/shared':
27 30 specifier: workspace:*
28 31 version: link:../../packages/shared
@@ -30,6 +33,55 @@ importers:
30 33 specifier: workspace:*
31 34 version: link:../../packages/storage
32 35
36 + apps/dashboard:
37 + dependencies:
38 + clsx:
39 + specifier: ^2.1.1
40 + version: 2.1.1
41 + d3-force:
42 + specifier: ^3.0.0
43 + version: 3.0.0
44 + framer-motion:
45 + specifier: ^12.23.0
46 + version: 12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
47 + lucide-react:
48 + specifier: ^1.0.0
49 + version: 1.44.0(react@19.2.8)
50 + next:
51 + specifier: 16.3.4
52 + version: 16.3.4(@types/node@24.13.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
53 + react:
54 + specifier: 19.2.8
55 + version: 19.2.8
56 + react-dom:
57 + specifier: 19.2.8
58 + version: 19.2.8(react@19.2.8)
59 + recharts:
60 + specifier: ^3.0.0
61 + version: 3.10.1(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react-is@19.3.0)(react@19.2.8)(redux@5.0.1)
62 + devDependencies:
63 + '@tailwindcss/postcss':
64 + specifier: ^4
65 + version: 4.3.3
66 + '@types/d3-force':
67 + specifier: ^3.0.10
68 + version: 3.0.10
69 + '@types/node':
70 + specifier: ^24.0.0
71 + version: 24.13.4
72 + '@types/react':
73 + specifier: ^19
74 + version: 19.3.0
75 + '@types/react-dom':
76 + specifier: ^19
77 + version: 19.3.0(@types/react@19.3.0)
78 + tailwindcss:
79 + specifier: ^4
80 + version: 4.3.3
81 + typescript:
82 + specifier: ^5.9.3
83 + version: 5.9.3
84 +
33 85 apps/worker:
34 86 dependencies:
35 87 '@src/agent':
@@ -170,6 +222,10 @@ importers:
170 222
171 223 packages:
172 224
225 + '@alloc/quick-lru@5.3.0':
226 + resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==}
227 + engines: {node: '>=10'}
228 +
173 229 '@anthropic-ai/sdk@0.125.0':
174 230 resolution: {integrity: sha512-Hq5wYlXupzJ9M1Fzqjqa3hObcuigEXVZJqSYDgeZGM3wF4qrNERM5Z1OOAeoT9rlod8awJ3uYyJjbQXp1ckIGg==}
175 231 hasBin: true
@@ -183,6 +239,9 @@ packages:
183 239 resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
184 240 engines: {node: '>=6.9.0'}
185 241
242 + '@emnapi/runtime@1.11.3':
243 + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
244 +
186 245 '@esbuild/aix-ppc64@0.28.2':
187 246 resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
188 247 engines: {node: '>=18'}
@@ -339,9 +398,184 @@ packages:
339 398 cpu: [x64]
340 399 os: [win32]
341 400
401 + '@img/colour@1.1.0':
402 + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
403 + engines: {node: '>=18'}
404 +
405 + '@img/sharp-darwin-arm64@0.35.4':
406 + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==}
407 + engines: {node: '>=20.9.0'}
408 + cpu: [arm64]
409 + os: [darwin]
410 +
411 + '@img/sharp-darwin-x64@0.35.4':
412 + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==}
413 + engines: {node: '>=20.9.0'}
414 + cpu: [x64]
415 + os: [darwin]
416 +
417 + '@img/sharp-freebsd-wasm32@0.35.4':
418 + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==}
419 + engines: {node: '>=20.9.0'}
420 + os: [freebsd]
421 +
422 + '@img/sharp-libvips-darwin-arm64@1.3.3':
423 + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==}
424 + cpu: [arm64]
425 + os: [darwin]
426 +
427 + '@img/sharp-libvips-darwin-x64@1.3.3':
428 + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==}
429 + cpu: [x64]
430 + os: [darwin]
431 +
432 + '@img/sharp-libvips-linux-arm64@1.3.3':
433 + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==}
434 + cpu: [arm64]
435 + os: [linux]
436 + libc: [glibc]
437 +
438 + '@img/sharp-libvips-linux-arm@1.3.3':
439 + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==}
440 + cpu: [arm]
441 + os: [linux]
442 + libc: [glibc]
443 +
444 + '@img/sharp-libvips-linux-ppc64@1.3.3':
445 + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==}
446 + cpu: [ppc64]
447 + os: [linux]
448 + libc: [glibc]
449 +
450 + '@img/sharp-libvips-linux-riscv64@1.3.3':
451 + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==}
452 + cpu: [riscv64]
453 + os: [linux]
454 + libc: [glibc]
455 +
456 + '@img/sharp-libvips-linux-s390x@1.3.3':
457 + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==}
458 + cpu: [s390x]
459 + os: [linux]
460 + libc: [glibc]
461 +
462 + '@img/sharp-libvips-linux-x64@1.3.3':
463 + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==}
464 + cpu: [x64]
465 + os: [linux]
466 + libc: [glibc]
467 +
468 + '@img/sharp-libvips-linuxmusl-arm64@1.3.3':
469 + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==}
470 + cpu: [arm64]
471 + os: [linux]
472 + libc: [musl]
473 +
474 + '@img/sharp-libvips-linuxmusl-x64@1.3.3':
475 + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==}
476 + cpu: [x64]
477 + os: [linux]
478 + libc: [musl]
479 +
480 + '@img/sharp-linux-arm64@0.35.4':
481 + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==}
482 + engines: {node: '>=20.9.0'}
483 + cpu: [arm64]
484 + os: [linux]
485 + libc: [glibc]
486 +
487 + '@img/sharp-linux-arm@0.35.4':
488 + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==}
489 + engines: {node: '>=20.9.0'}
490 + cpu: [arm]
491 + os: [linux]
492 + libc: [glibc]
493 +
494 + '@img/sharp-linux-ppc64@0.35.4':
495 + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==}
496 + engines: {node: '>=20.9.0'}
497 + cpu: [ppc64]
498 + os: [linux]
499 + libc: [glibc]
500 +
501 + '@img/sharp-linux-riscv64@0.35.4':
502 + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==}
503 + engines: {node: '>=20.9.0'}
504 + cpu: [riscv64]
505 + os: [linux]
506 + libc: [glibc]
507 +
508 + '@img/sharp-linux-s390x@0.35.4':
509 + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==}
510 + engines: {node: '>=20.9.0'}
511 + cpu: [s390x]
512 + os: [linux]
513 + libc: [glibc]
514 +
515 + '@img/sharp-linux-x64@0.35.4':
516 + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==}
517 + engines: {node: '>=20.9.0'}
518 + cpu: [x64]
519 + os: [linux]
520 + libc: [glibc]
521 +
522 + '@img/sharp-linuxmusl-arm64@0.35.4':
523 + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==}
524 + engines: {node: '>=20.9.0'}
525 + cpu: [arm64]
526 + os: [linux]
527 + libc: [musl]
528 +
529 + '@img/sharp-linuxmusl-x64@0.35.4':
530 + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==}
531 + engines: {node: '>=20.9.0'}
532 + cpu: [x64]
533 + os: [linux]
534 + libc: [musl]
535 +
536 + '@img/sharp-wasm32@0.35.4':
537 + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==}
538 + engines: {node: '>=20.9.0'}
539 +
540 + '@img/sharp-webcontainers-wasm32@0.35.4':
541 + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==}
542 + engines: {node: '>=20.9.0'}
543 + cpu: [wasm32]
544 +
545 + '@img/sharp-win32-arm64@0.35.4':
546 + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==}
547 + engines: {node: '>=20.9.0'}
548 + cpu: [arm64]
549 + os: [win32]
550 +
551 + '@img/sharp-win32-ia32@0.35.4':
552 + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==}
553 + engines: {node: ^20.9.0}
554 + cpu: [ia32]
555 + os: [win32]
556 +
557 + '@img/sharp-win32-x64@0.35.4':
558 + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==}
559 + engines: {node: '>=20.9.0'}
560 + cpu: [x64]
561 + os: [win32]
562 +
563 + '@jridgewell/gen-mapping@0.3.13':
564 + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
565 +
566 + '@jridgewell/remapping@2.3.5':
567 + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
568 +
569 + '@jridgewell/resolve-uri@3.1.2':
570 + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
571 + engines: {node: '>=6.0.0'}
572 +
342 573 '@jridgewell/sourcemap-codec@1.6.0':
343 574 resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
344 575
576 + '@jridgewell/trace-mapping@0.3.31':
577 + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
578 +
345 579 '@napi-rs/lzma-linux-x64-gnu@1.5.1':
346 580 resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
347 581 engines: {node: ^22.20 || ^24.12 || >=25}
@@ -349,6 +583,72 @@ packages:
349 583 os: [linux]
350 584 libc: [glibc]
351 585
586 + '@next/env@16.3.4':
587 + resolution: {integrity: sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==}
588 +
589 + '@next/swc-darwin-arm64@16.3.4':
590 + resolution: {integrity: sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==}
591 + engines: {node: '>= 10'}
592 + cpu: [arm64]
593 + os: [darwin]
594 +
595 + '@next/swc-darwin-x64@16.3.4':
596 + resolution: {integrity: sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==}
597 + engines: {node: '>= 10'}
598 + cpu: [x64]
599 + os: [darwin]
600 +
601 + '@next/swc-linux-arm64-gnu@16.3.4':
602 + resolution: {integrity: sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==}
603 + engines: {node: '>= 10'}
604 + cpu: [arm64]
605 + os: [linux]
606 + libc: [glibc]
607 +
608 + '@next/swc-linux-arm64-musl@16.3.4':
609 + resolution: {integrity: sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==}
610 + engines: {node: '>= 10'}
611 + cpu: [arm64]
612 + os: [linux]
613 + libc: [musl]
614 +
615 + '@next/swc-linux-x64-gnu@16.3.4':
616 + resolution: {integrity: sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==}
617 + engines: {node: '>= 10'}
618 + cpu: [x64]
619 + os: [linux]
620 + libc: [glibc]
621 +
622 + '@next/swc-linux-x64-musl@16.3.4':
623 + resolution: {integrity: sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==}
624 + engines: {node: '>= 10'}
625 + cpu: [x64]
626 + os: [linux]
627 + libc: [musl]
628 +
629 + '@next/swc-win32-arm64-msvc@16.3.4':
630 + resolution: {integrity: sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==}
631 + engines: {node: '>= 10'}
632 + cpu: [arm64]
633 + os: [win32]
634 +
635 + '@next/swc-win32-x64-msvc@16.3.4':
636 + resolution: {integrity: sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==}
637 + engines: {node: '>= 10'}
638 + cpu: [x64]
639 + os: [win32]
640 +
641 + '@reduxjs/toolkit@2.12.0':
642 + resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==}
643 + peerDependencies:
644 + react: ^16.9.0 || ^17.0.0 || ^18 || ^19
645 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
646 + peerDependenciesMeta:
647 + react:
648 + optional: true
649 + react-redux:
650 + optional: true
651 +
352 652 '@rollup/rollup-android-arm-eabi@4.63.1':
353 653 resolution: {integrity: sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==}
354 654 cpu: [arm]
@@ -490,9 +790,140 @@ packages:
490 790 '@stablelib/base64@1.0.1':
491 791 resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
492 792
793 + '@standard-schema/spec@1.1.0':
794 + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
795 +
796 + '@standard-schema/utils@0.3.0':
797 + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
798 +
799 + '@swc/helpers@0.5.23':
800 + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
801 +
802 + '@tailwindcss/node@4.3.3':
803 + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
804 +
805 + '@tailwindcss/oxide-android-arm64@4.3.3':
806 + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==}
807 + engines: {node: '>= 20'}
808 + cpu: [arm64]
809 + os: [android]
810 +
811 + '@tailwindcss/oxide-darwin-arm64@4.3.3':
812 + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==}
813 + engines: {node: '>= 20'}
814 + cpu: [arm64]
815 + os: [darwin]
816 +
817 + '@tailwindcss/oxide-darwin-x64@4.3.3':
818 + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==}
819 + engines: {node: '>= 20'}
820 + cpu: [x64]
821 + os: [darwin]
822 +
823 + '@tailwindcss/oxide-freebsd-x64@4.3.3':
824 + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==}
825 + engines: {node: '>= 20'}
826 + cpu: [x64]
827 + os: [freebsd]
828 +
829 + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
830 + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==}
831 + engines: {node: '>= 20'}
832 + cpu: [arm]
833 + os: [linux]
834 +
835 + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
836 + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==}
837 + engines: {node: '>= 20'}
838 + cpu: [arm64]
839 + os: [linux]
840 + libc: [glibc]
841 +
842 + '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
843 + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==}
844 + engines: {node: '>= 20'}
845 + cpu: [arm64]
846 + os: [linux]
847 + libc: [musl]
848 +
849 + '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
850 + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==}
851 + engines: {node: '>= 20'}
852 + cpu: [x64]
853 + os: [linux]
854 + libc: [glibc]
855 +
856 + '@tailwindcss/oxide-linux-x64-musl@4.3.3':
857 + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==}
858 + engines: {node: '>= 20'}
859 + cpu: [x64]
860 + os: [linux]
861 + libc: [musl]
862 +
863 + '@tailwindcss/oxide-wasm32-wasi@4.3.3':
864 + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==}
865 + engines: {node: '>=14.0.0'}
866 + cpu: [wasm32]
867 + bundledDependencies:
868 + - '@napi-rs/wasm-runtime'
869 + - '@emnapi/core'
870 + - '@emnapi/runtime'
871 + - '@tybys/wasm-util'
872 + - '@emnapi/wasi-threads'
873 + - tslib
874 +
875 + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
876 + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==}
877 + engines: {node: '>= 20'}
878 + cpu: [arm64]
879 + os: [win32]
880 +
881 + '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
882 + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==}
883 + engines: {node: '>= 20'}
884 + cpu: [x64]
885 + os: [win32]
886 +
887 + '@tailwindcss/oxide@4.3.3':
888 + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==}
889 + engines: {node: '>= 20'}
890 +
891 + '@tailwindcss/postcss@4.3.3':
892 + resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==}
893 +
493 894 '@types/chai@5.2.3':
494 895 resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
495 896
897 + '@types/d3-array@3.2.2':
898 + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
899 +
900 + '@types/d3-color@3.1.3':
901 + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
902 +
903 + '@types/d3-ease@3.0.2':
904 + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
905 +
906 + '@types/d3-force@3.0.10':
907 + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
908 +
909 + '@types/d3-interpolate@3.0.4':
910 + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
911 +
912 + '@types/d3-path@3.1.1':
913 + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
914 +
915 + '@types/d3-scale@4.0.9':
916 + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
917 +
918 + '@types/d3-shape@3.2.0':
919 + resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==}
920 +
921 + '@types/d3-time@3.0.4':
922 + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
923 +
924 + '@types/d3-timer@3.0.2':
925 + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
926 +
496 927 '@types/deep-eql@4.0.2':
497 928 resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
498 929
@@ -505,6 +936,17 @@ packages:
505 936 '@types/pg@8.23.1':
506 937 resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
507 938
939 + '@types/react-dom@19.3.0':
940 + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
941 + peerDependencies:
942 + '@types/react': ^19.3.0
943 +
944 + '@types/react@19.3.0':
945 + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==}
946 +
947 + '@types/use-sync-external-store@0.0.6':
948 + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
949 +
508 950 '@vitest/expect@3.2.7':
509 951 resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==}
510 952
@@ -538,10 +980,18 @@ packages:
538 980 resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
539 981 engines: {node: '>=12'}
540 982
983 + baseline-browser-mapping@2.11.22:
984 + resolution: {integrity: sha512-pWc4w51fBFd7mav43/zKRC+RI6f4yfzQoVlfvE8dECePyfkn1bzLp01Fj0QACcyCZyFhiEMyD2qScfKRWgWibA==}
985 + engines: {node: '>=6.0.0'}
986 + hasBin: true
987 +
541 988 cac@6.7.14:
542 989 resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
543 990 engines: {node: '>=8'}
544 991
992 + caniuse-lite@1.0.30001810:
993 + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==}
994 +
545 995 chai@5.3.3:
546 996 resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
547 997 engines: {node: '>=18'}
@@ -550,6 +1000,72 @@ packages:
550 1000 resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
551 1001 engines: {node: '>= 16'}
552 1002
1003 + client-only@0.0.1:
1004 + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
1005 +
1006 + clsx@2.1.1:
1007 + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
1008 + engines: {node: '>=6'}
1009 +
1010 + csstype@3.2.3:
1011 + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
1012 +
1013 + d3-array@3.2.4:
1014 + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
1015 + engines: {node: '>=12'}
1016 +
1017 + d3-color@3.1.0:
1018 + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
1019 + engines: {node: '>=12'}
1020 +
1021 + d3-dispatch@3.0.1:
1022 + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
1023 + engines: {node: '>=12'}
1024 +
1025 + d3-ease@3.0.1:
1026 + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
1027 + engines: {node: '>=12'}
1028 +
1029 + d3-force@3.0.0:
1030 + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
1031 + engines: {node: '>=12'}
1032 +
1033 + d3-format@3.1.2:
1034 + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
1035 + engines: {node: '>=12'}
1036 +
1037 + d3-interpolate@3.0.1:
1038 + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
1039 + engines: {node: '>=12'}
1040 +
1041 + d3-path@3.1.0:
1042 + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
1043 + engines: {node: '>=12'}
1044 +
1045 + d3-quadtree@3.0.1:
1046 + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
1047 + engines: {node: '>=12'}
1048 +
1049 + d3-scale@4.0.2:
1050 + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
1051 + engines: {node: '>=12'}
1052 +
1053 + d3-shape@3.2.0:
1054 + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
1055 + engines: {node: '>=12'}
1056 +
1057 + d3-time-format@4.1.0:
1058 + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
1059 + engines: {node: '>=12'}
1060 +
1061 + d3-time@3.1.0:
1062 + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
1063 + engines: {node: '>=12'}
1064 +
1065 + d3-timer@3.0.1:
1066 + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
1067 + engines: {node: '>=12'}
1068 +
553 1069 debug@4.4.3:
554 1070 resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
555 1071 engines: {node: '>=6.0'}
@@ -559,13 +1075,27 @@ packages:
559 1075 supports-color:
560 1076 optional: true
561 1077
1078 + decimal.js-light@2.5.1:
1079 + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
1080 +
562 1081 deep-eql@5.0.2:
563 1082 resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
564 1083 engines: {node: '>=6'}
565 1084
1085 + detect-libc@2.1.2:
1086 + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
1087 + engines: {node: '>=8'}
1088 +
1089 + enhanced-resolve@5.24.5:
1090 + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==}
1091 + engines: {node: '>=10.13.0'}
1092 +
566 1093 es-module-lexer@1.7.0:
567 1094 resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
568 1095
1096 + es-toolkit@1.52.0:
1097 + resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==}
1098 +
569 1099 esbuild@0.28.2:
570 1100 resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
571 1101 engines: {node: '>=18'}
@@ -574,6 +1104,9 @@ packages:
574 1104 estree-walker@3.0.3:
575 1105 resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
576 1106
1107 + eventemitter3@5.0.4:
1108 + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
1109 +
577 1110 expect-type@1.4.0:
578 1111 resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
579 1112 engines: {node: '>=12.0.0'}
@@ -590,11 +1123,39 @@ packages:
590 1123 picomatch:
591 1124 optional: true
592 1125
1126 + framer-motion@12.43.0:
1127 + resolution: {integrity: sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==}
1128 + peerDependencies:
1129 + '@emotion/is-prop-valid': '*'
1130 + react: ^18.0.0 || ^19.0.0
1131 + react-dom: ^18.0.0 || ^19.0.0
1132 + peerDependenciesMeta:
1133 + '@emotion/is-prop-valid':
1134 + optional: true
1135 + react:
1136 + optional: true
1137 + react-dom:
1138 + optional: true
1139 +
593 1140 fsevents@2.3.3:
594 1141 resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
595 1142 engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
596 1143 os: [darwin]
597 1144
1145 + graceful-fs@4.2.11:
1146 + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
1147 +
1148 + immer@11.1.18:
1149 + resolution: {integrity: sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==}
1150 +
1151 + internmap@2.0.3:
1152 + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
1153 + engines: {node: '>=12'}
1154 +
1155 + jiti@2.7.0:
1156 + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
1157 + hasBin: true
1158 +
598 1159 js-tokens@9.0.1:
599 1160 resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
600 1161
@@ -602,12 +1163,97 @@ packages:
602 1163 resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
603 1164 engines: {node: '>=16'}
604 1165
605 − loupe@3.2.1:
606 − resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
1166 + lightningcss-android-arm64@1.32.0:
1167 + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
1168 + engines: {node: '>= 12.0.0'}
1169 + cpu: [arm64]
1170 + os: [android]
1171 +
1172 + lightningcss-darwin-arm64@1.32.0:
1173 + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
1174 + engines: {node: '>= 12.0.0'}
1175 + cpu: [arm64]
1176 + os: [darwin]
1177 +
1178 + lightningcss-darwin-x64@1.32.0:
1179 + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
1180 + engines: {node: '>= 12.0.0'}
1181 + cpu: [x64]
1182 + os: [darwin]
1183 +
1184 + lightningcss-freebsd-x64@1.32.0:
1185 + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
1186 + engines: {node: '>= 12.0.0'}
1187 + cpu: [x64]
1188 + os: [freebsd]
1189 +
1190 + lightningcss-linux-arm-gnueabihf@1.32.0:
1191 + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
1192 + engines: {node: '>= 12.0.0'}
1193 + cpu: [arm]
1194 + os: [linux]
1195 +
1196 + lightningcss-linux-arm64-gnu@1.32.0:
1197 + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
1198 + engines: {node: '>= 12.0.0'}
1199 + cpu: [arm64]
1200 + os: [linux]
1201 + libc: [glibc]
1202 +
1203 + lightningcss-linux-arm64-musl@1.32.0:
1204 + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
1205 + engines: {node: '>= 12.0.0'}
1206 + cpu: [arm64]
1207 + os: [linux]
1208 + libc: [musl]
1209 +
1210 + lightningcss-linux-x64-gnu@1.32.0:
1211 + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
1212 + engines: {node: '>= 12.0.0'}
1213 + cpu: [x64]
1214 + os: [linux]
1215 + libc: [glibc]
1216 +
1217 + lightningcss-linux-x64-musl@1.32.0:
1218 + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
1219 + engines: {node: '>= 12.0.0'}
1220 + cpu: [x64]
1221 + os: [linux]
1222 + libc: [musl]
1223 +
1224 + lightningcss-win32-arm64-msvc@1.32.0:
1225 + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
1226 + engines: {node: '>= 12.0.0'}
1227 + cpu: [arm64]
1228 + os: [win32]
1229 +
1230 + lightningcss-win32-x64-msvc@1.32.0:
1231 + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
1232 + engines: {node: '>= 12.0.0'}
1233 + cpu: [x64]
1234 + os: [win32]
1235 +
1236 + lightningcss@1.32.0:
1237 + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
1238 + engines: {node: '>= 12.0.0'}
1239 +
1240 + loupe@3.2.1:
1241 + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
1242 +
1243 + lucide-react@1.44.0:
1244 + resolution: {integrity: sha512-2egNApH4hX4j/qdCgRublh88+9u3mEhz9iSlW5ckm4kaQEqZbXbMr0l5u5JZLy8nmWRx2dbHGQkEDYz6C9aCgw==}
1245 + peerDependencies:
1246 + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
607 1247
608 1248 magic-string@0.30.21:
609 1249 resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
610 1250
1251 + motion-dom@12.43.0:
1252 + resolution: {integrity: sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==}
1253 +
1254 + motion-utils@12.39.0:
1255 + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==}
1256 +
611 1257 ms@2.1.3:
612 1258 resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
613 1259
@@ -616,6 +1262,27 @@ packages:
616 1262 engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
617 1263 hasBin: true
618 1264
1265 + next@16.3.4:
1266 + resolution: {integrity: sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==}
1267 + engines: {node: '>=20.9.0'}
1268 + hasBin: true
1269 + peerDependencies:
1270 + '@opentelemetry/api': ^1.1.0
1271 + '@playwright/test': ^1.51.1
1272 + babel-plugin-react-compiler: '*'
1273 + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
1274 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
1275 + sass: ^1.3.0
1276 + peerDependenciesMeta:
1277 + '@opentelemetry/api':
1278 + optional: true
1279 + '@playwright/test':
1280 + optional: true
1281 + babel-plugin-react-compiler:
1282 + optional: true
1283 + sass:
1284 + optional: true
1285 +
619 1286 pathe@2.0.3:
620 1287 resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
621 1288
@@ -674,6 +1341,10 @@ packages:
674 1341 engines: {node: '>=20'}
675 1342 hasBin: true
676 1343
1344 + postcss@8.5.23:
1345 + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
1346 + engines: {node: ^10 || ^12 || >=14}
1347 +
677 1348 postcss@8.5.28:
678 1349 resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
679 1350 engines: {node: ^10 || ^12 || >=14}
@@ -694,11 +1365,71 @@ packages:
694 1365 resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
695 1366 engines: {node: '>=0.10.0'}
696 1367
1368 + react-dom@19.2.8:
1369 + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==}
1370 + peerDependencies:
1371 + react: ^19.2.8
1372 +
1373 + react-is@19.3.0:
1374 + resolution: {integrity: sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==}
1375 +
1376 + react-redux@9.3.0:
1377 + resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
1378 + peerDependencies:
1379 + '@types/react': ^18.2.25 || ^19
1380 + react: ^18.0 || ^19
1381 + redux: ^5.0.0
1382 + peerDependenciesMeta:
1383 + '@types/react':
1384 + optional: true
1385 + redux:
1386 + optional: true
1387 +
1388 + react@19.2.8:
1389 + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
1390 + engines: {node: '>=0.10.0'}
1391 +
1392 + recharts@3.10.1:
1393 + resolution: {integrity: sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==}
1394 + engines: {node: '>=18'}
1395 + peerDependencies:
1396 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
1397 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
1398 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
1399 +
1400 + redux-thunk@3.1.0:
1401 + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
1402 + peerDependencies:
1403 + redux: ^5.0.0
1404 +
1405 + redux@5.0.1:
1406 + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
1407 +
1408 + reselect@5.2.0:
1409 + resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
1410 +
697 1411 rollup@4.63.1:
698 1412 resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==}
699 1413 engines: {node: '>=18.0.0', npm: '>=8.0.0'}
700 1414 hasBin: true
701 1415
1416 + scheduler@0.27.0:
1417 + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
1418 +
1419 + semver@7.8.5:
1420 + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
1421 + engines: {node: '>=10'}
1422 + hasBin: true
1423 +
1424 + sharp@0.35.4:
1425 + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==}
1426 + engines: {node: '>=20.9.0'}
1427 + peerDependencies:
1428 + '@types/node': '*'
1429 + peerDependenciesMeta:
1430 + '@types/node':
1431 + optional: true
1432 +
702 1433 siginfo@2.0.0:
703 1434 resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
704 1435
@@ -722,6 +1453,29 @@ packages:
722 1453 strip-literal@3.1.0:
723 1454 resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
724 1455
1456 + styled-jsx@5.1.6:
1457 + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
1458 + engines: {node: '>= 12.0.0'}
1459 + peerDependencies:
1460 + '@babel/core': '*'
1461 + babel-plugin-macros: '*'
1462 + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0'
1463 + peerDependenciesMeta:
1464 + '@babel/core':
1465 + optional: true
1466 + babel-plugin-macros:
1467 + optional: true
1468 +
1469 + tailwindcss@4.3.3:
1470 + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
1471 +
1472 + tapable@2.3.3:
1473 + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
1474 + engines: {node: '>=6'}
1475 +
1476 + tiny-invariant@1.3.3:
1477 + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
1478 +
725 1479 tinybench@2.9.0:
726 1480 resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
727 1481
@@ -747,6 +1501,9 @@ packages:
747 1501 ts-algebra@2.0.0:
748 1502 resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
749 1503
1504 + tslib@2.8.1:
1505 + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
1506 +
750 1507 tsx@4.23.13:
751 1508 resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==}
752 1509 engines: {node: '>=18.0.0'}
@@ -760,6 +1517,14 @@ packages:
760 1517 undici-types@7.18.2:
761 1518 resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
762 1519
1520 + use-sync-external-store@1.7.0:
1521 + resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==}
1522 + peerDependencies:
1523 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
1524 +
1525 + victory-vendor@37.3.6:
1526 + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
1527 +
763 1528 vite-node@3.2.4:
764 1529 resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
765 1530 engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -844,6 +1609,8 @@ packages:
844 1609
845 1610 snapshots:
846 1611
1612 + '@alloc/quick-lru@5.3.0': {}
1613 +
847 1614 '@anthropic-ai/sdk@0.125.0':
848 1615 dependencies:
849 1616 json-schema-to-ts: 3.1.1
@@ -851,6 +1618,11 @@ snapshots:
851 1618
852 1619 '@babel/runtime@7.29.7': {}
853 1620
1621 + '@emnapi/runtime@1.11.3':
1622 + dependencies:
1623 + tslib: 2.8.1
1624 + optional: true
1625 +
854 1626 '@esbuild/aix-ppc64@0.28.2':
855 1627 optional: true
856 1628
@@ -929,11 +1701,173 @@ snapshots:
929 1701 '@esbuild/win32-x64@0.28.2':
930 1702 optional: true
931 1703
1704 + '@img/colour@1.1.0':
1705 + optional: true
1706 +
1707 + '@img/sharp-darwin-arm64@0.35.4':
1708 + optionalDependencies:
1709 + '@img/sharp-libvips-darwin-arm64': 1.3.3
1710 + optional: true
1711 +
1712 + '@img/sharp-darwin-x64@0.35.4':
1713 + optionalDependencies:
1714 + '@img/sharp-libvips-darwin-x64': 1.3.3
1715 + optional: true
1716 +
1717 + '@img/sharp-freebsd-wasm32@0.35.4':
1718 + dependencies:
1719 + '@img/sharp-wasm32': 0.35.4
1720 + optional: true
1721 +
1722 + '@img/sharp-libvips-darwin-arm64@1.3.3':
1723 + optional: true
1724 +
1725 + '@img/sharp-libvips-darwin-x64@1.3.3':
1726 + optional: true
1727 +
1728 + '@img/sharp-libvips-linux-arm64@1.3.3':
1729 + optional: true
1730 +
1731 + '@img/sharp-libvips-linux-arm@1.3.3':
1732 + optional: true
1733 +
1734 + '@img/sharp-libvips-linux-ppc64@1.3.3':
1735 + optional: true
1736 +
1737 + '@img/sharp-libvips-linux-riscv64@1.3.3':
1738 + optional: true
1739 +
1740 + '@img/sharp-libvips-linux-s390x@1.3.3':
1741 + optional: true
1742 +
1743 + '@img/sharp-libvips-linux-x64@1.3.3':
1744 + optional: true
1745 +
1746 + '@img/sharp-libvips-linuxmusl-arm64@1.3.3':
1747 + optional: true
1748 +
1749 + '@img/sharp-libvips-linuxmusl-x64@1.3.3':
1750 + optional: true
1751 +
1752 + '@img/sharp-linux-arm64@0.35.4':
1753 + optionalDependencies:
1754 + '@img/sharp-libvips-linux-arm64': 1.3.3
1755 + optional: true
1756 +
1757 + '@img/sharp-linux-arm@0.35.4':
1758 + optionalDependencies:
1759 + '@img/sharp-libvips-linux-arm': 1.3.3
1760 + optional: true
1761 +
1762 + '@img/sharp-linux-ppc64@0.35.4':
1763 + optionalDependencies:
1764 + '@img/sharp-libvips-linux-ppc64': 1.3.3
1765 + optional: true
1766 +
1767 + '@img/sharp-linux-riscv64@0.35.4':
1768 + optionalDependencies:
1769 + '@img/sharp-libvips-linux-riscv64': 1.3.3
1770 + optional: true
1771 +
1772 + '@img/sharp-linux-s390x@0.35.4':
1773 + optionalDependencies:
1774 + '@img/sharp-libvips-linux-s390x': 1.3.3
1775 + optional: true
1776 +
1777 + '@img/sharp-linux-x64@0.35.4':
1778 + optionalDependencies:
1779 + '@img/sharp-libvips-linux-x64': 1.3.3
1780 + optional: true
1781 +
1782 + '@img/sharp-linuxmusl-arm64@0.35.4':
1783 + optionalDependencies:
1784 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3
1785 + optional: true
1786 +
1787 + '@img/sharp-linuxmusl-x64@0.35.4':
1788 + optionalDependencies:
1789 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3
1790 + optional: true
1791 +
1792 + '@img/sharp-wasm32@0.35.4':
1793 + dependencies:
1794 + '@emnapi/runtime': 1.11.3
1795 + optional: true
1796 +
1797 + '@img/sharp-webcontainers-wasm32@0.35.4':
1798 + dependencies:
1799 + '@img/sharp-wasm32': 0.35.4
1800 + optional: true
1801 +
1802 + '@img/sharp-win32-arm64@0.35.4':
1803 + optional: true
1804 +
1805 + '@img/sharp-win32-ia32@0.35.4':
1806 + optional: true
1807 +
1808 + '@img/sharp-win32-x64@0.35.4':
1809 + optional: true
1810 +
1811 + '@jridgewell/gen-mapping@0.3.13':
1812 + dependencies:
1813 + '@jridgewell/sourcemap-codec': 1.6.0
1814 + '@jridgewell/trace-mapping': 0.3.31
1815 +
1816 + '@jridgewell/remapping@2.3.5':
1817 + dependencies:
1818 + '@jridgewell/gen-mapping': 0.3.13
1819 + '@jridgewell/trace-mapping': 0.3.31
1820 +
1821 + '@jridgewell/resolve-uri@3.1.2': {}
1822 +
932 1823 '@jridgewell/sourcemap-codec@1.6.0': {}
933 1824
1825 + '@jridgewell/trace-mapping@0.3.31':
1826 + dependencies:
1827 + '@jridgewell/resolve-uri': 3.1.2
1828 + '@jridgewell/sourcemap-codec': 1.6.0
1829 +
934 1830 '@napi-rs/lzma-linux-x64-gnu@1.5.1':
935 1831 optional: true
936 1832
1833 + '@next/env@16.3.4': {}
1834 +
1835 + '@next/swc-darwin-arm64@16.3.4':
1836 + optional: true
1837 +
1838 + '@next/swc-darwin-x64@16.3.4':
1839 + optional: true
1840 +
1841 + '@next/swc-linux-arm64-gnu@16.3.4':
1842 + optional: true
1843 +
1844 + '@next/swc-linux-arm64-musl@16.3.4':
1845 + optional: true
1846 +
1847 + '@next/swc-linux-x64-gnu@16.3.4':
1848 + optional: true
1849 +
1850 + '@next/swc-linux-x64-musl@16.3.4':
1851 + optional: true
1852 +
1853 + '@next/swc-win32-arm64-msvc@16.3.4':
1854 + optional: true
1855 +
1856 + '@next/swc-win32-x64-msvc@16.3.4':
1857 + optional: true
1858 +
1859 + '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.3.0)(react@19.2.8)(redux@5.0.1))(react@19.2.8)':
1860 + dependencies:
1861 + '@standard-schema/spec': 1.1.0
1862 + '@standard-schema/utils': 0.3.0
1863 + immer: 11.1.18
1864 + redux: 5.0.1
1865 + redux-thunk: 3.1.0(redux@5.0.1)
1866 + reselect: 5.2.0
1867 + optionalDependencies:
1868 + react: 19.2.8
1869 + react-redux: 9.3.0(@types/react@19.3.0)(react@19.2.8)(redux@5.0.1)
1870 +
937 1871 '@rollup/rollup-android-arm-eabi@4.63.1':
938 1872 optional: true
939 1873
@@ -1011,11 +1945,114 @@ snapshots:
1011 1945
1012 1946 '@stablelib/base64@1.0.1': {}
1013 1947
1948 + '@standard-schema/spec@1.1.0': {}
1949 +
1950 + '@standard-schema/utils@0.3.0': {}
1951 +
1952 + '@swc/helpers@0.5.23':
1953 + dependencies:
1954 + tslib: 2.8.1
1955 +
1956 + '@tailwindcss/node@4.3.3':
1957 + dependencies:
1958 + '@jridgewell/remapping': 2.3.5
1959 + enhanced-resolve: 5.24.5
1960 + jiti: 2.7.0
1961 + lightningcss: 1.32.0
1962 + magic-string: 0.30.21
1963 + source-map-js: 1.2.1
1964 + tailwindcss: 4.3.3
1965 +
1966 + '@tailwindcss/oxide-android-arm64@4.3.3':
1967 + optional: true
1968 +
1969 + '@tailwindcss/oxide-darwin-arm64@4.3.3':
1970 + optional: true
1971 +
1972 + '@tailwindcss/oxide-darwin-x64@4.3.3':
1973 + optional: true
1974 +
1975 + '@tailwindcss/oxide-freebsd-x64@4.3.3':
1976 + optional: true
1977 +
1978 + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
1979 + optional: true
1980 +
1981 + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
1982 + optional: true
1983 +
1984 + '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
1985 + optional: true
1986 +
1987 + '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
1988 + optional: true
1989 +
1990 + '@tailwindcss/oxide-linux-x64-musl@4.3.3':
1991 + optional: true
1992 +
1993 + '@tailwindcss/oxide-wasm32-wasi@4.3.3':
1994 + optional: true
1995 +
1996 + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
1997 + optional: true
1998 +
1999 + '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
2000 + optional: true
2001 +
2002 + '@tailwindcss/oxide@4.3.3':
2003 + optionalDependencies:
2004 + '@tailwindcss/oxide-android-arm64': 4.3.3
2005 + '@tailwindcss/oxide-darwin-arm64': 4.3.3
2006 + '@tailwindcss/oxide-darwin-x64': 4.3.3
2007 + '@tailwindcss/oxide-freebsd-x64': 4.3.3
2008 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3
2009 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3
2010 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3
2011 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3
2012 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3
2013 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3
2014 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
2015 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3
2016 +
2017 + '@tailwindcss/postcss@4.3.3':
2018 + dependencies:
2019 + '@alloc/quick-lru': 5.3.0
2020 + '@tailwindcss/node': 4.3.3
2021 + '@tailwindcss/oxide': 4.3.3
2022 + postcss: 8.5.28
2023 + tailwindcss: 4.3.3
2024 +
1014 2025 '@types/chai@5.2.3':
1015 2026 dependencies:
1016 2027 '@types/deep-eql': 4.0.2
1017 2028 assertion-error: 2.0.1
1018 2029
2030 + '@types/d3-array@3.2.2': {}
2031 +
2032 + '@types/d3-color@3.1.3': {}
2033 +
2034 + '@types/d3-ease@3.0.2': {}
2035 +
2036 + '@types/d3-force@3.0.10': {}
2037 +
2038 + '@types/d3-interpolate@3.0.4':
2039 + dependencies:
2040 + '@types/d3-color': 3.1.3
2041 +
2042 + '@types/d3-path@3.1.1': {}
2043 +
2044 + '@types/d3-scale@4.0.9':
2045 + dependencies:
2046 + '@types/d3-time': 3.0.4
2047 +
2048 + '@types/d3-shape@3.2.0':
2049 + dependencies:
2050 + '@types/d3-path': 3.1.1
2051 +
2052 + '@types/d3-time@3.0.4': {}
2053 +
2054 + '@types/d3-timer@3.0.2': {}
2055 +
1019 2056 '@types/deep-eql@4.0.2': {}
1020 2057
1021 2058 '@types/estree@1.0.9': {}
@@ -1030,6 +2067,16 @@ snapshots:
1030 2067 pg-protocol: 1.16.0
1031 2068 pg-types: 2.2.0
1032 2069
2070 + '@types/react-dom@19.3.0(@types/react@19.3.0)':
2071 + dependencies:
2072 + '@types/react': 19.3.0
2073 +
2074 + '@types/react@19.3.0':
2075 + dependencies:
2076 + csstype: 3.2.3
2077 +
2078 + '@types/use-sync-external-store@0.0.6': {}
2079 +
1033 2080 '@vitest/expect@3.2.7':
1034 2081 dependencies:
1035 2082 '@types/chai': 5.2.3
@@ -1038,13 +2085,13 @@ snapshots:
1038 2085 chai: 5.3.3
1039 2086 tinyrainbow: 2.0.0
1040 2087
1041 − '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.13.4)(tsx@4.23.13))':
2088 + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.13.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13))':
1042 2089 dependencies:
1043 2090 '@vitest/spy': 3.2.7
1044 2091 estree-walker: 3.0.3
1045 2092 magic-string: 0.30.21
1046 2093 optionalDependencies:
1047 − vite: 7.3.6(@types/node@24.13.4)(tsx@4.23.13)
2094 + vite: 7.3.6(@types/node@24.13.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)
1048 2095
1049 2096 '@vitest/pretty-format@3.2.7':
1050 2097 dependencies:
@@ -1074,8 +2121,12 @@ snapshots:
1074 2121
1075 2122 assertion-error@2.0.1: {}
1076 2123
2124 + baseline-browser-mapping@2.11.22: {}
2125 +
1077 2126 cac@6.7.14: {}
1078 2127
2128 + caniuse-lite@1.0.30001810: {}
2129 +
1079 2130 chai@5.3.3:
1080 2131 dependencies:
1081 2132 assertion-error: 2.0.1
@@ -1086,14 +2137,79 @@ snapshots:
1086 2137
1087 2138 check-error@2.1.3: {}
1088 2139
2140 + client-only@0.0.1: {}
2141 +
2142 + clsx@2.1.1: {}
2143 +
2144 + csstype@3.2.3: {}
2145 +
2146 + d3-array@3.2.4:
2147 + dependencies:
2148 + internmap: 2.0.3
2149 +
2150 + d3-color@3.1.0: {}
2151 +
2152 + d3-dispatch@3.0.1: {}
2153 +
2154 + d3-ease@3.0.1: {}
2155 +
2156 + d3-force@3.0.0:
2157 + dependencies:
2158 + d3-dispatch: 3.0.1
2159 + d3-quadtree: 3.0.1
2160 + d3-timer: 3.0.1
2161 +
2162 + d3-format@3.1.2: {}
2163 +
2164 + d3-interpolate@3.0.1:
2165 + dependencies:
2166 + d3-color: 3.1.0
2167 +
2168 + d3-path@3.1.0: {}
2169 +
2170 + d3-quadtree@3.0.1: {}
2171 +
2172 + d3-scale@4.0.2:
2173 + dependencies:
2174 + d3-array: 3.2.4
2175 + d3-format: 3.1.2
2176 + d3-interpolate: 3.0.1
2177 + d3-time: 3.1.0
2178 + d3-time-format: 4.1.0
2179 +
2180 + d3-shape@3.2.0:
2181 + dependencies:
2182 + d3-path: 3.1.0
2183 +
2184 + d3-time-format@4.1.0:
2185 + dependencies:
2186 + d3-time: 3.1.0
2187 +
2188 + d3-time@3.1.0:
2189 + dependencies:
2190 + d3-array: 3.2.4
2191 +
2192 + d3-timer@3.0.1: {}
2193 +
1089 2194 debug@4.4.3:
1090 2195 dependencies:
1091 2196 ms: 2.1.3
1092 2197
2198 + decimal.js-light@2.5.1: {}
2199 +
1093 2200 deep-eql@5.0.2: {}
1094 2201
2202 + detect-libc@2.1.2: {}
2203 +
2204 + enhanced-resolve@5.24.5:
2205 + dependencies:
2206 + graceful-fs: 4.2.11
2207 + tapable: 2.3.3
2208 +
1095 2209 es-module-lexer@1.7.0: {}
1096 2210
2211 + es-toolkit@1.52.0: {}
2212 +
1097 2213 esbuild@0.28.2:
1098 2214 optionalDependencies:
1099 2215 '@esbuild/aix-ppc64': 0.28.2
@@ -1127,6 +2243,8 @@ snapshots:
1127 2243 dependencies:
1128 2244 '@types/estree': 1.0.9
1129 2245
2246 + eventemitter3@5.0.4: {}
2247 +
1130 2248 expect-type@1.4.0: {}
1131 2249
1132 2250 fast-sha256@1.3.0: {}
@@ -1135,9 +2253,26 @@ snapshots:
1135 2253 optionalDependencies:
1136 2254 picomatch: 4.0.7
1137 2255
2256 + framer-motion@12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
2257 + dependencies:
2258 + motion-dom: 12.43.0
2259 + motion-utils: 12.39.0
2260 + tslib: 2.8.1
2261 + optionalDependencies:
2262 + react: 19.2.8
2263 + react-dom: 19.2.8(react@19.2.8)
2264 +
1138 2265 fsevents@2.3.3:
1139 2266 optional: true
1140 2267
2268 + graceful-fs@4.2.11: {}
2269 +
2270 + immer@11.1.18: {}
2271 +
2272 + internmap@2.0.3: {}
2273 +
2274 + jiti@2.7.0: {}
2275 +
1141 2276 js-tokens@9.0.1: {}
1142 2277
1143 2278 json-schema-to-ts@3.1.1:
@@ -1145,16 +2280,100 @@ snapshots:
1145 2280 '@babel/runtime': 7.29.7
1146 2281 ts-algebra: 2.0.0
1147 2282
2283 + lightningcss-android-arm64@1.32.0:
2284 + optional: true
2285 +
2286 + lightningcss-darwin-arm64@1.32.0:
2287 + optional: true
2288 +
2289 + lightningcss-darwin-x64@1.32.0:
2290 + optional: true
2291 +
2292 + lightningcss-freebsd-x64@1.32.0:
2293 + optional: true
2294 +
2295 + lightningcss-linux-arm-gnueabihf@1.32.0:
2296 + optional: true
2297 +
2298 + lightningcss-linux-arm64-gnu@1.32.0:
2299 + optional: true
2300 +
2301 + lightningcss-linux-arm64-musl@1.32.0:
2302 + optional: true
2303 +
2304 + lightningcss-linux-x64-gnu@1.32.0:
2305 + optional: true
2306 +
2307 + lightningcss-linux-x64-musl@1.32.0:
2308 + optional: true
2309 +
2310 + lightningcss-win32-arm64-msvc@1.32.0:
2311 + optional: true
2312 +
2313 + lightningcss-win32-x64-msvc@1.32.0:
2314 + optional: true
2315 +
2316 + lightningcss@1.32.0:
2317 + dependencies:
2318 + detect-libc: 2.1.2
2319 + optionalDependencies:
2320 + lightningcss-android-arm64: 1.32.0
2321 + lightningcss-darwin-arm64: 1.32.0
2322 + lightningcss-darwin-x64: 1.32.0
2323 + lightningcss-freebsd-x64: 1.32.0
2324 + lightningcss-linux-arm-gnueabihf: 1.32.0
2325 + lightningcss-linux-arm64-gnu: 1.32.0
2326 + lightningcss-linux-arm64-musl: 1.32.0
2327 + lightningcss-linux-x64-gnu: 1.32.0
2328 + lightningcss-linux-x64-musl: 1.32.0
2329 + lightningcss-win32-arm64-msvc: 1.32.0
2330 + lightningcss-win32-x64-msvc: 1.32.0
2331 +
1148 2332 loupe@3.2.1: {}
1149 2333
2334 + lucide-react@1.44.0(react@19.2.8):
2335 + dependencies:
2336 + react: 19.2.8
2337 +
1150 2338 magic-string@0.30.21:
1151 2339 dependencies:
1152 2340 '@jridgewell/sourcemap-codec': 1.6.0
1153 2341
2342 + motion-dom@12.43.0:
2343 + dependencies:
2344 + motion-utils: 12.39.0
2345 +
2346 + motion-utils@12.39.0: {}
2347 +
1154 2348 ms@2.1.3: {}
1155 2349
1156 2350 nanoid@3.3.19: {}
1157 2351
2352 + next@16.3.4(@types/node@24.13.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
2353 + dependencies:
2354 + '@next/env': 16.3.4
2355 + '@swc/helpers': 0.5.23
2356 + baseline-browser-mapping: 2.11.22
2357 + caniuse-lite: 1.0.30001810
2358 + postcss: 8.5.23
2359 + react: 19.2.8
2360 + react-dom: 19.2.8(react@19.2.8)
2361 + styled-jsx: 5.1.6(react@19.2.8)
2362 + optionalDependencies:
2363 + '@next/swc-darwin-arm64': 16.3.4
2364 + '@next/swc-darwin-x64': 16.3.4
2365 + '@next/swc-linux-arm64-gnu': 16.3.4
2366 + '@next/swc-linux-arm64-musl': 16.3.4
2367 + '@next/swc-linux-x64-gnu': 16.3.4
2368 + '@next/swc-linux-x64-musl': 16.3.4
2369 + '@next/swc-win32-arm64-msvc': 16.3.4
2370 + '@next/swc-win32-x64-msvc': 16.3.4
2371 + sharp: 0.35.4(@types/node@24.13.4)
2372 + transitivePeerDependencies:
2373 + - '@babel/core'
2374 + - '@types/node'
2375 + - babel-plugin-macros
2376 +
1158 2377 pathe@2.0.3: {}
1159 2378
1160 2379 pathval@2.0.1: {}
@@ -1204,6 +2423,12 @@ snapshots:
1204 2423 dependencies:
1205 2424 playwright-core: 1.63.0
1206 2425
2426 + postcss@8.5.23:
2427 + dependencies:
2428 + nanoid: 3.3.19
2429 + picocolors: 1.1.1
2430 + source-map-js: 1.2.1
2431 +
1207 2432 postcss@8.5.28:
1208 2433 dependencies:
1209 2434 nanoid: 3.3.19
@@ -1220,6 +2445,52 @@ snapshots:
1220 2445 dependencies:
1221 2446 xtend: 4.0.2
1222 2447
2448 + react-dom@19.2.8(react@19.2.8):
2449 + dependencies:
2450 + react: 19.2.8
2451 + scheduler: 0.27.0
2452 +
2453 + react-is@19.3.0: {}
2454 +
2455 + react-redux@9.3.0(@types/react@19.3.0)(react@19.2.8)(redux@5.0.1):
2456 + dependencies:
2457 + '@types/use-sync-external-store': 0.0.6
2458 + react: 19.2.8
2459 + use-sync-external-store: 1.7.0(react@19.2.8)
2460 + optionalDependencies:
2461 + '@types/react': 19.3.0
2462 + redux: 5.0.1
2463 +
2464 + react@19.2.8: {}
2465 +
2466 + recharts@3.10.1(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react-is@19.3.0)(react@19.2.8)(redux@5.0.1):
2467 + dependencies:
2468 + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.3.0)(react@19.2.8)(redux@5.0.1))(react@19.2.8)
2469 + clsx: 2.1.1
2470 + decimal.js-light: 2.5.1
2471 + es-toolkit: 1.52.0
2472 + eventemitter3: 5.0.4
2473 + immer: 11.1.18
2474 + react: 19.2.8
2475 + react-dom: 19.2.8(react@19.2.8)
2476 + react-is: 19.3.0
2477 + react-redux: 9.3.0(@types/react@19.3.0)(react@19.2.8)(redux@5.0.1)
2478 + reselect: 5.2.0
2479 + tiny-invariant: 1.3.3
2480 + use-sync-external-store: 1.7.0(react@19.2.8)
2481 + victory-vendor: 37.3.6
2482 + transitivePeerDependencies:
2483 + - '@types/react'
2484 + - redux
2485 +
2486 + redux-thunk@3.1.0(redux@5.0.1):
2487 + dependencies:
2488 + redux: 5.0.1
2489 +
2490 + redux@5.0.1: {}
2491 +
2492 + reselect@5.2.0: {}
2493 +
1223 2494 rollup@4.63.1:
1224 2495 dependencies:
1225 2496 '@types/estree': 1.0.9
@@ -1252,6 +2523,45 @@ snapshots:
1252 2523 '@rollup/rollup-win32-x64-msvc': 4.63.1
1253 2524 fsevents: 2.3.3
1254 2525
2526 + scheduler@0.27.0: {}
2527 +
2528 + semver@7.8.5:
2529 + optional: true
2530 +
2531 + sharp@0.35.4(@types/node@24.13.4):
2532 + dependencies:
2533 + '@img/colour': 1.1.0
2534 + detect-libc: 2.1.2
2535 + semver: 7.8.5
2536 + optionalDependencies:
2537 + '@img/sharp-darwin-arm64': 0.35.4
2538 + '@img/sharp-darwin-x64': 0.35.4
2539 + '@img/sharp-freebsd-wasm32': 0.35.4
2540 + '@img/sharp-libvips-darwin-arm64': 1.3.3
2541 + '@img/sharp-libvips-darwin-x64': 1.3.3
2542 + '@img/sharp-libvips-linux-arm': 1.3.3
2543 + '@img/sharp-libvips-linux-arm64': 1.3.3
2544 + '@img/sharp-libvips-linux-ppc64': 1.3.3
2545 + '@img/sharp-libvips-linux-riscv64': 1.3.3
2546 + '@img/sharp-libvips-linux-s390x': 1.3.3
2547 + '@img/sharp-libvips-linux-x64': 1.3.3
2548 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3
2549 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3
2550 + '@img/sharp-linux-arm': 0.35.4
2551 + '@img/sharp-linux-arm64': 0.35.4
2552 + '@img/sharp-linux-ppc64': 0.35.4
2553 + '@img/sharp-linux-riscv64': 0.35.4
2554 + '@img/sharp-linux-s390x': 0.35.4
2555 + '@img/sharp-linux-x64': 0.35.4
2556 + '@img/sharp-linuxmusl-arm64': 0.35.4
2557 + '@img/sharp-linuxmusl-x64': 0.35.4
2558 + '@img/sharp-webcontainers-wasm32': 0.35.4
2559 + '@img/sharp-win32-arm64': 0.35.4
2560 + '@img/sharp-win32-ia32': 0.35.4
2561 + '@img/sharp-win32-x64': 0.35.4
2562 + '@types/node': 24.13.4
2563 + optional: true
2564 +
1255 2565 siginfo@2.0.0: {}
1256 2566
1257 2567 source-map-js@1.2.1: {}
@@ -1271,6 +2581,17 @@ snapshots:
1271 2581 dependencies:
1272 2582 js-tokens: 9.0.1
1273 2583
2584 + styled-jsx@5.1.6(react@19.2.8):
2585 + dependencies:
2586 + client-only: 0.0.1
2587 + react: 19.2.8
2588 +
2589 + tailwindcss@4.3.3: {}
2590 +
2591 + tapable@2.3.3: {}
2592 +
2593 + tiny-invariant@1.3.3: {}
2594 +
1274 2595 tinybench@2.9.0: {}
1275 2596
1276 2597 tinyexec@0.3.2: {}
@@ -1288,6 +2609,8 @@ snapshots:
1288 2609
1289 2610 ts-algebra@2.0.0: {}
1290 2611
2612 + tslib@2.8.1: {}
2613 +
1291 2614 tsx@4.23.13:
1292 2615 dependencies:
1293 2616 esbuild: 0.28.2
@@ -1298,13 +2621,34 @@ snapshots:
1298 2621
1299 2622 undici-types@7.18.2: {}
1300 2623
1301 − vite-node@3.2.4(@types/node@24.13.4)(tsx@4.23.13):
2624 + use-sync-external-store@1.7.0(react@19.2.8):
2625 + dependencies:
2626 + react: 19.2.8
2627 +
2628 + victory-vendor@37.3.6:
2629 + dependencies:
2630 + '@types/d3-array': 3.2.2
2631 + '@types/d3-ease': 3.0.2
2632 + '@types/d3-interpolate': 3.0.4
2633 + '@types/d3-scale': 4.0.9
2634 + '@types/d3-shape': 3.2.0
2635 + '@types/d3-time': 3.0.4
2636 + '@types/d3-timer': 3.0.2
2637 + d3-array: 3.2.4
2638 + d3-ease: 3.0.1
2639 + d3-interpolate: 3.0.1
2640 + d3-scale: 4.0.2
2641 + d3-shape: 3.2.0
2642 + d3-time: 3.1.0
2643 + d3-timer: 3.0.1
2644 +
2645 + vite-node@3.2.4(@types/node@24.13.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13):
1302 2646 dependencies:
1303 2647 cac: 6.7.14
1304 2648 debug: 4.4.3
1305 2649 es-module-lexer: 1.7.0
1306 2650 pathe: 2.0.3
1307 − vite: 7.3.6(@types/node@24.13.4)(tsx@4.23.13)
2651 + vite: 7.3.6(@types/node@24.13.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)
1308 2652 transitivePeerDependencies:
1309 2653 - '@types/node'
1310 2654 - jiti
@@ -1319,7 +2663,7 @@ snapshots:
1319 2663 - tsx
1320 2664 - yaml
1321 2665
1322 − vite@7.3.6(@types/node@24.13.4)(tsx@4.23.13):
2666 + vite@7.3.6(@types/node@24.13.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13):
1323 2667 dependencies:
1324 2668 esbuild: 0.28.2
1325 2669 fdir: 6.5.0(picomatch@4.0.7)
@@ -1330,13 +2674,15 @@ snapshots:
1330 2674 optionalDependencies:
1331 2675 '@types/node': 24.13.4
1332 2676 fsevents: 2.3.3
2677 + jiti: 2.7.0
2678 + lightningcss: 1.32.0
1333 2679 tsx: 4.23.13
1334 2680
1335 − vitest@3.2.7(@types/node@24.13.4)(tsx@4.23.13):
2681 + vitest@3.2.7(@types/node@24.13.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13):
1336 2682 dependencies:
1337 2683 '@types/chai': 5.2.3
1338 2684 '@vitest/expect': 3.2.7
1339 − '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.13.4)(tsx@4.23.13))
2685 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.13.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13))
1340 2686 '@vitest/pretty-format': 3.2.7
1341 2687 '@vitest/runner': 3.2.7
1342 2688 '@vitest/snapshot': 3.2.7
@@ -1354,8 +2700,8 @@ snapshots:
1354 2700 tinyglobby: 0.2.17
1355 2701 tinypool: 1.1.1
1356 2702 tinyrainbow: 2.0.0
1357 − vite: 7.3.6(@types/node@24.13.4)(tsx@4.23.13)
1358 − vite-node: 3.2.4(@types/node@24.13.4)(tsx@4.23.13)
2703 + vite: 7.3.6(@types/node@24.13.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)
2704 + vite-node: 3.2.4(@types/node@24.13.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)
1359 2705 why-is-node-running: 2.3.0
1360 2706 optionalDependencies:
1361 2707 '@types/node': 24.13.4
modified tsconfig.json +1 −1
@@ -30,5 +30,5 @@
30 30 }
31 31 },
32 32 "include": ["apps/**/*.ts", "packages/**/*.ts", "scripts/**/*.ts"],
33 − "exclude": ["node_modules", "**/node_modules", "data", "connectors"]
33 + "exclude": ["node_modules", "**/node_modules", "data", "connectors", "apps/dashboard"]
34 34 }
35 35