抓取规则对照 · yopu → MusicXML/简谱

延音线 / 连音线 / 多连音
两个代码库怎么抓的

同一首曲子 《最长的电影》(wXmvEqzP),两个项目对 tie / slur / tuplet 的抓取规则从根上不同——因为它们拿到的根本不是同一种数据:一个走 yopu API 结构化 JSON,一个走浏览器渲染出的 SVG。规则差异全部源于这个数据形态差异。

🎬 数据源实证样本 · yopu.co/view/wXmvEqzP
前提 两边到底拿到什么 —— 这决定了后面一切
CNYuepuService 结构化 JSON

数据形态:AlphaTab notation JSON —— 带音乐语义的字段,纯 httpx API 抓取,信息无损。

  • a.GET yopu.co/api/sheet?code=wXmvEqzP → 解出 scoreUrlV4
  • b.GET <scoreUrlV4> (cdn.yopu.co/nier4/…) → decode → AlphaTab JSON
  • c.beat.notes[].tieDestinationNoteIdbeat.tupletNumerator字段
cnmusicscore 渲染 SVG

数据形态:浏览器渲染出的 SVG 几何 —— 弧线 path、数字文本、坐标盒子,像素级、有损反推。

  • a.QWebEngine(PySide6 Qt 浏览器)加载 yopu.co/view/wXmvEqzP
  • b.从渲染 DOM 提取 svg_systems / beat_groups / texts
  • c.识别 path d="…Q…" 弧线、数字文本、box 几何反推

全仓搜 isTieOrigin / tieDestinationNoteId / isLegatoOrigin / tupletNumerator 在 cnmusicscore 零命中——它根本拿不到这些字段,只能从 SVG 反推。

实测 实际抓取 wXmvEqzP 的 alphatab JSON,三种符号真实分布

用 CNYuepuService 爬虫从 scoreUrlV4 = cdn.yopu.co/nier4/276274cc… 实抓 701 KB alphatab JSON:43 小节、4 轨(Steel Guitar / Vocal / Bass / Drumkit)。层级 tracks[].staves[].bars[].voices[].beats[].notes[]。三种符号的真实挂载层级与字段:

tie 延音线note 级 · 122 处
# m5 beat[3] 起点(歌词"始")
{"id":276, "tieDestinationNoteId":277}
# m5 beat[4] 终点
{"id":277, "tieOriginNoteId":276,
 "isTieDestination":true}

全曲 isTieOrigin/isTie——起点只认 tieDestinationNoteId

legato 连音线beat 级 · 26 处
# m16 三处连续起点
beat[3] {"isLegatoOrigin":true}
beat[4] {"isLegatoOrigin":true}
beat[7] {"isLegatoOrigin":true}

只标起点,无任何终点字段——终点隐含为下一发声拍。

tuplet 多连音beat 级 · 9 处
# m42 beat[6..8] 三连音
{"id":2144, "duration":8,
 "tupletNumerator":3,
 "tupletDenominator":2}

分母 2源字段直给,不用推算;全曲仅 m42 有连音。

同一首 wXmvEqzP,cnmusicscore 拿到的是 QWebEngine 渲染的 SVG——上面这些字段一个都不存在,它只能从弧线 path、数字文本、坐标盒子里反推出等价信息。

延音线

tie — 同音高延音
分歧本质CNYuepuService 源头就知道这是 tie(note 级 tie 字段);cnmusicscore 源头分不清 tie 和 slur,两者抓取端都写成同样的 ( ) 括号,靠"跨小节 + 两端同音"这条几何+音高启发式事后判定。
① 抓取源字段
alphatab_to_musicxml.pyL654–666
# note 级字段。yopu 起点不带 isTieOrigin,
# 而带 tieDestinationNoteId(指向下一 note)
if (
    n.get("isTieOrigin") or n.get("isTie")
    or n.get("tieDestinationNoteId") is not None
):
    tie_start = True
if n.get("isTieDestination") or \
   n.get("tieOriginNoteId") is not None:
    tie_stop = True
