diff --git a/json_schema/zh.json b/json_schema/zh.json
index 429915d8..745762f5 100644
--- a/json_schema/zh.json
+++ b/json_schema/zh.json
@@ -147,6 +147,12 @@
       "items": {
         "type": "string"
       }
+    },
+    "descendants": {
+      "type": "array",
+      "items": {
+        "$ref": "#/$defs/descendant"
+      }
     }
   },
   "$defs": {
@@ -315,6 +321,46 @@
           "enum": ["zh-Hant", "zh-Hans"]
         }
       }
+    },
+    "descendant": {
+      "type": "object",
+      "properties": {
+        "lang_code": {
+          "description": "ISO 639-1 code",
+          "type": "string"
+        },
+        "lang_name": {
+          "type": "string"
+        },
+        "word": {
+          "type": "string"
+        },
+        "roman": {
+          "type": "string"
+        },
+        "tags": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          }
+        },
+        "descendants": {
+          "type": "array",
+          "items": {
+            "$refs": "#/$defs/descendant"
+          }
+        },
+        "ruby": {
+          "description": "Japanese Kanji and furigana",
+          "type": "array",
+          "items": {
+            "type": "array",
+            "items": {
+              "type": "string"
+            }
+          }
+        }
+      }
     }
   }
 }
diff --git a/tests/test_zh_descendant.py b/tests/test_zh_descendant.py
new file mode 100644
index 00000000..34b0ac79
--- /dev/null
+++ b/tests/test_zh_descendant.py
@@ -0,0 +1,117 @@
+from collections import defaultdict
+from unittest import TestCase
+from unittest.mock import Mock
+
+from wikitextprocessor import Wtp
+
+from wiktextract.extractor.zh.descendant import extract_descendants
+from wiktextract.thesaurus import close_thesaurus_db
+from wiktextract.wxr_context import WiktextractContext
+
+
+class TestDescendant(TestCase):
+    def setUp(self):
+        self.wxr = WiktextractContext(Wtp(lang_code="zh"), Mock())
+
+    def tearDown(self):
+        self.wxr.wtp.close_db_conn()
+        close_thesaurus_db(
+            self.wxr.thesaurus_db_path, self.wxr.thesaurus_db_conn
+        )
+
+    def test_ruby(self):
+        # https://zh.wiktionary.org/wiki/你好
+        self.wxr.wtp.start_page("你好")
+        self.wxr.wtp.add_page(
+            "Template:desc",
+            10,
+            '<span class="desc-arr" title="借詞">→</span> 日語:',
+        )
+        self.wxr.wtp.add_page(
+            "Template:ja-r",
+            10,
+            '<span class="Jpan" lang="ja">[[你好#日語|-{<ruby>你好<rp>(</rp><rt>ニイハオ</rt><rp>)</rp></ruby>}-]]</span> <span class="mention-gloss-paren annotation-paren">(</span><span class="tr"><span class="mention-tr tr">nīhao</span></span><span class="mention-gloss-paren annotation-paren">)</span>',
+        )
+        root = self.wxr.wtp.parse("* {{desc|bor=1|ja|-}} {{ja-r|你好|ニイハオ}}")
+        page_data = defaultdict(list)
+        extract_descendants(self.wxr, root, page_data)
+        self.assertEqual(
+            page_data.get("descendants"),
+            [
+                {
+                    "lang_code": "ja",
+                    "lang_name": "日語",
+                    "roman": "nīhao",
+                    "ruby": [("你好", "ニイハオ")],
+                    "word": "你好",
+                }
+            ],
+        )
+
+    def test_roman_only_list(self):
+        self.wxr.wtp.start_page("你好")
+        self.wxr.wtp.add_page(
+            "Template:desc",
+            10,
+            '<span class="desc-arr" title="仿譯詞">→</span> 壯語:<span class="Latn" lang="za">[[mwngz ndei#壯語|-{mwngz ndei}-]]</span> <span class="ib-brac qualifier-brac">(</span><span class="ib-content qualifier-content">仿譯</span><span class="ib-brac qualifier-brac">)</span>',
+        )
+        root = self.wxr.wtp.parse("* {{desc|za|mwngz ndei|cal=1}}")
+        page_data = defaultdict(list)
+        extract_descendants(self.wxr, root, page_data)
+        self.assertEqual(
+            page_data.get("descendants"),
+            [
+                {
+                    "lang_code": "za",
+                    "lang_name": "壯語",
+                    "tags": ["仿譯"],
+                    "word": "mwngz ndei",
+                }
+            ],
+        )
+
+    def test_nested_list(self):
+        # https://zh.wiktionary.org/wiki/オタク
+        self.wxr.wtp.start_page("オタク")
+        self.wxr.wtp.add_page(
+            "Template:desc",
+            10,
+            '<span class="desc-arr" title="詞形受類比影響或添加了額外詞素">⇒</span> 官話:',
+        )
+        self.wxr.wtp.add_page(
+            "Template:zh-l",
+            10,
+            '<span class="Hani" lang="zh">{{{1}}}</span> (<i><span class="tr Latn" lang="la">{{{1}}}</span></i>',
+        )
+        root = self.wxr.wtp.parse(
+            """*: {{desc|cmn|-}} {{zh-l|御宅族}}
+*:* {{desc|cmn|-|der=1}} {{zh-l|宅男}}
+*:* {{desc|cmn|-|der=1}} {{zh-l|宅女}}"""
+        )
+        page_data = defaultdict(list)
+        extract_descendants(self.wxr, root, page_data)
+        self.assertEqual(
+            page_data.get("descendants"),
+            [
+                {
+                    "descendants": [
+                        {
+                            "lang_code": "cmn",
+                            "lang_name": "官話",
+                            "roman": "宅男",
+                            "word": "宅男",
+                        },
+                        {
+                            "lang_code": "cmn",
+                            "lang_name": "官話",
+                            "roman": "宅女",
+                            "word": "宅女",
+                        },
+                    ],
+                    "lang_code": "cmn",
+                    "lang_name": "官話",
+                    "roman": "御宅族",
+                    "word": "御宅族",
+                }
+            ],
+        )
diff --git a/wiktextract/data/zh/linkage_subtitles.json b/wiktextract/data/zh/linkage_subtitles.json
index 20048177..369e2221 100644
--- a/wiktextract/data/zh/linkage_subtitles.json
+++ b/wiktextract/data/zh/linkage_subtitles.json
@@ -84,7 +84,6 @@
   "派生詞": "derived",
   "派生詞彙": "derived",
   "派生詞語": "derived",
