{
  "data": {
    "id": "0a5GXomczgZPh2iFX",
    "actId": "jrued2m8jluwhSMcX",
    "userId": "IYgBDiXtHOijAaUel",
    "startedAt": "2026-10-24T15:47:23.498Z",
    "finishedAt": "2026-10-24T15:47:35.260Z",
    "status": "SUCCEEDED",
    "meta": {
      "origin": "CLI"
    },
    "stats": {
      "durationMillis": 11681,
      "runTimeSecs": 11.681,
      "computeUnits": 0.012978888888888889,
      "imageSizeBytes": 318616064
    },
    "options": {
      "useCache": true,
      "betaPackages": false,
      "memoryMbytes": 4096,
      "diskMbytes": 8192
    },
    "createdByOrganizationMemberUserId": "QYo53EZkJk0prePWY",
    "exitCode": 0,
    "inputSchema": "{\n  \"title\": \"YouTube Comments & Reply Thread Scraper Input\",\n  \"description\": \"Scrape public YouTube comments and reply threads without login, user cookies, or an API key. Provide one or more videos, channels, playlists, or search queries.\",\n  \"type\": \"object\",\n  \"schemaVersion\": 1,\n  \"properties\": {\n    \"videoUrls\": {\n      \"title\": \"Video URLs or IDs\",\n      \"type\": \"array\",\n      \"description\": \"Watch, youtu.be, Shorts, embed, live, v/ links, or bare 11-character IDs. Every video is normalized to a watch URL internally.\",\n      \"editor\": \"stringList\",\n      \"items\": {\n        \"type\": \"string\"\n      },\n      \"default\": [],\n      \"prefill\": [\n        \"https://www.youtube.com/watch?v=dQw4w9WgXcQ\"\n      ]\n    },\n    \"channelUrls\": {\n      \"title\": \"Channel URLs or handles\",\n      \"type\": \"array\",\n      \"description\": \"Channel /videos sources: /@handle, /channel/UC..., /c/name, /user/name, a bare @handle, or a UC... channel ID. Scrapes up to Max videos per source.\",\n      \"editor\": \"stringList\",\n      \"items\": {\n        \"type\": \"string\"\n      },\n      \"default\": [],\n      \"prefill\": []\n    },\n    \"playlistUrls\": {\n      \"title\": \"Playlist URLs or IDs\",\n      \"type\": \"array\",\n      \"description\": \"Playlist sources: youtube.com/playlist?list=... or a bare playlist ID. Scrapes up to Max videos per source.\",\n      \"editor\": \"stringList\",\n      \"items\": {\n        \"type\": \"string\"\n      },\n      \"default\": [],\n      \"prefill\": []\n    },\n    \"searchQueries\": {\n      \"title\": \"Search queries\",\n      \"type\": \"array\",\n      \"description\": \"YouTube search terms or search-result URLs. Only video results are used, deduplicated, up to Max videos per source.\",\n      \"editor\": \"stringList\",\n      \"items\": {\n        \"type\": \"string\"\n      },\n      \"default\": [],\n      \"prefill\": []\n    },\n    \"maxVideosPerSource\": {\n      \"title\": \"Max videos per channel/playlist/search\",\n      \"type\": \"integer\",\n      \"description\": \"Discovery cap for channel, playlist and search sources. 0 = every video the source exposes.\",\n      \"default\": 20,\n      \"minimum\": 0,\n      \"maximum\": 5000,\n      \"editor\": \"number\"\n    },\n    \"maxComments\": {\n      \"title\": \"Max top-level comments per video\",\n      \"type\": \"integer\",\n      \"description\": \"Top-level comments to examine per video. 0 = all. Comments rejected by filters still count toward this scan cap (so restrictive filters cannot paginate forever); replies do not. Both emitted comments and replies count toward Max total rows.\",\n      \"default\": 100,\n      \"minimum\": 0,\n      \"maximum\": 50000,\n      \"editor\": \"number\",\n      \"prefill\": 3\n    },\n    \"includeReplies\": {\n      \"title\": \"Include replies\",\n      \"type\": \"boolean\",\n      \"description\": \"Fetch reply threads. Emitted as flat rows by default; use Output mode tree to embed them instead.\",\n      \"default\": true,\n      \"prefill\": true\n    },\n    \"maxRepliesPerComment\": {\n      \"title\": \"Max replies per comment\",\n      \"type\": \"integer\",\n      \"description\": \"Per-thread reply cap. 0 = all replies, stopping when the continuation token is exhausted. YouTube serves roughly 9-10 on the first reply page and 44-49 afterwards.\",\n      \"default\": 50,\n      \"minimum\": 0,\n      \"maximum\": 10000,\n      \"editor\": \"number\",\n      \"prefill\": 3\n    },\n    \"maxReplyDepth\": {\n      \"title\": \"Max reply depth\",\n      \"type\": \"integer\",\n      \"description\": \"0 = top-level comments only, 1 = direct replies (the shape observed publicly). Values above 1 are reserved for nested sub-threads, which YouTube has not exposed in the samples used to build this actor.\",\n      \"default\": 1,\n      \"minimum\": 0,\n      \"maximum\": 6,\n      \"editor\": \"number\",\n      \"prefill\": 1\n    },\n    \"maxTotalRows\": {\n      \"title\": \"Max total rows\",\n      \"type\": \"integer\",\n      \"description\": \"Global budget for emitted comment + reply rows. 0 = unlimited. Status rows are never counted and are not billable data rows.\",\n      \"default\": 5000,\n      \"minimum\": 0,\n      \"maximum\": 1000000,\n      \"editor\": \"number\",\n      \"prefill\": 20\n    },\n    \"sortBy\": {\n      \"title\": \"Sort comments by\",\n      \"type\": \"string\",\n      \"description\": \"top = YouTube relevance order; newest = chronological, recommended for date cutoffs and complete windows. A date cutoff forces newest automatically.\",\n      \"editor\": \"select\",\n      \"enum\": [\n        \"top\",\n        \"newest\"\n      ],\n      \"enumTitles\": [\n        \"Top comments\",\n        \"Newest first\"\n      ],\n      \"default\": \"top\",\n      \"prefill\": \"top\"\n    },\n    \"commentsSince\": {\n      \"title\": \"Comments since\",\n      \"type\": \"string\",\n      \"description\": \"Optional cutoff as an ISO date/datetime (2026-10-16) or relative duration (7d, 24h, 30m). Forces newest sort. Pinned comments are always kept; pagination stops at the first non-pinned comment older than the cutoff.\",\n      \"editor\": \"textfield\"\n    },\n    \"keywords\": {\n      \"title\": \"Keywords (ANY match)\",\n      \"type\": \"array\",\n      \"description\": \"Case-insensitive OR filter on comment and reply text. Empty = no keyword filter. A parent comment that does not match is skipped, so its replies are not fetched.\",\n      \"editor\": \"stringList\",\n      \"items\": {\n        \"type\": \"string\"\n      },\n      \"default\": [],\n      \"prefill\": []\n    },\n    \"minLikes\": {\n      \"title\": \"Minimum likes\",\n      \"type\": \"integer\",\n      \"description\": \"Drop comments and replies below this like count. Applied after fetching. YouTube only exposes rounded counts (K/M), so the comparison uses the rounded value.\",\n      \"default\": 0,\n      \"minimum\": 0,\n      \"maximum\": 1000000000,\n      \"editor\": \"number\",\n      \"prefill\": 0\n    },\n    \"onlyCreatorHearted\": {\n      \"title\": \"Only creator-hearted comments\",\n      \"type\": \"boolean\",\n      \"description\": \"Keep only comments/replies that the video creator hearted. HeartedBy identifies the creator where YouTube exposes the tooltip.\",\n      \"default\": false,\n      \"prefill\": false\n    },\n    \"outputMode\": {\n      \"title\": \"Output mode\",\n      \"type\": \"string\",\n      \"description\": \"flat = one row per comment and reply (recommended for CSV/Excel/SQL); tree = parent rows with embedded replies[] (backward compatible); both = flat rows plus parent rows carrying replies[].\",\n      \"editor\": \"select\",\n      \"enum\": [\n        \"flat\",\n        \"tree\",\n        \"both\"\n      ],\n      \"enumTitles\": [\n        \"Flat rows\",\n        \"Tree with embedded replies\",\n        \"Both\"\n      ],\n      \"default\": \"flat\",\n      \"prefill\": \"flat\"\n    },\n    \"emitStatusRows\": {\n      \"title\": \"Emit status rows\",\n      \"type\": \"boolean\",\n      \"description\": \"Write one recordType=status row for blocked, private, unavailable, comments-off, empty-token, invalid-input, partial and budget-stop cases. Status rows are never counted toward Max total rows; disable this to keep the dataset data-only.\",\n      \"default\": true,\n      \"prefill\": true\n    }\n  }\n}",
    "readme": "# YouTube Video Comment & Reply Thread Scraper\r\n\r\nScrape **YouTube comments and reply threads** as analysis-ready rows. Give it video links, channel video tabs, playlists, or search queries, and get one row per comment or reply with author identity, engagement signals, thread position, and video context. Works on public videos without user cookies, login, or an API key.\r\n\r\n## What this actor does\r\n\r\n- **Four source types:** individual videos (watch, Shorts, live, embed, youtu.be, bare ID), channel video tabs, playlists, and video search results\r\n- **Flat rows by default:** every comment and reply is its own row with `parentCommentId`, `rootCommentId`, `replyLevel`, and `threadPosition`, so CSV, Excel, SQL, and sentiment pipelines can use it directly\r\n- **Full reply threads:** fetch every page of a thread, cap per comment, or skip replies entirely; `maxRepliesPerComment: 0` means all\r\n- **Real filters:** Top/Newest sort, date cutoff, multiple keywords (ANY match), minimum likes, and only creator-hearted comments\r\n- **Honest engagement data:** rounded like counts with the original text, advertised reply counts, pinned/hearted/verified flags, and approximate dates with a precision and range\r\n- **Video context on every row:** title, channel, views, likes, comment count, duration, publish date, and description\r\n- **Empty fields are omitted** — records only contain values that are actually populated.\r\n\r\n## Output per comment or reply\r\n\r\nEach dataset row is one comment (`recordType: \"comment\"`, `replyLevel: 0`) or one reply (`recordType: \"reply\"`, `replyLevel: 1`).\r\n\r\n| Field | Type | Description |\r\n|---|---|---|\r\n| `recordType` | string | `comment`, `reply`, or `status` |\r\n| `commentId` | string | YouTube comment identifier |\r\n| `parentCommentId` | string | Root comment ID on replies; omitted on top-level comments |\r\n| `rootCommentId` | string | Thread root ID (own ID on top-level comments) |\r\n| `replyLevel` | integer | `0` for comments, `1` for direct replies |\r\n| `threadPosition` | integer | 1-based order in the emitted feed or reply thread |\r\n| `isReply` | boolean | Backward-compatible alias for `replyLevel > 0` |\r\n| `replyDepth` | integer | Backward-compatible alias for `replyLevel` |\r\n| `text` | string | Comment text, with unicode and newlines preserved |\r\n| `authorName` | string | Display name |\r\n| `authorHandle` | string | `@handle` when YouTube exposes it |\r\n| `authorChannelId` | string | `UC...` channel ID |\r\n| `authorChannelUrl` | string | `https://www.youtube.com/@handle` or `/channel/UC...` |\r\n| `authorAvatarUrl` | string | Avatar image URL |\r\n| `authorIsVerified` | boolean | Author channel is verified |\r\n| `authorIsCreator` | boolean | Author is the video's channel owner |\r\n| `authorIsArtist` | boolean | Author is marked as a YouTube Music artist |\r\n| `likeCount` | integer | Rounded like count (`69K` becomes `69000`) |\r\n| `likeCountText` | string | Original formatted count (`69K`) |\r\n| `replyCount` | integer | YouTube's advertised reply count; may exceed visible replies |\r\n| `replyCountText` | string | Original reply-count text when YouTube exposes it (`963 replies`) |\r\n| `repliesFetched` | integer | Reply rows actually attached/emitted for this comment (present when replies were fetched) |\r\n| `isPinned` | boolean | Comment is pinned |\r\n| `pinnedByText` | string | Pinned-by tooltip (`Pinned by @channel`) |\r\n| `isHearted` | boolean | Creator hearted the comment |\r\n| `heartedBy` | string | Creator handle from the heart tooltip |\r\n| `heartedByText` | string | Normalized creator label for the heart |\r\n| `creatorReplied` | boolean | Whether any emitted reply is authored by the video creator (present when replies were fetched) |\r\n| `creatorReplyCount` | integer | Number of emitted replies authored by the creator (present when replies were fetched) |\r\n| `isEdited` | boolean | Comment was edited, derived from the `(edited)` label |\r\n| `publishedTimeText` | string | Relative publish text exactly as YouTube shows it |\r\n| `publishedAtApprox` | string | Midpoint of the inferred date range (ISO-8601) |\r\n| `publishedAtFrom` | string | Earliest possible publish time |\r\n| `publishedAtTo` | string | Latest possible publish time |\r\n| `publishedAtPrecision` | string | `second`, `minute`, `hour`, `day`, `week`, `month`, or `year` |\r\n| `ageDaysMin` | number | Lower bound of comment age in days |\r\n| `ageDaysMax` | number | Upper bound of comment age in days |\r\n| `ageDaysApprox` | number | Midpoint of the age range |\r\n| `commentUrl` | string | Direct link to the comment (`...&lc=COMMENT_ID`) |\r\n| `videoId` | string | Video ID |\r\n| `videoUrl` | string | Canonical watch URL |\r\n| `videoTitle` | string | Video title |\r\n| `videoChannelId` | string | Video channel ID |\r\n| `videoChannelHandle` | string | Video channel path (`/@handle`) |\r\n| `videoChannelName` | string | Video channel display name |\r\n| `videoViewCount` | integer | View count |\r\n| `videoLikeCount` | integer | Video like count (YouTube may round it) |\r\n| `videoCommentCount` | integer | Approximate total comments shown by YouTube |\r\n| `videoPublishDate` | string | Raw publish date text (`Oct 24, 2009`) |\r\n| `videoPublishedAt` | string | Parsed publish date (`2009-10-24`) |\r\n| `videoDurationSeconds` | integer | Video length in seconds |\r\n| `videoDescription` | string | Video description |\r\n| `sourceInput` | string | The original URL, handle, or query that produced this video |\r\n| `sourceScope` | string | `video`, `channel`, `playlist`, or `search` |\r\n| `sourceVideoId` | string | Video selected from the source |\r\n| `scrapedAt` | string | ISO-8601 UTC timestamp of the run |\r\n| `replies` | array | Embedded reply rows, only with `outputMode: tree` or `both` |\r\n\r\n### Status rows\r\n\r\nWhen `emitStatusRows` is enabled, blocked/empty/unavailable sources and partial or budget-limited runs add one row with `recordType: \"status\"`, `status`, `inputUrl`, optional video fields, `error`, and `rowsEmitted` when known. Status rows are never counted toward `maxTotalRows` and do not consume the data-row budget. Status values: `no_comments`, `comments_off`, `unavailable`, `private`, `age_restricted`, `members_only`, `region_blocked`, `live_chat_only`, `blocked`, `stale_token`, `transient_empty`, `parse_empty`, `partial`, `budget_reached`, `input_invalid`. Successful comment runs do not add an `ok` status row.\r\n\r\n### Dates and counts are approximate\r\n\r\nYouTube's public comment payload does not expose exact timestamps. Every date field is derived from the relative label (`6 years ago`) and comes with a precision plus earliest/latest bounds, so no exact timestamp is invented. Like counts above 999 are rounded by YouTube itself; `likeCountText` preserves the original value. `replyCount` is YouTube's advertised count and can be higher than the number of replies the public thread returns.\r\n\r\n## Input\r\n\r\n| Field | Type | Default | Description |\r\n|---|---|---|---|\r\n| `videoUrls` | array | `[]` | Video URLs or bare IDs (watch, youtu.be, Shorts, embed, live, v/) |\r\n| `channelUrls` | array | `[]` | Channel video tabs: `/@handle`, `/channel/UC...`, `/c/name`, `/user/name`, `@handle`, or `UC...` |\r\n| `playlistUrls` | array | `[]` | Playlist URLs or bare playlist IDs |\r\n| `searchQueries` | array | `[]` | Search terms or YouTube search-result URLs; video results only |\r\n| `maxVideosPerSource` | integer | `20` | Video discovery cap per channel/playlist/search; `0` = all |\r\n| `maxComments` | integer | `100` | Top-level comments to examine per video; `0` = all. Filtered-out comments still count toward this scan cap |\r\n| `includeReplies` | boolean | `true` | Fetch reply threads |\r\n| `maxRepliesPerComment` | integer | `50` | Replies per thread; `0` = all until the token is exhausted |\r\n| `maxReplyDepth` | integer | `1` | `0` = top-level only, `1` = direct replies |\r\n| `maxTotalRows` | integer | `5000` | Global comment + reply budget; `0` = unlimited |\r\n| `sortBy` | string | `top` | `top` or `newest` |\r\n| `commentsSince` | string | – | ISO date/datetime or relative duration (`7d`, `24h`); forces newest |\r\n| `keywords` | array | `[]` | Case-insensitive ANY-match filter on comment/reply text |\r\n| `minLikes` | integer | `0` | Drop rows below this rounded like count |\r\n| `onlyCreatorHearted` | boolean | `false` | Keep only creator-hearted rows |\r\n| `outputMode` | string | `flat` | `flat`, `tree`, or `both` |\r\n| `emitStatusRows` | boolean | `true` | Emit `recordType: \"status\"` rows for blocked/empty/invalid sources |\r\n\r\nAt least one of `videoUrls`, `channelUrls`, `playlistUrls`, or `searchQueries` is required.\r\n\r\n### Example: one video with replies\r\n\r\n```json\r\n{\r\n  \"videoUrls\": [\"https://www.youtube.com/watch?v=dQw4w9WgXcQ\"],\r\n  \"maxComments\": 50,\r\n  \"includeReplies\": true,\r\n  \"maxRepliesPerComment\": 20,\r\n  \"sortBy\": \"top\",\r\n  \"maxTotalRows\": 500\r\n}\r\n```\r\n\r\n### Example: recent comments from a channel\r\n\r\n```json\r\n{\r\n  \"channelUrls\": [\"https://www.youtube.com/@RickAstleyYT\"],\r\n  \"maxVideosPerSource\": 10,\r\n  \"sortBy\": \"newest\",\r\n  \"commentsSince\": \"7d\",\r\n  \"maxComments\": 100,\r\n  \"minLikes\": 5,\r\n  \"maxTotalRows\": 1000\r\n}\r\n```\r\n\r\n### Example: search with creator-hearted comments\r\n\r\n```json\r\n{\r\n  \"searchQueries\": [\"product launch review\"],\r\n  \"maxVideosPerSource\": 5,\r\n  \"onlyCreatorHearted\": true,\r\n  \"maxRepliesPerComment\": 0,\r\n  \"maxTotalRows\": 2000\r\n}\r\n```\r\n\r\n### Example: backward-compatible tree output\r\n\r\n```json\r\n{\r\n  \"videoUrls\": [\"https://www.youtube.com/watch?v=jNQXAC9IVRw\"],\r\n  \"maxComments\": 20,\r\n  \"includeReplies\": true,\r\n  \"maxRepliesPerComment\": 50,\r\n  \"outputMode\": \"tree\"\r\n}\r\n```\r\n\r\n## Use cases\r\n\r\n- **Sentiment and topic analysis** — export flat comment and reply rows directly into a notebook or BI tool\r\n- **Creator engagement audits** — measure which comments the creator pinned, hearted, or replied to\r\n- **Audience research** — mine questions and recurring themes across a channel's recent uploads or a playlist\r\n- **Moderation datasets** — collect long reply threads with parent/root IDs for context-aware classifiers\r\n- **Campaign monitoring** — pull the last few days of comments from several videos with a date cutoff and minimum-like floor\r\n- **Competitive research** — compare comment volume and engagement on the same topic across search results\r\n\r\n## FAQ\r\n\r\n### Do I need a YouTube API key or cookies?\r\n\r\nNo. The actor reads publicly available comment data without login, user cookies, or an API key. It sends YouTube's standard consent cookie so the EU consent page does not replace the video page.\r\n\r\n### Why are some comments missing?\r\n\r\nYouTube's \"Top comments\" feed exposes a relevance-ordered subset (roughly 800–1,200 comments on very large videos). Use `sortBy: \"newest\"` for complete date windows and deeper coverage. The actor also continues with the newest feed when the top feed is exhausted, and `maxComments`, `maxTotalRows`, and your filters can all stop a run earlier than the source total.\r\n\r\n### Are replies complete?\r\n\r\nThe actor follows every reply continuation until the thread token is exhausted when `maxRepliesPerComment: 0`. YouTube can hide deleted, spam, or moderated replies, so the advertised `replyCount` may be higher than the rows returned. `repliesFetched` tells you exactly how many were emitted for a comment.\r\n\r\n### Why are timestamps approximate?\r\n\r\nThe public comment payload only contains relative labels such as \"2 days ago\". The actor converts each label into a date range with a precision (`hour`, `day`, `week`, `month`, `year`) and exposes the earliest and latest possible time. It never claims an exact timestamp.\r\n\r\n### Why do like counts end in round numbers?\r\n\r\nYouTube exposes formatted counts (`1.2K`, `69K`, `4.8M`). `likeCount` is the rounded integer and `likeCountText` preserves the original string.\r\n\r\n### What does a date cutoff do?\r\n\r\n`commentsSince` forces the newest-first feed, keeps pinned comments, and stops paginating at the first non-pinned comment older than the cutoff. Because dates are approximate, a comment whose range overlaps the cutoff is kept.\r\n\r\n### What happens if a video is private, age-restricted, or has comments off?\r\n\r\nThe actor classifies the page and, when `emitStatusRows` is enabled, writes one status row explaining the outcome. It does not try to bypass login, age, or region restrictions.\r\n\r\n### What is the difference between `flat`, `tree`, and `both`?\r\n\r\n`flat` emits one row per comment and reply. `tree` emits parent comment rows with a `replies[]` array (useful for older integrations). `both` emits flat reply rows and also attaches `replies[]` to each parent.\r\n\r\n### Do I need to configure a proxy?\r\n\r\nNo. The actor chooses a working connection to YouTube automatically. Just fill in the input and run it.\r\n\r\n### Is this affiliated with YouTube?\r\n\r\nNo. This is an independent tool and is not affiliated with, endorsed by, or sponsored by YouTube or Google.\r\n\r\n## Limitations\r\n\r\n- Exact comment timestamps and exact like counts are not available in the public payload.\r\n- Nested replies beyond direct level 1 are not exposed in the observed public payload; `maxReplyDepth` above 1 is reserved for future support.\r\n- The top-comments feed is capped by YouTube; use newest-first plus a date cutoff for exhaustive windows.\r\n- Age-restricted, private, members-only, and region-blocked videos are reported as statuses, not bypassed.\r\n- `replyCount` is advertised, not guaranteed; use `repliesFetched` for the delivered count.\r\n",
    "actorDefinition": {
      "actorSpecification": 1,
      "name": "youtube-video-comment-and-reply-thread-scraper",
      "title": "YouTube Video Comment & Reply Thread Scraper",
      "description": "Scrape public YouTube comments and full reply threads as flat rows: author identity, rounded likes, pinned/hearted flags, honest approximate dates, thread position, and video statistics. Videos, Shorts, channels, playlists, search, top/newest, date and engagement filters.",
      "version": "1.0",
      "buildTag": "latest",
      "environmentVariables": {},
      "dockerfile": "../Dockerfile",
      "input": {
        "title": "YouTube Comments & Reply Thread Scraper Input",
        "description": "Scrape public YouTube comments and reply threads without login, user cookies, or an API key. Provide one or more videos, channels, playlists, or search queries.",
        "type": "object",
        "schemaVersion": 1,
        "properties": {
          "videoUrls": {
            "title": "Video URLs or IDs",
            "type": "array",
            "description": "Watch, youtu.be, Shorts, embed, live, v/ links, or bare 11-character IDs. Every video is normalized to a watch URL internally.",
            "editor": "stringList",
            "items": {
              "type": "string"
            },
            "default": [],
            "prefill": [
              "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
            ]
          },
          "channelUrls": {
            "title": "Channel URLs or handles",
            "type": "array",
            "description": "Channel /videos sources: /@handle, /channel/UC..., /c/name, /user/name, a bare @handle, or a UC... channel ID. Scrapes up to Max videos per source.",
            "editor": "stringList",
            "items": {
              "type": "string"
            },
            "default": [],
            "prefill": []
          },
          "playlistUrls": {
            "title": "Playlist URLs or IDs",
            "type": "array",
            "description": "Playlist sources: youtube.com/playlist?list=... or a bare playlist ID. Scrapes up to Max videos per source.",
            "editor": "stringList",
            "items": {
              "type": "string"
            },
            "default": [],
            "prefill": []
          },
          "searchQueries": {
            "title": "Search queries",
            "type": "array",
            "description": "YouTube search terms or search-result URLs. Only video results are used, deduplicated, up to Max videos per source.",
            "editor": "stringList",
            "items": {
              "type": "string"
            },
            "default": [],
            "prefill": []
          },
          "maxVideosPerSource": {
            "title": "Max videos per channel/playlist/search",
            "type": "integer",
            "description": "Discovery cap for channel, playlist and search sources. 0 = every video the source exposes.",
            "default": 20,
            "minimum": 0,
            "maximum": 5000,
            "editor": "number"
          },
          "maxComments": {
            "title": "Max top-level comments per video",
            "type": "integer",
            "description": "Top-level comments to examine per video. 0 = all. Comments rejected by filters still count toward this scan cap (so restrictive filters cannot paginate forever); replies do not. Both emitted comments and replies count toward Max total rows.",
            "default": 100,
            "minimum": 0,
            "maximum": 50000,
            "editor": "number",
            "prefill": 3
          },
          "includeReplies": {
            "title": "Include replies",
            "type": "boolean",
            "description": "Fetch reply threads. Emitted as flat rows by default; use Output mode tree to embed them instead.",
            "default": true,
            "prefill": true
          },
          "maxRepliesPerComment": {
            "title": "Max replies per comment",
            "type": "integer",
            "description": "Per-thread reply cap. 0 = all replies, stopping when the continuation token is exhausted. YouTube serves roughly 9-10 on the first reply page and 44-49 afterwards.",
            "default": 50,
            "minimum": 0,
            "maximum": 10000,
            "editor": "number",
            "prefill": 3
          },
          "maxReplyDepth": {
            "title": "Max reply depth",
            "type": "integer",
            "description": "0 = top-level comments only, 1 = direct replies (the shape observed publicly). Values above 1 are reserved for nested sub-threads, which YouTube has not exposed in the samples used to build this actor.",
            "default": 1,
            "minimum": 0,
            "maximum": 6,
            "editor": "number",
            "prefill": 1
          },
          "maxTotalRows": {
            "title": "Max total rows",
            "type": "integer",
            "description": "Global budget for emitted comment + reply rows. 0 = unlimited. Status rows are never counted and are not billable data rows.",
            "default": 5000,
            "minimum": 0,
            "maximum": 1000000,
            "editor": "number",
            "prefill": 20
          },
          "sortBy": {
            "title": "Sort comments by",
            "type": "string",
            "description": "top = YouTube relevance order; newest = chronological, recommended for date cutoffs and complete windows. A date cutoff forces newest automatically.",
            "editor": "select",
            "enum": [
              "top",
              "newest"
            ],
            "enumTitles": [
              "Top comments",
              "Newest first"
            ],
            "default": "top",
            "prefill": "top"
          },
          "commentsSince": {
            "title": "Comments since",
            "type": "string",
            "description": "Optional cutoff as an ISO date/datetime (2026-10-16) or relative duration (7d, 24h, 30m). Forces newest sort. Pinned comments are always kept; pagination stops at the first non-pinned comment older than the cutoff.",
            "editor": "textfield"
          },
          "keywords": {
            "title": "Keywords (ANY match)",
            "type": "array",
            "description": "Case-insensitive OR filter on comment and reply text. Empty = no keyword filter. A parent comment that does not match is skipped, so its replies are not fetched.",
            "editor": "stringList",
            "items": {
              "type": "string"
            },
            "default": [],
            "prefill": []
          },
          "minLikes": {
            "title": "Minimum likes",
            "type": "integer",
            "description": "Drop comments and replies below this like count. Applied after fetching. YouTube only exposes rounded counts (K/M), so the comparison uses the rounded value.",
            "default": 0,
            "minimum": 0,
            "maximum": 1000000000,
            "editor": "number",
            "prefill": 0
          },
          "onlyCreatorHearted": {
            "title": "Only creator-hearted comments",
            "type": "boolean",
            "description": "Keep only comments/replies that the video creator hearted. HeartedBy identifies the creator where YouTube exposes the tooltip.",
            "default": false,
            "prefill": false
          },
          "outputMode": {
            "title": "Output mode",
            "type": "string",
            "description": "flat = one row per comment and reply (recommended for CSV/Excel/SQL); tree = parent rows with embedded replies[] (backward compatible); both = flat rows plus parent rows carrying replies[].",
            "editor": "select",
            "enum": [
              "flat",
              "tree",
              "both"
            ],
            "enumTitles": [
              "Flat rows",
              "Tree with embedded replies",
              "Both"
            ],
            "default": "flat",
            "prefill": "flat"
          },
          "emitStatusRows": {
            "title": "Emit status rows",
            "type": "boolean",
            "description": "Write one recordType=status row for blocked, private, unavailable, comments-off, empty-token, invalid-input, partial and budget-stop cases. Status rows are never counted toward Max total rows; disable this to keep the dataset data-only.",
            "default": true,
            "prefill": true
          }
        }
      },
      "output": {
        "actorOutputSchemaVersion": 1,
        "title": "YouTube Video Comment & Reply Thread Scraper Output",
        "description": "Flat comment and reply rows with author identity, rounded engagement counts, honest approximate dates, thread fields, video statistics, and optional status rows.",
        "properties": {
          "comments": {
            "type": "string",
            "title": "YouTube comments, replies and statuses",
            "description": "Dataset containing one row per comment or reply (plus optional status rows when emitStatusRows is enabled).",
            "template": "{{links.apiDefaultDatasetUrl}}/items"
          }
        }
      },
      "readme": "# YouTube Video Comment & Reply Thread Scraper\r\n\r\nScrape **YouTube comments and reply threads** as analysis-ready rows. Give it video links, channel video tabs, playlists, or search queries, and get one row per comment or reply with author identity, engagement signals, thread position, and video context. Works on public videos without user cookies, login, or an API key.\r\n\r\n## What this actor does\r\n\r\n- **Four source types:** individual videos (watch, Shorts, live, embed, youtu.be, bare ID), channel video tabs, playlists, and video search results\r\n- **Flat rows by default:** every comment and reply is its own row with `parentCommentId`, `rootCommentId`, `replyLevel`, and `threadPosition`, so CSV, Excel, SQL, and sentiment pipelines can use it directly\r\n- **Full reply threads:** fetch every page of a thread, cap per comment, or skip replies entirely; `maxRepliesPerComment: 0` means all\r\n- **Real filters:** Top/Newest sort, date cutoff, multiple keywords (ANY match), minimum likes, and only creator-hearted comments\r\n- **Honest engagement data:** rounded like counts with the original text, advertised reply counts, pinned/hearted/verified flags, and approximate dates with a precision and range\r\n- **Video context on every row:** title, channel, views, likes, comment count, duration, publish date, and description\r\n- **Empty fields are omitted** — records only contain values that are actually populated.\r\n\r\n## Output per comment or reply\r\n\r\nEach dataset row is one comment (`recordType: \"comment\"`, `replyLevel: 0`) or one reply (`recordType: \"reply\"`, `replyLevel: 1`).\r\n\r\n| Field | Type | Description |\r\n|---|---|---|\r\n| `recordType` | string | `comment`, `reply`, or `status` |\r\n| `commentId` | string | YouTube comment identifier |\r\n| `parentCommentId` | string | Root comment ID on replies; omitted on top-level comments |\r\n| `rootCommentId` | string | Thread root ID (own ID on top-level comments) |\r\n| `replyLevel` | integer | `0` for comments, `1` for direct replies |\r\n| `threadPosition` | integer | 1-based order in the emitted feed or reply thread |\r\n| `isReply` | boolean | Backward-compatible alias for `replyLevel > 0` |\r\n| `replyDepth` | integer | Backward-compatible alias for `replyLevel` |\r\n| `text` | string | Comment text, with unicode and newlines preserved |\r\n| `authorName` | string | Display name |\r\n| `authorHandle` | string | `@handle` when YouTube exposes it |\r\n| `authorChannelId` | string | `UC...` channel ID |\r\n| `authorChannelUrl` | string | `https://www.youtube.com/@handle` or `/channel/UC...` |\r\n| `authorAvatarUrl` | string | Avatar image URL |\r\n| `authorIsVerified` | boolean | Author channel is verified |\r\n| `authorIsCreator` | boolean | Author is the video's channel owner |\r\n| `authorIsArtist` | boolean | Author is marked as a YouTube Music artist |\r\n| `likeCount` | integer | Rounded like count (`69K` becomes `69000`) |\r\n| `likeCountText` | string | Original formatted count (`69K`) |\r\n| `replyCount` | integer | YouTube's advertised reply count; may exceed visible replies |\r\n| `replyCountText` | string | Original reply-count text when YouTube exposes it (`963 replies`) |\r\n| `repliesFetched` | integer | Reply rows actually attached/emitted for this comment (present when replies were fetched) |\r\n| `isPinned` | boolean | Comment is pinned |\r\n| `pinnedByText` | string | Pinned-by tooltip (`Pinned by @channel`) |\r\n| `isHearted` | boolean | Creator hearted the comment |\r\n| `heartedBy` | string | Creator handle from the heart tooltip |\r\n| `heartedByText` | string | Normalized creator label for the heart |\r\n| `creatorReplied` | boolean | Whether any emitted reply is authored by the video creator (present when replies were fetched) |\r\n| `creatorReplyCount` | integer | Number of emitted replies authored by the creator (present when replies were fetched) |\r\n| `isEdited` | boolean | Comment was edited, derived from the `(edited)` label |\r\n| `publishedTimeText` | string | Relative publish text exactly as YouTube shows it |\r\n| `publishedAtApprox` | string | Midpoint of the inferred date range (ISO-8601) |\r\n| `publishedAtFrom` | string | Earliest possible publish time |\r\n| `publishedAtTo` | string | Latest possible publish time |\r\n| `publishedAtPrecision` | string | `second`, `minute`, `hour`, `day`, `week`, `month`, or `year` |\r\n| `ageDaysMin` | number | Lower bound of comment age in days |\r\n| `ageDaysMax` | number | Upper bound of comment age in days |\r\n| `ageDaysApprox` | number | Midpoint of the age range |\r\n| `commentUrl` | string | Direct link to the comment (`...&lc=COMMENT_ID`) |\r\n| `videoId` | string | Video ID |\r\n| `videoUrl` | string | Canonical watch URL |\r\n| `videoTitle` | string | Video title |\r\n| `videoChannelId` | string | Video channel ID |\r\n| `videoChannelHandle` | string | Video channel path (`/@handle`) |\r\n| `videoChannelName` | string | Video channel display name |\r\n| `videoViewCount` | integer | View count |\r\n| `videoLikeCount` | integer | Video like count (YouTube may round it) |\r\n| `videoCommentCount` | integer | Approximate total comments shown by YouTube |\r\n| `videoPublishDate` | string | Raw publish date text (`Oct 24, 2009`) |\r\n| `videoPublishedAt` | string | Parsed publish date (`2009-10-24`) |\r\n| `videoDurationSeconds` | integer | Video length in seconds |\r\n| `videoDescription` | string | Video description |\r\n| `sourceInput` | string | The original URL, handle, or query that produced this video |\r\n| `sourceScope` | string | `video`, `channel`, `playlist`, or `search` |\r\n| `sourceVideoId` | string | Video selected from the source |\r\n| `scrapedAt` | string | ISO-8601 UTC timestamp of the run |\r\n| `replies` | array | Embedded reply rows, only with `outputMode: tree` or `both` |\r\n\r\n### Status rows\r\n\r\nWhen `emitStatusRows` is enabled, blocked/empty/unavailable sources and partial or budget-limited runs add one row with `recordType: \"status\"`, `status`, `inputUrl`, optional video fields, `error`, and `rowsEmitted` when known. Status rows are never counted toward `maxTotalRows` and do not consume the data-row budget. Status values: `no_comments`, `comments_off`, `unavailable`, `private`, `age_restricted`, `members_only`, `region_blocked`, `live_chat_only`, `blocked`, `stale_token`, `transient_empty`, `parse_empty`, `partial`, `budget_reached`, `input_invalid`. Successful comment runs do not add an `ok` status row.\r\n\r\n### Dates and counts are approximate\r\n\r\nYouTube's public comment payload does not expose exact timestamps. Every date field is derived from the relative label (`6 years ago`) and comes with a precision plus earliest/latest bounds, so no exact timestamp is invented. Like counts above 999 are rounded by YouTube itself; `likeCountText` preserves the original value. `replyCount` is YouTube's advertised count and can be higher than the number of replies the public thread returns.\r\n\r\n## Input\r\n\r\n| Field | Type | Default | Description |\r\n|---|---|---|---|\r\n| `videoUrls` | array | `[]` | Video URLs or bare IDs (watch, youtu.be, Shorts, embed, live, v/) |\r\n| `channelUrls` | array | `[]` | Channel video tabs: `/@handle`, `/channel/UC...`, `/c/name`, `/user/name`, `@handle`, or `UC...` |\r\n| `playlistUrls` | array | `[]` | Playlist URLs or bare playlist IDs |\r\n| `searchQueries` | array | `[]` | Search terms or YouTube search-result URLs; video results only |\r\n| `maxVideosPerSource` | integer | `20` | Video discovery cap per channel/playlist/search; `0` = all |\r\n| `maxComments` | integer | `100` | Top-level comments to examine per video; `0` = all. Filtered-out comments still count toward this scan cap |\r\n| `includeReplies` | boolean | `true` | Fetch reply threads |\r\n| `maxRepliesPerComment` | integer | `50` | Replies per thread; `0` = all until the token is exhausted |\r\n| `maxReplyDepth` | integer | `1` | `0` = top-level only, `1` = direct replies |\r\n| `maxTotalRows` | integer | `5000` | Global comment + reply budget; `0` = unlimited |\r\n| `sortBy` | string | `top` | `top` or `newest` |\r\n| `commentsSince` | string | – | ISO date/datetime or relative duration (`7d`, `24h`); forces newest |\r\n| `keywords` | array | `[]` | Case-insensitive ANY-match filter on comment/reply text |\r\n| `minLikes` | integer | `0` | Drop rows below this rounded like count |\r\n| `onlyCreatorHearted` | boolean | `false` | Keep only creator-hearted rows |\r\n| `outputMode` | string | `flat` | `flat`, `tree`, or `both` |\r\n| `emitStatusRows` | boolean | `true` | Emit `recordType: \"status\"` rows for blocked/empty/invalid sources |\r\n\r\nAt least one of `videoUrls`, `channelUrls`, `playlistUrls`, or `searchQueries` is required.\r\n\r\n### Example: one video with replies\r\n\r\n```json\r\n{\r\n  \"videoUrls\": [\"https://www.youtube.com/watch?v=dQw4w9WgXcQ\"],\r\n  \"maxComments\": 50,\r\n  \"includeReplies\": true,\r\n  \"maxRepliesPerComment\": 20,\r\n  \"sortBy\": \"top\",\r\n  \"maxTotalRows\": 500\r\n}\r\n```\r\n\r\n### Example: recent comments from a channel\r\n\r\n```json\r\n{\r\n  \"channelUrls\": [\"https://www.youtube.com/@RickAstleyYT\"],\r\n  \"maxVideosPerSource\": 10,\r\n  \"sortBy\": \"newest\",\r\n  \"commentsSince\": \"7d\",\r\n  \"maxComments\": 100,\r\n  \"minLikes\": 5,\r\n  \"maxTotalRows\": 1000\r\n}\r\n```\r\n\r\n### Example: search with creator-hearted comments\r\n\r\n```json\r\n{\r\n  \"searchQueries\": [\"product launch review\"],\r\n  \"maxVideosPerSource\": 5,\r\n  \"onlyCreatorHearted\": true,\r\n  \"maxRepliesPerComment\": 0,\r\n  \"maxTotalRows\": 2000\r\n}\r\n```\r\n\r\n### Example: backward-compatible tree output\r\n\r\n```json\r\n{\r\n  \"videoUrls\": [\"https://www.youtube.com/watch?v=jNQXAC9IVRw\"],\r\n  \"maxComments\": 20,\r\n  \"includeReplies\": true,\r\n  \"maxRepliesPerComment\": 50,\r\n  \"outputMode\": \"tree\"\r\n}\r\n```\r\n\r\n## Use cases\r\n\r\n- **Sentiment and topic analysis** — export flat comment and reply rows directly into a notebook or BI tool\r\n- **Creator engagement audits** — measure which comments the creator pinned, hearted, or replied to\r\n- **Audience research** — mine questions and recurring themes across a channel's recent uploads or a playlist\r\n- **Moderation datasets** — collect long reply threads with parent/root IDs for context-aware classifiers\r\n- **Campaign monitoring** — pull the last few days of comments from several videos with a date cutoff and minimum-like floor\r\n- **Competitive research** — compare comment volume and engagement on the same topic across search results\r\n\r\n## FAQ\r\n\r\n### Do I need a YouTube API key or cookies?\r\n\r\nNo. The actor reads publicly available comment data without login, user cookies, or an API key. It sends YouTube's standard consent cookie so the EU consent page does not replace the video page.\r\n\r\n### Why are some comments missing?\r\n\r\nYouTube's \"Top comments\" feed exposes a relevance-ordered subset (roughly 800–1,200 comments on very large videos). Use `sortBy: \"newest\"` for complete date windows and deeper coverage. The actor also continues with the newest feed when the top feed is exhausted, and `maxComments`, `maxTotalRows`, and your filters can all stop a run earlier than the source total.\r\n\r\n### Are replies complete?\r\n\r\nThe actor follows every reply continuation until the thread token is exhausted when `maxRepliesPerComment: 0`. YouTube can hide deleted, spam, or moderated replies, so the advertised `replyCount` may be higher than the rows returned. `repliesFetched` tells you exactly how many were emitted for a comment.\r\n\r\n### Why are timestamps approximate?\r\n\r\nThe public comment payload only contains relative labels such as \"2 days ago\". The actor converts each label into a date range with a precision (`hour`, `day`, `week`, `month`, `year`) and exposes the earliest and latest possible time. It never claims an exact timestamp.\r\n\r\n### Why do like counts end in round numbers?\r\n\r\nYouTube exposes formatted counts (`1.2K`, `69K`, `4.8M`). `likeCount` is the rounded integer and `likeCountText` preserves the original string.\r\n\r\n### What does a date cutoff do?\r\n\r\n`commentsSince` forces the newest-first feed, keeps pinned comments, and stops paginating at the first non-pinned comment older than the cutoff. Because dates are approximate, a comment whose range overlaps the cutoff is kept.\r\n\r\n### What happens if a video is private, age-restricted, or has comments off?\r\n\r\nThe actor classifies the page and, when `emitStatusRows` is enabled, writes one status row explaining the outcome. It does not try to bypass login, age, or region restrictions.\r\n\r\n### What is the difference between `flat`, `tree`, and `both`?\r\n\r\n`flat` emits one row per comment and reply. `tree` emits parent comment rows with a `replies[]` array (useful for older integrations). `both` emits flat reply rows and also attaches `replies[]` to each parent.\r\n\r\n### Do I need to configure a proxy?\r\n\r\nNo. The actor chooses a working connection to YouTube automatically. Just fill in the input and run it.\r\n\r\n### Is this affiliated with YouTube?\r\n\r\nNo. This is an independent tool and is not affiliated with, endorsed by, or sponsored by YouTube or Google.\r\n\r\n## Limitations\r\n\r\n- Exact comment timestamps and exact like counts are not available in the public payload.\r\n- Nested replies beyond direct level 1 are not exposed in the observed public payload; `maxReplyDepth` above 1 is reserved for future support.\r\n- The top-comments feed is capped by YouTube; use newest-first plus a date cutoff for exhaustive windows.\r\n- Age-restricted, private, members-only, and region-blocked videos are reported as statuses, not bypassed.\r\n- `replyCount` is advertised, not guaranteed; use `repliesFetched` for the delivered count.\r\n",
      "storages": {
        "dataset": {
          "actorSpecification": 1,
          "views": {
            "overview": {
              "title": "Comments and replies",
              "transformation": {
                "fields": [
                  "recordType",
                  "commentId",
                  "authorName",
                  "text",
                  "likeCount",
                  "replyCount",
                  "isHearted",
                  "videoTitle"
                ]
              },
              "display": {
                "component": "table",
                "properties": {
                  "recordType": {
                    "label": "Type",
                    "format": "text"
                  },
                  "commentId": {
                    "label": "Comment ID",
                    "format": "text"
                  },
                  "authorName": {
                    "label": "Author",
                    "format": "text"
                  },
                  "text": {
                    "label": "Text",
                    "format": "text"
                  },
                  "likeCount": {
                    "label": "Likes",
                    "format": "number"
                  },
                  "replyCount": {
                    "label": "Replies",
                    "format": "number"
                  },
                  "isHearted": {
                    "label": "Hearted",
                    "format": "boolean"
                  },
                  "videoTitle": {
                    "label": "Video",
                    "format": "text"
                  }
                }
              }
            },
            "thread": {
              "title": "Thread fields",
              "transformation": {
                "fields": [
                  "recordType",
                  "commentId",
                  "parentCommentId",
                  "rootCommentId",
                  "replyLevel",
                  "threadPosition",
                  "authorHandle",
                  "likeCountText",
                  "replyCountText",
                  "isPinned",
                  "heartedBy",
                  "creatorReplied",
                  "publishedTimeText",
                  "publishedAtApprox",
                  "publishedAtPrecision",
                  "videoId",
                  "sourceInput",
                  "sourceScope"
                ]
              },
              "display": {
                "component": "table",
                "properties": {
                  "recordType": {
                    "label": "Type",
                    "format": "text"
                  },
                  "commentId": {
                    "label": "Comment ID",
                    "format": "text"
                  },
                  "parentCommentId": {
                    "label": "Parent ID",
                    "format": "text"
                  },
                  "rootCommentId": {
                    "label": "Root ID",
                    "format": "text"
                  },
                  "replyLevel": {
                    "label": "Level",
                    "format": "number"
                  },
                  "threadPosition": {
                    "label": "Position",
                    "format": "number"
                  },
                  "authorHandle": {
                    "label": "Handle",
                    "format": "text"
                  },
                  "likeCountText": {
                    "label": "Likes (text)",
                    "format": "text"
                  },
                  "replyCountText": {
                    "label": "Replies (text)",
                    "format": "text"
                  },
                  "isPinned": {
                    "label": "Pinned",
                    "format": "boolean"
                  },
                  "heartedBy": {
                    "label": "Hearted by",
                    "format": "text"
                  },
                  "creatorReplied": {
                    "label": "Creator replied",
                    "format": "boolean"
                  },
                  "publishedTimeText": {
                    "label": "Published",
                    "format": "text"
                  },
                  "publishedAtApprox": {
                    "label": "Approx. published",
                    "format": "text"
                  },
                  "publishedAtPrecision": {
                    "label": "Date precision",
                    "format": "text"
                  },
                  "videoId": {
                    "label": "Video ID",
                    "format": "text"
                  },
                  "sourceInput": {
                    "label": "Source input",
                    "format": "text"
                  },
                  "sourceScope": {
                    "label": "Source scope",
                    "format": "text"
                  }
                }
              }
            }
          }
        }
      }
    },
    "buildNumber": "1.0.3",
    "imageDigest": "108995ae7b1badb41048ef9b314fede6a979fcacb2c0fdc1470a80294e754b0c",
    "actVersion": {
      "sourceType": "SOURCE_FILES",
      "buildTag": "latest",
      "versionNumber": "1.0"
    },
    "usage": {
      "ACTOR_COMPUTE_UNITS": 0.012978888888888889
    }
  }
}