@tiangong-ai/cli 0.0.51 → 0.0.56
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/AGENTS.md +22 -2
- package/README.md +171 -7
- package/dist/cli.js +10 -1
- package/dist/cli.js.map +1 -1
- package/dist/data/builtins.d.ts +1 -0
- package/dist/data/builtins.js +40 -0
- package/dist/data/builtins.js.map +1 -0
- package/dist/data/catalog.d.ts +25 -0
- package/dist/data/catalog.js +305 -0
- package/dist/data/catalog.js.map +1 -0
- package/dist/data/commands.d.ts +9 -0
- package/dist/data/commands.js +307 -0
- package/dist/data/commands.js.map +1 -0
- package/dist/data/connectors/airnow-hourly-observations.d.ts +2 -0
- package/dist/data/connectors/airnow-hourly-observations.js +522 -0
- package/dist/data/connectors/airnow-hourly-observations.js.map +1 -0
- package/dist/data/connectors/airnow-hourly-observations.schemas.d.ts +299 -0
- package/dist/data/connectors/airnow-hourly-observations.schemas.js +188 -0
- package/dist/data/connectors/airnow-hourly-observations.schemas.js.map +1 -0
- package/dist/data/connectors/bluesky-public-posts.d.ts +2 -0
- package/dist/data/connectors/bluesky-public-posts.js +705 -0
- package/dist/data/connectors/bluesky-public-posts.js.map +1 -0
- package/dist/data/connectors/bluesky-public-posts.schemas.d.ts +733 -0
- package/dist/data/connectors/bluesky-public-posts.schemas.js +422 -0
- package/dist/data/connectors/bluesky-public-posts.schemas.js.map +1 -0
- package/dist/data/connectors/epa-eis-records.d.ts +2 -0
- package/dist/data/connectors/epa-eis-records.js +566 -0
- package/dist/data/connectors/epa-eis-records.js.map +1 -0
- package/dist/data/connectors/epa-eis-records.schemas.d.ts +199 -0
- package/dist/data/connectors/epa-eis-records.schemas.js +146 -0
- package/dist/data/connectors/epa-eis-records.schemas.js.map +1 -0
- package/dist/data/connectors/federal-register-documents.d.ts +2 -0
- package/dist/data/connectors/federal-register-documents.js +517 -0
- package/dist/data/connectors/federal-register-documents.js.map +1 -0
- package/dist/data/connectors/federal-register-documents.schemas.d.ts +357 -0
- package/dist/data/connectors/federal-register-documents.schemas.js +243 -0
- package/dist/data/connectors/federal-register-documents.schemas.js.map +1 -0
- package/dist/data/connectors/gdelt-doc-search.d.ts +2 -0
- package/dist/data/connectors/gdelt-doc-search.js +658 -0
- package/dist/data/connectors/gdelt-doc-search.js.map +1 -0
- package/dist/data/connectors/gdelt-doc-search.schemas.d.ts +446 -0
- package/dist/data/connectors/gdelt-doc-search.schemas.js +330 -0
- package/dist/data/connectors/gdelt-doc-search.schemas.js.map +1 -0
- package/dist/data/connectors/gdelt-file-feeds.d.ts +4 -0
- package/dist/data/connectors/gdelt-file-feeds.js +591 -0
- package/dist/data/connectors/gdelt-file-feeds.js.map +1 -0
- package/dist/data/connectors/gdelt-file-feeds.schemas.d.ts +40 -0
- package/dist/data/connectors/gdelt-file-feeds.schemas.js +256 -0
- package/dist/data/connectors/gdelt-file-feeds.schemas.js.map +1 -0
- package/dist/data/connectors/nasa-firms-fire.d.ts +2 -0
- package/dist/data/connectors/nasa-firms-fire.js +578 -0
- package/dist/data/connectors/nasa-firms-fire.js.map +1 -0
- package/dist/data/connectors/nasa-firms-fire.schemas.d.ts +369 -0
- package/dist/data/connectors/nasa-firms-fire.schemas.js +238 -0
- package/dist/data/connectors/nasa-firms-fire.schemas.js.map +1 -0
- package/dist/data/connectors/open-meteo-air-quality.d.ts +2 -0
- package/dist/data/connectors/open-meteo-air-quality.js +401 -0
- package/dist/data/connectors/open-meteo-air-quality.js.map +1 -0
- package/dist/data/connectors/open-meteo-air-quality.schemas.d.ts +335 -0
- package/dist/data/connectors/open-meteo-air-quality.schemas.js +225 -0
- package/dist/data/connectors/open-meteo-air-quality.schemas.js.map +1 -0
- package/dist/data/connectors/open-meteo-flood.d.ts +2 -0
- package/dist/data/connectors/open-meteo-flood.js +446 -0
- package/dist/data/connectors/open-meteo-flood.js.map +1 -0
- package/dist/data/connectors/open-meteo-flood.schemas.d.ts +388 -0
- package/dist/data/connectors/open-meteo-flood.schemas.js +227 -0
- package/dist/data/connectors/open-meteo-flood.schemas.js.map +1 -0
- package/dist/data/connectors/open-meteo-historical-weather.d.ts +2 -0
- package/dist/data/connectors/open-meteo-historical-weather.js +468 -0
- package/dist/data/connectors/open-meteo-historical-weather.js.map +1 -0
- package/dist/data/connectors/open-meteo-historical-weather.schemas.d.ts +420 -0
- package/dist/data/connectors/open-meteo-historical-weather.schemas.js +282 -0
- package/dist/data/connectors/open-meteo-historical-weather.schemas.js.map +1 -0
- package/dist/data/connectors/openaq-air-quality.d.ts +2 -0
- package/dist/data/connectors/openaq-air-quality.js +734 -0
- package/dist/data/connectors/openaq-air-quality.js.map +1 -0
- package/dist/data/connectors/openaq-air-quality.schemas.d.ts +1067 -0
- package/dist/data/connectors/openaq-air-quality.schemas.js +512 -0
- package/dist/data/connectors/openaq-air-quality.schemas.js.map +1 -0
- package/dist/data/connectors/regulations-gov-attachments.d.ts +2 -0
- package/dist/data/connectors/regulations-gov-attachments.js +486 -0
- package/dist/data/connectors/regulations-gov-attachments.js.map +1 -0
- package/dist/data/connectors/regulations-gov-attachments.schemas.d.ts +270 -0
- package/dist/data/connectors/regulations-gov-attachments.schemas.js +181 -0
- package/dist/data/connectors/regulations-gov-attachments.schemas.js.map +1 -0
- package/dist/data/connectors/regulations-gov-comments.d.ts +2 -0
- package/dist/data/connectors/regulations-gov-comments.js +718 -0
- package/dist/data/connectors/regulations-gov-comments.js.map +1 -0
- package/dist/data/connectors/regulations-gov-comments.schemas.d.ts +717 -0
- package/dist/data/connectors/regulations-gov-comments.schemas.js +389 -0
- package/dist/data/connectors/regulations-gov-comments.schemas.js.map +1 -0
- package/dist/data/connectors/usbr-project-records.d.ts +2 -0
- package/dist/data/connectors/usbr-project-records.js +467 -0
- package/dist/data/connectors/usbr-project-records.js.map +1 -0
- package/dist/data/connectors/usbr-project-records.schemas.d.ts +194 -0
- package/dist/data/connectors/usbr-project-records.schemas.js +137 -0
- package/dist/data/connectors/usbr-project-records.schemas.js.map +1 -0
- package/dist/data/connectors/usbr-rise.d.ts +2 -0
- package/dist/data/connectors/usbr-rise.js +625 -0
- package/dist/data/connectors/usbr-rise.js.map +1 -0
- package/dist/data/connectors/usbr-rise.schemas.d.ts +623 -0
- package/dist/data/connectors/usbr-rise.schemas.js +407 -0
- package/dist/data/connectors/usbr-rise.schemas.js.map +1 -0
- package/dist/data/connectors/usgs-water-instantaneous-values.d.ts +2 -0
- package/dist/data/connectors/usgs-water-instantaneous-values.js +521 -0
- package/dist/data/connectors/usgs-water-instantaneous-values.js.map +1 -0
- package/dist/data/connectors/usgs-water-instantaneous-values.schemas.d.ts +496 -0
- package/dist/data/connectors/usgs-water-instantaneous-values.schemas.js +309 -0
- package/dist/data/connectors/usgs-water-instantaneous-values.schemas.js.map +1 -0
- package/dist/data/connectors/youtube-public-content.d.ts +2 -0
- package/dist/data/connectors/youtube-public-content.js +974 -0
- package/dist/data/connectors/youtube-public-content.js.map +1 -0
- package/dist/data/connectors/youtube-public-content.schemas.d.ts +773 -0
- package/dist/data/connectors/youtube-public-content.schemas.js +567 -0
- package/dist/data/connectors/youtube-public-content.schemas.js.map +1 -0
- package/dist/data/contracts.d.ts +374 -0
- package/dist/data/contracts.js +10 -0
- package/dist/data/contracts.js.map +1 -0
- package/dist/data/runtime/artifacts.d.ts +6 -0
- package/dist/data/runtime/artifacts.js +103 -0
- package/dist/data/runtime/artifacts.js.map +1 -0
- package/dist/data/runtime/bounded-http.d.ts +10 -0
- package/dist/data/runtime/bounded-http.js +403 -0
- package/dist/data/runtime/bounded-http.js.map +1 -0
- package/dist/data/runtime/canonical-json.d.ts +3 -0
- package/dist/data/runtime/canonical-json.js +59 -0
- package/dist/data/runtime/canonical-json.js.map +1 -0
- package/dist/data/runtime/credentials.d.ts +14 -0
- package/dist/data/runtime/credentials.js +67 -0
- package/dist/data/runtime/credentials.js.map +1 -0
- package/dist/data/runtime/csv.d.ts +4 -0
- package/dist/data/runtime/csv.js +51 -0
- package/dist/data/runtime/csv.js.map +1 -0
- package/dist/data/runtime/errors.d.ts +22 -0
- package/dist/data/runtime/errors.js +101 -0
- package/dist/data/runtime/errors.js.map +1 -0
- package/dist/data/runtime/execute.d.ts +11 -0
- package/dist/data/runtime/execute.js +358 -0
- package/dist/data/runtime/execute.js.map +1 -0
- package/dist/data/runtime/receipts.d.ts +13 -0
- package/dist/data/runtime/receipts.js +38 -0
- package/dist/data/runtime/receipts.js.map +1 -0
- package/dist/data/schemas/catalog.v1.json +73 -0
- package/dist/data/schemas/core-receipt.v1.json +117 -0
- package/dist/data/schemas/describe.v1.json +24 -0
- package/dist/data/schemas/discovery.v1.json +130 -0
- package/dist/data/schemas/doctor.v1.json +40 -0
- package/dist/data/schemas/error.v1.json +48 -0
- package/dist/data/schemas/manifest.v1.json +174 -0
- package/dist/data/schemas/run-request.v1.json +37 -0
- package/dist/data/schemas/run-result.v1.json +77 -0
- package/dist/data/schemas.d.ts +27 -0
- package/dist/data/schemas.js +69 -0
- package/dist/data/schemas.js.map +1 -0
- package/dist/env.js.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/http.js.map +1 -1
- package/dist/kb/config.js.map +1 -1
- package/dist/kb/pipeline-health.js.map +1 -1
- package/dist/research/orchestration.js +54 -7
- package/dist/research/orchestration.js.map +1 -1
- package/dist/research/workspace/acquisition.js.map +1 -1
- package/dist/research/workspace/audit-bundle.js +7 -2
- package/dist/research/workspace/audit-bundle.js.map +1 -1
- package/dist/research/workspace/broker.js.map +1 -1
- package/dist/research/workspace/constants.js.map +1 -1
- package/dist/research/workspace/credentials.d.ts +3 -0
- package/dist/research/workspace/credentials.js +20 -3
- package/dist/research/workspace/credentials.js.map +1 -1
- package/dist/research/workspace/data-evidence-adapter.d.ts +60 -0
- package/dist/research/workspace/data-evidence-adapter.js +314 -0
- package/dist/research/workspace/data-evidence-adapter.js.map +1 -0
- package/dist/research/workspace/discovery-status.js +10 -7
- package/dist/research/workspace/discovery-status.js.map +1 -1
- package/dist/research/workspace/discovery.js +12 -11
- package/dist/research/workspace/discovery.js.map +1 -1
- package/dist/research/workspace/evidence-ledger.d.ts +8 -1
- package/dist/research/workspace/evidence-ledger.js +46 -2
- package/dist/research/workspace/evidence-ledger.js.map +1 -1
- package/dist/research/workspace/evidence.d.ts +31 -0
- package/dist/research/workspace/evidence.js +77 -0
- package/dist/research/workspace/evidence.js.map +1 -1
- package/dist/research/workspace/executor.js.map +1 -1
- package/dist/research/workspace/external-skills.d.ts +49 -49
- package/dist/research/workspace/external-skills.js.map +1 -1
- package/dist/research/workspace/platform-capabilities.js.map +1 -1
- package/dist/research/workspace/preflight.d.ts +4 -4
- package/dist/research/workspace/preflight.js +33 -8
- package/dist/research/workspace/preflight.js.map +1 -1
- package/dist/research/workspace/projects.js.map +1 -1
- package/dist/research/workspace/publication.js.map +1 -1
- package/dist/research/workspace/research-policy-wizard.js.map +1 -1
- package/dist/research/workspace/research-policy.js.map +1 -1
- package/dist/research/workspace/runtime.d.ts +7 -0
- package/dist/research/workspace/runtime.js +77 -20
- package/dist/research/workspace/runtime.js.map +1 -1
- package/dist/research/workspace/sanitization.js.map +1 -1
- package/dist/research/workspace/schemas.js +1 -1
- package/dist/research/workspace/schemas.js.map +1 -1
- package/dist/research/workspace/scientific-design.js.map +1 -1
- package/dist/research/workspace/scientific-review.js.map +1 -1
- package/dist/research/workspace/setup-catalog.d.ts +9 -9
- package/dist/research/workspace/setup-catalog.js +2 -2
- package/dist/research/workspace/setup-catalog.js.map +1 -1
- package/dist/research/workspace/setup-declarative.js.map +1 -1
- package/dist/research/workspace/setup-instructions.d.ts +42 -0
- package/dist/research/workspace/setup-instructions.js +413 -0
- package/dist/research/workspace/setup-instructions.js.map +1 -0
- package/dist/research/workspace/setup-invocation.js.map +1 -1
- package/dist/research/workspace/setup-wizard.js +7 -0
- package/dist/research/workspace/setup-wizard.js.map +1 -1
- package/dist/research/workspace/setup.d.ts +18 -15
- package/dist/research/workspace/setup.js +71 -3
- package/dist/research/workspace/setup.js.map +1 -1
- package/dist/research/workspace/workspace.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,974 @@
|
|
|
1
|
+
import { DATA_MANIFEST_SCHEMA_VERSION } from "../contracts.js";
|
|
2
|
+
import { DataRuntimeError } from "../runtime/errors.js";
|
|
3
|
+
import { YOUTUBE_COMMENTS_INPUT_SCHEMA, YOUTUBE_COMMENTS_OUTPUT_SCHEMA, YOUTUBE_VIDEO_SEARCH_INPUT_SCHEMA, YOUTUBE_VIDEO_SEARCH_OUTPUT_SCHEMA, } from "./youtube-public-content.schemas.js";
|
|
4
|
+
const API_PREFIX = "/youtube/v3/";
|
|
5
|
+
const SEARCH_PATH = `${API_PREFIX}search`;
|
|
6
|
+
const VIDEOS_PATH = `${API_PREFIX}videos`;
|
|
7
|
+
const COMMENT_THREADS_PATH = `${API_PREFIX}commentThreads`;
|
|
8
|
+
const COMMENTS_PATH = `${API_PREFIX}comments`;
|
|
9
|
+
const VIDEO_DETAIL_BATCH_SIZE = 50;
|
|
10
|
+
const MAX_VIDEO_SEARCH_PAGES = 10;
|
|
11
|
+
const MAX_VIDEO_CANDIDATES = 250;
|
|
12
|
+
const VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/;
|
|
13
|
+
export const youtubePublicContentConnector = {
|
|
14
|
+
schemaVersion: DATA_MANIFEST_SCHEMA_VERSION,
|
|
15
|
+
capabilityId: "youtube.public-content",
|
|
16
|
+
capabilityVersion: "1.0.0",
|
|
17
|
+
minimumCliVersion: "0.0.55",
|
|
18
|
+
provider: { providerId: "youtube", name: "YouTube Data API" },
|
|
19
|
+
sourceCategory: "public-video-and-comment-metadata",
|
|
20
|
+
endpoints: [
|
|
21
|
+
{
|
|
22
|
+
endpointId: "youtube-data-api",
|
|
23
|
+
baseUrl: "https://www.googleapis.com",
|
|
24
|
+
pathPrefixes: [API_PREFIX],
|
|
25
|
+
allowedMethods: ["GET"],
|
|
26
|
+
allowedContentTypes: ["application/json"],
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
license: {
|
|
30
|
+
name: "YouTube API Services Terms and public-content policies",
|
|
31
|
+
url: "https://developers.google.com/youtube/terms/api-services-terms-of-service",
|
|
32
|
+
restrictions: [
|
|
33
|
+
"Use the API and returned data in accordance with YouTube API Services Terms, developer policies, and applicable law.",
|
|
34
|
+
"Videos, comments, authors, and engagement statistics are mutable user-generated content and can be removed, moderated, hidden, or unsafe.",
|
|
35
|
+
"Do not treat search ranking, views, likes, or comment counts as representative public opinion, verified identity, endorsement, or factual truth.",
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
credentials: [
|
|
39
|
+
{
|
|
40
|
+
credentialId: "api-key",
|
|
41
|
+
environmentVariable: "YOUTUBE_API_KEY",
|
|
42
|
+
required: true,
|
|
43
|
+
endpointIds: ["youtube-data-api"],
|
|
44
|
+
injection: { kind: "header", name: "X-Goog-Api-Key", prefix: "" },
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
limits: {
|
|
48
|
+
timeoutMs: 60_000,
|
|
49
|
+
maxRequestBytes: 4_096,
|
|
50
|
+
maxResponseBytes: 20_000_000,
|
|
51
|
+
maxPages: 100,
|
|
52
|
+
maxRecords: 5_000,
|
|
53
|
+
maxRetries: 4,
|
|
54
|
+
maxRetryDelayMs: 120_000,
|
|
55
|
+
maxRedirects: 1,
|
|
56
|
+
},
|
|
57
|
+
diagnostics: { static: true, live: false },
|
|
58
|
+
freshness: {
|
|
59
|
+
kind: "provider-current",
|
|
60
|
+
description: "Search results, metadata, statistics, visibility, comments, and moderation state reflect the YouTube Data API at request time and may change later.",
|
|
61
|
+
},
|
|
62
|
+
limitations: [
|
|
63
|
+
"Search ranking and result availability are provider-defined; bounded pages and quota do not guarantee exhaustive topic coverage.",
|
|
64
|
+
"Search and detail operations consume project quota under the provider's current quota policy, which can change independently of this connector.",
|
|
65
|
+
"search.list uses the provider's separate Search Queries quota bucket; the available call allocation is project-specific and must be checked outside this connector.",
|
|
66
|
+
"Public statistics can be hidden, delayed, rounded, disabled, or absent and are not stable measurements of audience or opinion.",
|
|
67
|
+
"YouTube changed the public viewCount definition on August 24, 2026, so comparisons spanning that date require an explicit metric-break caveat.",
|
|
68
|
+
"commentThreads.list may embed only a subset of replies; this connector uses comments.list when replies are requested, but page and record caps can still truncate them.",
|
|
69
|
+
"Comments can be disabled, deleted, held, moderated, or unavailable and can contain personal, sensitive, deceptive, or unsafe content.",
|
|
70
|
+
"The connector returns metadata and text only; it does not download video/audio, captions, transcripts, thumbnails, or channel profile content.",
|
|
71
|
+
],
|
|
72
|
+
discovery: {
|
|
73
|
+
source: {
|
|
74
|
+
maintainedBy: "Google LLC / YouTube",
|
|
75
|
+
summary: "Public YouTube video search metadata, enriched video details, and visible comment threads exposed by YouTube Data API v3.",
|
|
76
|
+
description: "The YouTube Data API exposes project-quota-governed views of public videos and comments. Search ranking, visibility, statistics, and comment moderation are provider-controlled and mutable.",
|
|
77
|
+
coverage: {
|
|
78
|
+
geographic: "Public YouTube content available to the API and applicable region settings.",
|
|
79
|
+
temporal: "Current provider index and visible public comments within caller filters and bounded pagination; no archive guarantee.",
|
|
80
|
+
granularity: "One enriched public video metadata record or one visible top-level/reply comment record.",
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
summary: "Discover enriched public YouTube videos and fetch bounded visible comment/reply text for explicit video IDs.",
|
|
84
|
+
description: "This capability offers two read-only operations on YouTube Data API v3: filtered search.list discovery enriched through videos.list, and explicit-ID commentThreads.list collection with comments.list reply pagination. API keys are injected in X-Goog-Api-Key rather than URL query strings.",
|
|
85
|
+
provides: [
|
|
86
|
+
"Filtered video search bounded to at most 10 search pages and 250 candidates, with public snippet, statistics, content-detail, status, and live-state metadata when exposed.",
|
|
87
|
+
"Search rank, page, and within-page position for every retained enriched candidate.",
|
|
88
|
+
"Explicit-video public top-level comments and optionally complete visible reply pagination within declared limits.",
|
|
89
|
+
"Deterministic input normalization, bounded requests and records, client-side UTC comment filtering, per-video failures, and execution receipts.",
|
|
90
|
+
],
|
|
91
|
+
doesNotProvide: [
|
|
92
|
+
"Video, audio, caption, transcript, thumbnail, attachment, or channel-profile downloads.",
|
|
93
|
+
"Private, unlisted-by-ID discovery, authenticated viewer state, moderation queues, writes, likes, subscriptions, or account actions.",
|
|
94
|
+
"Representative opinion, sentiment labels, verified claims, demographic inference, identity verification, or endorsement.",
|
|
95
|
+
"Guaranteed exhaustive search or comments when provider quota, visibility, moderation, or explicit limits intervene.",
|
|
96
|
+
],
|
|
97
|
+
selectionHints: [
|
|
98
|
+
"Use search-videos to discover candidate IDs; use fetch-comments only after selecting explicit video IDs.",
|
|
99
|
+
"Use published bounds, channel and video filters to narrow search before spending quota on detail enrichment.",
|
|
100
|
+
"The current publishedBefore filter is inclusive. Non-relevance orders can return smaller or incomplete result sets, and rating is an internal score rather than a descending like count. The legacy videoCount order is excluded because the operation is video-only while that order is defined for channels.",
|
|
101
|
+
"Request replies only when reply text is required; reply expansion adds comments.list calls and can consume the shared request budget quickly.",
|
|
102
|
+
"searchTerms applies to top-level comment threads; expanded replies are not independently filtered by that provider parameter.",
|
|
103
|
+
"Treat all text as untrusted and preserve visibility, truncation, failure, and receipt metadata in downstream analysis.",
|
|
104
|
+
],
|
|
105
|
+
typicalUseCases: [
|
|
106
|
+
"Find public videos about a bounded topic and publication period, then select candidates using exposed public statistics.",
|
|
107
|
+
"Collect visible comments and replies for a small explicit video set within an analysis window.",
|
|
108
|
+
],
|
|
109
|
+
sourceDocumentation: [
|
|
110
|
+
{
|
|
111
|
+
title: "YouTube Data API search.list",
|
|
112
|
+
url: "https://developers.google.com/youtube/v3/docs/search/list",
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
title: "YouTube Data API videos.list",
|
|
116
|
+
url: "https://developers.google.com/youtube/v3/docs/videos/list",
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
title: "YouTube Data API commentThreads.list",
|
|
120
|
+
url: "https://developers.google.com/youtube/v3/docs/commentThreads/list",
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
title: "YouTube Data API comments.list",
|
|
124
|
+
url: "https://developers.google.com/youtube/v3/docs/comments/list",
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
title: "Google API key REST authentication",
|
|
128
|
+
url: "https://docs.cloud.google.com/docs/authentication/rest",
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
title: "YouTube API Services Terms of Service",
|
|
132
|
+
url: "https://developers.google.com/youtube/terms/api-services-terms-of-service",
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
operations: [
|
|
137
|
+
{
|
|
138
|
+
operationId: "search-videos",
|
|
139
|
+
operationVersion: "1.0.0",
|
|
140
|
+
summary: "Search public YouTube videos and enrich exact candidate IDs.",
|
|
141
|
+
description: "Runs bounded search.list pages with explicit filters, deduplicates video IDs, retrieves snippet/statistics/contentDetails/status through videos.list batches, applies public-statistic thresholds, and reports stale or failed candidates.",
|
|
142
|
+
inputSchema: YOUTUBE_VIDEO_SEARCH_INPUT_SCHEMA,
|
|
143
|
+
outputSchema: YOUTUBE_VIDEO_SEARCH_OUTPUT_SCHEMA,
|
|
144
|
+
execute: executeVideoSearch,
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
operationId: "fetch-comments",
|
|
148
|
+
operationVersion: "1.0.0",
|
|
149
|
+
summary: "Fetch visible public comments and replies for explicit YouTube video IDs.",
|
|
150
|
+
description: "Runs bounded commentThreads.list pages for caller-supplied IDs, optionally expands every visible reply page through comments.list, applies an optional UTC window client-side, and preserves completed videos when another video fails.",
|
|
151
|
+
inputSchema: YOUTUBE_COMMENTS_INPUT_SCHEMA,
|
|
152
|
+
outputSchema: YOUTUBE_COMMENTS_OUTPUT_SCHEMA,
|
|
153
|
+
execute: executeCommentsFetch,
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
};
|
|
157
|
+
async function executeVideoSearch(context) {
|
|
158
|
+
const query = normalizeVideoSearch(context.input);
|
|
159
|
+
if (context.limits.maxPages < 2) {
|
|
160
|
+
throw new DataRuntimeError("invalid-request", "YouTube video search requires at least two operation-wide requests so one search page can be enriched through videos.list.");
|
|
161
|
+
}
|
|
162
|
+
const observations = [];
|
|
163
|
+
const searchPages = [];
|
|
164
|
+
const candidates = [];
|
|
165
|
+
const seenIds = new Set();
|
|
166
|
+
let pageToken;
|
|
167
|
+
let requestCount = 0;
|
|
168
|
+
let stopReason = "completed";
|
|
169
|
+
let pageNumber = 0;
|
|
170
|
+
let searchFailure;
|
|
171
|
+
const candidateLimit = Math.min(context.limits.maxRecords, MAX_VIDEO_CANDIDATES);
|
|
172
|
+
while (requestCount < context.limits.maxPages && pageNumber < query.maxSearchPages) {
|
|
173
|
+
pageNumber += 1;
|
|
174
|
+
requestCount += 1;
|
|
175
|
+
try {
|
|
176
|
+
const response = await context.http.request({
|
|
177
|
+
endpointId: "youtube-data-api",
|
|
178
|
+
method: "GET",
|
|
179
|
+
path: SEARCH_PATH,
|
|
180
|
+
query: searchParameters(query, pageToken),
|
|
181
|
+
credentialId: "api-key",
|
|
182
|
+
});
|
|
183
|
+
observations.push({ ...response.observation, sourceId: `search:page:${pageNumber}` });
|
|
184
|
+
const parsed = parseVideoSearchPage(response.json());
|
|
185
|
+
let newVideoIds = 0;
|
|
186
|
+
let recordLimitDiscarded = false;
|
|
187
|
+
for (const [position, videoId] of parsed.videoIds.entries()) {
|
|
188
|
+
if (seenIds.has(videoId))
|
|
189
|
+
continue;
|
|
190
|
+
seenIds.add(videoId);
|
|
191
|
+
if (candidates.length >= candidateLimit) {
|
|
192
|
+
recordLimitDiscarded = true;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
candidates.push({
|
|
196
|
+
videoId,
|
|
197
|
+
searchRank: candidates.length + 1,
|
|
198
|
+
searchPage: pageNumber,
|
|
199
|
+
searchPosition: position + 1,
|
|
200
|
+
});
|
|
201
|
+
newVideoIds += 1;
|
|
202
|
+
}
|
|
203
|
+
searchPages.push({ pageNumber, inputRecords: parsed.videoIds.length, newVideoIds });
|
|
204
|
+
if (candidates.length >= candidateLimit) {
|
|
205
|
+
stopReason = recordLimitDiscarded || parsed.nextPageToken ? "max-records" : "completed";
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
if (!parsed.nextPageToken) {
|
|
209
|
+
stopReason = candidates.length === 0 ? "no-results" : "completed";
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
if (parsed.nextPageToken === pageToken) {
|
|
213
|
+
throw providerInvalid("YouTube search returned a repeated page token.");
|
|
214
|
+
}
|
|
215
|
+
pageToken = parsed.nextPageToken;
|
|
216
|
+
if (pageNumber >= query.maxSearchPages) {
|
|
217
|
+
stopReason = "max-pages";
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
const projectedCandidateCount = Math.min(candidateLimit, candidates.length + query.pageSize);
|
|
221
|
+
const reservedDetailRequests = Math.ceil(projectedCandidateCount / VIDEO_DETAIL_BATCH_SIZE);
|
|
222
|
+
if (requestCount + 1 + reservedDetailRequests > context.limits.maxPages) {
|
|
223
|
+
stopReason = "max-pages";
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
if (searchPages.length === 0)
|
|
229
|
+
throw normalizeProviderFailure(error);
|
|
230
|
+
searchFailure = error;
|
|
231
|
+
stopReason = "partial";
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const records = [];
|
|
236
|
+
const filteredOut = [];
|
|
237
|
+
const failures = [];
|
|
238
|
+
const failureValues = [];
|
|
239
|
+
const remainingCandidates = [...candidates];
|
|
240
|
+
let detailBatchCount = 0;
|
|
241
|
+
while (remainingCandidates.length > 0 && requestCount < context.limits.maxPages) {
|
|
242
|
+
const batch = remainingCandidates.splice(0, VIDEO_DETAIL_BATCH_SIZE);
|
|
243
|
+
const batchIds = batch.map((candidate) => candidate.videoId);
|
|
244
|
+
requestCount += 1;
|
|
245
|
+
detailBatchCount += 1;
|
|
246
|
+
try {
|
|
247
|
+
const response = await context.http.request({
|
|
248
|
+
endpointId: "youtube-data-api",
|
|
249
|
+
method: "GET",
|
|
250
|
+
path: VIDEOS_PATH,
|
|
251
|
+
query: {
|
|
252
|
+
part: "snippet,statistics,contentDetails,status,liveStreamingDetails",
|
|
253
|
+
id: batchIds.join(","),
|
|
254
|
+
maxResults: batchIds.length,
|
|
255
|
+
},
|
|
256
|
+
credentialId: "api-key",
|
|
257
|
+
});
|
|
258
|
+
observations.push({
|
|
259
|
+
...response.observation,
|
|
260
|
+
sourceId: `videos:${batchIds[0]}:${batchIds.length}`,
|
|
261
|
+
});
|
|
262
|
+
const details = parseVideoDetails(response.json(), batchIds);
|
|
263
|
+
const byId = new Map(details.map((record) => [record.videoId, record]));
|
|
264
|
+
const missingBatchIds = [];
|
|
265
|
+
for (const candidate of batch) {
|
|
266
|
+
const record = byId.get(candidate.videoId);
|
|
267
|
+
if (!record) {
|
|
268
|
+
missingBatchIds.push(candidate.videoId);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const reason = videoFilterReason(record, query);
|
|
272
|
+
if (reason)
|
|
273
|
+
filteredOut.push({ videoId: candidate.videoId, reason });
|
|
274
|
+
else
|
|
275
|
+
records.push({ ...record, ...candidate });
|
|
276
|
+
}
|
|
277
|
+
if (missingBatchIds.length > 0) {
|
|
278
|
+
const missingError = providerInvalid("YouTube videos.list omitted one or more requested candidate IDs.");
|
|
279
|
+
failures.push({ videoIds: missingBatchIds, code: missingError.code });
|
|
280
|
+
failureValues.push(missingError);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
const normalized = normalizeProviderFailure(error);
|
|
285
|
+
failures.push({ videoIds: batchIds, code: normalized.code });
|
|
286
|
+
failureValues.push(normalized);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (remainingCandidates.length > 0) {
|
|
290
|
+
throw new DataRuntimeError("invalid-request", "The operation-wide request limit left discovered YouTube candidates without required detail enrichment.");
|
|
291
|
+
}
|
|
292
|
+
const truncated = stopReason === "max-pages" || stopReason === "max-records";
|
|
293
|
+
const partial = searchFailure !== undefined || failures.length > 0;
|
|
294
|
+
if (partial)
|
|
295
|
+
stopReason = "partial";
|
|
296
|
+
if (records.length === 0 && failures.length > 0 && filteredOut.length === 0) {
|
|
297
|
+
throw failureValues[0];
|
|
298
|
+
}
|
|
299
|
+
const missingIds = failures.flatMap((failure) => failure.videoIds);
|
|
300
|
+
const errors = partial
|
|
301
|
+
? [
|
|
302
|
+
{
|
|
303
|
+
code: "partial-result",
|
|
304
|
+
message: searchFailure === undefined
|
|
305
|
+
? "Some YouTube video-detail batches could not be retrieved or validated."
|
|
306
|
+
: "A later YouTube search page could not be retrieved or validated.",
|
|
307
|
+
retryable: [searchFailure, ...failureValues].some((value) => value instanceof DataRuntimeError && (value.options.retryable ?? false)),
|
|
308
|
+
userActionRequired: false,
|
|
309
|
+
details: {
|
|
310
|
+
...(missingIds.length > 0 ? { missingVideoIds: missingIds } : {}),
|
|
311
|
+
...(searchFailure === undefined ? {} : { failedSearchPage: pageNumber }),
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
]
|
|
315
|
+
: [];
|
|
316
|
+
const missing = [
|
|
317
|
+
...(missingIds.length > 0 ? [{ kind: "range", identifiers: missingIds }] : []),
|
|
318
|
+
...(searchFailure === undefined
|
|
319
|
+
? []
|
|
320
|
+
: [{ kind: "page", identifiers: [String(pageNumber)] }]),
|
|
321
|
+
];
|
|
322
|
+
return {
|
|
323
|
+
status: partial ? "partial" : "success",
|
|
324
|
+
data: {
|
|
325
|
+
source: sourceDescriptor("metadata"),
|
|
326
|
+
query,
|
|
327
|
+
searchPages,
|
|
328
|
+
records,
|
|
329
|
+
filteredOut,
|
|
330
|
+
failures,
|
|
331
|
+
stopReason,
|
|
332
|
+
},
|
|
333
|
+
summary: {
|
|
334
|
+
recordCount: records.length,
|
|
335
|
+
pageCount: observations.length,
|
|
336
|
+
chunkCount: detailBatchCount,
|
|
337
|
+
truncated,
|
|
338
|
+
completeness: partial ? "partial" : "complete",
|
|
339
|
+
...(missing.length > 0 ? { missing } : {}),
|
|
340
|
+
},
|
|
341
|
+
warnings: [
|
|
342
|
+
"YouTube search ranking, visibility, metadata, and public statistics are mutable provider snapshots.",
|
|
343
|
+
"YouTube search.list consumes the provider's separate Search Queries quota bucket; project-specific availability is not inferred from this result.",
|
|
344
|
+
"YouTube changed the public viewCount definition on August 24, 2026; comparisons spanning that date require a metric-break caveat.",
|
|
345
|
+
"Video and engagement metadata must not be treated as representative public opinion, endorsement, verified identity, or factual verification.",
|
|
346
|
+
...(query.order === "relevance"
|
|
347
|
+
? []
|
|
348
|
+
: [
|
|
349
|
+
"YouTube warns that non-relevance ordering can return a smaller or incomplete result set, especially with publication-date filters.",
|
|
350
|
+
]),
|
|
351
|
+
...(truncated ? ["The result stopped at an explicit request or record limit."] : []),
|
|
352
|
+
],
|
|
353
|
+
errors,
|
|
354
|
+
observations,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
async function executeCommentsFetch(context) {
|
|
358
|
+
const query = normalizeCommentsQuery(context.input);
|
|
359
|
+
const records = [];
|
|
360
|
+
const seenComments = new Set();
|
|
361
|
+
const observations = [];
|
|
362
|
+
const videos = [];
|
|
363
|
+
const failures = [];
|
|
364
|
+
const failureValues = [];
|
|
365
|
+
let requestCount = 0;
|
|
366
|
+
let truncationReason = null;
|
|
367
|
+
let globalStop = false;
|
|
368
|
+
for (const [videoIndex, videoId] of query.videoIds.entries()) {
|
|
369
|
+
if (requestCount >= context.limits.maxPages) {
|
|
370
|
+
truncationReason ??= "max-pages";
|
|
371
|
+
globalStop = true;
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
if (records.length >= context.limits.maxRecords) {
|
|
375
|
+
truncationReason = "max-records";
|
|
376
|
+
globalStop = true;
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
const startCount = records.length;
|
|
380
|
+
const videoSummary = {
|
|
381
|
+
videoId,
|
|
382
|
+
threadPages: 0,
|
|
383
|
+
replyPages: 0,
|
|
384
|
+
threads: 0,
|
|
385
|
+
comments: 0,
|
|
386
|
+
truncated: false,
|
|
387
|
+
};
|
|
388
|
+
let nextThreadPageToken;
|
|
389
|
+
try {
|
|
390
|
+
for (let threadPage = 1; threadPage <= query.maxThreadPagesPerVideo; threadPage += 1) {
|
|
391
|
+
if (requestCount >= context.limits.maxPages) {
|
|
392
|
+
videoSummary.truncated = true;
|
|
393
|
+
truncationReason ??= "max-pages";
|
|
394
|
+
globalStop = true;
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
requestCount += 1;
|
|
398
|
+
const response = await context.http.request({
|
|
399
|
+
endpointId: "youtube-data-api",
|
|
400
|
+
method: "GET",
|
|
401
|
+
path: COMMENT_THREADS_PATH,
|
|
402
|
+
query: {
|
|
403
|
+
part: "snippet",
|
|
404
|
+
videoId,
|
|
405
|
+
maxResults: query.pageSize,
|
|
406
|
+
order: query.order,
|
|
407
|
+
textFormat: "plainText",
|
|
408
|
+
...(query.searchTerms ? { searchTerms: query.searchTerms } : {}),
|
|
409
|
+
...(nextThreadPageToken ? { pageToken: nextThreadPageToken } : {}),
|
|
410
|
+
},
|
|
411
|
+
credentialId: "api-key",
|
|
412
|
+
});
|
|
413
|
+
observations.push({
|
|
414
|
+
...response.observation,
|
|
415
|
+
sourceId: `comments:${videoId}:page:${threadPage}`,
|
|
416
|
+
});
|
|
417
|
+
videoSummary.threadPages += 1;
|
|
418
|
+
const page = parseCommentThreadsPage(response.json(), videoId);
|
|
419
|
+
for (const thread of page.threads) {
|
|
420
|
+
if (records.length >= context.limits.maxRecords) {
|
|
421
|
+
videoSummary.truncated = true;
|
|
422
|
+
truncationReason = "max-records";
|
|
423
|
+
globalStop = true;
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
videoSummary.threads += 1;
|
|
427
|
+
if (insideCommentWindow(thread.topLevel, query)) {
|
|
428
|
+
addComment(records, seenComments, thread.topLevel, context.limits.maxRecords);
|
|
429
|
+
}
|
|
430
|
+
if (records.length >= context.limits.maxRecords) {
|
|
431
|
+
videoSummary.truncated = true;
|
|
432
|
+
truncationReason = "max-records";
|
|
433
|
+
globalStop = true;
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
if (!query.includeReplies || thread.totalReplyCount === 0)
|
|
437
|
+
continue;
|
|
438
|
+
let nextReplyPageToken;
|
|
439
|
+
for (let replyPage = 1; replyPage <= query.maxReplyPagesPerThread; replyPage += 1) {
|
|
440
|
+
if (requestCount >= context.limits.maxPages ||
|
|
441
|
+
records.length >= context.limits.maxRecords) {
|
|
442
|
+
videoSummary.truncated = true;
|
|
443
|
+
truncationReason =
|
|
444
|
+
records.length >= context.limits.maxRecords
|
|
445
|
+
? "max-records"
|
|
446
|
+
: (truncationReason ?? "max-pages");
|
|
447
|
+
globalStop = true;
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
requestCount += 1;
|
|
451
|
+
const replyResponse = await context.http.request({
|
|
452
|
+
endpointId: "youtube-data-api",
|
|
453
|
+
method: "GET",
|
|
454
|
+
path: COMMENTS_PATH,
|
|
455
|
+
query: {
|
|
456
|
+
part: "snippet",
|
|
457
|
+
parentId: thread.topLevel.commentId,
|
|
458
|
+
maxResults: query.pageSize,
|
|
459
|
+
textFormat: "plainText",
|
|
460
|
+
...(nextReplyPageToken ? { pageToken: nextReplyPageToken } : {}),
|
|
461
|
+
},
|
|
462
|
+
credentialId: "api-key",
|
|
463
|
+
});
|
|
464
|
+
observations.push({
|
|
465
|
+
...replyResponse.observation,
|
|
466
|
+
sourceId: `replies:${thread.topLevel.commentId}:page:${replyPage}`,
|
|
467
|
+
});
|
|
468
|
+
videoSummary.replyPages += 1;
|
|
469
|
+
const replyResult = parseRepliesPage(replyResponse.json(), videoId, thread.threadId, thread.topLevel.commentId);
|
|
470
|
+
for (const reply of replyResult.records) {
|
|
471
|
+
if (records.length >= context.limits.maxRecords)
|
|
472
|
+
break;
|
|
473
|
+
if (insideCommentWindow(reply, query)) {
|
|
474
|
+
addComment(records, seenComments, reply, context.limits.maxRecords);
|
|
475
|
+
}
|
|
476
|
+
if (records.length >= context.limits.maxRecords) {
|
|
477
|
+
videoSummary.truncated = true;
|
|
478
|
+
truncationReason = "max-records";
|
|
479
|
+
globalStop = true;
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (globalStop)
|
|
484
|
+
break;
|
|
485
|
+
if (!replyResult.nextPageToken)
|
|
486
|
+
break;
|
|
487
|
+
if (replyResult.nextPageToken === nextReplyPageToken) {
|
|
488
|
+
throw providerInvalid("YouTube replies returned a repeated page token.");
|
|
489
|
+
}
|
|
490
|
+
nextReplyPageToken = replyResult.nextPageToken;
|
|
491
|
+
if (replyPage >= query.maxReplyPagesPerThread) {
|
|
492
|
+
videoSummary.truncated = true;
|
|
493
|
+
truncationReason ??= "max-pages";
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (globalStop)
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
if (globalStop)
|
|
500
|
+
break;
|
|
501
|
+
if (!page.nextPageToken)
|
|
502
|
+
break;
|
|
503
|
+
if (page.nextPageToken === nextThreadPageToken) {
|
|
504
|
+
throw providerInvalid("YouTube comment threads returned a repeated page token.");
|
|
505
|
+
}
|
|
506
|
+
nextThreadPageToken = page.nextPageToken;
|
|
507
|
+
if (threadPage >= query.maxThreadPagesPerVideo) {
|
|
508
|
+
videoSummary.truncated = true;
|
|
509
|
+
truncationReason ??= "max-pages";
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
catch (error) {
|
|
514
|
+
const normalized = normalizeProviderFailure(error);
|
|
515
|
+
failures.push({ videoId, code: normalized.code });
|
|
516
|
+
failureValues.push(normalized);
|
|
517
|
+
}
|
|
518
|
+
videoSummary.comments = records.length - startCount;
|
|
519
|
+
videos.push(videoSummary);
|
|
520
|
+
if (globalStop && videoIndex < query.videoIds.length - 1)
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
523
|
+
const blockingFailure = failureValues.find((failure) => !isCommentsDisabledFailure(failure));
|
|
524
|
+
if (records.length === 0 && blockingFailure)
|
|
525
|
+
throw blockingFailure;
|
|
526
|
+
const partial = failures.length > 0;
|
|
527
|
+
const stopReason = partial
|
|
528
|
+
? "partial"
|
|
529
|
+
: (truncationReason ?? (records.length === 0 ? "no-results" : "completed"));
|
|
530
|
+
const missingVideoIds = failures.map((failure) => failure.videoId);
|
|
531
|
+
const errors = partial
|
|
532
|
+
? [
|
|
533
|
+
{
|
|
534
|
+
code: "partial-result",
|
|
535
|
+
message: "Some YouTube videos or reply pages could not be retrieved or validated.",
|
|
536
|
+
retryable: failureValues.some((value) => value instanceof DataRuntimeError && (value.options.retryable ?? false)),
|
|
537
|
+
userActionRequired: false,
|
|
538
|
+
details: {
|
|
539
|
+
missingVideoIds,
|
|
540
|
+
causeCodes: [...new Set(failures.map((failure) => failure.code))],
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
]
|
|
544
|
+
: [];
|
|
545
|
+
const truncated = truncationReason !== null;
|
|
546
|
+
const completeReplies = query.includeReplies && !truncated && !partial;
|
|
547
|
+
return {
|
|
548
|
+
status: partial ? "partial" : "success",
|
|
549
|
+
data: {
|
|
550
|
+
source: sourceDescriptor("comments"),
|
|
551
|
+
query,
|
|
552
|
+
videos,
|
|
553
|
+
records,
|
|
554
|
+
failures,
|
|
555
|
+
replyCompleteness: {
|
|
556
|
+
requested: query.includeReplies,
|
|
557
|
+
strategy: query.includeReplies ? "comments-list-pagination" : "not-requested",
|
|
558
|
+
completeWithinLimits: completeReplies,
|
|
559
|
+
},
|
|
560
|
+
stopReason,
|
|
561
|
+
},
|
|
562
|
+
summary: {
|
|
563
|
+
recordCount: records.length,
|
|
564
|
+
pageCount: observations.length,
|
|
565
|
+
chunkCount: videos.length,
|
|
566
|
+
truncated,
|
|
567
|
+
completeness: partial ? "partial" : "complete",
|
|
568
|
+
...(partial ? { missing: [{ kind: "range", identifiers: missingVideoIds }] } : {}),
|
|
569
|
+
},
|
|
570
|
+
warnings: [
|
|
571
|
+
"YouTube comments are mutable user-generated content and can contain personal, sensitive, deceptive, or unsafe material.",
|
|
572
|
+
"Visible comments are self-selected and moderation-dependent; they must not be treated as representative opinion or statistically valid sentiment.",
|
|
573
|
+
...(query.includeReplies
|
|
574
|
+
? [
|
|
575
|
+
"Replies were read with comments.list rather than the incomplete embedded sample, but explicit request and record limits can still truncate them.",
|
|
576
|
+
]
|
|
577
|
+
: [
|
|
578
|
+
"Replies were not requested; top-level comments do not represent the full conversation.",
|
|
579
|
+
]),
|
|
580
|
+
...(query.searchTerms
|
|
581
|
+
? [
|
|
582
|
+
"The provider searchTerms filter applies to top-level comment threads; expanded replies were not independently term-filtered.",
|
|
583
|
+
]
|
|
584
|
+
: []),
|
|
585
|
+
...(truncated ? ["The comment result stopped at an explicit request or record limit."] : []),
|
|
586
|
+
],
|
|
587
|
+
errors,
|
|
588
|
+
observations,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
function normalizeVideoSearch(input) {
|
|
592
|
+
const publishedAfter = input.publishedAfter ? exactDateTime(input.publishedAfter) : null;
|
|
593
|
+
const publishedBefore = input.publishedBefore ? exactDateTime(input.publishedBefore) : null;
|
|
594
|
+
if (publishedAfter && publishedBefore && publishedAfter >= publishedBefore) {
|
|
595
|
+
throw new DataRuntimeError("invalid-request", "YouTube publishedAfter must precede publishedBefore.");
|
|
596
|
+
}
|
|
597
|
+
return {
|
|
598
|
+
query: nonBlankInput(input.query, "YouTube search query"),
|
|
599
|
+
channelId: nullableText(input.channelId),
|
|
600
|
+
publishedAfter,
|
|
601
|
+
publishedBefore,
|
|
602
|
+
order: input.order ?? "relevance",
|
|
603
|
+
regionCode: nullableText(input.regionCode),
|
|
604
|
+
relevanceLanguage: nullableText(input.relevanceLanguage),
|
|
605
|
+
safeSearch: input.safeSearch ?? "moderate",
|
|
606
|
+
videoCaption: input.videoCaption ?? null,
|
|
607
|
+
videoDefinition: input.videoDefinition ?? null,
|
|
608
|
+
videoDimension: input.videoDimension ?? null,
|
|
609
|
+
videoDuration: input.videoDuration ?? null,
|
|
610
|
+
videoEmbeddable: input.videoEmbeddable ?? null,
|
|
611
|
+
videoEventType: input.videoEventType ?? null,
|
|
612
|
+
videoLicense: input.videoLicense ?? null,
|
|
613
|
+
videoPaidProductPlacement: input.videoPaidProductPlacement ?? null,
|
|
614
|
+
videoSyndicated: input.videoSyndicated ?? null,
|
|
615
|
+
videoType: input.videoType ?? null,
|
|
616
|
+
pageSize: input.pageSize ?? 25,
|
|
617
|
+
maxSearchPages: input.maxSearchPages ?? 5,
|
|
618
|
+
requirePublicComments: input.requirePublicComments ?? true,
|
|
619
|
+
minimumCommentCount: input.minimumCommentCount ?? 0,
|
|
620
|
+
minimumViewCount: input.minimumViewCount ?? 0,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
function searchParameters(query, pageToken) {
|
|
624
|
+
return {
|
|
625
|
+
part: "snippet",
|
|
626
|
+
type: "video",
|
|
627
|
+
q: query.query,
|
|
628
|
+
order: query.order,
|
|
629
|
+
safeSearch: query.safeSearch,
|
|
630
|
+
maxResults: query.pageSize,
|
|
631
|
+
...(pageToken ? { pageToken } : {}),
|
|
632
|
+
...(query.channelId ? { channelId: query.channelId } : {}),
|
|
633
|
+
...(query.publishedAfter ? { publishedAfter: query.publishedAfter } : {}),
|
|
634
|
+
...(query.publishedBefore ? { publishedBefore: query.publishedBefore } : {}),
|
|
635
|
+
...(query.regionCode ? { regionCode: query.regionCode } : {}),
|
|
636
|
+
...(query.relevanceLanguage ? { relevanceLanguage: query.relevanceLanguage } : {}),
|
|
637
|
+
...(query.videoCaption ? { videoCaption: query.videoCaption } : {}),
|
|
638
|
+
...(query.videoDefinition ? { videoDefinition: query.videoDefinition } : {}),
|
|
639
|
+
...(query.videoDimension ? { videoDimension: query.videoDimension } : {}),
|
|
640
|
+
...(query.videoDuration ? { videoDuration: query.videoDuration } : {}),
|
|
641
|
+
...(query.videoEmbeddable ? { videoEmbeddable: query.videoEmbeddable } : {}),
|
|
642
|
+
...(query.videoEventType ? { eventType: query.videoEventType } : {}),
|
|
643
|
+
...(query.videoLicense ? { videoLicense: query.videoLicense } : {}),
|
|
644
|
+
...(query.videoPaidProductPlacement
|
|
645
|
+
? { videoPaidProductPlacement: query.videoPaidProductPlacement }
|
|
646
|
+
: {}),
|
|
647
|
+
...(query.videoSyndicated ? { videoSyndicated: query.videoSyndicated } : {}),
|
|
648
|
+
...(query.videoType ? { videoType: query.videoType } : {}),
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
function parseVideoSearchPage(value) {
|
|
652
|
+
const root = object(value, "YouTube search response");
|
|
653
|
+
if (!Array.isArray(root.items))
|
|
654
|
+
throw providerInvalid("YouTube search items are missing.");
|
|
655
|
+
const videoIds = root.items.map((item) => {
|
|
656
|
+
const id = object(object(item, "YouTube search item").id, "YouTube search item ID");
|
|
657
|
+
return requiredVideoId(id.videoId, "YouTube search video ID");
|
|
658
|
+
});
|
|
659
|
+
return { videoIds, ...optionalPageToken(root.nextPageToken) };
|
|
660
|
+
}
|
|
661
|
+
function parseVideoDetails(value, requestedIds) {
|
|
662
|
+
const root = object(value, "YouTube videos response");
|
|
663
|
+
if (!Array.isArray(root.items))
|
|
664
|
+
throw providerInvalid("YouTube video-detail items are missing.");
|
|
665
|
+
const requested = new Set(requestedIds);
|
|
666
|
+
const seen = new Set();
|
|
667
|
+
return root.items.map((item) => {
|
|
668
|
+
const record = object(item, "YouTube video detail");
|
|
669
|
+
const videoId = requiredVideoId(record.id, "YouTube video ID");
|
|
670
|
+
if (!requested.has(videoId)) {
|
|
671
|
+
throw providerInvalid("YouTube videos.list returned an unrequested video ID.");
|
|
672
|
+
}
|
|
673
|
+
if (seen.has(videoId))
|
|
674
|
+
throw providerInvalid("YouTube returned a duplicate video detail.");
|
|
675
|
+
seen.add(videoId);
|
|
676
|
+
const snippet = objectOrEmpty(record.snippet);
|
|
677
|
+
const statistics = objectOrEmpty(record.statistics);
|
|
678
|
+
const contentDetails = objectOrEmpty(record.contentDetails);
|
|
679
|
+
const status = objectOrEmpty(record.status);
|
|
680
|
+
const liveStreamingDetails = objectOrEmpty(record.liveStreamingDetails);
|
|
681
|
+
return {
|
|
682
|
+
videoId,
|
|
683
|
+
publishedAt: optionalDateTime(snippet.publishedAt),
|
|
684
|
+
channelId: optionalString(snippet.channelId),
|
|
685
|
+
channelTitle: optionalString(snippet.channelTitle),
|
|
686
|
+
title: typeof snippet.title === "string" ? snippet.title : "",
|
|
687
|
+
description: typeof snippet.description === "string" ? snippet.description : "",
|
|
688
|
+
tags: stringArray(snippet.tags),
|
|
689
|
+
thumbnailUrls: httpsUrlsFromObject(snippet.thumbnails),
|
|
690
|
+
categoryId: optionalString(snippet.categoryId),
|
|
691
|
+
defaultLanguage: optionalString(snippet.defaultLanguage),
|
|
692
|
+
defaultAudioLanguage: optionalString(snippet.defaultAudioLanguage),
|
|
693
|
+
liveBroadcastContent: optionalString(snippet.liveBroadcastContent),
|
|
694
|
+
statistics: {
|
|
695
|
+
viewCount: numericString(statistics.viewCount),
|
|
696
|
+
likeCount: numericString(statistics.likeCount),
|
|
697
|
+
commentCount: numericString(statistics.commentCount),
|
|
698
|
+
},
|
|
699
|
+
contentDetails: {
|
|
700
|
+
duration: optionalString(contentDetails.duration),
|
|
701
|
+
caption: optionalString(contentDetails.caption),
|
|
702
|
+
definition: optionalString(contentDetails.definition),
|
|
703
|
+
dimension: optionalString(contentDetails.dimension),
|
|
704
|
+
licensedContent: optionalBoolean(contentDetails.licensedContent),
|
|
705
|
+
projection: optionalString(contentDetails.projection),
|
|
706
|
+
},
|
|
707
|
+
status: {
|
|
708
|
+
privacyStatus: optionalString(status.privacyStatus),
|
|
709
|
+
embeddable: optionalBoolean(status.embeddable),
|
|
710
|
+
license: optionalString(status.license),
|
|
711
|
+
madeForKids: optionalBoolean(status.madeForKids),
|
|
712
|
+
selfDeclaredMadeForKids: optionalBoolean(status.selfDeclaredMadeForKids),
|
|
713
|
+
},
|
|
714
|
+
liveStreamingDetails: {
|
|
715
|
+
actualStartTime: optionalDateTime(liveStreamingDetails.actualStartTime),
|
|
716
|
+
actualEndTime: optionalDateTime(liveStreamingDetails.actualEndTime),
|
|
717
|
+
scheduledStartTime: optionalDateTime(liveStreamingDetails.scheduledStartTime),
|
|
718
|
+
scheduledEndTime: optionalDateTime(liveStreamingDetails.scheduledEndTime),
|
|
719
|
+
},
|
|
720
|
+
};
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
function videoFilterReason(record, query) {
|
|
724
|
+
const comments = record.statistics.commentCount;
|
|
725
|
+
const views = record.statistics.viewCount;
|
|
726
|
+
if (query.requirePublicComments && (comments === null || comments === 0)) {
|
|
727
|
+
return "public-comments-unavailable";
|
|
728
|
+
}
|
|
729
|
+
if (query.minimumCommentCount > 0 &&
|
|
730
|
+
(comments === null || comments < query.minimumCommentCount)) {
|
|
731
|
+
return "below-minimum-comment-count";
|
|
732
|
+
}
|
|
733
|
+
if (query.minimumViewCount > 0 && (views === null || views < query.minimumViewCount)) {
|
|
734
|
+
return "below-minimum-view-count";
|
|
735
|
+
}
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
function normalizeCommentsQuery(input) {
|
|
739
|
+
const startDateTime = input.startDateTime ? exactDateTime(input.startDateTime) : null;
|
|
740
|
+
const endDateTime = input.endDateTime ? exactDateTime(input.endDateTime) : null;
|
|
741
|
+
if ((startDateTime === null) !== (endDateTime === null)) {
|
|
742
|
+
throw new DataRuntimeError("invalid-request", "YouTube comment startDateTime and endDateTime must be provided together.");
|
|
743
|
+
}
|
|
744
|
+
if (startDateTime && endDateTime && startDateTime >= endDateTime) {
|
|
745
|
+
throw new DataRuntimeError("invalid-request", "YouTube comment startDateTime must precede endDateTime.");
|
|
746
|
+
}
|
|
747
|
+
const videoIds = input.videoIds.map(videoIdInput);
|
|
748
|
+
if (new Set(videoIds).size !== videoIds.length) {
|
|
749
|
+
throw new DataRuntimeError("invalid-request", "YouTube video IDs must remain unique after whitespace normalization.");
|
|
750
|
+
}
|
|
751
|
+
return {
|
|
752
|
+
videoIds,
|
|
753
|
+
startDateTime,
|
|
754
|
+
endDateTime,
|
|
755
|
+
timeField: input.timeField ?? "published",
|
|
756
|
+
includeReplies: input.includeReplies ?? true,
|
|
757
|
+
searchTerms: nullableText(input.searchTerms),
|
|
758
|
+
order: input.order ?? "time",
|
|
759
|
+
pageSize: input.pageSize ?? 100,
|
|
760
|
+
maxThreadPagesPerVideo: input.maxThreadPagesPerVideo ?? 10,
|
|
761
|
+
maxReplyPagesPerThread: input.maxReplyPagesPerThread ?? 20,
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
function parseCommentThreadsPage(value, requestedVideoId) {
|
|
765
|
+
const root = object(value, "YouTube commentThreads response");
|
|
766
|
+
if (!Array.isArray(root.items))
|
|
767
|
+
throw providerInvalid("YouTube comment-thread items are missing.");
|
|
768
|
+
const threads = root.items.map((item) => {
|
|
769
|
+
const record = object(item, "YouTube comment thread");
|
|
770
|
+
const threadId = requiredText(record.id, "YouTube comment-thread ID");
|
|
771
|
+
const snippet = object(record.snippet, "YouTube comment-thread snippet");
|
|
772
|
+
const topLevel = object(snippet.topLevelComment, "YouTube top-level comment");
|
|
773
|
+
const topSnippet = object(topLevel.snippet, "YouTube top-level comment snippet");
|
|
774
|
+
const videoId = optionalString(snippet.videoId) ?? optionalString(topSnippet.videoId);
|
|
775
|
+
if (videoId && videoId !== requestedVideoId) {
|
|
776
|
+
throw providerInvalid("YouTube comment thread belongs to an unexpected video.");
|
|
777
|
+
}
|
|
778
|
+
return {
|
|
779
|
+
threadId,
|
|
780
|
+
topLevel: normalizeComment(topLevel, threadId, requestedVideoId, "top-level"),
|
|
781
|
+
totalReplyCount: nonNegativeInteger(snippet.totalReplyCount) ?? 0,
|
|
782
|
+
};
|
|
783
|
+
});
|
|
784
|
+
return { threads, ...optionalPageToken(root.nextPageToken) };
|
|
785
|
+
}
|
|
786
|
+
function parseRepliesPage(value, videoId, threadId, parentCommentId) {
|
|
787
|
+
const root = object(value, "YouTube comments response");
|
|
788
|
+
if (!Array.isArray(root.items))
|
|
789
|
+
throw providerInvalid("YouTube reply items are missing.");
|
|
790
|
+
return {
|
|
791
|
+
records: root.items.map((item) => normalizeComment(item, threadId, videoId, "reply", parentCommentId)),
|
|
792
|
+
...optionalPageToken(root.nextPageToken),
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
function normalizeComment(value, threadId, videoId, kind, expectedParentId = null) {
|
|
796
|
+
const root = object(value, "YouTube comment");
|
|
797
|
+
const snippet = object(root.snippet, "YouTube comment snippet");
|
|
798
|
+
const authorChannelId = objectOrEmpty(snippet.authorChannelId);
|
|
799
|
+
const providerVideoId = optionalString(snippet.videoId);
|
|
800
|
+
if (providerVideoId && providerVideoId !== videoId) {
|
|
801
|
+
throw providerInvalid("YouTube comment belongs to an unexpected video.");
|
|
802
|
+
}
|
|
803
|
+
const parentId = kind === "reply" ? optionalString(snippet.parentId) : null;
|
|
804
|
+
if (kind === "reply" && parentId !== expectedParentId) {
|
|
805
|
+
throw providerInvalid("YouTube reply belongs to an unexpected parent comment.");
|
|
806
|
+
}
|
|
807
|
+
return {
|
|
808
|
+
commentId: requiredText(root.id, "YouTube comment ID"),
|
|
809
|
+
threadId,
|
|
810
|
+
videoId,
|
|
811
|
+
parentId,
|
|
812
|
+
kind,
|
|
813
|
+
channelId: optionalString(snippet.channelId),
|
|
814
|
+
authorDisplayName: optionalString(snippet.authorDisplayName),
|
|
815
|
+
authorChannelId: optionalString(authorChannelId.value),
|
|
816
|
+
authorChannelUrl: optionalString(snippet.authorChannelUrl),
|
|
817
|
+
authorProfileImageUrl: optionalString(snippet.authorProfileImageUrl),
|
|
818
|
+
textDisplay: typeof snippet.textDisplay === "string" ? snippet.textDisplay : "",
|
|
819
|
+
textOriginal: optionalString(snippet.textOriginal),
|
|
820
|
+
publishedAt: optionalDateTime(snippet.publishedAt),
|
|
821
|
+
updatedAt: optionalDateTime(snippet.updatedAt),
|
|
822
|
+
likeCount: nonNegativeInteger(snippet.likeCount),
|
|
823
|
+
viewerRating: optionalString(snippet.viewerRating),
|
|
824
|
+
canRate: optionalBoolean(snippet.canRate),
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
function insideCommentWindow(record, query) {
|
|
828
|
+
if (!query.startDateTime && !query.endDateTime)
|
|
829
|
+
return true;
|
|
830
|
+
const value = query.timeField === "published" ? record.publishedAt : record.updatedAt;
|
|
831
|
+
if (!value)
|
|
832
|
+
return false;
|
|
833
|
+
if (query.startDateTime && value < query.startDateTime)
|
|
834
|
+
return false;
|
|
835
|
+
return !(query.endDateTime && value >= query.endDateTime);
|
|
836
|
+
}
|
|
837
|
+
function addComment(records, seen, record, maxRecords) {
|
|
838
|
+
if (records.length >= maxRecords || seen.has(record.commentId))
|
|
839
|
+
return;
|
|
840
|
+
seen.add(record.commentId);
|
|
841
|
+
records.push(record);
|
|
842
|
+
}
|
|
843
|
+
function sourceDescriptor(mode) {
|
|
844
|
+
return mode === "metadata"
|
|
845
|
+
? {
|
|
846
|
+
providerId: "youtube",
|
|
847
|
+
apiVersion: "v3",
|
|
848
|
+
metadataOnly: true,
|
|
849
|
+
userGeneratedContent: true,
|
|
850
|
+
}
|
|
851
|
+
: {
|
|
852
|
+
providerId: "youtube",
|
|
853
|
+
apiVersion: "v3",
|
|
854
|
+
publicComments: true,
|
|
855
|
+
userGeneratedContent: true,
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
function exactDateTime(value) {
|
|
859
|
+
const parsed = new Date(value);
|
|
860
|
+
if (!Number.isFinite(parsed.getTime())) {
|
|
861
|
+
throw new DataRuntimeError("invalid-request", "YouTube datetime values must be valid UTC timestamps.");
|
|
862
|
+
}
|
|
863
|
+
return parsed.toISOString();
|
|
864
|
+
}
|
|
865
|
+
function nullableText(value) {
|
|
866
|
+
const normalized = value?.trim();
|
|
867
|
+
return normalized ? normalized : null;
|
|
868
|
+
}
|
|
869
|
+
function nonBlankInput(value, label) {
|
|
870
|
+
const normalized = value.trim();
|
|
871
|
+
if (!normalized)
|
|
872
|
+
throw new DataRuntimeError("invalid-request", `${label} must not be blank.`);
|
|
873
|
+
return normalized;
|
|
874
|
+
}
|
|
875
|
+
function videoIdInput(value) {
|
|
876
|
+
const normalized = nonBlankInput(value, "YouTube video ID");
|
|
877
|
+
if (!VIDEO_ID_PATTERN.test(normalized)) {
|
|
878
|
+
throw new DataRuntimeError("invalid-request", "YouTube video IDs must contain exactly 11 URL-safe identifier characters.");
|
|
879
|
+
}
|
|
880
|
+
return normalized;
|
|
881
|
+
}
|
|
882
|
+
function optionalDateTime(value) {
|
|
883
|
+
if (typeof value !== "string" || !value)
|
|
884
|
+
return null;
|
|
885
|
+
const parsed = new Date(value);
|
|
886
|
+
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null;
|
|
887
|
+
}
|
|
888
|
+
function optionalPageToken(value) {
|
|
889
|
+
if (value === undefined)
|
|
890
|
+
return {};
|
|
891
|
+
if (typeof value !== "string" || !value)
|
|
892
|
+
throw providerInvalid("YouTube page token is invalid.");
|
|
893
|
+
return { nextPageToken: value };
|
|
894
|
+
}
|
|
895
|
+
function object(value, label) {
|
|
896
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
897
|
+
throw providerInvalid(`${label} must be an object.`);
|
|
898
|
+
}
|
|
899
|
+
return value;
|
|
900
|
+
}
|
|
901
|
+
function objectOrEmpty(value) {
|
|
902
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
903
|
+
? value
|
|
904
|
+
: {};
|
|
905
|
+
}
|
|
906
|
+
function requiredText(value, label) {
|
|
907
|
+
if (typeof value !== "string" || !value.trim())
|
|
908
|
+
throw providerInvalid(`${label} is missing.`);
|
|
909
|
+
return value.trim();
|
|
910
|
+
}
|
|
911
|
+
function requiredVideoId(value, label) {
|
|
912
|
+
const normalized = requiredText(value, label);
|
|
913
|
+
if (!VIDEO_ID_PATTERN.test(normalized)) {
|
|
914
|
+
throw providerInvalid(`${label} must contain exactly 11 URL-safe identifier characters.`);
|
|
915
|
+
}
|
|
916
|
+
return normalized;
|
|
917
|
+
}
|
|
918
|
+
function optionalString(value) {
|
|
919
|
+
return typeof value === "string" && value ? value : null;
|
|
920
|
+
}
|
|
921
|
+
function optionalBoolean(value) {
|
|
922
|
+
return typeof value === "boolean" ? value : null;
|
|
923
|
+
}
|
|
924
|
+
function stringArray(value) {
|
|
925
|
+
if (!Array.isArray(value))
|
|
926
|
+
return [];
|
|
927
|
+
return value.filter((item) => typeof item === "string");
|
|
928
|
+
}
|
|
929
|
+
function httpsUrlsFromObject(value) {
|
|
930
|
+
const source = objectOrEmpty(value);
|
|
931
|
+
const urls = [];
|
|
932
|
+
const seen = new Set();
|
|
933
|
+
for (const candidate of Object.values(source)) {
|
|
934
|
+
const url = optionalString(objectOrEmpty(candidate).url);
|
|
935
|
+
if (!url || seen.has(url))
|
|
936
|
+
continue;
|
|
937
|
+
try {
|
|
938
|
+
if (new URL(url).protocol !== "https:")
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
catch {
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
944
|
+
seen.add(url);
|
|
945
|
+
urls.push(url);
|
|
946
|
+
}
|
|
947
|
+
return urls;
|
|
948
|
+
}
|
|
949
|
+
function nonNegativeInteger(value) {
|
|
950
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
951
|
+
}
|
|
952
|
+
function numericString(value) {
|
|
953
|
+
if (typeof value !== "string" || !/^\d+$/.test(value))
|
|
954
|
+
return null;
|
|
955
|
+
const parsed = Number(value);
|
|
956
|
+
return Number.isSafeInteger(parsed) ? parsed : null;
|
|
957
|
+
}
|
|
958
|
+
function normalizeProviderFailure(error) {
|
|
959
|
+
if (!(error instanceof DataRuntimeError)) {
|
|
960
|
+
return providerInvalid("YouTube response could not be normalized.");
|
|
961
|
+
}
|
|
962
|
+
if (error.options.details?.providerReason === "commentsDisabled") {
|
|
963
|
+
return new DataRuntimeError("provider-response-invalid", "YouTube comments are unavailable for this video.", { details: { providerReason: "commentsDisabled" } });
|
|
964
|
+
}
|
|
965
|
+
return error;
|
|
966
|
+
}
|
|
967
|
+
function isCommentsDisabledFailure(error) {
|
|
968
|
+
return (error instanceof DataRuntimeError &&
|
|
969
|
+
error.options.details?.providerReason === "commentsDisabled");
|
|
970
|
+
}
|
|
971
|
+
function providerInvalid(message) {
|
|
972
|
+
return new DataRuntimeError("provider-response-invalid", message);
|
|
973
|
+
}
|
|
974
|
+
//# sourceMappingURL=youtube-public-content.js.map
|