@vibes.diy/prompts 14.1.14 → 14.1.16
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/llms/index.d.ts +2 -1
- package/llms/index.js +3 -0
- package/llms/index.js.map +1 -1
- package/llms/youtube.d.ts +2 -0
- package/llms/youtube.js +15 -0
- package/llms/youtube.js.map +1 -0
- package/llms/youtube.md +584 -0
- package/package.json +4 -4
package/llms/index.d.ts
CHANGED
|
@@ -13,5 +13,6 @@ export { useVibeConfig } from "./use-vibe.js";
|
|
|
13
13
|
export { createVibeConfig } from "./create-vibe.js";
|
|
14
14
|
export { accessConfig } from "./access.js";
|
|
15
15
|
export { connectionsConfig } from "./connections.js";
|
|
16
|
+
export { youtubeConfig } from "./youtube.js";
|
|
16
17
|
export type { LlmConfig } from "./types.js";
|
|
17
|
-
export declare const allConfigs: readonly [import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig];
|
|
18
|
+
export declare const allConfigs: readonly [import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig];
|
package/llms/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import { useVibeConfig } from "./use-vibe.js";
|
|
|
13
13
|
import { createVibeConfig } from "./create-vibe.js";
|
|
14
14
|
import { accessConfig } from "./access.js";
|
|
15
15
|
import { connectionsConfig } from "./connections.js";
|
|
16
|
+
import { youtubeConfig } from "./youtube.js";
|
|
16
17
|
export { backendConfig } from "./backend.js";
|
|
17
18
|
export { calendarConfig } from "./calendar.js";
|
|
18
19
|
export { callaiConfig } from "./callai.js";
|
|
@@ -28,6 +29,7 @@ export { useVibeConfig } from "./use-vibe.js";
|
|
|
28
29
|
export { createVibeConfig } from "./create-vibe.js";
|
|
29
30
|
export { accessConfig } from "./access.js";
|
|
30
31
|
export { connectionsConfig } from "./connections.js";
|
|
32
|
+
export { youtubeConfig } from "./youtube.js";
|
|
31
33
|
export const allConfigs = [
|
|
32
34
|
callaiConfig,
|
|
33
35
|
imageGenConfig,
|
|
@@ -44,5 +46,6 @@ export const allConfigs = [
|
|
|
44
46
|
calendarConfig,
|
|
45
47
|
accessConfig,
|
|
46
48
|
connectionsConfig,
|
|
49
|
+
youtubeConfig,
|
|
47
50
|
];
|
|
48
51
|
//# sourceMappingURL=index.js.map
|
package/llms/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../jsr/llms/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAClD,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../jsr/llms/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAClD,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAE7C,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAClD,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAI7C,MAAM,CAAC,MAAM,UAAU,GAAG;IACxB,YAAY;IACZ,cAAc;IACd,cAAc;IACd,QAAQ;IACR,aAAa;IACb,WAAW;IACX,eAAe;IACf,WAAW;IACX,eAAe;IACf,aAAa;IACb,gBAAgB;IAChB,aAAa;IACb,cAAc;IACd,YAAY;IACZ,iBAAiB;IACjB,aAAa;CACL,CAAC"}
|
package/llms/youtube.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const youtubeConfig = {
|
|
2
|
+
name: "youtube",
|
|
3
|
+
label: "YouTube channel stats",
|
|
4
|
+
description: "YouTube channel dashboards on the owner's own YouTube Data API v3 key: the setup steps the app shows when the key is missing, keeping the key in backend.js, resolving a channel from a handle or URL, reading videos through the uploads playlist instead of the 100-unit search endpoint, daily snapshots to chart change over time, and labeling the numbers an API key cannot see",
|
|
5
|
+
cues: [
|
|
6
|
+
"youtube",
|
|
7
|
+
"my channel",
|
|
8
|
+
"subscriber count",
|
|
9
|
+
"subscribers",
|
|
10
|
+
"video views",
|
|
11
|
+
"youtube stats",
|
|
12
|
+
"channel analytics",
|
|
13
|
+
],
|
|
14
|
+
};
|
|
15
|
+
//# sourceMappingURL=youtube.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"youtube.js","sourceRoot":"","sources":["../../jsr/llms/youtube.ts"],"names":[],"mappings":"AAKA,MAAM,CAAC,MAAM,aAAa,GAAc;IACtC,IAAI,EAAE,SAAS;IACf,KAAK,EAAE,uBAAuB;IAC9B,WAAW,EACT,uXAAuX;IACzX,IAAI,EAAE;QACJ,SAAS;QACT,YAAY;QACZ,kBAAkB;QAClB,aAAa;QACb,aAAa;QACb,eAAe;QACf,mBAAmB;KACpB;CACF,CAAC"}
|
package/llms/youtube.md
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
# YouTube channel stats — with the owner's own API key
|
|
2
|
+
|
|
3
|
+
When someone asks for a dashboard of their own YouTube channel — subscribers,
|
|
4
|
+
views, which videos are picking up — build it on the **YouTube Data API v3**
|
|
5
|
+
with a key the owner supplies. This is a `backend.js` feature (see the backend
|
|
6
|
+
docs for the general handler contract); this page is the recipe that makes the
|
|
7
|
+
key safe, the quota survivable, and the numbers honest about what they are.
|
|
8
|
+
|
|
9
|
+
## 1. Bring your own key, and explain how
|
|
10
|
+
|
|
11
|
+
The owner gets a key from Google, once:
|
|
12
|
+
|
|
13
|
+
1. Open the Google Cloud console and create or select a project.
|
|
14
|
+
2. Go to APIs & Services, then Library, and enable **YouTube Data API v3**.
|
|
15
|
+
3. Go to Credentials, then Create credentials, then API key.
|
|
16
|
+
4. Restrict the key to the YouTube Data API v3.
|
|
17
|
+
5. Set it on the vibe as `YOUTUBE_API_KEY` — the vibe's settings page, or
|
|
18
|
+
`vibes-diy secrets set YOUTUBE_API_KEY <the key>`.
|
|
19
|
+
|
|
20
|
+
The app carries those five steps in its own UI, because the person reading them
|
|
21
|
+
is the person who has to do them.
|
|
22
|
+
|
|
23
|
+
When the key is missing, the app shows the owner the setup steps for a YouTube Data API v3 key instead of an error.
|
|
24
|
+
|
|
25
|
+
The backend answers `{ ok: false, reason: "missing-key" }` when
|
|
26
|
+
`ctx.secrets.YOUTUBE_API_KEY` is absent, and the client renders the numbered
|
|
27
|
+
steps, naming "YouTube Data API v3" and `YOUTUBE_API_KEY` in so many words so
|
|
28
|
+
the owner knows what to enable and what to call it.
|
|
29
|
+
|
|
30
|
+
Only the owner can set or rotate a secret, and handlers see the current value on
|
|
31
|
+
every invocation — so the dashboard starts working the moment the key lands, with
|
|
32
|
+
no redeploy.
|
|
33
|
+
|
|
34
|
+
## 2. The key never reaches the browser
|
|
35
|
+
|
|
36
|
+
Every YouTube request is made in backend.js with ctx.fetch and ctx.secrets.YOUTUBE_API_KEY; App.jsx never calls googleapis.com.
|
|
37
|
+
|
|
38
|
+
The client talks to the app's own `/_api/...` routes with a relative `fetch`, and the
|
|
39
|
+
backend is the only place that knows the key exists. A key pasted into a React
|
|
40
|
+
component ships to every visitor who views source, and a restricted key is still
|
|
41
|
+
the owner's quota to spend.
|
|
42
|
+
|
|
43
|
+
### The `/_api` routes are the second door
|
|
44
|
+
|
|
45
|
+
Keeping the key server-side is only half of it. A published vibe's `/_api/...`
|
|
46
|
+
routes answer *anyone*, so a handler that calls Google on every request hands a
|
|
47
|
+
stranger the owner's 10,000 daily units and a refresh button.
|
|
48
|
+
|
|
49
|
+
Live YouTube calls are made only for the vibe owner; every other visitor is served from the stored snapshot, so a stranger can never spend the owner's quota.
|
|
50
|
+
|
|
51
|
+
A `fetch` handler is told who it is acting for: `ctx.appInfo.ownerHandle` is this
|
|
52
|
+
app's owner and `ctx.userInfo` is `{ userHandle }` or `null` when the caller is
|
|
53
|
+
anonymous. One comparison at the top of the handler is the whole gate:
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
const isOwner = ctx.userInfo?.userHandle != null && ctx.userInfo.userHandle === ctx.appInfo.ownerHandle;
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Everyone else is answered from the daily snapshot docs section 5 already writes —
|
|
60
|
+
`{ ok: true, source: "snapshot", asOf: <day>, ... }`, so the client can say "as of
|
|
61
|
+
Tuesday" instead of implying the numbers are live — and
|
|
62
|
+
`{ ok: false, reason: "no-snapshot-yet" }` before the first tick has run. Nothing
|
|
63
|
+
about a visitor's request reaches googleapis.com.
|
|
64
|
+
|
|
65
|
+
Working out *which* channel the visitor meant has to be free too. Snapshots are
|
|
66
|
+
stored under the resolved `UC…` id, and a visitor arrives with `?q=@handle` — so
|
|
67
|
+
comparing the raw `q` against a stored id matches nothing, and resolving the
|
|
68
|
+
handle with Google is the quota spend this gate exists to stop. The tracked
|
|
69
|
+
channel doc (`type: "channel"`) therefore carries an `aliases` array: every
|
|
70
|
+
spelling that names this channel — the `UC…` id, the `@handle` from
|
|
71
|
+
`snippet.customUrl`, and any legacy username the owner connected it with — each
|
|
72
|
+
lowercased. The visitor path normalizes `q` through the same `channelQuery`
|
|
73
|
+
helper the owner path uses, lowercases what comes out, and looks for it among the
|
|
74
|
+
tracked channels' aliases. That is a comparison against documents the app already
|
|
75
|
+
holds, not a call.
|
|
76
|
+
|
|
77
|
+
An empty `q` is not a failure either: it is the owner's dashboard as visitors see
|
|
78
|
+
it, so answer with the newest snapshot of every tracked channel.
|
|
79
|
+
|
|
80
|
+
That visitor read happens on the `fetch` lane, which reads nothing for an
|
|
81
|
+
anonymous caller and cannot read an access-fn-bound database until the
|
|
82
|
+
backend opts in, so the recipe declares
|
|
83
|
+
`config.fetch.unfilteredReads = { dbs: ["youtube"], why: ... }` (see the
|
|
84
|
+
backend docs). The obligation that comes with it is small here: the snapshot
|
|
85
|
+
docs hold only a channel's public numbers, and the handler serves nothing else
|
|
86
|
+
from that database.
|
|
87
|
+
|
|
88
|
+
## 3. Resolve a channel from whatever the person pastes
|
|
89
|
+
|
|
90
|
+
People arrive with a handle, a URL, or a raw id. Normalize before you call:
|
|
91
|
+
|
|
92
|
+
| What they paste | What you call |
|
|
93
|
+
| --- | --- |
|
|
94
|
+
| `UC…` (24 chars, starts `UC`) | `channels?id=UC…` |
|
|
95
|
+
| `@handle`, or `youtube.com/@handle` | `channels?forHandle=@handle` |
|
|
96
|
+
| `youtube.com/channel/UC…` | `channels?id=UC…` |
|
|
97
|
+
| `youtube.com/user/name` (legacy) | `channels?forUsername=name` |
|
|
98
|
+
|
|
99
|
+
The request:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
GET https://www.googleapis.com/youtube/v3/channels
|
|
103
|
+
?part=snippet,statistics,contentDetails
|
|
104
|
+
&forHandle=@name
|
|
105
|
+
&key=<YOUTUBE_API_KEY>
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`snippet` carries the title, description and thumbnails; `statistics` carries
|
|
109
|
+
`subscriberCount`, `viewCount`, `videoCount` and `hiddenSubscriberCount`;
|
|
110
|
+
`contentDetails.relatedPlaylists.uploads` is the uploads playlist id you need
|
|
111
|
+
for the video list. An empty `items` array means no such channel — say that, and
|
|
112
|
+
keep the input on screen so the person can fix it.
|
|
113
|
+
|
|
114
|
+
## 4. Video performance without burning quota
|
|
115
|
+
|
|
116
|
+
Read videos through the uploads playlist (playlistItems, then videos?part=statistics) and never through search, which costs 100 quota units per call.
|
|
117
|
+
|
|
118
|
+
The default daily quota is 10,000 units, and `channels`,
|
|
119
|
+
`playlistItems` and `videos` cost 1 unit each, so a full refresh of a channel
|
|
120
|
+
with 50 recent videos costs about 3 units. One `search` call costs 100 and
|
|
121
|
+
returns less.
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
GET .../youtube/v3/playlistItems?part=contentDetails&playlistId=<uploads>&maxResults=50&key=…
|
|
125
|
+
GET .../youtube/v3/videos?part=snippet,statistics&id=<up to 50 comma-joined ids>&key=…
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
"Gaining attention" is a comparison, not a field: take each video's current
|
|
129
|
+
`viewCount` and subtract the `viewCount` the previous snapshot stored for the
|
|
130
|
+
same video id. Rank by that delta, and label the list with the window it covers
|
|
131
|
+
("since yesterday's snapshot"). With one snapshot on file there is no delta yet —
|
|
132
|
+
say "first snapshot saved, changes appear tomorrow" rather than showing zeros.
|
|
133
|
+
|
|
134
|
+
Counts arrive as strings. `Number(stats.viewCount ?? 0)` before you do arithmetic.
|
|
135
|
+
|
|
136
|
+
## 5. A daily snapshot the app can chart
|
|
137
|
+
|
|
138
|
+
The scheduled tick writes one snapshot doc per channel per day under a fixed _id, and skips the write when nothing changed.
|
|
139
|
+
|
|
140
|
+
The fixed `_id` is what makes the tick idempotent: it can run every hour, and the day's row is written once and
|
|
141
|
+
then left alone.
|
|
142
|
+
|
|
143
|
+
```js
|
|
144
|
+
export const config = { scheduled: { interval: "1h" } };
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The doc:
|
|
148
|
+
|
|
149
|
+
```js
|
|
150
|
+
{
|
|
151
|
+
_id: `snapshot-${channelId}-${day}`, // day is YYYY-MM-DD
|
|
152
|
+
type: "snapshot",
|
|
153
|
+
channelDay: `${channelId}|${day}`, // one channel's history, readable by prefix
|
|
154
|
+
day,
|
|
155
|
+
channelId,
|
|
156
|
+
title, // so a visitor's snapshot answer can name the channel
|
|
157
|
+
subscribers,
|
|
158
|
+
views,
|
|
159
|
+
videoCount,
|
|
160
|
+
videos: [{ id, title, views, likes, comments }],
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Beside them sits one tracked-channel doc per channel — the list the tick sweeps
|
|
165
|
+
and the list a visitor's `q` is resolved against:
|
|
166
|
+
|
|
167
|
+
```js
|
|
168
|
+
{
|
|
169
|
+
_id: `channel-${channelId}`,
|
|
170
|
+
type: "channel",
|
|
171
|
+
channelId,
|
|
172
|
+
aliases: ["uc…", "@handle", "legacyname"], // lowercased, from section 2
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
`channelDay` is what keeps the reads channel-selective. Asking for
|
|
177
|
+
`{ field: "type", key: "snapshot", limit: 200 }` and filtering in the handler
|
|
178
|
+
reads every channel's whole history to answer about one of them, and goes blind
|
|
179
|
+
as soon as the app tracks more days than the page holds. Ask for the rows you
|
|
180
|
+
want instead:
|
|
181
|
+
|
|
182
|
+
```js
|
|
183
|
+
const page = await ctx.db.query({ db: DB, field: "channelDay", prefix: `${channelId}|`, limit: 100, after });
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
and walk `after = page.next` to the end before picking the largest `day`. The
|
|
187
|
+
tracked-channel read walks the same cursor. A filtered page can come back
|
|
188
|
+
**empty and still carry `next`**, so the loop condition is the cursor, never
|
|
189
|
+
emptiness (the backend docs spell this out).
|
|
190
|
+
|
|
191
|
+
Point-read the existing doc by that `_id`, compare the tracked fields **and the
|
|
192
|
+
per-video rows** — id, title, views, likes and comments — and return without
|
|
193
|
+
writing when they all match. A channel whose subscriber and view totals sat still
|
|
194
|
+
overnight can still have picked up likes, comments or a retitled video, and
|
|
195
|
+
comparing only the channel totals would throw that day's change away. A tick that
|
|
196
|
+
genuinely changes nothing writes nothing, which is the zero-churn rule from the
|
|
197
|
+
backend docs.
|
|
198
|
+
|
|
199
|
+
When the channel call succeeds but the video call fails, keep the rows the last
|
|
200
|
+
snapshot stored and mark the doc `videosStale: true`. Writing `videos: []` over
|
|
201
|
+
real rows lets one upstream blip erase a day of video history, and tomorrow's
|
|
202
|
+
delta then reads every video as brand new.
|
|
203
|
+
|
|
204
|
+
The client charts the history with `useLiveQuery("type", { key: "snapshot" })`,
|
|
205
|
+
sorts by `day`, and draws subscribers and views over time.
|
|
206
|
+
|
|
207
|
+
## 6. Label what an API key cannot see
|
|
208
|
+
|
|
209
|
+
An API key reads public data.
|
|
210
|
+
|
|
211
|
+
Watch time, revenue, impressions, traffic sources and demographics need YouTube Analytics with OAuth and are labeled "Not available with an API key", never shown as zero.
|
|
212
|
+
|
|
213
|
+
Give them their own section in the UI with that label, so the owner sees the
|
|
214
|
+
boundary instead of believing the channel earned nothing.
|
|
215
|
+
|
|
216
|
+
Two more honest labels:
|
|
217
|
+
|
|
218
|
+
- `statistics.hiddenSubscriberCount === true` means the channel hides the
|
|
219
|
+
number. Show "hidden by the channel", not `0`.
|
|
220
|
+
- YouTube rounds public subscriber counts (three significant figures above
|
|
221
|
+
1,000). Put "YouTube rounds public subscriber counts" in a caption under the
|
|
222
|
+
subscriber tile.
|
|
223
|
+
- `likeCount` is absent when the channel hides likes, and `commentCount` is
|
|
224
|
+
absent when comments are off. Render "hidden" for an absent field.
|
|
225
|
+
|
|
226
|
+
## 7. Errors keep their reason
|
|
227
|
+
|
|
228
|
+
Google answers a refusal with `error.errors[0].reason`. Classify on it and pass
|
|
229
|
+
the reason through:
|
|
230
|
+
|
|
231
|
+
- `quotaExceeded` — "Daily quota used; resets at midnight Pacific."
|
|
232
|
+
- `keyInvalid` or `API_KEY_INVALID` — show the setup steps from section 1 again.
|
|
233
|
+
- `accessNotConfigured` — "Enable YouTube Data API v3 on this key's project."
|
|
234
|
+
- anything else — show the API's own `error.message`.
|
|
235
|
+
|
|
236
|
+
A failure keeps its reason all the way to the screen. "No data" tells the owner
|
|
237
|
+
nothing they can act on, and the four cases above each have a different fix.
|
|
238
|
+
|
|
239
|
+
## Complete backend.js
|
|
240
|
+
|
|
241
|
+
```js
|
|
242
|
+
export const config = {
|
|
243
|
+
scheduled: { interval: "1h" },
|
|
244
|
+
// The visitor path below reads the snapshot docs on the fetch lane, which
|
|
245
|
+
// reads nothing for a caller with no session unless the db is named here.
|
|
246
|
+
// The snapshots hold only public channel numbers, so the obligation is light.
|
|
247
|
+
fetch: { unfilteredReads: { dbs: ["youtube"], why: "serves public channel snapshots to visitors" } },
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const API = "https://www.googleapis.com/youtube/v3";
|
|
251
|
+
const DB = "youtube";
|
|
252
|
+
|
|
253
|
+
function channelQuery(raw) {
|
|
254
|
+
const q = String(raw ?? "").trim();
|
|
255
|
+
const url = /^https?:\/\//i.test(q) ? q : "";
|
|
256
|
+
const path = url ? new URL(url).pathname : q;
|
|
257
|
+
const byId = /(?:^|\/)(UC[\w-]{22})(?:\/|$)/.exec(path);
|
|
258
|
+
if (byId) return { forHandle: null, id: byId[1], forUsername: null };
|
|
259
|
+
const byHandle = /(?:^|\/)@([\w.-]+)/.exec(path);
|
|
260
|
+
if (byHandle) return { id: null, forHandle: `@${byHandle[1]}`, forUsername: null };
|
|
261
|
+
const byUser = /\/user\/([\w-]+)/.exec(path);
|
|
262
|
+
if (byUser) return { id: null, forHandle: null, forUsername: byUser[1] };
|
|
263
|
+
return { id: null, forHandle: `@${q.replace(/^@/, "")}`, forUsername: null };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const aliasKey = (value) => String(value ?? "").trim().toLowerCase();
|
|
267
|
+
|
|
268
|
+
/** Every spelling that names this channel — the id, the @handle, and what the owner pasted. */
|
|
269
|
+
function channelAliases(channel, raw) {
|
|
270
|
+
const asked = channelQuery(raw);
|
|
271
|
+
const all = [channel.channelId, channel.handle, asked.id, asked.forHandle, asked.forUsername, raw];
|
|
272
|
+
return [...new Set(all.map(aliasKey).filter(Boolean))];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function callYouTube(ctx, resource, params) {
|
|
276
|
+
const key = ctx.secrets.YOUTUBE_API_KEY;
|
|
277
|
+
if (!key) return { ok: false, reason: "missing-key" };
|
|
278
|
+
const url = new URL(`${API}/${resource}`);
|
|
279
|
+
for (const [k, v] of Object.entries(params)) if (v != null) url.searchParams.set(k, String(v));
|
|
280
|
+
url.searchParams.set("key", key);
|
|
281
|
+
const res = await ctx.fetch(url.toString(), { headers: { accept: "application/json" } });
|
|
282
|
+
const body = await res.json().catch(() => null);
|
|
283
|
+
if (res.status === 200 && body) return { ok: true, body };
|
|
284
|
+
const failure = body?.error?.errors?.[0]?.reason ?? "unknown";
|
|
285
|
+
return { ok: false, reason: failure, message: body?.error?.message ?? `HTTP ${res.status}` };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function loadChannel(ctx, raw) {
|
|
289
|
+
const result = await callYouTube(ctx, "channels", {
|
|
290
|
+
part: "snippet,statistics,contentDetails",
|
|
291
|
+
...channelQuery(raw),
|
|
292
|
+
});
|
|
293
|
+
if (!result.ok) return result;
|
|
294
|
+
const item = result.body.items?.[0];
|
|
295
|
+
if (!item) return { ok: false, reason: "not-found", message: "No channel matches that handle or URL." };
|
|
296
|
+
const stats = item.statistics ?? {};
|
|
297
|
+
const channel = {
|
|
298
|
+
channelId: item.id,
|
|
299
|
+
title: item.snippet?.title ?? item.id,
|
|
300
|
+
handle: item.snippet?.customUrl ?? null,
|
|
301
|
+
thumbnail: item.snippet?.thumbnails?.default?.url ?? null,
|
|
302
|
+
uploads: item.contentDetails?.relatedPlaylists?.uploads ?? null,
|
|
303
|
+
subscribers: stats.hiddenSubscriberCount === true ? null : Number(stats.subscriberCount ?? 0),
|
|
304
|
+
subscribersHidden: stats.hiddenSubscriberCount === true,
|
|
305
|
+
views: Number(stats.viewCount ?? 0),
|
|
306
|
+
videoCount: Number(stats.videoCount ?? 0),
|
|
307
|
+
};
|
|
308
|
+
// The client stores these on the tracked-channel doc, so a visitor's `?q=@handle` resolves
|
|
309
|
+
// against a document instead of costing a `channels?forHandle=` call.
|
|
310
|
+
channel.aliases = channelAliases(channel, raw);
|
|
311
|
+
return { ok: true, channel };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function loadVideos(ctx, uploads) {
|
|
315
|
+
const list = await callYouTube(ctx, "playlistItems", {
|
|
316
|
+
part: "contentDetails",
|
|
317
|
+
playlistId: uploads,
|
|
318
|
+
maxResults: 50,
|
|
319
|
+
});
|
|
320
|
+
if (!list.ok) return list;
|
|
321
|
+
const ids = (list.body.items ?? []).map((i) => i.contentDetails?.videoId).filter(Boolean);
|
|
322
|
+
if (ids.length === 0) return { ok: true, videos: [] };
|
|
323
|
+
const detail = await callYouTube(ctx, "videos", { part: "snippet,statistics", id: ids.join(",") });
|
|
324
|
+
if (!detail.ok) return detail;
|
|
325
|
+
const videos = (detail.body.items ?? []).map((v) => ({
|
|
326
|
+
id: v.id,
|
|
327
|
+
title: v.snippet?.title ?? v.id,
|
|
328
|
+
published: v.snippet?.publishedAt ?? null,
|
|
329
|
+
views: Number(v.statistics?.viewCount ?? 0),
|
|
330
|
+
likes: v.statistics?.likeCount == null ? null : Number(v.statistics.likeCount),
|
|
331
|
+
comments: v.statistics?.commentCount == null ? null : Number(v.statistics.commentCount),
|
|
332
|
+
}));
|
|
333
|
+
return { ok: true, videos };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Every page of a keyed read. `next` rides on the array, so take it before any array operation. */
|
|
337
|
+
async function readAll(ctx, query) {
|
|
338
|
+
const rows = [];
|
|
339
|
+
let after;
|
|
340
|
+
do {
|
|
341
|
+
const page = await ctx.db.query({ ...query, after });
|
|
342
|
+
after = page.next;
|
|
343
|
+
rows.push(...page);
|
|
344
|
+
} while (after);
|
|
345
|
+
return rows;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function trackedChannels(ctx) {
|
|
349
|
+
return readAll(ctx, { db: DB, field: "type", key: "channel", limit: 20 });
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** One channel's history, selected by prefix rather than paged whole and filtered in the handler. */
|
|
353
|
+
async function newestSnapshot(ctx, channelId) {
|
|
354
|
+
const rows = await readAll(ctx, { db: DB, field: "channelDay", prefix: `${channelId}|`, limit: 100 });
|
|
355
|
+
return rows.sort((a, b) => String(b.day).localeCompare(String(a.day)))[0] ?? null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Does this tracked channel answer to what the visitor pasted? Comparison only — no call. */
|
|
359
|
+
function matchesPastedQuery(row, raw) {
|
|
360
|
+
const asked = channelQuery(raw);
|
|
361
|
+
const wanted = [asked.id, asked.forHandle, asked.forUsername, raw].map(aliasKey).filter(Boolean);
|
|
362
|
+
const aliases = (row.aliases ?? [row.channelId]).map(aliasKey);
|
|
363
|
+
return wanted.some((w) => aliases.includes(w));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function snapshotChannel(row) {
|
|
367
|
+
return {
|
|
368
|
+
channelId: row.channelId,
|
|
369
|
+
title: row.title ?? row.channelId,
|
|
370
|
+
subscribers: row.subscribers,
|
|
371
|
+
views: row.views,
|
|
372
|
+
videoCount: row.videoCount,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function json(payload, status = 200) {
|
|
377
|
+
return new Response(JSON.stringify(payload), {
|
|
378
|
+
status,
|
|
379
|
+
headers: { "content-type": "application/json" },
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export async function fetch(request, ctx) {
|
|
384
|
+
const url = new URL(request.url);
|
|
385
|
+
if (request.method !== "GET") return new Response("method not allowed", { status: 405, headers: { allow: "GET" } });
|
|
386
|
+
|
|
387
|
+
const isOwner = ctx.userInfo?.userHandle != null && ctx.userInfo.userHandle === ctx.appInfo.ownerHandle;
|
|
388
|
+
|
|
389
|
+
// Anyone who is not the owner is answered from what the tick already stored, and the channel they
|
|
390
|
+
// asked for is resolved against the tracked docs' aliases. Nothing here reaches googleapis.com, so
|
|
391
|
+
// a stranger with a refresh button cannot spend the owner's daily quota.
|
|
392
|
+
if (!isOwner) {
|
|
393
|
+
const asked = url.searchParams.get("channelId") ?? url.searchParams.get("q") ?? "";
|
|
394
|
+
const tracked = await trackedChannels(ctx);
|
|
395
|
+
const wanted = asked.trim() === "" ? tracked : tracked.filter((row) => matchesPastedQuery(row, asked));
|
|
396
|
+
const newest = [];
|
|
397
|
+
for (const row of wanted) {
|
|
398
|
+
const snapshot = await newestSnapshot(ctx, row.channelId);
|
|
399
|
+
if (snapshot) newest.push(snapshot);
|
|
400
|
+
}
|
|
401
|
+
if (newest.length === 0) return json({ ok: false, reason: "no-snapshot-yet" });
|
|
402
|
+
if (url.pathname === "/channel") {
|
|
403
|
+
return json({
|
|
404
|
+
ok: true,
|
|
405
|
+
source: "snapshot",
|
|
406
|
+
asOf: newest.map((r) => String(r.day)).sort().at(-1),
|
|
407
|
+
channel: snapshotChannel(newest[0]),
|
|
408
|
+
channels: newest.map(snapshotChannel),
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
if (url.pathname === "/videos") {
|
|
412
|
+
return json({
|
|
413
|
+
ok: true,
|
|
414
|
+
source: "snapshot",
|
|
415
|
+
asOf: newest[0].day,
|
|
416
|
+
videos: newest[0].videos ?? [],
|
|
417
|
+
videosStale: newest[0].videosStale === true,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
return new Response("not found", { status: 404 });
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (url.pathname === "/channel") {
|
|
424
|
+
const result = await loadChannel(ctx, url.searchParams.get("q"));
|
|
425
|
+
return json(result.ok ? { ...result, source: "live" } : result);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (url.pathname === "/videos") {
|
|
429
|
+
const channel = await loadChannel(ctx, url.searchParams.get("channelId"));
|
|
430
|
+
if (!channel.ok) return json(channel);
|
|
431
|
+
if (!channel.channel.uploads) return json({ ok: true, source: "live", videos: [] });
|
|
432
|
+
const videos = await loadVideos(ctx, channel.channel.uploads);
|
|
433
|
+
return json(videos.ok ? { ...videos, source: "live" } : videos);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return new Response("not found", { status: 404 });
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Stable across key order and list order, so only a real change looks like one. */
|
|
440
|
+
function videoFingerprint(videos) {
|
|
441
|
+
return JSON.stringify(
|
|
442
|
+
(videos ?? [])
|
|
443
|
+
.map((v) => [v.id, v.title, v.views, v.likes, v.comments])
|
|
444
|
+
.sort((a, b) => String(a[0]).localeCompare(String(b[0]))),
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function sameSnapshot(existing, next) {
|
|
449
|
+
if (!existing) return false;
|
|
450
|
+
const totals = ["subscribers", "views", "videoCount"].every((k) => existing[k] === next[k]);
|
|
451
|
+
return totals && videoFingerprint(existing.videos) === videoFingerprint(next.videos);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export async function scheduled(event, ctx) {
|
|
455
|
+
const tracked = await trackedChannels(ctx);
|
|
456
|
+
const day = new Date(event.scheduledTime ?? Date.now()).toISOString().slice(0, 10);
|
|
457
|
+
for (const row of tracked) {
|
|
458
|
+
const channel = await loadChannel(ctx, row.channelId);
|
|
459
|
+
if (!channel.ok) {
|
|
460
|
+
ctx.log("warn", "youtube refresh failed", { channelId: row.channelId, reason: channel.reason });
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
const videos = channel.channel.uploads ? await loadVideos(ctx, channel.channel.uploads) : { ok: true, videos: [] };
|
|
464
|
+
const id = `snapshot-${channel.channel.channelId}-${day}`;
|
|
465
|
+
const existing = await ctx.db.get(id, { db: DB });
|
|
466
|
+
// A failed video call keeps the last good rows. Writing `videos: []` over them would erase a
|
|
467
|
+
// day of history for one upstream blip, and tomorrow's delta would read every video as new.
|
|
468
|
+
const lastGood = existing?.videos ?? (await newestSnapshot(ctx, channel.channel.channelId))?.videos ?? [];
|
|
469
|
+
const next = {
|
|
470
|
+
_id: id,
|
|
471
|
+
type: "snapshot",
|
|
472
|
+
channelDay: `${channel.channel.channelId}|${day}`,
|
|
473
|
+
day,
|
|
474
|
+
channelId: channel.channel.channelId,
|
|
475
|
+
title: channel.channel.title,
|
|
476
|
+
subscribers: channel.channel.subscribers,
|
|
477
|
+
views: channel.channel.views,
|
|
478
|
+
videoCount: channel.channel.videoCount,
|
|
479
|
+
videos: videos.ok
|
|
480
|
+
? videos.videos.map((v) => ({ id: v.id, title: v.title, views: v.views, likes: v.likes, comments: v.comments }))
|
|
481
|
+
: lastGood,
|
|
482
|
+
};
|
|
483
|
+
if (!videos.ok) {
|
|
484
|
+
next.videosStale = true;
|
|
485
|
+
ctx.log("warn", "youtube videos refresh failed; keeping the last good rows", {
|
|
486
|
+
channelId: channel.channel.channelId,
|
|
487
|
+
reason: videos.reason,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
if (sameSnapshot(existing, next)) continue;
|
|
491
|
+
await ctx.db.put(next, { db: DB });
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
## App.jsx sketch
|
|
497
|
+
|
|
498
|
+
```jsx
|
|
499
|
+
import React, { useState } from "react";
|
|
500
|
+
import { useFireproof } from "use-fireproof";
|
|
501
|
+
|
|
502
|
+
const SETUP_STEPS = [
|
|
503
|
+
"Open the Google Cloud console and create or select a project.",
|
|
504
|
+
"In APIs & Services, enable YouTube Data API v3.",
|
|
505
|
+
"In Credentials, create an API key and restrict it to YouTube Data API v3.",
|
|
506
|
+
"Set it on this vibe as YOUTUBE_API_KEY in the settings page.",
|
|
507
|
+
];
|
|
508
|
+
|
|
509
|
+
export default function App() {
|
|
510
|
+
const { useLiveQuery, database } = useFireproof("youtube");
|
|
511
|
+
const { docs: snapshots } = useLiveQuery("type", { key: "snapshot" });
|
|
512
|
+
const [input, setInput] = useState("");
|
|
513
|
+
const [channel, setChannel] = useState(null);
|
|
514
|
+
const [problem, setProblem] = useState(null);
|
|
515
|
+
const [source, setSource] = useState(null);
|
|
516
|
+
const [asOf, setAsOf] = useState(null);
|
|
517
|
+
|
|
518
|
+
const connect = async () => {
|
|
519
|
+
const res = await fetch(`/_api/channel?q=${encodeURIComponent(input)}`);
|
|
520
|
+
const data = await res.json();
|
|
521
|
+
if (!data.ok) {
|
|
522
|
+
setProblem(data);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
setProblem(null);
|
|
526
|
+
setChannel(data.channel);
|
|
527
|
+
setSource(data.source);
|
|
528
|
+
setAsOf(data.asOf ?? null);
|
|
529
|
+
await database.put({
|
|
530
|
+
_id: `channel-${data.channel.channelId}`,
|
|
531
|
+
type: "channel",
|
|
532
|
+
channelId: data.channel.channelId,
|
|
533
|
+
aliases: data.channel.aliases ?? [data.channel.channelId.toLowerCase()],
|
|
534
|
+
});
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
const history = snapshots.slice().sort((a, b) => a.day.localeCompare(b.day));
|
|
538
|
+
|
|
539
|
+
return (
|
|
540
|
+
<div>
|
|
541
|
+
<input value={input} onChange={(e) => setInput(e.target.value)} placeholder="@handle or channel URL" />
|
|
542
|
+
<button onClick={connect}>Connect channel</button>
|
|
543
|
+
|
|
544
|
+
{problem?.reason === "missing-key" && (
|
|
545
|
+
<section>
|
|
546
|
+
<h2>Add a YouTube Data API v3 key</h2>
|
|
547
|
+
<ol>
|
|
548
|
+
{SETUP_STEPS.map((step) => (
|
|
549
|
+
<li key={step}>{step}</li>
|
|
550
|
+
))}
|
|
551
|
+
</ol>
|
|
552
|
+
<p>The key is stored as YOUTUBE_API_KEY and stays on the server.</p>
|
|
553
|
+
</section>
|
|
554
|
+
)}
|
|
555
|
+
{problem?.reason === "no-snapshot-yet" && <p>The first daily snapshot has not been saved yet.</p>}
|
|
556
|
+
{problem && !["missing-key", "no-snapshot-yet"].includes(problem.reason) && <p role="alert">{problem.message}</p>}
|
|
557
|
+
|
|
558
|
+
{channel && (
|
|
559
|
+
<section>
|
|
560
|
+
<h2>{channel.title}</h2>
|
|
561
|
+
<p>{channel.subscribersHidden ? "Subscribers hidden by the channel" : `${channel.subscribers} subscribers`}</p>
|
|
562
|
+
<small>YouTube rounds public subscriber counts.</small>
|
|
563
|
+
<p>{channel.views} total views across {channel.videoCount} videos</p>
|
|
564
|
+
{source === "snapshot" && <small>As of {asOf} — live numbers are refreshed for the channel owner.</small>}
|
|
565
|
+
</section>
|
|
566
|
+
)}
|
|
567
|
+
|
|
568
|
+
<section>
|
|
569
|
+
<h3>Not available with an API key</h3>
|
|
570
|
+
<p>Watch time, revenue, impressions, traffic sources and demographics come from YouTube Analytics with OAuth.</p>
|
|
571
|
+
</section>
|
|
572
|
+
|
|
573
|
+
<section>
|
|
574
|
+
<h3>Daily snapshots</h3>
|
|
575
|
+
{history.length === 0 ? <p>First snapshot saves within the hour.</p> : <Chart rows={history} />}
|
|
576
|
+
</section>
|
|
577
|
+
</div>
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
Chart the `history` rows with whichever chart surface the app already uses:
|
|
583
|
+
`day` on the x axis, `subscribers` and `views` as two series, and the per-video
|
|
584
|
+
deltas between the last two snapshots as the "gaining attention" list.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibes.diy/prompts",
|
|
3
|
-
"version": "14.1.
|
|
3
|
+
"version": "14.1.16",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./index.js",
|
|
6
6
|
"exports": {
|
|
@@ -34,9 +34,9 @@
|
|
|
34
34
|
"license": "Apache-2.0",
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@adviser/cement": "~0.5.34",
|
|
37
|
-
"@vibes.diy/call-ai-v2": "14.1.
|
|
38
|
-
"@vibes.diy/identity": "14.1.
|
|
39
|
-
"@vibes.diy/use-vibes-types": "14.1.
|
|
37
|
+
"@vibes.diy/call-ai-v2": "14.1.16",
|
|
38
|
+
"@vibes.diy/identity": "14.1.16",
|
|
39
|
+
"@vibes.diy/use-vibes-types": "14.1.16",
|
|
40
40
|
"arktype": "~2.2.3",
|
|
41
41
|
"json-schema-faker": "~0.6.3"
|
|
42
42
|
},
|