yopu_importer_v2.py无独立 tie 抽取
# 没有 tie 概念。tie 与 slur 共用同一个
# SVG 弧线抽取器 _extract_svg_slur_items;
# 一条弧到底是 tie 还是 slur,抓取阶段
# 无法区分,先一律当"弧线"收集。
#
# 区分推迟到 ② 配对(跨小节+同音)
# 和底座 document.py 的最终分类。
② 配对 / 判定归属
alphatab_to_musicxml.pyL654–666
# 无需显式配对。tie_start / tie_stop
# 各自独立打在相邻 beat 上,
# MusicXML 靠"同音高相邻 start/stop"
# 隐式配对,无 number 属性。
# 链式 tie 中间音 start/stop 皆 True(合法)
yopu_importer_v2.pyL7525–7543
# 跨小节弧线:必须两端同音才保留
if not _slots_share_pitch(
    bars[left_global_bar], left_slot,
    bars[right_global_bar], right_slot,
):
    continue   # 不同音 ⇒ 丢弃
_apply_jianpu_group_markers(…, slur_start=True)
_apply_jianpu_group_markers(…, slur_end=True)

cnmusicscore 里 tie 写出来的也是 slur_start/slur_end 括号——真正分成 tie 的动作在底座 document.py:_tokens_share_pitch_for_tie(相邻两音同音+有括号 ⇒ kind="tie",L275–308)。

③ 发射 MusicXML
alphatab_to_musicxml.pyL987–1002
# 声音级 <tie> + 视觉级 <tied> 双写
if beat.tie_stop:
    SubElement(note, "tie", {"type":"stop"})
if beat.tie_start:
    SubElement(note, "tie", {"type":"start"})
# … 再写 <notations><tied type=…>
musicxml_exporter.py底座
# 从 doc.tie_links 的 (bar,slot) 端点集
# 匹配起止,再写 <tie> + <tied>
if tied_starts_here and not is_rest:
    SubElement(note, "tie", {"type":"start"})
if tied_stops_here and not is_rest:
    SubElement(note, "tie", {"type":"stop"})

连音线

slur / legato — 跨音高圆滑线
分歧本质CNYuepuService 读 beat 级 isLegatoOrigin(只标起点)+ 后处理把连续区间合成一条弧,终点=下一发声拍;cnmusicscore 从 SVG 弧线 path 的几何盒子(宽 12–220px、高 3–12px、y 窗口)识别,用 left/right 覆盖音符定起止。
① 抓取源
alphatab_to_musicxml.pyL637–641 / 667–670
# (b) beat 级 legato —— yopu 实测主用,只标起点
legato_origin = bool(
    beat.get("isLegatoOrigin")
    or beat.get("isLegato")
    or beat.get("slur")
)
# (a) note 级显式 slur(少见)
if n.get("slurOrigin") or n.get("isSlurOrigin"):
    slur_start = True
yopu_importer_v2.pyL5390–5499
# 从 beat_groups 的子 path 找弧线几何
if not d or not re.search(r"[QC]", d):
    continue          # 必须是贝塞尔曲线
if width < 12.0 or width > 220.0: continue
if height < 3.0 or height > 12.0: continue
if not (jianpu_row_y-160 <= top
        <= jianpu_row_y-6): continue
items.append({"left":…, "right":…, "y":top})
② 配对
alphatab_to_musicxml.pyL481–511 · _apply_legato_slurs
# 连续多个 legato 起点 = 同一条弧
while i < n:
    if flat[i].legato_origin:
        j = i
        while j+1 < n and flat[j+1].legato_origin:
            j += 1
        if j+1 < n:
            flat[i].slur_start = True
            flat[j+1].slur_stop = True  # 下一发声拍
        i = j+1
# 号由 _SlurState 栈递增,支持嵌套
yopu_importer_v2.pyL7550–7640 / resolver
# 弧线 left/right 覆盖的音符定起止
# 起点取最靠近 slur_left 的音,
# 终点取最靠近 slur_right 的音
_apply_jianpu_group_markers(…, slur_start=True)
_apply_jianpu_group_markers(…, slur_end=True)
# yopu_slur_resolver.py 用正则
#   M x,y Q … z 抽端点后就近对音,
# 是"连线唯一权威来源"。number 恒 "1"
③ 发射 MusicXML
alphatab_to_musicxml.pyL1076–1085
if stop_num is not None:
    SubElement(notations, "slur",
      {"type":"stop", "number":str(stop_num)})