-  "派生語彙": "derived",
   "派生词": "derived",
   "派生词汇": "derived",
   "派生词组": "derived",
@@ -133,4 +132,4 @@
   "部分詞": "meronyms",
   "關聯詞": "related",
   "關聯詞彙": "related"
-}
\ No newline at end of file
+}
diff --git a/wiktextract/data/zh/other_subtitles.json b/wiktextract/data/zh/other_subtitles.json
index 2c963e9e..fb924e47 100644
--- a/wiktextract/data/zh/other_subtitles.json
+++ b/wiktextract/data/zh/other_subtitles.json
@@ -74,5 +74,8 @@
   "translations": [
     "翻譯",
     "翻译"
+  ],
+  "descendants": [
+    "派生語彙"
   ]
-}
\ No newline at end of file
+}
diff --git a/wiktextract/extractor/ruby.py b/wiktextract/extractor/ruby.py
index e453a2c3..528c6ac3 100644
--- a/wiktextract/extractor/ruby.py
+++ b/wiktextract/extractor/ruby.py
@@ -1,6 +1,7 @@
 from typing import List, Optional, Tuple, Union
 
 from wikitextprocessor import NodeKind, WikiNode
+from wikitextprocessor.parser import HTMLNode, LevelNode, TemplateNode
 
 from wiktextract.page import clean_node
 from wiktextract.wxr_context import WiktextractContext
