{
  "format": "mosael-workflow",
  "version": 1,
  "workflow_revision": 1,
  "name": "Comment insights and audience",
  "description": "Paste a post link to fetch its most-liked comments (50 by default: one page via TikHub, about 20–50; the browser scrolls through the comment section), group them by topic, infer the audience, needs and pain points, open questions, objections and sentiment, suggest next topics and replies to the comments worth answering, saved as a note. Pick the data source from the start node's drop-down: the built-in browser (nothing to set up and free; Xiaohongshu comments need a signed-in browser profile) or TikHub (needs the plugin and an API key; 1 call per run; complete fields, steadier, no sign-in). Plus one or two AI chat calls.",
  "graph_hash": "6f945bedd19aa8c93b9159cc7ae7a91a05f252292ee25575f8f2a1acd6efc74c",
  "graph": {
    "meta": {
      "template_id": "comment_insights",
      "template_version": 4,
      "source": "official"
    },
    "nodes": [
      {
        "id": "start",
        "type": "start",
        "name": "Post and data source",
        "position": {
          "x": 40,
          "y": 535
        },
        "config": {
          "params": {
            "video_link": "",
            "data_source": "",
            "platform": "",
            "comment_count": 50,
            "focus": "",
            "report_language": "English"
          },
          "required_params": [
            "video_link",
            "data_source"
          ],
          "param_options": {
            "data_source": [
              {
                "value": "browser",
                "label": "Built-in browser",
                "description": "Nothing to set up and free; for pages that need a sign-in (Xiaohongshu always, Douyin mostly), switch “Open in the built-in browser” to a signed-in browser-pool profile. Bilibili shows signed-out visitors only the first 3 top-level comments (and 20 replies under each); to fetch them all, use a profile signed in to Bilibili too. Numbers on the page are rounded and publish times are often missing"
              },
              {
                "value": "tikhub",
                "label": "TikHub",
                "description": "Needs the TikHub plugin with a connection and your API key (billed per call); fuller, steadier data and no sign-in",
                "requires": "tikhub_comments"
              }
            ]
          }
        }
      },
      {
        "id": "link",
        "type": "social_link",
        "name": "Work out the platform and id",
        "position": {
          "x": 360,
          "y": 535
        },
        "config": {
          "link": "{{start.video_link}}",
          "platform": "{{start.platform}}",
          "expect": "video"
        }
      },
      {
        "id": "use_tikhub",
        "type": "condition",
        "name": "Fetch with TikHub?",
        "position": {
          "x": 1000,
          "y": 535
        },
        "config": {
          "left": "{{start.data_source}}",
          "op": "equals",
          "right": "tikhub"
        }
      },
      {
        "id": "tk_is_dy",
        "type": "condition",
        "name": "Is it Douyin?",
        "position": {
          "x": 1320,
          "y": 60
        },
        "config": {
          "left": "",
          "op": "equals",
          "right": "douyin"
        },
        "inputs": [
          "left"
        ]
      },
      {
        "id": "dy_comments",
        "type": "plugin_tool",
        "name": "TikHub: Douyin comments",
        "position": {
          "x": 1640,
          "y": 60
        },
        "config": {
          "plugin_id": "dev.mosael.tikhub",
          "tool_name": "douyin_web_fetch_video_comments",
          "instance_id": "",
          "input": {
            "aweme_id": "{{link.id}}",
            "count": "{{start.comment_count}}"
          }
        }
      },
      {
        "id": "dy_comments_m",
        "type": "social_metrics",
        "name": "Rank the comments",
        "position": {
          "x": 1960,
          "y": 60
        },
        "config": {
          "data": "",
          "kind": "comments",
          "limit": "2000",
          "table_limit": "{{start.comment_count}}"
        },
        "inputs": [
          "data"
        ]
      },
      {
        "id": "tk_is_xhs",
        "type": "condition",
        "name": "Is it Xiaohongshu?",
        "position": {
          "x": 1320,
          "y": 250
        },
        "config": {
          "left": "",
          "op": "equals",
          "right": "xiaohongshu"
        },
        "inputs": [
          "left"
        ]
      },
      {
        "id": "xhs_comments",
        "type": "plugin_tool",
        "name": "TikHub: Xiaohongshu comments",
        "position": {
          "x": 1640,
          "y": 250
        },
        "config": {
          "plugin_id": "dev.mosael.tikhub",
          "tool_name": "xiaohongshu_app_v2_get_note_comments",
          "instance_id": "",
          "input": {
            "note_id": "{{link.id}}",
            "share_text": "{{link.url}}"
          }
        }
      },
      {
        "id": "xhs_comments_m",
        "type": "social_metrics",
        "name": "Rank the comments",
        "position": {
          "x": 1960,
          "y": 250
        },
        "config": {
          "data": "",
          "kind": "comments",
          "limit": "2000",
          "table_limit": "{{start.comment_count}}"
        },
        "inputs": [
          "data"
        ]
      },
      {
        "id": "tk_is_bili",
        "type": "condition",
        "name": "Is it Bilibili?",
        "position": {
          "x": 1320,
          "y": 440
        },
        "config": {
          "left": "",
          "op": "equals",
          "right": "bilibili"
        },
        "inputs": [
          "left"
        ]
      },
      {
        "id": "bili_comments",
        "type": "plugin_tool",
        "name": "TikHub: Bilibili comments",
        "position": {
          "x": 1640,
          "y": 440
        },
        "config": {
          "plugin_id": "dev.mosael.tikhub",
          "tool_name": "bilibili_web_fetch_video_comments",
          "instance_id": "",
          "input": {
            "bv_id": "{{link.id}}",
            "pn": "1"
          }
        }
      },
      {
        "id": "bili_comments_m",
        "type": "social_metrics",
        "name": "Rank the comments",
        "position": {
          "x": 1960,
          "y": 440
        },
        "config": {
          "data": "",
          "kind": "comments",
          "limit": "2000",
          "table_limit": "{{start.comment_count}}",
          "unescape_html": "yes"
        },
        "inputs": [
          "data"
        ]
      },
      {
        "id": "tk_unsupported",
        "type": "notify",
        "name": "TikHub route can't take this platform",
        "position": {
          "x": 1640,
          "y": 630
        },
        "config": {
          "title": "Analysis not started: the TikHub route doesn't cover this platform",
          "body": "The platform was read as “{{link.platform}}”. The TikHub route covers Douyin, Xiaohongshu and Bilibili; for anything else pick “Built-in browser” as the data source (data_source) on the start node and run again. If the platform was misread, name it in the start node's platform."
        }
      },
      {
        "id": "web_open",
        "type": "browser_open",
        "name": "Open in the built-in browser",
        "position": {
          "x": 1320,
          "y": 858
        },
        "config": {
          "url": "",
          "session_mode": "named",
          "session_name": "Social analysis"
        },
        "inputs": [
          "url"
        ]
      },
      {
        "id": "web_read",
        "type": "browser_evaluate",
        "name": "Scroll and read the page text",
        "position": {
          "x": 1640,
          "y": 858
        },
        "config": {
          "session": "",
          "expression": "(async () => {\n  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));\n  const BILI = /bilibili\\.com\\/video\\/(BV[\\w]+)/;\n  const bili = location.href.match(BILI) || String(input.video_url || \"\").match(BILI);\n  if (bili) {\n    //: B 站:页面上下文直接调它自己的接口 —— 带登录态、结构化 JSON、分页器和楼中楼都在里面,\n    //: 评论区组件渲染失败(风控「玩命加载」)也不受影响。接口变了就落空,落回下面读 DOM 那条路。\n    try {\n      const getJson = async (url) => (await fetch(url, { credentials: \"include\" })).json();\n      const started = Date.now();\n      //: 时间预算要落在节点的脚本上限(timeout_ms)之内;页与页之间隔一下,不踩频控。\n      const budget = Number(input.budget_ms) || 15000;\n      const pause = input.page_pause_ms == null ? 400 : Number(input.page_pause_ms);\n      const outOfTime = () => Date.now() - started > budget;\n      const view = await getJson(\"https://api.bilibili.com/x/web-interface/view?bvid=\" + bili[1]);\n      const v = view && view.data;\n      if (v && v.aid) {\n        const total = v.stat ? Number(v.stat.reply) || 0 : 0;\n        const nav = await getJson(\"https://api.bilibili.com/x/web-interface/nav\").catch(() => null);\n        const loggedIn = Boolean(nav && nav.data && nav.data.isLogin);\n        //: 评论有多少抓多少,上限 comment_max(最多 2000 条)。\n        const wanted = Math.min(Number(input.comment_max) || 1000, 2000);\n        const seen = new Set();\n        const picked = [];\n        let stoppedBy = \"\";\n        //: 同一条评论只算一次(一级里自带的楼中楼预览、置顶,和翻出来的是同一条)。楼中楼拍平、带 ↳ 前缀。\n        const take = (c, isReply) => {\n          const id = c && (c.rpid_str || (c.rpid ? String(c.rpid) : \"\"));\n          if (!id || seen.has(id)) return;\n          seen.add(id);\n          picked.push({ isReply, comment: {\n            author: c.member ? c.member.uname : \"\",\n            //: 原文照搬:换行是原样的一部分;接口交回的 HTML 转义(&#39;)留给整理节点解一次(unescape_html)。\n            text: (isReply ? \"↳ \" : \"\") + ((c.content ? c.content.message : \"\") || \"\").trim(),\n            likes: typeof c.like === \"number\" ? c.like : \"\",\n            published_at: c.ctime ? new Date(c.ctime * 1000).toISOString().slice(0, 10) : \"\",\n          } });\n        };\n        const full = () => {\n          if (picked.length >= wanted) { stoppedBy = \"limit\"; return true; }\n          if (outOfTime()) { stoppedBy = \"time\"; return true; }\n          return false;\n        };\n        //: 一级评论逐页翻到平台不再给(第 1 页另带置顶)。未登录时 B 站只给 1 页 3 条 —— 照实停下,下面说清。\n        const threads = [];\n        for (let pn = 1; !(pn > 1 && full()); pn += 1) {\n          const r = await getJson(\"https://api.bilibili.com/x/v2/reply?type=1&oid=\" + v.aid + \"&sort=2&ps=20&pn=\" + pn);\n          const d = (r && r.data) || {};\n          const replies = d.replies || [];\n          const pinned = pn === 1 ? [...(d.top_replies || []), ...(d.upper && d.upper.top ? [d.upper.top] : [])] : [];\n          for (const one of [...pinned, ...replies]) {\n            take(one, false);\n            for (const sub of one.replies || []) take(sub, true);\n            threads.push(one);\n          }\n          if (replies.length < 20) break;\n          await wait(pause);\n        }\n        //: 楼中楼:一级里只自带前几条预览,回复更多的楼逐页翻 reply/reply,翻到平台不再给。\n        for (const one of threads) {\n          if (stoppedBy || full()) break;\n          if ((one.rcount || 0) <= (one.replies || []).length) continue;\n          for (let pn = 1; !full(); pn += 1) {\n            const r = await getJson(\"https://api.bilibili.com/x/v2/reply/reply?type=1&oid=\" + v.aid + \"&root=\" + (one.rpid_str || one.rpid) + \"&ps=20&pn=\" + pn);\n            const subs = (r && r.data && r.data.replies) || [];\n            for (const sub of subs) take(sub, true);\n            if (subs.length < 20) break;\n            await wait(pause);\n          }\n        }\n        const kept = picked.slice(0, wanted);\n        if (kept.length) {\n          const fetchedReplies = kept.filter((one) => one.isReply).length;\n          //: 取到的比平台说的少,说清是哪一种:没登录(平台只给前几条)、到了条数 / 时间上限、或者平台就是没给\n          //: (被删除、折叠、仅自己可见)。这几句话由模板按界面语言交进来(input.notes)。\n          const notes = input.notes || {};\n          const gap = kept.length >= total ? \"\"\n            : stoppedBy === \"limit\" ? notes.limit\n            : stoppedBy === \"time\" ? notes.time\n            : !loggedIn ? notes.login\n            : notes.platform;\n          //: 视频本身的数据也在 view 接口里(播放、点赞、时长、发布时间……)—— 一并交回,爆款拆解直接整理它。\n          //: 只带整理要用的那几格,原样的 view 返回很大,会白占运行记录。\n          const video = { bvid: v.bvid, aid: v.aid, title: v.title, pubdate: v.pubdate, duration: v.duration, desc: v.desc,\n                          owner: v.owner ? { name: v.owner.name, mid: v.owner.mid } : null, stat: v.stat || {} };\n          return { url: location.href, title: document.title, now: new Date().toISOString(), mode: \"api\", total,\n                   fetched: kept.length, fetched_roots: kept.length - fetchedReplies, fetched_replies: fetchedReplies,\n                   logged_in: loggedIn, gap_note: gap || \"\", expanded: -1, video,\n                   text: JSON.stringify({ total, comments: kept.map((one) => one.comment) }) };\n        }\n      }\n    } catch (e) { /* 接口这条路不通就落回读 DOM */ }\n  }\n  await wait(Number(input.settle_ms) || 2500);\n  const rounds = Math.min(Number(input.scrolls) || 0, 12);\n  let last = -1, stable = 0;\n  for (let i = 0; i < rounds; i += 1) {\n    window.scrollBy(0, Math.max(window.innerHeight, 800));\n    await wait(Number(input.pause_ms) || 1300);\n    const h = document.body.scrollHeight;\n    const atBottom = window.scrollY + window.innerHeight >= h - 4;\n    if (atBottom && h === last) { stable += 1; if (stable >= 2) break; } else { stable = 0; }\n    last = h;\n  }\n  const expandMax = Math.min(Number(input.expand_max) || 0, 20);\n  const expanders = [];\n  if (expandMax > 0) {\n    await wait(Number(input.expand_settle_ms) || 1200);\n    //: 目标站点说什么语言不归界面语言管 —— 两种都认。英文站点(YouTube 等)的折叠钮是\n    //: 「View 12 replies」「Show more replies」;认不出就这轮不展开,读到的仍是顶层评论。\n    const PATTERN = /^(共\\s*\\d+\\s*条回复|展开\\s*\\d*\\s*条回复|展开更多回复|查看回复|view\\s+\\d+\\s+(more\\s+)?repl|show\\s+more\\s+repl)/i;\n    //: 折叠钮的文字可能是「共<em>43</em>条回复」这种带子标签的 —— 不要求叶子,\n    //: 取**最深的**那个匹配(后代里没有再匹配的),点它才对得上点击处理器。\n    const collect = (root) => {\n      root.querySelectorAll(\"*\").forEach((el) => {\n        if (el.shadowRoot) collect(el.shadowRoot);\n        if (expanders.length >= expandMax || el.tagName === \"A\") return;\n        const text = (el.textContent || \"\").trim();\n        if (text.length <= 2 || text.length >= 40 || !PATTERN.test(text)) return;\n        const nested = Array.from(el.children).some((c) => PATTERN.test((c.textContent || \"\").trim()));\n        if (!nested) expanders.push(el);\n      });\n    };\n    collect(document);\n    //: 说明文字旁边另有一个「点击查看」时点那个:B 站的折叠钮是 <span>共5条回复，</span><bili-text-button>点击查看\n    //: </bili-text-button>,点 span 什么也不发生(实测展开报了 8 次、楼中楼一条没出来)。\n    const VIEW = /^(点击查看|查看|展开|view|show)/i;\n    const clickTarget = (el) => Array.from((el.parentElement && el.parentElement.children) || [])\n      .find((one) => one !== el && VIEW.test((one.textContent || \"\").trim())) || el;\n    for (const el of expanders) {\n      const target = clickTarget(el);\n      target.scrollIntoView({ block: \"center\" });\n      target.click();\n      await wait(Number(input.expand_wait_ms) || 600);\n    }\n  }\n  await wait(Number(input.final_ms) || 1500);\n  const SKIP = new Set([\"STYLE\", \"SCRIPT\", \"NOSCRIPT\", \"LINK\", \"META\"]);\n  const parts = [(document.body && document.body.innerText) || \"\"];\n  const visit = (root) => {\n    root.querySelectorAll(\"*\").forEach((el) => {\n      if (!el.shadowRoot) return;\n      const text = Array.from(el.shadowRoot.children)\n        .filter((one) => !SKIP.has(one.tagName))\n        .map((one) => one.innerText || \"\").join(\"\\n\").trim();\n      if (text) parts.push(text);\n      visit(el.shadowRoot);\n    });\n  };\n  visit(document);\n  const limit = Number(input.max_chars) || 30000;\n  return { url: location.href, title: document.title, now: new Date().toISOString(), expanded: expanders.length, text: parts.join(\"\\n\\n\").slice(0, limit) };\n})()",
          "input": {
            "settle_ms": 2500,
            "scrolls": 7,
            "pause_ms": 1200,
            "max_chars": 120000,
            "expand_max": 8,
            "comment_max": 2000,
            "budget_ms": 90000,
            "page_pause_ms": 400,
            "notes": {
              "login": "Not signed in: Bilibili shows signed-out visitors only the first 3 top-level comments and the first 20 replies under each. To fetch them all, switch “Open in the built-in browser” to the browser pool and pick a profile signed in to Bilibili.",
              "limit": "Stopped at this run's comment limit; the rest weren't fetched.",
              "time": "Stopped at this run's time limit for fetching; the rest weren't fetched.",
              "platform": "Bilibili didn't return the rest (deleted, folded or private comments)."
            }
          },
          "timeout_ms": 120000
        },
        "inputs": [
          "session"
        ]
      },
      {
        "id": "web_close",
        "type": "browser_close",
        "name": "Close the browser",
        "position": {
          "x": 1960,
          "y": 1010
        },
        "config": {
          "session": ""
        },
        "inputs": [
          "session"
        ]
      },
      {
        "id": "web_is_api",
        "type": "condition",
        "name": "Came from the site API?",
        "position": {
          "x": 1832,
          "y": 858
        },
        "config": {
          "left": "{{web_read.value.mode}}",
          "op": "equals",
          "right": "api"
        }
      },
      {
        "id": "web_api_m",
        "type": "social_metrics",
        "name": "Tidy the API comments",
        "position": {
          "x": 2152,
          "y": 820
        },
        "config": {
          "data": "{{web_read.value.text}}",
          "kind": "comments",
          "limit": "2000",
          "table_limit": "{{start.comment_count}}",
          "unescape_html": "yes"
        }
      },
      {
        "id": "web_api_coverage",
        "type": "template",
        "name": "How many comments came back",
        "position": {
          "x": 2152,
          "y": 877
        },
        "config": {
          "template": "Comment count: the platform shows {{web_read.value.total}}; this run fetched {{web_read.value.fetched}} ({{web_read.value.fetched_roots}} top-level, {{web_read.value.fetched_replies}} replies). {{web_read.value.gap_note}}"
        }
      },
      {
        "id": "web_struct",
        "type": "llm",
        "name": "Turn the page text into a comment list",
        "position": {
          "x": 2152,
          "y": 933
        },
        "config": {
          "profile_id": "",
          "model": "",
          "preset": "precise",
          "system": "你会收到内嵌浏览器打开一条自媒体作品、往下滚过评论区之后读到的页面文字。把这条作品的标题和页面上能看到的每一条评论整理出来(原文一字不改,赞多的优先;回复楼里的也算,但不要把推荐视频的标题当成评论)。\n规则:\n- 只抄页面上真有的,不编、不补、不估;页面上没有的字段写空字符串。\n- 数字照页面原样抄(1.2万、3.4w、10万+ 都原样写),不要自己换算。\n- 发布时间:能确定就写成 YYYY-MM-DD(有钟点就 YYYY-MM-DD HH:MM);写的是相对时间(3天前、昨天、2小时前)就按\n  「读取时间」换算成日期;只写了月-日(09-21)就补上读取时间那一年;实在看不出就空字符串。\n- 页面主要是登录框、验证码、「登录后查看更多」时 login_wall = true,并在 notes 里说清看到了什么;\n  这时宁可少抄,不要把推荐位、广告、别人的作品当成这个账号的。\n只输出符合 JSON Schema 的对象。",
          "prompt": "平台(可能为空):{{link.platform}}\n作品:{{link.url}}\n读取时间:{{web_read.value.now}}\n页面标题:{{web_read.value.title}}\n\n页面文字:\n{{web_read.value.text}}",
          "response_format": "json_schema",
          "json_schema_name": "comments_page",
          "json_schema": {
            "type": "object",
            "properties": {
              "title": {
                "type": "string",
                "description": "这条作品的标题"
              },
              "comment_total": {
                "type": "string",
                "description": "页面上显示的评论总数,照页面原样抄(「1.2万」也照抄);页面上没有就空字符串"
              },
              "comments": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "text": {
                      "type": "string",
                      "description": "评论原文,一字不改"
                    },
                    "likes": {
                      "type": "string",
                      "description": "照页面原样抄(如 1.2万、3456、10万+);页面上没有就空字符串"
                    },
                    "author": {
                      "type": "string"
                    },
                    "published_at": {
                      "type": "string",
                      "description": "能看出就写 YYYY-MM-DD,否则空字符串"
                    }
                  },
                  "required": [
                    "text",
                    "likes",
                    "author",
                    "published_at"
                  ],
                  "additionalProperties": false
                },
                "maxItems": 150
              },
              "login_wall": {
                "type": "boolean"
              },
              "notes": {
                "type": "string",
                "description": "页面上异常的地方;没有就空字符串"
              }
            },
            "required": [
              "title",
              "comment_total",
              "comments",
              "login_wall",
              "notes"
            ],
            "additionalProperties": false
          },
          "json_schema_strict": "true",
          "temperature": 0.1,
          "max_tokens": 16000
        }
      },
      {
        "id": "web_comments_m",
        "type": "social_metrics",
        "name": "Rank the comments",
        "position": {
          "x": 2472,
          "y": 933
        },
        "config": {
          "data": "{{web_struct.json.comments}}",
          "kind": "comments",
          "limit": "2000",
          "table_limit": "{{start.comment_count}}"
        }
      },
      {
        "id": "web_dom_coverage",
        "type": "template",
        "name": "How many comments were read",
        "position": {
          "x": 2664,
          "y": 933
        },
        "config": {
          "template": "Comment count: this run read {{web_comments_m.count}} from the page, only the ones the page had loaded, not all of them (total shown on the page: {{web_struct.json.comment_total}})."
        }
      },
      {
        "id": "data_block",
        "type": "template",
        "name": "Collect the comments from whichever route ran",
        "position": {
          "x": 2600,
          "y": 535
        },
        "config": {
          "template": "{{web_api_coverage.text}}{{web_dom_coverage.text}}\n\n{{dy_comments_m.summary}}{{xhs_comments_m.summary}}{{bili_comments_m.summary}}{{web_api_m.summary}}{{web_comments_m.summary}}\n\n### All comments (by likes)\n\n{{dy_comments_m.table}}{{xhs_comments_m.table}}{{bili_comments_m.table}}{{web_api_m.table}}{{web_comments_m.table}}"
        }
      },
      {
        "id": "has_comments",
        "type": "condition",
        "name": "Were any comments fetched?",
        "position": {
          "x": 2920,
          "y": 535
        },
        "config": {
          "left": "{{dy_comments_m.count}}{{xhs_comments_m.count}}{{bili_comments_m.count}}{{web_api_m.count}}{{web_comments_m.count}}",
          "op": "gt",
          "right": "0"
        }
      },
      {
        "id": "no_comments_notice",
        "type": "notify",
        "name": "No comments fetched",
        "position": {
          "x": 3240,
          "y": 858
        },
        "config": {
          "title": "Comment insights stopped: no comments were fetched",
          "body": "No comments came back for {{link.url}}. Usual causes: comments need a sign-in (always on Xiaohongshu — switch “Open in the built-in browser” to a signed-in browser-pool profile); comments are off or there are none yet; the TikHub tools aren't enabled or the quota ran out. {{web_struct.json.notes}}"
        }
      },
      {
        "id": "all_items",
        "type": "template",
        "name": "Collect every comment",
        "position": {
          "x": 2920,
          "y": 535
        },
        "config": {
          "template": "{{dy_comments_m.items}}{{xhs_comments_m.items}}{{bili_comments_m.items}}{{web_api_m.items}}{{web_comments_m.items}}"
        }
      },
      {
        "id": "chunks",
        "type": "list_chunk",
        "name": "Split into batches",
        "position": {
          "x": 2920,
          "y": 649
        },
        "config": {
          "items": "",
          "size": 80
        },
        "inputs": [
          "items"
        ]
      },
      {
        "id": "batch_analyze",
        "type": "loop_foreach",
        "name": "Analyse batch by batch",
        "position": {
          "x": 2920,
          "y": 763
        },
        "config": {
          "items": "",
          "concurrency": 2,
          "inputs": {
            "url": "{{link.url}}",
            "focus": "{{start.focus}}"
          },
          "body": {
            "nodes": [
              {
                "id": "batch_notes",
                "type": "llm",
                "name": "Analyse this batch",
                "position": {
                  "x": 80,
                  "y": 120
                },
                "config": {
                  "profile_id": "",
                  "model": "",
                  "preset": "precise",
                  "system": "你是用户研究助手,负责读完一批评论并留下紧凑的观察笔记。只根据给到的这批评论说话:\n- 这批在聊什么(话题 + 条数);\n- 大家在问什么、哪里不满、哪里惊喜;\n- 值得回复的评论(高赞的问题、误解、负面),写出建议回复;\n- 值得原样引用的金句(抄原文)。\n「」里只放评论原文的逐字摘录(太长可以用……删节,不改字、不把两条拼成一句);归纳、转述、话题名、建议回复的台词都不要加「」。\n用户写了关心的问题就优先回应它。只输出符合 JSON Schema 的对象。",
                  "prompt": "作品:{{input.url}}\n用户关心的问题(可能为空):{{input.focus}}\n\n这一批评论(JSON):\n{{loop.item}}",
                  "response_format": "json_schema",
                  "json_schema_name": "comment_insights_batch",
                  "json_schema": {
                    "type": "object",
                    "properties": {
                      "notes_markdown": {
                        "type": "string",
                        "description": "这一批的观察笔记,紧凑 markdown"
                      },
                      "standout_comments": {
                        "type": "array",
                        "items": {
                          "type": "string",
                          "description": "值得原样引用的评论原文"
                        },
                        "description": "最多 8 条"
                      }
                    },
                    "required": [
                      "notes_markdown",
                      "standout_comments"
                    ],
                    "additionalProperties": false
                  },
                  "json_schema_strict": "true",
                  "temperature": 0.3,
                  "max_tokens": 6000
                }
              }
            ],
            "edges": []
          },
          "output": "{{batch_notes.json.notes_markdown}}"
        },
        "inputs": [
          "items"
        ]
      },
      {
        "id": "insight",
        "type": "llm",
        "name": "Synthesize the batch notes",
        "position": {
          "x": 3240,
          "y": 535
        },
        "config": {
          "profile_id": "",
          "model": "",
          "preset": "precise",
          "system": "你是用户研究和社区运营专家。你会收到:一份按赞排的整体统计,以及**分批覆盖这次取到的全部评论**的\n分析笔记(评论太多装不下一次读完时,分批读过再交给你综合)。以分批笔记为准,统计用来校准比例。\n数据开头「评论条数」那句写着这次取到多少、平台显示多少:取到的比平台少时,在 report_markdown 开头如实写明只分析了\n取到的这些、为什么没取全,结论按这批评论下,不要说成全部评论;取全了就不必提。做评论区洞察。\n\n- verdict:一句话,评论区最值得注意的事。\n- report_markdown 用二级标题分成这几节:\n  1. 大家在聊什么:按话题聚类,每类写条数和 1–2 条代表评论原文;\n  2. 观众画像:从用词、关心的事推断年龄段、身份、所处阶段,每一条写明依据,并注明是推断;\n  3. 需求与痛点;\n  4. 疑问清单:大家在问什么,哪些值得单独做一条内容回答(用自己的话概括成问题时不加「」,要引原话就逐字摘);\n  5. 异议与负面:说了什么、有多少、建议怎么回应;\n  6. 情绪分布:正面 / 中性 / 负面的大致比例(注明是估计);\n  7. 下一条可以做什么:3–5 个选题,每个写清依据的是哪几条评论。\n- reply_suggestions_markdown:挑 5–10 条最值得回复的评论(高赞的问题、误解、负面),写出建议回复,语气真诚、不抬杠。\n- 只引用给到的评论原文,不要编评论;样本少就明说结论的把握有限。\n- 「」里只放评论原文的逐字摘录(太长可以用……删节,不改字、不把两条拼成一句);归纳、转述、话题名、建议回复的台词都不要加「」。\n- 用户写了关心的问题就单独一节回应。\n- 用{{start.report_language}}写。\n只输出符合 JSON Schema 的对象。",
          "prompt": "作品:{{link.url}}\n平台:{{link.platform}}\n标题(可能为空):{{web_struct.json.title}}{{web_read.value.title}}\n用户关心的问题(可能为空):{{start.focus}}\n\n按赞排的整体情况:\n{{data_block.text}}\n\n分批分析笔记(覆盖了全部评论,JSON 数组,逐条是一份笔记):\n{{batch_analyze.results}}",
          "response_format": "json_schema",
          "json_schema_name": "comment_insights",
          "json_schema": {
            "type": "object",
            "properties": {
              "title": {
                "type": "string",
                "description": "笔记标题:带上作品标题和「评论区洞察」"
              },
              "verdict": {
                "type": "string",
                "description": "一句话:评论区最值得注意的事"
              },
              "report_markdown": {
                "type": "string",
                "description": "完整洞察,Markdown"
              },
              "reply_suggestions_markdown": {
                "type": "string",
                "description": "值得回复的评论与建议回复,Markdown"
              }
            },
            "required": [
              "title",
              "verdict",
              "report_markdown",
              "reply_suggestions_markdown"
            ],
            "additionalProperties": false
          },
          "json_schema_strict": "true",
          "temperature": 0.3,
          "max_tokens": 12000
        }
      },
      {
        "id": "quotes",
        "type": "quote_check",
        "name": "Check the quotes against the source",
        "position": {
          "x": 3560,
          "y": 535
        },
        "config": {
          "texts": {
            "verdict": "{{insight.json.verdict}}",
            "report": "{{insight.json.report_markdown}}",
            "replies": "{{insight.json.reply_suggestions_markdown}}"
          },
          "sources": {
            "comments": "{{all_items.text}}",
            "data": "{{data_block.text}}",
            "title": "{{web_struct.json.title}}{{web_read.value.title}}",
            "focus": "{{start.focus}}"
          }
        }
      },
      {
        "id": "save_note",
        "type": "note_create",
        "name": "Save as a note",
        "position": {
          "x": 3880,
          "y": 535
        },
        "config": {
          "title": "{{insight.json.title}}",
          "markdown": "> {{quotes.texts.verdict}}\n\n{{web_api_coverage.text}}{{web_dom_coverage.text}}\n\n{{quotes.summary}}\n\n{{quotes.texts.report}}\n\n## Suggested replies\n\n{{quotes.texts.replies}}\n\n---\n\n## Appendix: the comments\n\n{{data_block.text}}",
          "tags": "comment insights"
        }
      },
      {
        "id": "done_notice",
        "type": "notify",
        "name": "Insights ready",
        "position": {
          "x": 4200,
          "y": 535
        },
        "config": {
          "title": "The comment insights are saved as a note",
          "body": "{{quotes.texts.verdict}}\n{{web_api_coverage.text}}{{web_dom_coverage.text}}\n{{quotes.summary}}"
        }
      },
      {
        "id": "output",
        "type": "output",
        "name": "Hand over the insights and replies",
        "position": {
          "x": 4520,
          "y": 535
        },
        "config": {
          "values": {
            "note_id": "{{save_note.note_id}}",
            "verdict": "{{quotes.texts.verdict}}",
            "report": "{{quotes.texts.report}}",
            "replies": "{{quotes.texts.replies}}",
            "quote_check": "{{quotes.summary}}"
          }
        }
      }
    ],
    "edges": [
      {
        "id": "start__link",
        "source": "start",
        "target": "link"
      },
      {
        "id": "link__use_tikhub",
        "source": "link",
        "target": "use_tikhub"
      },
      {
        "id": "use_tikhub__tk_is_dy__true",
        "source": "use_tikhub",
        "target": "tk_is_dy",
        "source_handle": "true"
      },
      {
        "id": "tk_is_dy__dy_comments__true",
        "source": "tk_is_dy",
        "target": "dy_comments",
        "source_handle": "true"
      },
      {
        "id": "dy_comments__dy_comments_m",
        "source": "dy_comments",
        "target": "dy_comments_m"
      },
      {
        "id": "tk_is_dy__tk_is_xhs__false",
        "source": "tk_is_dy",
        "target": "tk_is_xhs",
        "source_handle": "false"
      },
      {
        "id": "tk_is_xhs__xhs_comments__true",
        "source": "tk_is_xhs",
        "target": "xhs_comments",
        "source_handle": "true"
      },
      {
        "id": "xhs_comments__xhs_comments_m",
        "source": "xhs_comments",
        "target": "xhs_comments_m"
      },
      {
        "id": "tk_is_xhs__tk_is_bili__false",
        "source": "tk_is_xhs",
        "target": "tk_is_bili",
        "source_handle": "false"
      },
      {
        "id": "tk_is_bili__bili_comments__true",
        "source": "tk_is_bili",
        "target": "bili_comments",
        "source_handle": "true"
      },
      {
        "id": "bili_comments__bili_comments_m",
        "source": "bili_comments",
        "target": "bili_comments_m"
      },
      {
        "id": "tk_is_bili__tk_unsupported__false",
        "source": "tk_is_bili",
        "target": "tk_unsupported",
        "source_handle": "false"
      },
      {
        "id": "use_tikhub__web_open__false",
        "source": "use_tikhub",
        "target": "web_open",
        "source_handle": "false"
      },
      {
        "id": "web_open__web_read",
        "source": "web_open",
        "target": "web_read"
      },
      {
        "id": "web_read__web_close",
        "source": "web_read",
        "target": "web_close"
      },
      {
        "id": "web_read__web_is_api",
        "source": "web_read",
        "target": "web_is_api"
      },
      {
        "id": "web_is_api__web_api_m__true",
        "source": "web_is_api",
        "target": "web_api_m",
        "source_handle": "true"
      },
      {
        "id": "web_is_api__web_api_coverage__true",
        "source": "web_is_api",
        "target": "web_api_coverage",
        "source_handle": "true"
      },
      {
        "id": "web_is_api__web_struct__false",
        "source": "web_is_api",
        "target": "web_struct",
        "source_handle": "false"
      },
      {
        "id": "web_struct__web_comments_m",
        "source": "web_struct",
        "target": "web_comments_m"
      },
      {
        "id": "web_comments_m__web_dom_coverage",
        "source": "web_comments_m",
        "target": "web_dom_coverage"
      },
      {
        "id": "dy_comments_m__data_block",
        "source": "dy_comments_m",
        "target": "data_block"
      },
      {
        "id": "xhs_comments_m__data_block",
        "source": "xhs_comments_m",
        "target": "data_block"
      },
      {
        "id": "bili_comments_m__data_block",
        "source": "bili_comments_m",
        "target": "data_block"
      },
      {
        "id": "web_api_m__data_block",
        "source": "web_api_m",
        "target": "data_block"
      },
      {
        "id": "web_comments_m__data_block",
        "source": "web_comments_m",
        "target": "data_block"
      },
      {
        "id": "web_api_coverage__data_block",
        "source": "web_api_coverage",
        "target": "data_block"
      },
      {
        "id": "web_dom_coverage__data_block",
        "source": "web_dom_coverage",
        "target": "data_block"
      },
      {
        "id": "data_block__has_comments",
        "source": "data_block",
        "target": "has_comments"
      },
      {
        "id": "has_comments__no_comments_notice__false",
        "source": "has_comments",
        "target": "no_comments_notice",
        "source_handle": "false"
      },
      {
        "id": "has_comments__all_items__true",
        "source": "has_comments",
        "target": "all_items",
        "source_handle": "true"
      },
      {
        "id": "all_items__chunks",
        "source": "all_items",
        "target": "chunks"
      },
      {
        "id": "chunks__batch_analyze",
        "source": "chunks",
        "target": "batch_analyze"
      },
      {
        "id": "batch_analyze__insight",
        "source": "batch_analyze",
        "target": "insight"
      },
      {
        "id": "insight__quotes",
        "source": "insight",
        "target": "quotes"
      },
      {
        "id": "quotes__save_note",
        "source": "quotes",
        "target": "save_note"
      },
      {
        "id": "save_note__done_notice",
        "source": "save_note",
        "target": "done_notice"
      },
      {
        "id": "done_notice__output",
        "source": "done_notice",
        "target": "output"
      },
      {
        "id": "d-link-platform-tk_is_dy-left",
        "source": "link",
        "target": "tk_is_dy",
        "kind": "data",
        "source_output": "platform",
        "target_input": "left"
      },
      {
        "id": "d-dy_comments-output-dy_comments_m-data",
        "source": "dy_comments",
        "target": "dy_comments_m",
        "kind": "data",
        "source_output": "output",
        "target_input": "data"
      },
      {
        "id": "d-link-platform-tk_is_xhs-left",
        "source": "link",
        "target": "tk_is_xhs",
        "kind": "data",
        "source_output": "platform",
        "target_input": "left"
      },
      {
        "id": "d-xhs_comments-output-xhs_comments_m-data",
        "source": "xhs_comments",
        "target": "xhs_comments_m",
        "kind": "data",
        "source_output": "output",
        "target_input": "data"
      },
      {
        "id": "d-link-platform-tk_is_bili-left",
        "source": "link",
        "target": "tk_is_bili",
        "kind": "data",
        "source_output": "platform",
        "target_input": "left"
      },
      {
        "id": "d-bili_comments-output-bili_comments_m-data",
        "source": "bili_comments",
        "target": "bili_comments_m",
        "kind": "data",
        "source_output": "output",
        "target_input": "data"
      },
      {
        "id": "d-link-url-web_open-url",
        "source": "link",
        "target": "web_open",
        "kind": "data",
        "source_output": "url",
        "target_input": "url"
      },
      {
        "id": "d-web_open-session-web_read-session",
        "source": "web_open",
        "target": "web_read",
        "kind": "data",
        "source_output": "session",
        "target_input": "session"
      },
      {
        "id": "d-web_read-session-web_close-session",
        "source": "web_read",
        "target": "web_close",
        "kind": "data",
        "source_output": "session",
        "target_input": "session"
      },
      {
        "id": "d-all_items-text-chunks-items",
        "source": "all_items",
        "target": "chunks",
        "kind": "data",
        "source_output": "text",
        "target_input": "items"
      },
      {
        "id": "d-chunks-batches-batch_analyze-items",
        "source": "chunks",
        "target": "batch_analyze",
        "kind": "data",
        "source_output": "batches",
        "target_input": "items"
      }
    ]
  }
}
