{
  "format": "mosael-workflow",
  "version": 1,
  "workflow_revision": 1,
  "name": "评论区洞察与观众画像",
  "description": "贴一条作品链接,取赞最多的一批评论(默认 50 条:TikHub 一次取一页,约 20–50 条;浏览器往下滚着读评论区),按话题聚类,推断观众画像、需求与痛点、疑问清单、异议与情绪分布,给出下一条可以做的选题和值得回复的评论的建议回复,存成笔记。数据来源在开始节点的下拉里二选一:内嵌浏览器(不用配置、不花钱,小红书评论要换成已登录的浏览器档案)或 TikHub(要装插件、填密钥,每次运行 1 次请求;字段全、更稳、不用登录)。另有 1–2 次 AI 对话。",
  "graph_hash": "6ce23dfa01ef29a79e4babd91fc8e90d63a49bc000d5c5cc6b0267a5794d2582",
  "graph": {
    "meta": {
      "template_id": "comment_insights",
      "template_version": 4,
      "source": "official"
    },
    "nodes": [
      {
        "id": "start",
        "type": "start",
        "name": "填作品与数据来源",
        "position": {
          "x": 40,
          "y": 535
        },
        "config": {
          "params": {
            "video_link": "",
            "data_source": "",
            "platform": "",
            "comment_count": 50,
            "focus": "",
            "report_language": "中文"
          },
          "required_params": [
            "video_link",
            "data_source"
          ],
          "param_options": {
            "data_source": [
              {
                "value": "browser",
                "label": "内嵌浏览器",
                "description": "不用配置、不花钱;要登录才看得到的页面(小红书一定要、抖音多半要),在「用内嵌浏览器打开」里换成浏览器池里已登录的档案。B 站评论不登录只给前 3 条一级评论(每条下前 20 条回复),要取全也换成已登录 B 站的档案。页面上的数字是约数,发布时间常常缺"
              },
              {
                "value": "tikhub",
                "label": "TikHub",
                "description": "需安装 TikHub 插件、接好连接并填上 API 密钥(按次计费);数据更全更稳、不用登录",
                "requires": "tikhub_comments"
              }
            ]
          }
        }
      },
      {
        "id": "link",
        "type": "social_link",
        "name": "认出平台和编号",
        "position": {
          "x": 360,
          "y": 535
        },
        "config": {
          "link": "{{start.video_link}}",
          "platform": "{{start.platform}}",
          "expect": "video"
        }
      },
      {
        "id": "use_tikhub",
        "type": "condition",
        "name": "用 TikHub 取数吗",
        "position": {
          "x": 1000,
          "y": 535
        },
        "config": {
          "left": "{{start.data_source}}",
          "op": "equals",
          "right": "tikhub"
        }
      },
      {
        "id": "tk_is_dy",
        "type": "condition",
        "name": "是抖音吗",
        "position": {
          "x": 1320,
          "y": 60
        },
        "config": {
          "left": "",
          "op": "equals",
          "right": "douyin"
        },
        "inputs": [
          "left"
        ]
      },
      {
        "id": "dy_comments",
        "type": "plugin_tool",
        "name": "TikHub 取抖音评论",
        "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": "按赞排好评论",
        "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": "是小红书吗",
        "position": {
          "x": 1320,
          "y": 250
        },
        "config": {
          "left": "",
          "op": "equals",
          "right": "xiaohongshu"
        },
        "inputs": [
          "left"
        ]
      },
      {
        "id": "xhs_comments",
        "type": "plugin_tool",
        "name": "TikHub 取小红书评论",
        "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": "按赞排好评论",
        "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": "是B站吗",
        "position": {
          "x": 1320,
          "y": 440
        },
        "config": {
          "left": "",
          "op": "equals",
          "right": "bilibili"
        },
        "inputs": [
          "left"
        ]
      },
      {
        "id": "bili_comments",
        "type": "plugin_tool",
        "name": "TikHub 取B站评论",
        "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": "按赞排好评论",
        "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 这一路不认这个平台",
        "position": {
          "x": 1640,
          "y": 630
        },
        "config": {
          "title": "没有开始分析:TikHub 这一路不支持这个平台",
          "body": "认出的平台是「{{link.platform}}」。TikHub 这一路接了抖音、小红书、B站;别的平台在开始节点的数据来源(data_source)里改选「内嵌浏览器」再运行。认错了的话,在开始节点的 platform 里写明平台。"
        }
      },
      {
        "id": "web_open",
        "type": "browser_open",
        "name": "用内嵌浏览器打开",
        "position": {
          "x": 1320,
          "y": 858
        },
        "config": {
          "url": "",
          "session_mode": "named",
          "session_name": "自媒体分析"
        },
        "inputs": [
          "url"
        ]
      },
      {
        "id": "web_read",
        "type": "browser_evaluate",
        "name": "往下滚几屏,读出页面文字",
        "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": "没登录:B 站对未登录的访问只给前 3 条一级评论、每条下前 20 条回复。要取全,在「用内嵌浏览器打开」上把会话方式换成浏览器池、选一个已登录 B 站的档案。",
              "limit": "到了这次最多取的条数,后面的没有再取。",
              "time": "到了这次取数的时间上限,后面的没有再取。",
              "platform": "其余的 B 站没有给(被删除、折叠或仅自己可见的评论)。"
            }
          },
          "timeout_ms": 120000
        },
        "inputs": [
          "session"
        ]
      },
      {
        "id": "web_close",
        "type": "browser_close",
        "name": "关掉浏览器",
        "position": {
          "x": 1960,
          "y": 1010
        },
        "config": {
          "session": ""
        },
        "inputs": [
          "session"
        ]
      },
      {
        "id": "web_is_api",
        "type": "condition",
        "name": "是接口取的吗",
        "position": {
          "x": 1832,
          "y": 858
        },
        "config": {
          "left": "{{web_read.value.mode}}",
          "op": "equals",
          "right": "api"
        }
      },
      {
        "id": "web_api_m",
        "type": "social_metrics",
        "name": "整理接口取回的评论",
        "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": "评论取到多少",
        "position": {
          "x": 2152,
          "y": 877
        },
        "config": {
          "template": "评论条数:平台显示 {{web_read.value.total}} 条,这次取到 {{web_read.value.fetched}} 条(一级 {{web_read.value.fetched_roots}} 条、楼中楼 {{web_read.value.fetched_replies}} 条)。{{web_read.value.gap_note}}"
        }
      },
      {
        "id": "web_struct",
        "type": "llm",
        "name": "把页面文字抄成评论清单",
        "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": "按赞排好评论",
        "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": "评论读到多少",
        "position": {
          "x": 2664,
          "y": 933
        },
        "config": {
          "template": "评论条数:这次从页面上读到 {{web_comments_m.count}} 条,只包括页面上已经加载出来的那些,不是全部(页面上显示的总数:{{web_struct.json.comment_total}})。"
        }
      },
      {
        "id": "data_block",
        "type": "template",
        "name": "汇合这一路的评论",
        "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### 全部评论(按赞排)\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": "取到评论了吗",
        "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": "没取到评论",
        "position": {
          "x": 3240,
          "y": 858
        },
        "config": {
          "title": "评论区洞察没有完成:一条评论都没取到",
          "body": "{{link.url}} 没取到评论。常见原因:要登录才看得到评论(小红书一定要 —— 在「用内嵌浏览器打开」上改用浏览器池里已登录的档案);作品关了评论或还没有评论;TikHub 的工具没勾选或额度用完。{{web_struct.json.notes}}"
        }
      },
      {
        "id": "all_items",
        "type": "template",
        "name": "汇合全部评论",
        "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": "拆成几批",
        "position": {
          "x": 2920,
          "y": 649
        },
        "config": {
          "items": "",
          "size": 80
        },
        "inputs": [
          "items"
        ]
      },
      {
        "id": "batch_analyze",
        "type": "loop_foreach",
        "name": "逐批分析",
        "position": {
          "x": 2920,
          "y": 763
        },
        "config": {
          "items": "",
          "concurrency": 2,
          "inputs": {
            "url": "{{link.url}}",
            "focus": "{{start.focus}}"
          },
          "body": {
            "nodes": [
              {
                "id": "batch_notes",
                "type": "llm",
                "name": "分析这一批",
                "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": "综合各批,做评论区洞察",
        "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": "核对引用的原文",
        "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": "存成笔记",
        "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## 建议回复\n\n{{quotes.texts.replies}}\n\n---\n\n## 附:评论数据\n\n{{data_block.text}}",
          "tags": "评论区洞察"
        }
      },
      {
        "id": "done_notice",
        "type": "notify",
        "name": "洞察完成通知",
        "position": {
          "x": 4200,
          "y": 535
        },
        "config": {
          "title": "评论区洞察已存成笔记",
          "body": "{{quotes.texts.verdict}}\n{{web_api_coverage.text}}{{web_dom_coverage.text}}\n{{quotes.summary}}"
        }
      },
      {
        "id": "output",
        "type": "output",
        "name": "交付洞察与建议回复",
        "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"
      }
    ]
  }
}