@@ -58,7 +59,6 @@ def extract_ruby(
     # Otherwise content is WikiNode, and we must recurse into it.
     kind = contents.kind
     new_node = WikiNode(kind, contents.loc)
-    new_contents.append(new_node)
     if kind in {
         NodeKind.LEVEL2,
         NodeKind.LEVEL3,
@@ -68,6 +68,8 @@ def extract_ruby(
         NodeKind.LINK,
     }:
         # Process args and children
+        if kind != NodeKind.LINK:
+            new_node = LevelNode(new_node.loc)
         new_args = []
         for arg in contents.largs:
             e1, c1 = extract_ruby(wxr, arg)
@@ -108,6 +110,8 @@ def extract_ruby(
         NodeKind.URL,
     }:
         # Process only args
+        if kind == NodeKind.TEMPLATE:
+            new_node = TemplateNode(new_node.loc)
         new_args = []
         for arg in contents.largs:
             e1, c1 = extract_ruby(wxr, arg)
@@ -116,6 +120,7 @@ def extract_ruby(
         new_node.largs = new_args
     elif kind == NodeKind.HTML:
         # Keep attrs and args as-is, process children
+        new_node = HTMLNode(new_node.loc)
         new_node.attrs = contents.attrs
         new_node.sarg = contents.sarg
         e1, c1 = extract_ruby(wxr, contents.children)
@@ -123,4 +128,5 @@ def extract_ruby(
         new_node.children = c1
     else:
         raise RuntimeError(f"extract_ruby: unhandled kind {kind}")
+    new_contents.append(new_node)
     return extracted, new_contents
diff --git a/wiktextract/extractor/zh/descendant.py b/wiktextract/extractor/zh/descendant.py
new file mode 100644
index 00000000..8bdeccc9
--- /dev/null
+++ b/wiktextract/extractor/zh/descendant.py
@@ -0,0 +1,97 @@
+from collections import defaultdict
+from typing import Dict
+
+from wikitextprocessor import NodeKind, WikiNode
+
+from wiktextract.page import clean_node
+from wiktextract.wxr_context import WiktextractContext
+
+from ..ruby import extract_ruby
+
+DESCENDANT_TEMPLATES = frozenset(["desc", "descendant"])
+
+
+def extract_descendants(
+    wxr: WiktextractContext,
+    level_node: WikiNode,
+    parent_data: Dict,
+) -> None:
+    for list_node in level_node.find_child(NodeKind.LIST):
+        for list_item_node in list_node.find_child(NodeKind.LIST_ITEM):
+            extract_descendant_list_item(wxr, list_item_node, parent_data)
+
+
+def extract_descendant_list_item(
+    wxr: WiktextractContext,
+    list_item_node: WikiNode,
+    parent_data: Dict,
+) -> None:
+    lang_code = ""
+    lang_name = ""
+    descendant_data = defaultdict(list)
+    for template_node in list_item_node.find_child(NodeKind.TEMPLATE):
+        expanded_template = wxr.wtp.parse(
+            wxr.wtp.node_to_wikitext(template_node), expand_all=True
+        )
+        if template_node.template_name.lower() in DESCENDANT_TEMPLATES:
+            lang_code = template_node.template_parameters.get(1)
+            descendant_data["lang_code"] = lang_code
+        ruby_data, nodes_without_ruby = extract_ruby(
+            wxr, expanded_template.children
+        )
+        if len(ruby_data) > 0:
+            descendant_data["ruby"] = ruby_data
+        for child_index, child_node in enumerate(nodes_without_ruby):
+            if isinstance(child_node, str) and child_node.endswith(":"):
+                lang_name = child_node.strip(" :")
+                descendant_data["lang_name"] = lang_name
+            elif (
+                isinstance(child_node, WikiNode)
+                and child_node.kind == NodeKind.HTML
+            ):
+                if child_node.tag == "span":
+                    class_names = child_node.attrs.get("class", "")
+                    if (
+                        "Latn" in class_names or "tr" in class_names
+                    ) and "word" in descendant_data:
+                        # template:ja-r
+                        descendant_data["roman"] = clean_node(
+                            wxr, None, child_node
+                        )
+                    elif "lang" in child_node.attrs:
+                        if "word" in descendant_data:
+                            parent_data["descendants"].append(descendant_data)
+                            descendant_data = defaultdict(
+                                list,
+                                {
+                                    "lang_code": lang_code,
+                                    "lang_name": lang_name,
+                                },
+                            )
+                            if len(ruby_data) > 0:
+                                descendant_data["ruby"] = ruby_data
+                        descendant_data["word"] = clean_node(
+                            wxr, None, child_node
+                        )
+                    if "qualifier-content" in class_names:
+                        descendant_data["tags"].append(
+                            clean_node(wxr, None, child_node)
+                        )
+                elif child_node.tag == "i":
+                    # template:zh-l
+                    for span_tag in child_node.find_html(
+                        "span", attr_name="class", attr_value="Latn"
+                    ):
+                        descendant_data["roman"] = clean_node(
+                            wxr, None, span_tag
+                        )
+
+        if "word" in descendant_data:
+            parent_data["descendants"].append(descendant_data)
+
+    if list_item_node.contain_node(NodeKind.LIST):
+        extract_descendants(
+            wxr,
+            list_item_node,
+            descendant_data if "word" in descendant_data else parent_data,
+        )
diff --git a/wiktextract/extractor/zh/linkage.py b/wiktextract/extractor/zh/linkage.py
index ca98cd04..d85d2e48 100644
--- a/wiktextract/extractor/zh/linkage.py
+++ b/wiktextract/extractor/zh/linkage.py
@@ -13,6 +13,7 @@
     split_chinese_variants,
     strip_nodes,
 )
+from .descendant import DESCENDANT_TEMPLATES, extract_descendant_list_item
 
 
 def extract_linkages(
@@ -34,6 +35,7 @@ def extract_linkages(
             append_to = find_similar_gloss(page_data, sense)
         elif isinstance(node, WikiNode):
             if node.kind == NodeKind.LIST_ITEM:
+                is_descendant = False
                 not_term_indexes = set()
                 filtered_children = list(node.filter_empty_str_child())
                 linkage_data = defaultdict(list)
@@ -57,6 +59,14 @@ def extract_linkages(
                             linkage_data["tags"].append(
                                 clean_node(wxr, None, item_child).strip("()")
                             )
+                        elif template_name.lower() in DESCENDANT_TEMPLATES:
+                            extract_descendant_list_item(
+                                wxr, node, page_data[-1]
+                            )
+                            is_descendant = True
+                            break
+                if is_descendant:
+                    continue
                 # sense template before entry and they are inside the same
                 # list item
                 terms = clean_node(
diff --git a/wiktextract/extractor/zh/page.py b/wiktextract/extractor/zh/page.py
index 3d726d0e..f8567099 100644
--- a/wiktextract/extractor/zh/page.py
+++ b/wiktextract/extractor/zh/page.py
@@ -10,6 +10,7 @@
 from wiktextract.page import LEVEL_KINDS, clean_node
 from wiktextract.wxr_context import WiktextractContext
 
+from .descendant import extract_descendants
 from .gloss import extract_gloss
 from .headword_line import extract_headword_line
 from .inflection import extract_inflections
@@ -19,76 +20,12 @@
 
 # Templates that are used to form panels on pages and that
 # should be ignored in various positions
-PANEL_TEMPLATES = {
-    "CJKV",
-    "French personal pronouns",
-    "French possessive adjectives",
-    "French possessive pronouns",
-    "Han etym",
-    "Japanese demonstratives",
-    "Latn-script",
-    "Webster 1913",
-    "attention",
-    "attn",
-    "character info",
-    "character info/new",
-    "character info/var",
-    "delete",
-    "dial syn",
-    "dialect synonyms",
-    "examples",
-    "hu-corr",
-    "hu-suff-pron",
-    "interwiktionary",
-    "ja-kanjitab",
-    "ko-hanja-search",
-    "maintenance box",
-    "maintenance line",
-    "merge",
-    "morse links",
-    "move",
-    "multiple images",
-    "picdic",
-    "picdicimg",
-    "picdiclabel",
-    "punctuation",
-    "reconstructed",
-    "request box",
-    "rfap",
-    "rfc",
-    "rfc-header",
-    "rfc-level",
-    "rfc-sense",
-    "rfd",
-    "rfdate",
-    "rfdatek",
-    "rfdef",
-    "rfe",
-    "rfe/dowork",
-    "rfgender",
-    "rfi",
-    "rfinfl",
-    "rfp",
-    "rfquotek",
-    "rfscript",
-    "rftranslit",
-    "selfref",
-    "stroke order",
-    "t-needed",
-    "unblock",
-    "unsupportedpage",
-    "wrongtitle",
-    "zh-forms",
-    "zh-hanzi-box",
-}
+PANEL_TEMPLATES = {}
 
 # Template name prefixes used for language-specific panel templates (i.e.,
 # templates that create side boxes or notice boxes or that should generally
 # be ignored).
-PANEL_PREFIXES = {
-    "list:compass points/",
-    "list:Gregorian calendar months/",
-}
+PANEL_PREFIXES = {}
 
 # Additional templates to be expanded in the pre-expand phase
 ADDITIONAL_EXPAND_TEMPLATES = {
@@ -174,6 +111,11 @@ def parse_section(
             and subtitle in wxr.config.OTHER_SUBTITLES["inflection_sections"]
         ):
             extract_inflections(wxr, page_data, node)
+        elif (
+                wxr.config.capture_descendants
+                and subtitle in wxr.config.OTHER_SUBTITLES["descendants"]
+        ):
+            extract_descendants(wxr, node, page_data[-1])
         else:
             wxr.wtp.debug(
                 f"Unhandled subtitle: {subtitle}",