@chatpanel/events 0.102.0 → 0.104.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/adapters.js +1 -83
- package/adaptive-tool-policy.js +1 -45
- package/agent.js +2 -255
- package/attribution.js +1 -132
- package/backup-envelope.js +1 -221
- package/board-tool.js +2 -165
- package/budget.js +1 -116
- package/capability.js +1 -126
- package/citations.js +5 -127
- package/client-prefs.js +1 -148
- package/context-attachments.js +8 -144
- package/cowriter-router.js +1 -83
- package/cowriter-writer.js +19 -193
- package/cowriter.js +1 -190
- package/curate.js +3 -509
- package/distance.js +1 -124
- package/engine.js +1 -131
- package/entitlement.js +1 -332
- package/entity.js +1 -354
- package/event.js +1 -175
- package/extraction.js +4 -293
- package/failover.js +1 -93
- package/find-tool.js +1 -66
- package/flowchart.js +2 -644
- package/gate.js +1 -74
- package/harness.js +4 -101
- package/index.js +1 -345
- package/invariants.js +1 -174
- package/job.js +3 -147
- package/kernel.js +1 -255
- package/knowledge-derive.js +1 -267
- package/knowledge.js +3 -230
- package/library.js +11 -280
- package/loop.js +1 -132
- package/manifest.js +1 -107
- package/markdown-authoring.js +11 -288
- package/markdown-render.js +0 -0
- package/mcp-client.js +8 -358
- package/mcp-dispatch.js +1 -52
- package/mcp-errors.js +1 -106
- package/mcp-manager.js +1 -127
- package/media-transcript.js +8 -531
- package/meeting-analyzers.js +1 -83
- package/meeting-insights.js +7 -164
- package/meeting-shape.js +1 -282
- package/meeting-text.js +5 -94
- package/memory.js +2 -638
- package/model-candidates.js +1 -358
- package/model-health.js +1 -182
- package/model-ledger.js +1 -228
- package/model-picker.js +1 -188
- package/note-actions.js +15 -246
- package/note-graph.js +1 -158
- package/note-links.js +1 -68
- package/note-mentions.js +8 -114
- package/note-plan.js +9 -107
- package/note-research.js +1 -135
- package/observability.js +1 -119
- package/omni.js +1 -125
- package/order.js +1 -78
- package/package.json +4 -5
- package/pdf-layout.js +7 -253
- package/policy.js +1 -0
- package/project-run.js +20 -444
- package/project.js +1 -170
- package/promotion.js +1 -171
- package/queue.js +1 -98
- package/reach.js +1 -23
- package/recipe-tool.js +2 -161
- package/recipe.js +1 -264
- package/record-list.js +2 -110
- package/recruit.js +3 -456
- package/redaction-tokens.js +1 -61
- package/ref.js +1 -55
- package/registry.js +1 -240
- package/route-graph.js +1 -115
- package/route-strategies.js +2 -232
- package/router.js +2 -880
- package/rrf.js +1 -89
- package/rules.js +1 -142
- package/schedule.js +1 -663
- package/scm-connection.js +1 -180
- package/scopes.js +1 -12
- package/scorecard.js +1 -335
- package/search-engines.js +1 -81
- package/skill-manifest.js +1 -330
- package/skill-scan.js +3 -209
- package/skill-sources.js +1 -162
- package/skill-vars.js +2 -252
- package/slash-commands.js +1 -158
- package/source-gate.js +1 -92
- package/sources-retrieval.js +5 -186
- package/sources.js +1 -273
- package/store.js +1 -171
- package/structured.js +4 -901
- package/subject-kinds.js +1 -5
- package/subject-name.js +1 -96
- package/sync-plan.js +1 -213
- package/synthesis.js +3 -123
- package/tags.js +1 -203
- package/team-board.js +6 -337
- package/team-cache.js +2 -63
- package/team-observe.js +1 -118
- package/team-org.js +1 -442
- package/team-plan.js +4 -131
- package/team-record.js +1 -225
- package/team-run.js +9 -652
- package/team-subtask.js +2 -191
- package/team-task.js +2 -124
- package/team-tool.js +2 -196
- package/team-trail.js +2 -73
- package/team-worklog.js +2 -149
- package/team.js +1 -302
- package/text-search.js +1 -155
- package/theme.js +17 -154
- package/titles.js +4 -285
- package/tool-discovery.js +1 -87
- package/tool-dispatch.js +5 -230
- package/tool-groups.js +1 -81
- package/tool-hints.js +10 -185
- package/tool-loop-guard.js +4 -182
- package/tool-need.js +2 -124
- package/tool-result.js +2 -438
- package/tool-round.js +0 -0
- package/tool-schema.js +1 -155
- package/tool-traits.js +1 -182
- package/toolset.js +2 -88
- package/trajectory.js +1 -530
- package/turn-loop.js +4 -440
- package/upcast.js +1 -37
- package/vault.js +2 -250
- package/view.js +1 -88
- package/voice-intents.js +2 -1488
- package/voice-speaker.js +1 -98
- package/weather-tool.js +1 -65
- package/weather.js +2 -211
- package/web-search-tool.js +14 -102
- package/web-search.js +1 -160
- package/widget.js +1 -153
package/media-transcript.js
CHANGED
|
@@ -1,535 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
// difference is where the words live. On a video page the words are NOT in the DOM: the
|
|
5
|
-
// article body a reader would summarise is a caption track, fetched separately, published
|
|
6
|
-
// in four different serialisations, and duplicated once per language plus once more for
|
|
7
|
-
// the machine-generated version.
|
|
8
|
-
//
|
|
9
|
-
// So the CHOOSING and the PARSING live here, not in a client:
|
|
10
|
-
// • which of eleven caption tracks is the one the user meant (manual over ASR, their
|
|
11
|
-
// language over the uploader's, an explicit ask over both);
|
|
12
|
-
// • json3 / srv3 / srv1 XML / WebVTT / SRT → one segment list;
|
|
13
|
-
// • segments → readable paragraphs with timestamps you can cite and click.
|
|
14
|
-
//
|
|
15
|
-
// None of that needs a browser, and every client will need all of it: the extension reads
|
|
16
|
-
// the tab, the bridge may be handed a URL by a CLI agent, the gateway may be asked to
|
|
17
|
-
// summarise one server-side, and a mobile app has no DOM to scrape at all. Written inside
|
|
18
|
-
// the extension it would be copied three times and would disagree three ways about which
|
|
19
|
-
// track is "the" transcript.
|
|
20
|
-
//
|
|
21
|
-
// WHAT IS NOT HERE: fetching. Every platform gates caption URLs on the session that asked
|
|
22
|
-
// (cookies, origin, a consent cookie, a per-load token), so the FETCH has to happen where
|
|
23
|
-
// that session is — in the page for the extension, behind the user's own credentials for a
|
|
24
|
-
// CLI. Callers pass a `fetchText` in; this module never reaches the network, which is also
|
|
25
|
-
// what keeps it testable and dependency-free.
|
|
1
|
+
const y=Object.freeze(["youtube.com","www.youtube.com","m.youtube.com","music.youtube.com","youtube-nocookie.com","www.youtube-nocookie.com","youtu.be"]),N=/^[A-Za-z0-9_-]{11}$/;function T(e){const t=String(e||"").trim();if(!t)return 0;if(/^\d+$/.test(t))return Number(t);const n=/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(t);return!n||!(n[1]||n[2]||n[3])?0:Number(n[1]||0)*3600+Number(n[2]||0)*60+Number(n[3]||0)}function $(e){let t;try{t=new URL(String(e||"").trim())}catch{return null}if(t.protocol!=="http:"&&t.protocol!=="https:")return null;const n=t.hostname.toLowerCase().replace(/^www\./,"");if(!y.some(c=>n===c.replace(/^www\./,"")))return null;let o="";if(n==="youtu.be")o=t.pathname.split("/").filter(Boolean)[0]||"";else{const c=t.pathname.split("/").filter(Boolean);c[0]==="watch"?o=t.searchParams.get("v")||"":["shorts","embed","live","v"].includes(c[0])&&(o=c[1]||"")}if(!N.test(o))return null;const i=T(t.searchParams.get("t")||t.searchParams.get("start"));return{videoId:o,start:i,url:`https://www.youtube.com/watch?v=${o}`}}function L(e){return $(e)!==null}function g(e){if(!e||typeof e!="object")return null;const t=String(e.baseUrl||e.url||"");if(!t)return null;const n=e.name?.simpleText||e.name?.runs?.map(r=>r.text).join("")||String(e.label||"");return{baseUrl:t,lang:String(e.languageCode||e.lang||"").toLowerCase(),name:n||"",generated:String(e.kind||"")==="asr"||/auto-generated/i.test(n),translatable:e.isTranslatable!==!1}}function D(e){const t=e?.captions?.playerCaptionsTracklistRenderer?.captionTracks;return Array.isArray(t)?t.map(g).filter(Boolean):[]}function B(e){const t=e?.videoDetails||{},n=String(t.videoId||""),r=Number(t.lengthSeconds||0)||0;return{videoId:n,title:String(t.title||""),author:String(t.author||""),durationSec:r,url:n?`https://www.youtube.com/watch?v=${n}`:"",description:String(t.shortDescription||""),live:!!t.isLiveContent}}function m(e){return String(e||"").toLowerCase().replace("_","-").split("-")[0]}function A(e,{language:t="",languages:n=["en"]}={}){const r=(Array.isArray(e)?e:[]).map(g).filter(Boolean);if(!r.length)return null;const o=String(t||"").toLowerCase(),i=(o?[o]:n||[]).map(s=>String(s).toLowerCase()),c=(s,u)=>{let a=0;const l=i.indexOf(s.lang),p=i.findIndex(f=>m(f)===m(s.lang));return l>=0?a+=1e3-l*10:p>=0&&(a+=800-p*10),s.generated||(a+=100),a-u};return r.map((s,u)=>({t:s,s:c(s,u)})).sort((s,u)=>u.s-s.s)[0].t}const I=Object.freeze({clientName:"ANDROID",clientVersion:"20.10.38"});function z(e){const t=/"INNERTUBE_API_KEY":\s*"([^"]+)"/.exec(String(e||""))||/INNERTUBE_API_KEY\\":\\"([^\\"]+)/.exec(String(e||""));return t?t[1]:""}function Y(e,{apiKey:t="",client:n=I}={}){return e?{url:`https://www.youtube.com/youtubei/v1/player${t?`?key=${encodeURIComponent(t)}`:""}`,method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({context:{client:{...n}},videoId:e})}:null}function b(e,{fmt:t="json3",language:n=""}={}){let r;try{r=new URL(String(e||""))}catch{return""}return t&&r.searchParams.set("fmt",t),n&&r.searchParams.set("tlang",n),r.toString()}const v={amp:"&",lt:"<",gt:">",quot:'"',apos:"'","#39":"'",nbsp:" "};function R(e){let t=String(e||"");for(let n=0;n<2&&(t=t.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g,(r,o)=>{if(o[0]==="#"){const c=o[1]==="x"||o[1]==="X"?parseInt(o.slice(2),16):parseInt(o.slice(1),10);return Number.isFinite(c)&&c>0?String.fromCodePoint(c):r}const i=v[o.toLowerCase()];return i===void 0?r:i}),!!/&[a-zA-Z#]/.test(t));n++);return t}const x=/<\/?(?:i|b|u|s|br|font|v|c|ruby|rt|rp)\b[^>]*>/gi;function d(e){return R(String(e||"").replace(x,"")).replace(x,"").replace(/\s+/g," ").trim()}function S(e){const n=String(e||"").trim().replace(",",".").split(":").map(Number);if(n.some(o=>!Number.isFinite(o)))return NaN;let r=0;for(const o of n)r=r*60+o;return Math.round(r*1e3)}function k(e){let t;try{t=typeof e=="string"?JSON.parse(e):e}catch{return null}if(!Array.isArray(t?.events))return null;const n=[];for(const r of t.events){const o=d((r?.segs||[]).map(i=>i?.utf8||"").join(""));o&&n.push({start:Number(r.tStartMs)||0,dur:Number(r.dDurationMs)||0,text:o})}return n}function E(e){const t=String(e||"");if(!/<(transcript|timedtext|text|p)\b/i.test(t))return null;const n=[],r=/<(?:text|p)\b([^>]*)>([\s\S]*?)<\/(?:text|p)>/gi;let o;for(;o=r.exec(t);){const i=o[1],c=f=>{const h=new RegExp(`${f}="([^"]*)"`,"i").exec(i);return h?Number(h[1]):NaN},s=c("start"),u=Number.isFinite(s)?s*1e3:c("t"),a=c("dur"),l=Number.isFinite(a)?a*1e3:c("d"),p=d(o[2]);p&&n.push({start:Math.round(Number.isFinite(u)?u:0),dur:Math.round(Number.isFinite(l)?l:0),text:p})}return n.length?n:null}function M(e,t){const n=String(e||"").replace(/\r\n?/g,`
|
|
2
|
+
`).split(/\n{2,}/),r=[];for(const o of n){const i=o.split(`
|
|
3
|
+
`).filter(f=>f.trim()),c=i.findIndex(f=>t.test(f));if(c<0)continue;const[s,u]=i[c].split(t),a=S(s),l=S(String(u||"").split(/\s+/)[0]),p=d(i.slice(c+1).join(" "));!p||!Number.isFinite(a)||r.push({start:a,dur:Number.isFinite(l)?Math.max(0,l-a):0,text:p})}return r.length?r:null}const U=e=>M(e,/\s*-->\s*/);function C(e){const t=typeof e=="string"?e:JSON.stringify(e??"");return t.trim()?k(t)||E(t)||U(t)||[]:[]}function w(e){const t=Math.max(0,Math.round(Number(e)||0)/1e3),n=Math.floor(t/3600),r=Math.floor(t%3600/60),o=Math.floor(t%60),i=c=>String(c).padStart(2,"0");return n?`${n}:${i(r)}:${i(o)}`:`${r}:${i(o)}`}function P(e,{windowMs:t=3e4,gapMs:n=2500}={}){const r=(Array.isArray(e)?e:[]).filter(s=>s&&s.text),o=[];let i=null,c=0;for(const s of r){const u=Number(s.start)||0,a=u-c,l=i&&u-i.start>=t,p=i&&c>0&&a>=n;!i||l||p?(i={start:u,text:s.text},o.push(i)):i.text+=" "+s.text,c=u+(Number(s.dur)||0)}for(const s of o)s.text=_(s.text);return o}function _(e){const t=String(e||"").split(" ");for(let n=Math.min(12,t.length>>1);n>=3;n--)for(let r=0;r+2*n<=t.length;r++){let o=!0;for(let i=0;i<n&&o;i++)o=t[r+i]===t[r+n+i];o&&(t.splice(r+n,n),r--)}return t.join(" ")}const j=12e4;function F(e,{timestamps:t=!0,windowMs:n=3e4,gapMs:r=2500,maxChars:o=j}={}){const s=P(e,{windowMs:n,gapMs:r}).map(u=>t?`[${w(u.start)}] ${u.text}`:u.text).join(`
|
|
26
4
|
|
|
27
|
-
|
|
28
|
-
export const YOUTUBE_HOSTS = Object.freeze([
|
|
29
|
-
'youtube.com', 'www.youtube.com', 'm.youtube.com', 'music.youtube.com',
|
|
30
|
-
'youtube-nocookie.com', 'www.youtube-nocookie.com', 'youtu.be',
|
|
31
|
-
]);
|
|
5
|
+
`);return s.length<=o?s:`${s.slice(0,o)}
|
|
32
6
|
|
|
33
|
-
|
|
7
|
+
…[transcript truncated at ${o} characters]`}function O({meta:e={},segments:t=[],language:n="",generated:r=!1,source:o="",...i}={}){const c=[];e.title&&c.push(`# ${e.title}`);const s=[];e.author&&s.push(`Channel: ${e.author}`),e.durationSec&&s.push(`Duration: ${w(e.durationSec*1e3)}`),n&&s.push(`Captions: ${n}${r?" (auto-generated)":""}`),e.url&&s.push(`URL: ${e.url}`),s.length&&c.push(s.join(" · "));const u=String(e.description||"").trim();u&&c.push(`## Description
|
|
8
|
+
${u.slice(0,2e3)}`),c.push("## Transcript");const a=F(t,i),l=`${c.join(`
|
|
34
9
|
|
|
35
|
-
|
|
36
|
-
function parseTimeParam(raw) {
|
|
37
|
-
const s = String(raw || '').trim();
|
|
38
|
-
if (!s) return 0;
|
|
39
|
-
if (/^\d+$/.test(s)) return Number(s);
|
|
40
|
-
const m = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(s);
|
|
41
|
-
if (!m || !(m[1] || m[2] || m[3])) return 0;
|
|
42
|
-
return Number(m[1] || 0) * 3600 + Number(m[2] || 0) * 60 + Number(m[3] || 0);
|
|
43
|
-
}
|
|
10
|
+
`)}
|
|
44
11
|
|
|
45
|
-
|
|
46
|
-
* Recognise a YouTube video URL in any of the shapes people actually paste.
|
|
47
|
-
*
|
|
48
|
-
* Every surface has its own: /watch?v=, youtu.be/, /shorts/, /embed/, /live/, /v/, and the
|
|
49
|
-
* mobile and music hosts on top. Matching only /watch?v= — the one everybody writes first —
|
|
50
|
-
* silently drops Shorts, which is most of what gets pasted into a chat.
|
|
51
|
-
*
|
|
52
|
-
* @returns {{videoId: string, start: number, url: string} | null}
|
|
53
|
-
*/
|
|
54
|
-
export function parseYouTubeUrl(input) {
|
|
55
|
-
let u;
|
|
56
|
-
try {
|
|
57
|
-
u = new URL(String(input || '').trim());
|
|
58
|
-
} catch {
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
61
|
-
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
|
62
|
-
const host = u.hostname.toLowerCase().replace(/^www\./, '');
|
|
63
|
-
const known = YOUTUBE_HOSTS.some((h) => host === h.replace(/^www\./, ''));
|
|
64
|
-
if (!known) return null;
|
|
65
|
-
|
|
66
|
-
let id = '';
|
|
67
|
-
if (host === 'youtu.be') {
|
|
68
|
-
id = u.pathname.split('/').filter(Boolean)[0] || '';
|
|
69
|
-
} else {
|
|
70
|
-
const parts = u.pathname.split('/').filter(Boolean);
|
|
71
|
-
if (parts[0] === 'watch') id = u.searchParams.get('v') || '';
|
|
72
|
-
else if (['shorts', 'embed', 'live', 'v'].includes(parts[0])) id = parts[1] || '';
|
|
73
|
-
}
|
|
74
|
-
if (!VIDEO_ID_RE.test(id)) return null;
|
|
75
|
-
const start = parseTimeParam(u.searchParams.get('t') || u.searchParams.get('start'));
|
|
76
|
-
return { videoId: id, start, url: `https://www.youtube.com/watch?v=${id}` };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function isYouTubeUrl(input) {
|
|
80
|
-
return parseYouTubeUrl(input) !== null;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// --------------------------------------------------------------------------
|
|
84
|
-
// Track selection
|
|
85
|
-
// --------------------------------------------------------------------------
|
|
86
|
-
|
|
87
|
-
/** Normalise one YouTube captionTracks entry into the shape the rest of this file uses. */
|
|
88
|
-
function normalizeTrack(t) {
|
|
89
|
-
if (!t || typeof t !== 'object') return null;
|
|
90
|
-
const baseUrl = String(t.baseUrl || t.url || '');
|
|
91
|
-
if (!baseUrl) return null;
|
|
92
|
-
const name = t.name?.simpleText
|
|
93
|
-
|| t.name?.runs?.map((r) => r.text).join('')
|
|
94
|
-
|| String(t.label || '');
|
|
95
|
-
return {
|
|
96
|
-
baseUrl,
|
|
97
|
-
lang: String(t.languageCode || t.lang || '').toLowerCase(),
|
|
98
|
-
name: name || '',
|
|
99
|
-
// `asr` is YouTube's marker for the machine-generated track. It is usually the ONLY
|
|
100
|
-
// track on a video, so it must never be filtered out — only ranked below a human one.
|
|
101
|
-
generated: String(t.kind || '') === 'asr' || /auto-generated/i.test(name),
|
|
102
|
-
translatable: t.isTranslatable !== false,
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Pull the caption tracks (and the video's own metadata) out of a YouTube player response.
|
|
108
|
-
*
|
|
109
|
-
* The player response is the JSON blob the watch page hands its player. Reading captions
|
|
110
|
-
* from it is what every open-source transcript library does, because it is the only place
|
|
111
|
-
* the *signed* caption URLs exist — they carry an expiring signature, so a URL guessed
|
|
112
|
-
* from the video id is rejected, and one copied from a previous load has expired.
|
|
113
|
-
*/
|
|
114
|
-
export function captionTracksFromPlayerResponse(pr) {
|
|
115
|
-
const list = pr?.captions?.playerCaptionsTracklistRenderer?.captionTracks;
|
|
116
|
-
if (!Array.isArray(list)) return [];
|
|
117
|
-
return list.map(normalizeTrack).filter(Boolean);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export function videoMetaFromPlayerResponse(pr) {
|
|
121
|
-
const d = pr?.videoDetails || {};
|
|
122
|
-
const videoId = String(d.videoId || '');
|
|
123
|
-
const seconds = Number(d.lengthSeconds || 0) || 0;
|
|
124
|
-
return {
|
|
125
|
-
videoId,
|
|
126
|
-
title: String(d.title || ''),
|
|
127
|
-
author: String(d.author || ''),
|
|
128
|
-
durationSec: seconds,
|
|
129
|
-
url: videoId ? `https://www.youtube.com/watch?v=${videoId}` : '',
|
|
130
|
-
// The uploader's own description is frequently where the links, chapters and
|
|
131
|
-
// corrections live — a summary that ignores it misses what the video points at.
|
|
132
|
-
description: String(d.shortDescription || ''),
|
|
133
|
-
live: !!d.isLiveContent,
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** `en-GB` and `en_gb` both mean the `en` family. */
|
|
138
|
-
function langFamily(code) {
|
|
139
|
-
return String(code || '').toLowerCase().replace('_', '-').split('-')[0];
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Choose the track to read.
|
|
144
|
-
*
|
|
145
|
-
* Ranked, not filtered: a video with only an auto-generated Hindi track must still return
|
|
146
|
-
* that track for an English-preferring user, because the alternative is telling them there
|
|
147
|
-
* is no transcript when there plainly is one. Preference order:
|
|
148
|
-
*
|
|
149
|
-
* 1. an explicitly requested language (exact code beats family)
|
|
150
|
-
* 2. one of the user's preferred languages
|
|
151
|
-
* 3. a human-written track over the machine one
|
|
152
|
-
* 4. the order YouTube itself listed them (its own default is first)
|
|
153
|
-
*/
|
|
154
|
-
export function pickCaptionTrack(tracks, { language = '', languages = ['en'] } = {}) {
|
|
155
|
-
const list = (Array.isArray(tracks) ? tracks : []).map(normalizeTrack).filter(Boolean);
|
|
156
|
-
if (!list.length) return null;
|
|
157
|
-
const wanted = String(language || '').toLowerCase();
|
|
158
|
-
const prefs = (wanted ? [wanted] : languages || []).map((l) => String(l).toLowerCase());
|
|
159
|
-
const score = (t, i) => {
|
|
160
|
-
let s = 0;
|
|
161
|
-
const exact = prefs.indexOf(t.lang);
|
|
162
|
-
const family = prefs.findIndex((p) => langFamily(p) === langFamily(t.lang));
|
|
163
|
-
if (exact >= 0) s += 1000 - exact * 10;
|
|
164
|
-
else if (family >= 0) s += 800 - family * 10;
|
|
165
|
-
if (!t.generated) s += 100;
|
|
166
|
-
return s - i; // stable: YouTube's own ordering breaks every remaining tie
|
|
167
|
-
};
|
|
168
|
-
return list
|
|
169
|
-
.map((t, i) => ({ t, s: score(t, i) }))
|
|
170
|
-
.sort((a, b) => b.s - a.s)[0].t;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// --------------------------------------------------------------------------
|
|
174
|
-
// The InnerTube player request — how a caption URL that WORKS is obtained
|
|
175
|
-
// --------------------------------------------------------------------------
|
|
176
|
-
//
|
|
177
|
-
// The caption URLs printed into the watch page's HTML answer HTTP 200 with an EMPTY BODY —
|
|
178
|
-
// measured on every video tried, with and without session cookies, Referer and Origin. The
|
|
179
|
-
// ones returned by the InnerTube player endpoint for the ANDROID client do not.
|
|
180
|
-
//
|
|
181
|
-
// AND THE CLIENT VERSION IS THE WHOLE DIFFERENCE, which is worth stating because it is
|
|
182
|
-
// invisible and it will go stale:
|
|
183
|
-
//
|
|
184
|
-
// clientVersion 20.10.38 -> 1 track, 60,441 bytes of captions
|
|
185
|
-
// clientVersion 19.09.37 -> no captionTracks at all
|
|
186
|
-
// clientVersion 17.31.35 -> no captionTracks at all
|
|
187
|
-
//
|
|
188
|
-
// A stale version does not error. It returns a well-formed player response with the
|
|
189
|
-
// `captions` block missing, which reads exactly like "this video has no subtitles" — so the
|
|
190
|
-
// failure mode of letting this rot is a feature that quietly claims videos have no captions.
|
|
191
|
-
// tests/media-transcript.test.js pins the shape; a live check is the client's job.
|
|
192
|
-
|
|
193
|
-
/** The InnerTube client whose player response carries usable caption URLs. */
|
|
194
|
-
export const INNERTUBE_ANDROID = Object.freeze({ clientName: 'ANDROID', clientVersion: '20.10.38' });
|
|
195
|
-
|
|
196
|
-
/** The public InnerTube key is printed into every watch page; it is not a secret. */
|
|
197
|
-
export function innertubeApiKeyFromHtml(html) {
|
|
198
|
-
const m = /"INNERTUBE_API_KEY":\s*"([^"]+)"/.exec(String(html || ''))
|
|
199
|
-
|| /INNERTUBE_API_KEY\\":\\"([^\\"]+)/.exec(String(html || ''));
|
|
200
|
-
return m ? m[1] : '';
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* The request to make, as data — so the caller performs it wherever its network is.
|
|
205
|
-
*
|
|
206
|
-
* Returned rather than sent for the same reason nothing else here fetches: the extension, the
|
|
207
|
-
* bridge and a mobile client each have their own idea of what "fetch" means, and this file
|
|
208
|
-
* has to run in all three.
|
|
209
|
-
*/
|
|
210
|
-
export function innertubePlayerRequest(videoId, { apiKey = '', client = INNERTUBE_ANDROID } = {}) {
|
|
211
|
-
if (!videoId) return null;
|
|
212
|
-
const query = apiKey ? `?key=${encodeURIComponent(apiKey)}` : '';
|
|
213
|
-
return {
|
|
214
|
-
url: `https://www.youtube.com/youtubei/v1/player${query}`,
|
|
215
|
-
method: 'POST',
|
|
216
|
-
headers: { 'content-type': 'application/json' },
|
|
217
|
-
body: JSON.stringify({ context: { client: { ...client } }, videoId }),
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Ask a caption URL for a specific serialisation.
|
|
223
|
-
*
|
|
224
|
-
* json3 is the one to want: it is the only format that reports per-segment durations
|
|
225
|
-
* reliably, and it needs no XML parser — which matters because a DOMParser does not exist
|
|
226
|
-
* in a service worker, in Node, or in a mobile JS runtime.
|
|
227
|
-
*/
|
|
228
|
-
export function timedTextUrl(baseUrl, { fmt = 'json3', language = '' } = {}) {
|
|
229
|
-
let u;
|
|
230
|
-
try {
|
|
231
|
-
u = new URL(String(baseUrl || ''));
|
|
232
|
-
} catch {
|
|
233
|
-
return '';
|
|
234
|
-
}
|
|
235
|
-
if (fmt) u.searchParams.set('fmt', fmt);
|
|
236
|
-
// Ask YouTube to translate only when the track we found is not already the language
|
|
237
|
-
// asked for; `tlang` on a matching track returns a machine round-trip of itself.
|
|
238
|
-
if (language) u.searchParams.set('tlang', language);
|
|
239
|
-
return u.toString();
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// --------------------------------------------------------------------------
|
|
243
|
-
// Parsing — four serialisations, one segment list
|
|
244
|
-
// --------------------------------------------------------------------------
|
|
245
|
-
|
|
246
|
-
const XML_ENTITIES = {
|
|
247
|
-
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", '#39': "'", nbsp: ' ',
|
|
248
|
-
};
|
|
249
|
-
|
|
250
|
-
/** Caption text is double-escaped on the XML endpoints (`&#39;` for an apostrophe). */
|
|
251
|
-
function decodeEntities(s) {
|
|
252
|
-
let out = String(s || '');
|
|
253
|
-
for (let pass = 0; pass < 2; pass++) {
|
|
254
|
-
out = out.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (m, name) => {
|
|
255
|
-
if (name[0] === '#') {
|
|
256
|
-
const code = name[1] === 'x' || name[1] === 'X'
|
|
257
|
-
? parseInt(name.slice(2), 16)
|
|
258
|
-
: parseInt(name.slice(1), 10);
|
|
259
|
-
return Number.isFinite(code) && code > 0 ? String.fromCodePoint(code) : m;
|
|
260
|
-
}
|
|
261
|
-
const hit = XML_ENTITIES[name.toLowerCase()];
|
|
262
|
-
return hit === undefined ? m : hit;
|
|
263
|
-
});
|
|
264
|
-
if (!/&[a-zA-Z#]/.test(out)) break;
|
|
265
|
-
}
|
|
266
|
-
return out;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// Only the tags a caption track actually carries. A blanket `<[^>]*>` strip would also
|
|
270
|
-
// eat `<tag>` where the speaker said "angle bracket tag" and the track escaped it.
|
|
271
|
-
const CAPTION_MARKUP_RE = /<\/?(?:i|b|u|s|br|font|v|c|ruby|rt|rp)\b[^>]*>/gi;
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* Caption text, as text.
|
|
275
|
-
*
|
|
276
|
-
* ORDER MATTERS TWICE. Literal markup is stripped BEFORE decoding, because srv3 wraps every
|
|
277
|
-
* word in an `<s>` span for karaoke timing. Then entities are decoded — twice, since the XML
|
|
278
|
-
* endpoints double-escape (`&#39;` for an apostrophe) — and markup is stripped once more,
|
|
279
|
-
* because `<i>` reaches us escaped rather than literal on some tracks. Decoding first would
|
|
280
|
-
* turn an escaped `<tag>` the speaker actually said into markup and delete it.
|
|
281
|
-
*/
|
|
282
|
-
function cleanText(s) {
|
|
283
|
-
return decodeEntities(String(s || '').replace(CAPTION_MARKUP_RE, ''))
|
|
284
|
-
.replace(CAPTION_MARKUP_RE, '')
|
|
285
|
-
.replace(/\s+/g, ' ')
|
|
286
|
-
.trim();
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/** `00:01:02.500` / `01:02,500` / `62.5` → milliseconds. */
|
|
290
|
-
function parseClock(raw) {
|
|
291
|
-
const s = String(raw || '').trim().replace(',', '.');
|
|
292
|
-
const parts = s.split(':').map(Number);
|
|
293
|
-
if (parts.some((n) => !Number.isFinite(n))) return NaN;
|
|
294
|
-
let sec = 0;
|
|
295
|
-
for (const p of parts) sec = sec * 60 + p;
|
|
296
|
-
return Math.round(sec * 1000);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
function parseJson3(body) {
|
|
300
|
-
let doc;
|
|
301
|
-
try {
|
|
302
|
-
doc = typeof body === 'string' ? JSON.parse(body) : body;
|
|
303
|
-
} catch {
|
|
304
|
-
return null;
|
|
305
|
-
}
|
|
306
|
-
if (!Array.isArray(doc?.events)) return null;
|
|
307
|
-
const out = [];
|
|
308
|
-
for (const ev of doc.events) {
|
|
309
|
-
const text = cleanText((ev?.segs || []).map((s) => s?.utf8 || '').join(''));
|
|
310
|
-
if (!text) continue; // json3 emits empty timing-only events between cues
|
|
311
|
-
out.push({
|
|
312
|
-
start: Number(ev.tStartMs) || 0,
|
|
313
|
-
dur: Number(ev.dDurationMs) || 0,
|
|
314
|
-
text,
|
|
315
|
-
});
|
|
316
|
-
}
|
|
317
|
-
return out;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
function parseTimedTextXml(body) {
|
|
321
|
-
const src = String(body || '');
|
|
322
|
-
if (!/<(transcript|timedtext|text|p)\b/i.test(src)) return null;
|
|
323
|
-
const out = [];
|
|
324
|
-
// srv1 uses <text start dur>, srv3 uses <p t d>. One regex over both beats requiring a
|
|
325
|
-
// DOMParser that half our runtimes do not have.
|
|
326
|
-
const re = /<(?:text|p)\b([^>]*)>([\s\S]*?)<\/(?:text|p)>/gi;
|
|
327
|
-
let m;
|
|
328
|
-
while ((m = re.exec(src))) {
|
|
329
|
-
const attrs = m[1];
|
|
330
|
-
const num = (name) => {
|
|
331
|
-
const a = new RegExp(`${name}="([^"]*)"`, 'i').exec(attrs);
|
|
332
|
-
return a ? Number(a[1]) : NaN;
|
|
333
|
-
};
|
|
334
|
-
const startSec = num('start');
|
|
335
|
-
const startMs = Number.isFinite(startSec) ? startSec * 1000 : num('t');
|
|
336
|
-
const durSec = num('dur');
|
|
337
|
-
const durMs = Number.isFinite(durSec) ? durSec * 1000 : num('d');
|
|
338
|
-
const text = cleanText(m[2]);
|
|
339
|
-
if (!text) continue;
|
|
340
|
-
out.push({
|
|
341
|
-
start: Math.round(Number.isFinite(startMs) ? startMs : 0),
|
|
342
|
-
dur: Math.round(Number.isFinite(durMs) ? durMs : 0),
|
|
343
|
-
text,
|
|
344
|
-
});
|
|
345
|
-
}
|
|
346
|
-
return out.length ? out : null;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
function parseCueList(body, sepRe) {
|
|
350
|
-
const blocks = String(body || '').replace(/\r\n?/g, '\n').split(/\n{2,}/);
|
|
351
|
-
const out = [];
|
|
352
|
-
for (const block of blocks) {
|
|
353
|
-
const lines = block.split('\n').filter((l) => l.trim());
|
|
354
|
-
const timeIdx = lines.findIndex((l) => sepRe.test(l));
|
|
355
|
-
if (timeIdx < 0) continue;
|
|
356
|
-
const [rawStart, rawEnd] = lines[timeIdx].split(sepRe);
|
|
357
|
-
const start = parseClock(rawStart);
|
|
358
|
-
const end = parseClock(String(rawEnd || '').split(/\s+/)[0]);
|
|
359
|
-
const text = cleanText(lines.slice(timeIdx + 1).join(' '));
|
|
360
|
-
if (!text || !Number.isFinite(start)) continue;
|
|
361
|
-
out.push({ start, dur: Number.isFinite(end) ? Math.max(0, end - start) : 0, text });
|
|
362
|
-
}
|
|
363
|
-
return out.length ? out : null;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
const parseVtt = (body) => parseCueList(body, /\s*-->\s*/);
|
|
367
|
-
|
|
368
|
-
/**
|
|
369
|
-
* Any caption serialisation → `[{ start, dur, text }]` (milliseconds), sniffed by content.
|
|
370
|
-
*
|
|
371
|
-
* Sniffing rather than trusting a declared format: the same `fmt=json3` URL answers with
|
|
372
|
-
* XML when the parameter is dropped by a proxy or the track predates json3, and a parser
|
|
373
|
-
* chosen from the request instead of the response fails on exactly those.
|
|
374
|
-
*/
|
|
375
|
-
export function parseTimedText(body) {
|
|
376
|
-
const src = typeof body === 'string' ? body : JSON.stringify(body ?? '');
|
|
377
|
-
if (!src.trim()) return [];
|
|
378
|
-
return parseJson3(src) || parseTimedTextXml(src) || parseVtt(src) || [];
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// --------------------------------------------------------------------------
|
|
382
|
-
// Formatting
|
|
383
|
-
// --------------------------------------------------------------------------
|
|
384
|
-
|
|
385
|
-
/** ms → `m:ss`, or `h:mm:ss` once it earns the hour. */
|
|
386
|
-
export function formatTimestamp(ms) {
|
|
387
|
-
const total = Math.max(0, Math.round(Number(ms) || 0) / 1000);
|
|
388
|
-
const h = Math.floor(total / 3600);
|
|
389
|
-
const m = Math.floor((total % 3600) / 60);
|
|
390
|
-
const s = Math.floor(total % 60);
|
|
391
|
-
const pad = (n) => String(n).padStart(2, '0');
|
|
392
|
-
return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
/**
|
|
396
|
-
* Caption cues → paragraphs.
|
|
397
|
-
*
|
|
398
|
-
* Cues are 2-6 words long and arrive several per second; handed over raw, a 40-minute talk
|
|
399
|
-
* is 8,000 lines of fragments in which nothing is a sentence. The model then spends its
|
|
400
|
-
* attention on reassembly rather than on the content, and every quote it produces is a
|
|
401
|
-
* fragment. Grouping by a time budget (and by a real pause) restores paragraphs and cuts
|
|
402
|
-
* the token count roughly in half by removing the line breaks alone.
|
|
403
|
-
*/
|
|
404
|
-
export function groupSegments(segments, { windowMs = 30000, gapMs = 2500 } = {}) {
|
|
405
|
-
const list = (Array.isArray(segments) ? segments : []).filter((s) => s && s.text);
|
|
406
|
-
const out = [];
|
|
407
|
-
let cur = null;
|
|
408
|
-
let prevEnd = 0;
|
|
409
|
-
for (const seg of list) {
|
|
410
|
-
const start = Number(seg.start) || 0;
|
|
411
|
-
const gap = start - prevEnd;
|
|
412
|
-
const tooLong = cur && start - cur.start >= windowMs;
|
|
413
|
-
const paused = cur && prevEnd > 0 && gap >= gapMs;
|
|
414
|
-
if (!cur || tooLong || paused) {
|
|
415
|
-
cur = { start, text: seg.text };
|
|
416
|
-
out.push(cur);
|
|
417
|
-
} else {
|
|
418
|
-
cur.text += ' ' + seg.text;
|
|
419
|
-
}
|
|
420
|
-
prevEnd = start + (Number(seg.dur) || 0);
|
|
421
|
-
}
|
|
422
|
-
// Auto-generated tracks repeat the tail of each cue as the head of the next (a rolling
|
|
423
|
-
// two-line caption). Left in, roughly a third of the transcript is duplicated text.
|
|
424
|
-
for (const p of out) p.text = dedupeOverlap(p.text);
|
|
425
|
-
return out;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
/**
|
|
429
|
-
* Collapse an immediately-repeated run of words: `a b c b c d` → `a b c d`.
|
|
430
|
-
*
|
|
431
|
-
* The rolling two-line caption an auto-generated track emits means each cue re-states the
|
|
432
|
-
* tail of the one before it, so a naive join duplicates roughly a third of the transcript.
|
|
433
|
-
* Longest run first, so `b c b c` collapses as one four-word repeat rather than twice.
|
|
434
|
-
*/
|
|
435
|
-
function dedupeOverlap(text) {
|
|
436
|
-
const words = String(text || '').split(' ');
|
|
437
|
-
for (let k = Math.min(12, words.length >> 1); k >= 3; k--) {
|
|
438
|
-
for (let i = 0; i + 2 * k <= words.length; i++) {
|
|
439
|
-
let same = true;
|
|
440
|
-
for (let j = 0; j < k && same; j++) same = words[i + j] === words[i + k + j];
|
|
441
|
-
if (same) { words.splice(i + k, k); i--; }
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
return words.join(' ');
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
export const TRANSCRIPT_MAX_CHARS = 120_000;
|
|
448
|
-
|
|
449
|
-
/**
|
|
450
|
-
* Segments → the text a model reads.
|
|
451
|
-
*
|
|
452
|
-
* Timestamps are kept by default and are not decoration: they are what lets an answer say
|
|
453
|
-
* "at 12:04 they say…", and what lets the panel turn that into a link that seeks the video.
|
|
454
|
-
*/
|
|
455
|
-
export function formatTranscript(segments, {
|
|
456
|
-
timestamps = true, windowMs = 30000, gapMs = 2500, maxChars = TRANSCRIPT_MAX_CHARS,
|
|
457
|
-
} = {}) {
|
|
458
|
-
const groups = groupSegments(segments, { windowMs, gapMs });
|
|
459
|
-
const lines = groups.map((g) => (timestamps ? `[${formatTimestamp(g.start)}] ${g.text}` : g.text));
|
|
460
|
-
const text = lines.join('\n\n');
|
|
461
|
-
if (text.length <= maxChars) return text;
|
|
462
|
-
return `${text.slice(0, maxChars)}\n\n…[transcript truncated at ${maxChars} characters]`;
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
/**
|
|
466
|
-
* The whole thing a caller attaches or hands a model: metadata header + transcript body.
|
|
467
|
-
*
|
|
468
|
-
* The header exists because a transcript alone is anonymous. "Summarise this" over bare
|
|
469
|
-
* caption text produces a summary that cannot say what it summarised, and a model that
|
|
470
|
-
* cannot see the duration guesses at the shape of what it is reading.
|
|
471
|
-
*/
|
|
472
|
-
export function buildTranscriptDocument({
|
|
473
|
-
meta = {}, segments = [], language = '', generated = false, source = '', ...opts
|
|
474
|
-
} = {}) {
|
|
475
|
-
const head = [];
|
|
476
|
-
if (meta.title) head.push(`# ${meta.title}`);
|
|
477
|
-
const facts = [];
|
|
478
|
-
if (meta.author) facts.push(`Channel: ${meta.author}`);
|
|
479
|
-
if (meta.durationSec) facts.push(`Duration: ${formatTimestamp(meta.durationSec * 1000)}`);
|
|
480
|
-
if (language) facts.push(`Captions: ${language}${generated ? ' (auto-generated)' : ''}`);
|
|
481
|
-
if (meta.url) facts.push(`URL: ${meta.url}`);
|
|
482
|
-
if (facts.length) head.push(facts.join(' · '));
|
|
483
|
-
const desc = String(meta.description || '').trim();
|
|
484
|
-
if (desc) head.push(`## Description\n${desc.slice(0, 2000)}`);
|
|
485
|
-
head.push('## Transcript');
|
|
486
|
-
const body = formatTranscript(segments, opts);
|
|
487
|
-
const text = `${head.join('\n\n')}\n\n${body}`;
|
|
488
|
-
return {
|
|
489
|
-
title: meta.title || 'Video transcript',
|
|
490
|
-
url: meta.url || '',
|
|
491
|
-
language,
|
|
492
|
-
generated,
|
|
493
|
-
source,
|
|
494
|
-
segments: segments.length,
|
|
495
|
-
durationSec: meta.durationSec || 0,
|
|
496
|
-
text,
|
|
497
|
-
chars: text.length,
|
|
498
|
-
};
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
/**
|
|
502
|
-
* The one orchestration worth sharing: tracks → chosen track → fetched body → document.
|
|
503
|
-
*
|
|
504
|
-
* `fetchText(url)` is injected, because WHERE the fetch runs is the entire reliability
|
|
505
|
-
* story and it differs per client (see the header). Everything either side of it is
|
|
506
|
-
* identical everywhere, so it lives here and gets tested with a fake fetch.
|
|
507
|
-
*/
|
|
508
|
-
export async function transcriptFromTracks({
|
|
509
|
-
tracks, meta = {}, fetchText, language = '', languages = ['en'], source = '', ...opts
|
|
510
|
-
} = {}) {
|
|
511
|
-
const track = pickCaptionTrack(tracks, { language, languages });
|
|
512
|
-
if (!track) return null;
|
|
513
|
-
// Only ask for a translation when the track genuinely is not the language wanted.
|
|
514
|
-
const wantTranslation = !!language && langFamily(track.lang) !== langFamily(language);
|
|
515
|
-
const attempts = [
|
|
516
|
-
timedTextUrl(track.baseUrl, { fmt: 'json3', language: wantTranslation ? language : '' }),
|
|
517
|
-
timedTextUrl(track.baseUrl, { fmt: 'srv1', language: wantTranslation ? language : '' }),
|
|
518
|
-
track.baseUrl,
|
|
519
|
-
].filter(Boolean);
|
|
520
|
-
for (const url of attempts) {
|
|
521
|
-
let body;
|
|
522
|
-
try {
|
|
523
|
-
body = await fetchText(url);
|
|
524
|
-
} catch {
|
|
525
|
-
continue; // a format the endpoint refuses is a reason to try the next, not to fail
|
|
526
|
-
}
|
|
527
|
-
const segments = parseTimedText(body);
|
|
528
|
-
if (segments.length) {
|
|
529
|
-
return buildTranscriptDocument({
|
|
530
|
-
meta, segments, language: track.lang, generated: track.generated, source, ...opts,
|
|
531
|
-
});
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
return null;
|
|
535
|
-
}
|
|
12
|
+
${a}`;return{title:e.title||"Video transcript",url:e.url||"",language:n,generated:r,source:o,segments:t.length,durationSec:e.durationSec||0,text:l,chars:l.length}}async function q({tracks:e,meta:t={},fetchText:n,language:r="",languages:o=["en"],source:i="",...c}={}){const s=A(e,{language:r,languages:o});if(!s)return null;const u=!!r&&m(s.lang)!==m(r),a=[b(s.baseUrl,{fmt:"json3",language:u?r:""}),b(s.baseUrl,{fmt:"srv1",language:u?r:""}),s.baseUrl].filter(Boolean);for(const l of a){let p;try{p=await n(l)}catch{continue}const f=C(p);if(f.length)return O({meta:t,segments:f,language:s.lang,generated:s.generated,source:i,...c})}return null}export{I as INNERTUBE_ANDROID,j as TRANSCRIPT_MAX_CHARS,y as YOUTUBE_HOSTS,O as buildTranscriptDocument,D as captionTracksFromPlayerResponse,w as formatTimestamp,F as formatTranscript,P as groupSegments,z as innertubeApiKeyFromHtml,Y as innertubePlayerRequest,L as isYouTubeUrl,C as parseTimedText,$ as parseYouTubeUrl,A as pickCaptionTrack,b as timedTextUrl,q as transcriptFromTracks,B as videoMetaFromPlayerResponse};
|
package/meeting-analyzers.js
CHANGED
|
@@ -1,83 +1 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// A meeting already generates several kinds of derived thing: a running summary, insight
|
|
4
|
-
// sections (decisions, action items, risks), and live monitors that watch for an answer to
|
|
5
|
-
// a standing question. Each is written separately — its own prompt shape, its own cadence,
|
|
6
|
-
// its own storage — so adding a fifth means touching several files and nothing outside the
|
|
7
|
-
// extension can offer one.
|
|
8
|
-
//
|
|
9
|
-
// They are the same shape underneath: run over the transcript so far, on some trigger,
|
|
10
|
-
// producing a typed result that is stored and shown. Declaring that shape gives three
|
|
11
|
-
// things at once — a Plugins entry the user can switch off, a cadence the runtime can honour
|
|
12
|
-
// without each analyzer implementing its own timer, and a contract the gateway could later
|
|
13
|
-
// run server-side without rewriting callers.
|
|
14
|
-
//
|
|
15
|
-
// WHAT THIS IS NOT: a scheduler. Declaring "every 90 seconds" does not start a timer here;
|
|
16
|
-
// the host decides when to run and this says what running means. Putting the clock in the
|
|
17
|
-
// contract would make it untestable and unrunnable off a browser.
|
|
18
|
-
|
|
19
|
-
export class AnalyzerError extends Error {
|
|
20
|
-
constructor(code, message) { super(message); this.name = 'AnalyzerError'; this.code = code; }
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** When an analyzer wants to run. The host maps these to its own timers and events. */
|
|
24
|
-
export const CADENCES = Object.freeze([
|
|
25
|
-
'periodic', // every `everyMs` while the meeting is live
|
|
26
|
-
'on-demand', // only when the user asks
|
|
27
|
-
'on-end', // once, when the meeting finishes
|
|
28
|
-
]);
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* @param produces what the result IS — 'summary' | 'sections' | 'answer' | 'text'. The host
|
|
32
|
-
* uses it to decide where the output goes, so an analyzer never has to know about
|
|
33
|
-
* storage.
|
|
34
|
-
* @param run async ({ transcript, summary, previous, meeting, ask }) => result. `ask` is
|
|
35
|
-
* the model call, injected: an analyzer that imported one could not run in the
|
|
36
|
-
* gateway, and could not be tested without a network.
|
|
37
|
-
*/
|
|
38
|
-
export function defineMeetingAnalyzer({
|
|
39
|
-
id, label, produces = 'text', cadence = 'on-demand', everyMs = 0,
|
|
40
|
-
minTranscriptChars = 0, description = '', run,
|
|
41
|
-
}) {
|
|
42
|
-
if (!id) throw new AnalyzerError('BAD_ANALYZER', 'analyzer.id required');
|
|
43
|
-
if (typeof run !== 'function') throw new AnalyzerError('BAD_ANALYZER', `analyzer '${id}': run required`);
|
|
44
|
-
if (!CADENCES.includes(cadence)) throw new AnalyzerError('BAD_ANALYZER', `analyzer '${id}': unknown cadence '${cadence}'`);
|
|
45
|
-
if (cadence === 'periodic' && !(everyMs > 0)) {
|
|
46
|
-
// A periodic analyzer with no interval would either never run or run every tick, and
|
|
47
|
-
// both look like a bug in the analyzer rather than in its declaration.
|
|
48
|
-
throw new AnalyzerError('BAD_ANALYZER', `analyzer '${id}': periodic cadence needs everyMs`);
|
|
49
|
-
}
|
|
50
|
-
return Object.freeze({ id, label: label || id, produces, cadence, everyMs, minTranscriptChars, description, run });
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function createAnalyzerRegistry() {
|
|
54
|
-
const analyzers = [];
|
|
55
|
-
return {
|
|
56
|
-
add(a) {
|
|
57
|
-
analyzers.push(a);
|
|
58
|
-
return () => { const i = analyzers.indexOf(a); if (i >= 0) analyzers.splice(i, 1); };
|
|
59
|
-
},
|
|
60
|
-
list: () => [...analyzers],
|
|
61
|
-
get: (id) => analyzers.find((a) => a.id === id) || null,
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Which analyzers are due right now.
|
|
65
|
-
*
|
|
66
|
-
* `lastRunAt` is passed in rather than held here: the registry is a declaration, and a
|
|
67
|
-
* registry that remembered when things ran would be a second place for that truth to
|
|
68
|
-
* live — beside the meeting record that already has to store it.
|
|
69
|
-
*/
|
|
70
|
-
due({ now, cadence = 'periodic', lastRunAt = {}, transcriptChars = 0, admit = null } = {}) {
|
|
71
|
-
return analyzers.filter((a) => {
|
|
72
|
-
if (a.cadence !== cadence) return false;
|
|
73
|
-
if (admit && !admit(a)) return false;
|
|
74
|
-
// Below the threshold there is nothing worth spending a model call on — an empty
|
|
75
|
-
// transcript summarised is a paragraph of apology.
|
|
76
|
-
if (transcriptChars < a.minTranscriptChars) return false;
|
|
77
|
-
if (a.cadence !== 'periodic') return true;
|
|
78
|
-
const last = lastRunAt[a.id] || 0;
|
|
79
|
-
return !last || (now - last) >= a.everyMs;
|
|
80
|
-
});
|
|
81
|
-
},
|
|
82
|
-
};
|
|
83
|
-
}
|
|
1
|
+
class l extends Error{constructor(r,n){super(n),this.name="AnalyzerError",this.code=r}}const u=Object.freeze(["periodic","on-demand","on-end"]);function f({id:e,label:r,produces:n="text",cadence:i="on-demand",everyMs:s=0,minTranscriptChars:c=0,description:t="",run:o}){if(!e)throw new l("BAD_ANALYZER","analyzer.id required");if(typeof o!="function")throw new l("BAD_ANALYZER",`analyzer '${e}': run required`);if(!u.includes(i))throw new l("BAD_ANALYZER",`analyzer '${e}': unknown cadence '${i}'`);if(i==="periodic"&&!(s>0))throw new l("BAD_ANALYZER",`analyzer '${e}': periodic cadence needs everyMs`);return Object.freeze({id:e,label:r||e,produces:n,cadence:i,everyMs:s,minTranscriptChars:c,description:t,run:o})}function a(){const e=[];return{add(r){return e.push(r),()=>{const n=e.indexOf(r);n>=0&&e.splice(n,1)}},list:()=>[...e],get:r=>e.find(n=>n.id===r)||null,due({now:r,cadence:n="periodic",lastRunAt:i={},transcriptChars:s=0,admit:c=null}={}){return e.filter(t=>{if(t.cadence!==n||c&&!c(t)||s<t.minTranscriptChars)return!1;if(t.cadence!=="periodic")return!0;const o=i[t.id]||0;return!o||r-o>=t.everyMs})}}}export{l as AnalyzerError,u as CADENCES,a as createAnalyzerRegistry,f as defineMeetingAnalyzer};
|