if start_num is not None:
    SubElement(notations, "slur",
      {"type":"start", "number":str(start_num)})
# 弧只挂和弦顶音(idx==0)
musicxml_exporter.py底座 · slur_stack 配对
# document.py 用栈把括号配成对:
if has_end and slur_stack:
    start_idx = slur_stack.pop()
    slur_links.append(NoteLink(kind="slur", …))
if has_start:
    slur_stack.append(idx)
# 导出恒 <slur number="1">,不支持嵌套

多连音

tuplet — 三连音等
分歧本质CNYuepuService 直读源字段 tupletNumerator / tupletDenominator,分母无损;cnmusicscore 从 SVG 数字文本反推,分母靠推算(3→2、6→4、否则 count−1),且语法只支持 3[ / 6[
① 抓取源
alphatab_to_musicxml.pyL692–693
# beat 级两个字段,直接读
tuplet_num = int(beat.get("tupletNumerator") or 0)
tuplet_den = int(beat.get("tupletDenominator") or 0)
# 判定统一: num > 1 and den > 0
yopu_importer_v2.pyL5336–5387
# 从 texts 找连音数字文本
ratio = re.fullmatch(r"(\d+)\s*:\s*(\d+)", text)
if ratio: count, den = …   # "8:4" 比例型
elif re.fullmatch(r"\d+", text):
    count, den = int(text), None  # 纯数字无分母
if re.search(r"\bb\d+\b", parent_class): continue
if count < 3: continue   # 只认 ≥3
② 配对 / 时值分母
alphatab_to_musicxml.pyL1088–1092
# actual/normal 都来自源字段,无损
tm = SubElement(note, "time-modification")
child(tm, "actual-notes", str(beat.tuplet_num))
child(tm, "normal-notes", str(beat.tuplet_den))
# 每 beat 各自开闭,不跨 beat 配组
musicxml_exporter.py底座 · normal 推算
# SVG 纯数字没有分母 → 硬推算
actual = int(token.tuplet_count)
normal = 2 if actual==3 else (
         4 if actual==6 else max(1, actual-1))
# 位置靠数字 x 坐标匹配音符窗口:
#   起点 tuplet_start_count=count (L7444)
#   终点 tuplet_end=True         (L7449)
③ 语法 / 发射
alphatab_to_musicxml.pyL1095–1103
if is_first:
    SubElement(notations, "tuplet",
      {"type":"start", "bracket":"yes"})
if is_last:
    SubElement(notations, "tuplet", {"type":"stop"})
# 任意连音数(num/den)都能表达
jianpu_parser.pyL1060 · 语法只认 [36]
# 简谱文本语法层
triplet = re.match(r"^(?P<count>[36])\[", remain)
if triplet:
    tuplet_start = True
    tuplet_count = int(triplet.group("count"))
# 正则 [36] → 只支持 3 连音 / 6 连音,
# 其它连音数无法被此语法保存
总览 三符号 × 两项目 逐维对照
维度 CNYuepuService · 结构化 JSON cnmusicscore · 渲染 SVG
数据源 yopu api/sheetscoreUrlV4 的 AlphaTab JSON(httpx) QWebEngine 渲染 view/ 页面的 SVG DOM
核心文件 alphatab_to_musicxml.py(单文件) yopu_importer_v2.py + yopu_slur_resolver.py + 底座
tie 抓取 note 级 tieDestinationNoteId / isTieDestination,起止独立标 无独立 tie有损=跨小节+同音弧线,_slots_share_pitch 判定
slur 抓取 beat 级 isLegatoOrigin(只起点)+ 后处理合弧 SVG path 几何([QC]/宽 12–220/高 3–12/y 窗口)
tie/slur 区分 源头即分开(不同字段) 抓取端同为 ( ) 括号,底座按同音启发式再分有损
slur number _SlurState 栈递增,支持嵌套 "1",不支持嵌套
tuplet 分母 直读 tupletDenominator,无损 推算 3→2 / 6→4 / 否则 count−1有损
tuplet 连音数 任意 num/den 语法仅 3[ / 6[(正则 [36])受限