Files
wehub-resource-sync bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:22:54 +08:00

1244 lines
39 KiB
Python
Executable File

#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
"""Standalone Lark Docs word and character counter for XML or Markdown input."""
from __future__ import annotations
import argparse
import json
import re
import sys
import unicodedata
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal, Protocol
from xml.etree import ElementTree as ET
# ---------------------------------------------------------------------------
# Data model
# ---------------------------------------------------------------------------
@dataclass
class TextRun:
text: str
attrs: dict[str, Any] = field(default_factory=dict)
@dataclass
class Block:
type: str
attrs: dict[str, Any] = field(default_factory=dict)
children: list["Block"] = field(default_factory=list)
text_runs: list[TextRun] = field(default_factory=list)
raw: Any = None
@dataclass
class Segment:
text: str
block_type: str
block_id: str | None = None
kind: str = "text"
boundary_before: bool = True
boundary_after: bool = True
def to_dict(self) -> dict[str, Any]:
return {
"text": self.text,
"block_type": self.block_type,
"block_id": self.block_id,
"kind": self.kind,
"boundary_before": self.boundary_before,
"boundary_after": self.boundary_after,
}
@dataclass(frozen=True)
class UnknownBlock:
type: str
block_id: str | None = None
action: str = "recurse_children"
def to_dict(self) -> dict[str, str | None]:
return {
"type": self.type,
"block_id": self.block_id,
"action": self.action,
}
# ---------------------------------------------------------------------------
# Counting rules
# ---------------------------------------------------------------------------
CHINESE_PUNCTUATION = set(",。!?;:、()《》〈〉“”‘’【】「」『』〔〕…—~·¥")
ENGLISH_PUNCTUATION = set(
r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~"""
)
LexemeKind = Literal["english", "number"]
URL_TOKEN_RE = re.compile(r"https?://[!-~]+")
ASCII_COMPOUND_TOKEN_RE = re.compile(
r"[A-Za-z0-9]+(?:[._/@:-][A-Za-z0-9]+)+"
)
@dataclass
class Stats:
word_count: int = 0
char_count: int = 0
han_chars: int = 0
english_words: int = 0
number_words: int = 0
chinese_punctuations: int = 0
english_letters: int = 0
digits: int = 0
english_punctuations: int = 0
symbol_words: int = 0
symbol_chars: int = 0
def to_dict(self) -> dict[str, object]:
return {
"word_count": self.word_count,
"char_count": self.char_count,
"breakdown": {
"han_chars": self.han_chars,
"english_words": self.english_words,
"number_words": self.number_words,
"chinese_punctuations": self.chinese_punctuations,
"english_letters": self.english_letters,
"digits": self.digits,
"english_punctuations": self.english_punctuations,
"symbol_words": self.symbol_words,
"symbol_chars": self.symbol_chars,
},
}
def is_han(ch: str) -> bool:
code = ord(ch)
return (
0x3400 <= code <= 0x4DBF
or 0x4E00 <= code <= 0x9FFF
or 0xF900 <= code <= 0xFAFF
or 0x20000 <= code <= 0x2A6DF
or 0x2A700 <= code <= 0x2B73F
or 0x2B740 <= code <= 0x2B81F
or 0x2B820 <= code <= 0x2CEAF
or 0x30000 <= code <= 0x3134F
)
def is_ascii_letter(ch: str) -> bool:
return ("a" <= ch <= "z") or ("A" <= ch <= "Z")
def is_digit(ch: str) -> bool:
return "0" <= ch <= "9"
def is_chinese_punctuation(ch: str) -> bool:
if ch in CHINESE_PUNCTUATION:
return True
return unicodedata.category(ch).startswith("P") and unicodedata.east_asian_width(ch) in {
"W",
"F",
}
def is_english_punctuation(ch: str) -> bool:
return ch in ENGLISH_PUNCTUATION
def is_unicode_symbol(ch: str) -> bool:
return unicodedata.category(ch).startswith("S")
def utf16_units(ch: str) -> int:
return len(ch.encode("utf-16-le")) // 2
class Counter:
def __init__(self) -> None:
self.stats = Stats()
self._lexeme_kind: LexemeKind | None = None
self._lexeme_has_digit = False
self._symbol_run_length = 0
self._at_boundary = True
def count_segments(self, segments: list[Segment]) -> Stats:
for segment in segments:
if segment.boundary_before:
self._end_unit()
self._at_boundary = True
if segment.kind == "marker":
self.write_marker(segment.text)
elif segment.kind == "code":
self.write_code(segment.text)
else:
self.write(segment.text)
if segment.boundary_after:
self._end_unit()
self._at_boundary = True
self._end_unit()
return self.stats
def write(self, text: str) -> None:
i = 0
while i < len(text):
consumed = self._write_ascii_compound_token(text, i)
if consumed:
i += consumed
continue
if self._write_visible_ascii_separator(text, i):
i += 1
continue
self._write_char(text[i])
i += 1
def write_marker(self, text: str) -> None:
for ch in text:
if ch.isspace():
continue
self._end_unit()
self.stats.word_count += 1
self.stats.char_count += 1
self._at_boundary = False
def write_code(self, text: str) -> None:
for ch in text:
self._write_code_char(ch)
def _write_code_char(self, ch: str) -> None:
if ch.isspace():
self._end_unit()
self._at_boundary = True
return
if is_han(ch):
self._end_lexeme()
self._end_symbol_run(count_word=False)
self.stats.han_chars += 1
self.stats.word_count += 1
self.stats.char_count += 1
self._at_boundary = False
return
if is_ascii_letter(ch):
self._end_symbol_run(count_word=False)
self.stats.english_letters += 1
self.stats.char_count += 1
if self._lexeme_kind is None:
self._lexeme_kind = "english"
elif self._lexeme_kind == "number":
self._lexeme_kind = "english"
self._at_boundary = False
return
if is_digit(ch):
self._end_symbol_run(count_word=False)
self.stats.digits += 1
self.stats.char_count += 1
self._at_boundary = False
return
if is_chinese_punctuation(ch):
self._end_lexeme()
self._end_symbol_run(count_word=False)
self.stats.chinese_punctuations += 1
self.stats.word_count += 1
self.stats.char_count += 1
self._at_boundary = False
return
if is_english_punctuation(ch):
keeps_lexeme = self._lexeme_kind == "english" and ch in {"'", "-"}
if not keeps_lexeme:
had_lexeme = self._lexeme_kind is not None
self._end_lexeme()
if not had_lexeme and (self._symbol_run_length > 0 or self._at_boundary):
self._symbol_run_length += 1
self.stats.english_punctuations += 1
self.stats.char_count += 1
if keeps_lexeme:
self._at_boundary = False
return
if is_unicode_symbol(ch):
self._write_symbol_char(ch)
return
self._end_lexeme()
self._end_symbol_run(count_word=False)
self._at_boundary = False
def _write_char(self, ch: str) -> None:
if ch.isspace():
self._end_unit()
self._at_boundary = True
return
if is_han(ch):
self._end_lexeme()
self._end_symbol_run(count_word=False)
self.stats.han_chars += 1
self.stats.word_count += 1
self.stats.char_count += 1
self._at_boundary = False
return
if is_ascii_letter(ch):
self._end_symbol_run(count_word=False)
self.stats.english_letters += 1
self.stats.char_count += 1
if self._lexeme_kind is None:
self._lexeme_kind = "english"
elif self._lexeme_kind == "number":
self._lexeme_kind = "english"
self._at_boundary = False
return
if is_digit(ch):
self._end_symbol_run(count_word=False)
self.stats.digits += 1
self.stats.char_count += 1
self._lexeme_has_digit = True
if self._lexeme_kind is None:
self._lexeme_kind = "number"
self._at_boundary = False
return
if is_chinese_punctuation(ch):
self._end_lexeme()
self._end_symbol_run(count_word=False)
self.stats.chinese_punctuations += 1
self.stats.word_count += 1
self.stats.char_count += 1
self._at_boundary = False
return
if is_english_punctuation(ch):
# Apostrophes/hyphens can connect English runs. Dot/comma/hyphen
# can format numeric runs such as 3.14, 1,000, 2026-06-30, or
# 7-9. Alphanumeric versions like v1.2.3 should remain one semantic
# run too. These punctuations still count as characters.
keeps_lexeme = (
self._lexeme_kind == "english"
and (ch in {"'", "-"} or (self._lexeme_has_digit and ch == "."))
) or (
self._lexeme_kind == "number"
and ch in {".", ",", "-"}
)
if not keeps_lexeme:
had_lexeme = self._lexeme_kind is not None
self._end_lexeme()
if not had_lexeme and (self._symbol_run_length > 0 or self._at_boundary):
self._symbol_run_length += 1
self.stats.english_punctuations += 1
self.stats.char_count += 1
if keeps_lexeme:
self._at_boundary = False
return
if is_unicode_symbol(ch):
self._write_symbol_char(ch)
return
self._end_lexeme()
self._end_symbol_run(count_word=False)
self._at_boundary = False
def _write_visible_ascii_separator(self, text: str, index: int) -> bool:
ch = text[index]
if ch != "/" or index == 0 or index + 1 >= len(text):
return False
if not is_han(text[index - 1]) or not is_han(text[index + 1]):
return False
self._end_unit()
self.stats.english_punctuations += 1
self.stats.symbol_words += 1
self.stats.word_count += 1
self.stats.char_count += 1
self._at_boundary = False
return True
def _write_ascii_compound_token(self, text: str, start: int) -> int:
token = self._match_ascii_compound_token(text, start)
if not token:
return 0
self._end_unit()
self.stats.english_words += 1
self.stats.word_count += 1
for ch in token:
if is_ascii_letter(ch):
self.stats.english_letters += 1
self.stats.char_count += 1
elif is_digit(ch):
self.stats.digits += 1
self.stats.char_count += 1
elif is_english_punctuation(ch):
self.stats.english_punctuations += 1
self.stats.char_count += 1
elif is_unicode_symbol(ch):
units = utf16_units(ch)
self.stats.symbol_chars += units
self.stats.char_count += units
elif is_chinese_punctuation(ch):
self.stats.chinese_punctuations += 1
self.stats.char_count += 1
elif is_han(ch):
self.stats.han_chars += 1
self.stats.char_count += 1
self._at_boundary = False
return len(token)
def _match_ascii_compound_token(self, text: str, start: int) -> str | None:
match = URL_TOKEN_RE.match(text, start)
if match:
return match.group(0)
match = ASCII_COMPOUND_TOKEN_RE.match(text, start)
if not match:
return None
token = match.group(0)
if any(is_ascii_letter(ch) for ch in token):
return token
return None
def _write_symbol_char(self, ch: str) -> None:
self._end_lexeme()
self._end_symbol_run(count_word=False)
units = utf16_units(ch)
self.stats.symbol_words += 1
self.stats.symbol_chars += units
self.stats.word_count += 1
self.stats.char_count += units
self._at_boundary = False
def _end_unit(self) -> None:
self._end_lexeme()
self._end_symbol_run(count_word=True)
def _end_lexeme(self) -> None:
if self._lexeme_kind == "english":
self.stats.english_words += 1
self.stats.word_count += 1
elif self._lexeme_kind == "number":
self.stats.number_words += 1
self.stats.word_count += 1
self._lexeme_kind = None
self._lexeme_has_digit = False
def _end_symbol_run(self, *, count_word: bool) -> None:
if self._symbol_run_length >= 1 and count_word:
self.stats.symbol_words += 1
self.stats.word_count += 1
if self._symbol_run_length:
self._at_boundary = False
self._symbol_run_length = 0
# ---------------------------------------------------------------------------
# Markdown parser
# ---------------------------------------------------------------------------
HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
LIST_RE = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s+(.*)$")
QUOTE_RE = re.compile(r"^\s*>\s?(.*)$")
TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$")
def parse_markdown(source: str) -> list[Block]:
lines = source.splitlines()
blocks: list[Block] = []
paragraph: list[str] = []
i = 0
def flush_paragraph() -> None:
if paragraph:
blocks.append(Block(type="paragraph", text_runs=[TextRun(clean_inline(" ".join(paragraph)))]))
paragraph.clear()
while i < len(lines):
line = lines[i]
stripped = line.strip()
if not stripped:
flush_paragraph()
i += 1
continue
if stripped.startswith("```") or stripped.startswith("~~~"):
flush_paragraph()
fence = stripped[:3]
code_lines: list[str] = []
i += 1
while i < len(lines) and not lines[i].strip().startswith(fence):
code_lines.append(lines[i])
i += 1
if i < len(lines):
i += 1
blocks.append(Block(type="code", text_runs=[TextRun("\n".join(code_lines))]))
continue
heading = HEADING_RE.match(line)
if heading:
flush_paragraph()
blocks.append(Block(type="heading", text_runs=[TextRun(clean_inline(heading.group(2)))]))
i += 1
continue
if _looks_like_table(lines, i):
flush_paragraph()
table, consumed = _parse_table(lines, i)
blocks.append(table)
i += consumed
continue
item = LIST_RE.match(line)
if item:
flush_paragraph()
items: list[Block] = []
while i < len(lines):
match = LIST_RE.match(lines[i])
if not match:
break
items.append(Block(type="list_item", text_runs=[TextRun(clean_inline(match.group(1)))]))
i += 1
blocks.append(Block(type="list", children=items))
continue
quote = QUOTE_RE.match(line)
if quote:
flush_paragraph()
quote_lines: list[str] = []
while i < len(lines):
match = QUOTE_RE.match(lines[i])
if not match:
break
quote_lines.append(match.group(1))
i += 1
blocks.append(Block(type="quote", text_runs=[TextRun(clean_inline(" ".join(quote_lines)))]))
continue
paragraph.append(stripped)
i += 1
flush_paragraph()
return blocks
def clean_inline(text: str) -> str:
text = re.sub(r"!\[([^\]]*)\]\([^)]+\)", r"\1", text)
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
text = re.sub(r"([*_`~]{1,3})(.*?)\1", r"\2", text)
text = text.replace("\\", "")
return text
def _looks_like_table(lines: list[str], i: int) -> bool:
return i + 1 < len(lines) and "|" in lines[i] and TABLE_SEP_RE.match(lines[i + 1]) is not None
def _parse_table(lines: list[str], i: int) -> tuple[Block, int]:
rows: list[Block] = []
consumed = 0
while i + consumed < len(lines):
line = lines[i + consumed]
stripped = line.strip()
if not stripped or "|" not in stripped:
break
if consumed == 1 and TABLE_SEP_RE.match(stripped):
consumed += 1
continue
cells = [cell.strip() for cell in stripped.strip("|").split("|")]
row = Block(
type="tr",
children=[
Block(type="table_cell", text_runs=[TextRun(clean_inline(cell))])
for cell in cells
if cell
],
)
rows.append(row)
consumed += 1
return Block(type="table", children=rows), consumed
# ---------------------------------------------------------------------------
# XML parser
# ---------------------------------------------------------------------------
INLINE_TAGS = {
"b",
"strong",
"i",
"em",
"u",
"s",
"del",
"span",
"text",
"plain_text",
"code",
"a",
"link",
"mention",
"mention-doc",
"mention-user",
}
TYPE_ALIASES = {
"doc": "document",
"document": "document",
"fragment": "fragment",
"p": "paragraph",
"paragraph": "paragraph",
"heading": "heading",
"h1": "heading",
"h2": "heading",
"h3": "heading",
"h4": "heading",
"h5": "heading",
"h6": "heading",
"h7": "heading",
"h8": "heading",
"h9": "heading",
"ul": "list",
"ol": "list",
"li": "list_item",
"task": "task",
"todo": "list_item",
"blockquote": "quote",
"quote": "quote",
"br": "br",
"hr": "hr",
"title": "title",
"checkbox": "checkbox",
"grid": "grid",
"column": "column",
"table": "table",
"colgroup": "colgroup",
"col": "col",
"tr": "tr",
"td": "table_cell",
"th": "table_cell",
"pre": "code",
"code_block": "code",
"callout": "callout",
"figure": "figure",
"toggle": "toggle",
"img": "image",
"source": "source",
"file": "file",
"media": "media",
"latex": "latex",
"cite": "cite",
"bookmark": "bookmark",
"button": "button",
"whiteboard": "whiteboard",
"mermaid": "mermaid",
"plantuml": "plantuml",
"poll": "poll",
"isv": "isv",
"mindnote": "mindnote",
"diagram": "diagram",
"sheet": "sheet",
"bitable": "bitable",
"base-ref": "base_ref",
"base_ref": "base_ref",
"base-refer": "base_ref",
"base_refer": "base_ref",
"synced-reference": "synced_reference",
"synced_reference": "synced_reference",
"synced-source": "synced_source",
"synced_source": "synced_source",
"okr": "okr",
"chat-card": "chat_card",
"chat_card": "chat_card",
"sub_page_list": "sub-page-list",
"sub-page-list": "sub-page-list",
}
SUBTYPE_ATTR_TAGS = {
"a",
"button",
"cite",
"img",
"sheet",
"source",
"whiteboard",
"base-ref",
"base_ref",
"base-refer",
"base_refer",
"synced-reference",
"synced_reference",
"synced-source",
"synced_source",
"okr",
"chat-card",
"chat_card",
"sub_page_list",
"sub-page-list",
}
MAX_XML_INPUT_CHARS = 20_000_000
FORBIDDEN_XML_DECL_RE = re.compile(r"<!\s*(?:DOCTYPE|ENTITY)\b", re.IGNORECASE)
class UserInputError(ValueError):
pass
def local_name(tag: str) -> str:
if "}" in tag:
return tag.rsplit("}", 1)[1]
return tag
def block_type_for(elem: ET.Element) -> str:
tag = local_name(elem.tag)
explicit = elem.attrib.get("block_type")
if explicit is None and tag not in SUBTYPE_ATTR_TAGS:
explicit = elem.attrib.get("type")
if explicit:
return TYPE_ALIASES.get(explicit, explicit)
return TYPE_ALIASES.get(tag, tag)
def ensure_safe_xml_source(source: str) -> None:
if len(source) > MAX_XML_INPUT_CHARS:
raise UserInputError(
f"XML input is too large ({len(source)} chars, limit {MAX_XML_INPUT_CHARS})"
)
if FORBIDDEN_XML_DECL_RE.search(source):
raise UserInputError("XML input must not contain DOCTYPE or ENTITY declarations")
def parse_xml(source: str) -> list[Block]:
source = source.strip()
if not source:
return []
ensure_safe_xml_source(source)
try:
root = ET.fromstring(source)
except ET.ParseError:
# docs +fetch raw output can occasionally include adjacent top-level
# blocks. Wrap them so standard ElementTree can parse the stream.
root = ET.fromstring(f"<fragment>{source}</fragment>")
return [_parse_block(root)]
def _parse_block(elem: ET.Element) -> Block:
block = Block(type=block_type_for(elem), attrs=dict(elem.attrib), raw=elem)
_collect_content(elem, block)
if not block.text_runs and not block.children:
if block.type == "image":
display = elem.attrib.get("caption")
else:
display = (
elem.attrib.get("text")
or elem.attrib.get("name")
or elem.attrib.get("title")
or elem.attrib.get("alt")
or elem.attrib.get("caption")
)
if display:
block.text_runs.append(TextRun(display, dict(elem.attrib)))
return block
def _collect_content(elem: ET.Element, block: Block) -> None:
if elem.text:
block.text_runs.append(TextRun(elem.text))
for child in list(elem):
tag = local_name(child.tag)
if tag == "br":
block.text_runs.append(TextRun("\n"))
elif tag in INLINE_TAGS:
_collect_inline(child, block)
else:
block.children.append(_parse_block(child))
if child.tail:
block.text_runs.append(TextRun(child.tail))
def _collect_inline(elem: ET.Element, block: Block) -> None:
if local_name(elem.tag) == "br":
block.text_runs.append(TextRun("\n", dict(elem.attrib)))
return
display = (
elem.attrib.get("text")
or elem.attrib.get("name")
or elem.attrib.get("title")
or elem.attrib.get("alt")
)
if display:
block.text_runs.append(TextRun(display, dict(elem.attrib)))
return
if elem.text:
block.text_runs.append(TextRun(elem.text, dict(elem.attrib)))
for child in list(elem):
_collect_inline(child, block)
if child.tail:
block.text_runs.append(TextRun(child.tail))
# ---------------------------------------------------------------------------
# Block extraction registry
# ---------------------------------------------------------------------------
@dataclass
class ExtractContext:
unknown_blocks: list[UnknownBlock] = field(default_factory=list)
resource_texts: dict[str, str] = field(default_factory=dict)
class Handler(Protocol):
def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
raise NotImplementedError
def block_id(block: Block) -> str | None:
for key in ("id", "block_id", "block-id", "token"):
value = block.attrs.get(key)
if isinstance(value, str) and value:
return value
return None
def runs_text(block: Block) -> str:
return "".join(run.text for run in block.text_runs)
def raw_tag(block: Block) -> str:
tag = getattr(getattr(block, "raw", None), "tag", "") or ""
if "}" in tag:
return tag.rsplit("}", 1)[1]
return tag
class TextBlockHandler:
def __init__(self, kind: str = "text") -> None:
self.kind = kind
def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
segments: list[Segment] = []
text = runs_text(block)
if text.strip():
segments.append(
Segment(
text=text,
block_type=block.type,
block_id=block_id(block),
kind=self.kind,
)
)
for child in block.children:
segments.extend(registry.extract(child, ctx))
return segments
class ContainerHandler:
def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
segments: list[Segment] = []
text = runs_text(block)
if text.strip():
segments.append(
Segment(text=text, block_type=block.type, block_id=block_id(block), kind="text")
)
for child in block.children:
segments.extend(registry.extract(child, ctx))
return segments
class ListHandler:
def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
tag = raw_tag(block)
if tag not in {"ol", "ul"}:
return ContainerHandler().extract(block, registry, ctx)
segments: list[Segment] = []
text = runs_text(block)
if text.strip():
segments.append(
Segment(text=text, block_type=block.type, block_id=block_id(block), kind="text")
)
next_seq = 1
for child in block.children:
if child.type == "list_item":
if tag == "ol":
seq = child.attrs.get("seq")
if isinstance(seq, str) and seq.isdigit():
marker = seq
next_seq = int(seq) + 1
else:
marker = str(next_seq)
next_seq += 1
segments.append(
Segment(
text=f"{marker}.",
block_type="list_marker",
block_id=block_id(child),
kind="text",
)
)
else:
segments.append(
Segment(
text="•",
block_type="list_marker",
block_id=block_id(child),
kind="marker",
)
)
segments.extend(registry.extract(child, ctx))
return segments
class CheckboxHandler(TextBlockHandler):
def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
return [
Segment(
text="☑" if block.attrs.get("done") == "true" else "☐",
block_type="checkbox_marker",
block_id=block_id(block),
kind="marker",
),
*super().extract(block, registry, ctx),
]
class UnknownHandler:
def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
ctx.unknown_blocks.append(UnknownBlock(type=block.type, block_id=block_id(block)))
return ContainerHandler().extract(block, registry, ctx)
class IgnoreHandler:
def __init__(self, action: str = "ignored") -> None:
self.action = action
def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
ctx.unknown_blocks.append(UnknownBlock(type=block.type, block_id=block_id(block), action=self.action))
return []
class TaskHandler:
def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
task_id = block.attrs.get("task-id") or block.attrs.get("task_id")
if isinstance(task_id, str) and task_id:
text = ctx.resource_texts.get(f"task:{task_id}")
if text and text.strip():
marker = "☑" if block.attrs.get("status") in {"done", "completed", "complete"} else "☐"
return [
Segment(text=marker, block_type="task_marker", block_id=block_id(block), kind="marker"),
Segment(text=text, block_type="task", block_id=block_id(block), kind="resource_title"),
]
ctx.unknown_blocks.append(UnknownBlock(type=block.type, block_id=block_id(block), action="ignored_resource"))
return []
class WhiteboardHandler:
def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
board_type = block.attrs.get("type")
is_empty_resource_shell = not board_type and not block.children and not runs_text(block).strip()
action = (
"ignored_resource"
if board_type in {"blank", "mermaid", "plantuml", "svg"} or is_empty_resource_shell
else "unsupported_resource"
)
ctx.unknown_blocks.append(UnknownBlock(type=block.type, block_id=block_id(block), action=action))
return []
class SyncedSourceHandler:
def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
if block.children:
segments: list[Segment] = []
for child in block.children:
segments.extend(registry.extract(child, ctx))
return segments
ctx.unknown_blocks.append(UnknownBlock(type=block.type, block_id=block_id(block), action="unsupported_resource"))
return []
class Registry:
def __init__(self) -> None:
self._handlers: dict[str, Handler] = {}
self._unknown = UnknownHandler()
def register(self, *types: str, handler: Handler) -> None:
for typ in types:
self._handlers[typ] = handler
def extract(self, block: Block, ctx: ExtractContext) -> list[Segment]:
handler = self._handlers.get(block.type, self._unknown)
return handler.extract(block, self, ctx)
def default_registry() -> Registry:
registry = Registry()
registry.register("document", "fragment", "root", handler=ContainerHandler())
registry.register("title", handler=TextBlockHandler("title"))
registry.register("paragraph", "p", handler=TextBlockHandler("text"))
registry.register(
"heading",
"h",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"h7",
"h8",
"h9",
handler=TextBlockHandler("heading"),
)
registry.register("list", "ul", "ol", handler=ListHandler())
registry.register("list_item", "li", "todo", handler=TextBlockHandler("list_item"))
registry.register("checkbox", handler=CheckboxHandler("list_item"))
registry.register(
"quote",
"blockquote",
"callout",
"toggle",
"grid",
"column",
"figure",
handler=ContainerHandler(),
)
registry.register("table", "thead", "tbody", "tr", handler=ContainerHandler())
registry.register("table_cell", "td", "th", handler=TextBlockHandler("table_cell"))
registry.register("code", "code_block", "pre", handler=TextBlockHandler("code"))
registry.register("link", "a", "mention", "mention-doc", "mention-user", "time", handler=TextBlockHandler("inline"))
registry.register("image", "img", handler=TextBlockHandler("caption"))
registry.register("colgroup", "col", "br", "hr", handler=IgnoreHandler("ignored_structure"))
registry.register("button", "cite", "latex", "bookmark", handler=IgnoreHandler("ignored_inline"))
registry.register("task", handler=TaskHandler())
registry.register("whiteboard", handler=WhiteboardHandler())
registry.register("synced_source", handler=SyncedSourceHandler())
registry.register(
"mermaid",
"sheet",
"source",
"file",
"media",
"chat_card",
"base_ref",
"bitable",
"synced_reference",
"poll",
"isv",
"mindnote",
"diagram",
"sub-page-list",
handler=IgnoreHandler("ignored_resource"),
)
registry.register(
"okr",
"plantuml",
handler=IgnoreHandler("unsupported_resource"),
)
return registry
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
VERSION = "0.1-alpha"
def build_diagnostics(items: list) -> dict[str, object]:
actions: dict[str, int] = {}
types: dict[str, int] = {}
unsupported_types: dict[str, int] = {}
unknown_types: dict[str, int] = {}
for item in items:
actions[item.action] = actions.get(item.action, 0) + 1
types[item.type] = types.get(item.type, 0) + 1
if item.action == "unsupported_resource":
unsupported_types[item.type] = unsupported_types.get(item.type, 0) + 1
if item.action == "recurse_children":
unknown_types[item.type] = unknown_types.get(item.type, 0) + 1
return {
"actions": actions,
"types": types,
"unsupported_types": unsupported_types,
"unknown_types": unknown_types,
"has_unsupported": bool(unsupported_types),
"has_unknown": bool(unknown_types),
}
def read_input(path: str) -> str:
if path == "-":
return sys.stdin.read()
return Path(path).read_text(encoding="utf-8")
def read_resource_texts(path: str | None) -> dict[str, str]:
if not path:
return {}
payload = json.loads(Path(path).read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError("--resource-texts must be a JSON object")
return {str(key): str(value) for key, value in payload.items()}
def extract_lark_json_content(source: str) -> str:
try:
envelope = json.loads(source)
except json.JSONDecodeError as exc:
raise UserInputError(f"could not parse lark-cli JSON envelope: {exc}") from exc
if not isinstance(envelope, dict):
raise UserInputError("lark-cli JSON envelope must be an object")
data = envelope.get("data")
if not isinstance(data, dict):
raise UserInputError("lark-cli JSON envelope is missing object field data")
document = data.get("document")
if not isinstance(document, dict):
raise UserInputError("lark-cli JSON envelope is missing object field data.document")
content = document.get("content")
if not isinstance(content, str):
raise UserInputError("lark-cli JSON envelope is missing string field data.document.content")
return content
HELP_EPILOG = """
Examples:
Local XML file:
python3 doc_word_stat.py --protocol xml /absolute/path/doc.xml
Local Markdown file:
python3 doc_word_stat.py --protocol md /absolute/path/doc.md
Pipe an extracted local file:
cat /absolute/path/doc.xml | python3 doc_word_stat.py --protocol xml --pretty
Lark CLI XML fetch, JSON envelope output:
lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \\
| python3 doc_word_stat.py --protocol xml --lark-json --pretty
Lark CLI Markdown fetch, raw content output:
lark-cli docs +fetch --doc "$URL" --doc-format markdown \\
| python3 doc_word_stat.py --protocol md
Strict integration for agents or automation:
lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \\
| python3 doc_word_stat.py --protocol xml --lark-json --fail-on-unsupported --fail-on-unknown
"""
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Count semantic words and visible characters in Lark Docs XML or Markdown.",
epilog=HELP_EPILOG,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--version",
action="version",
version=f"%(prog)s {VERSION}",
)
parser.add_argument(
"input",
nargs="?",
default="-",
help="input file path, or '-' / omitted for stdin",
)
parser.add_argument(
"--protocol",
choices=("xml", "md"),
required=True,
help="input protocol produced by docs +fetch",
)
parser.add_argument(
"--pretty",
action="store_true",
help="pretty-print JSON output",
)
parser.add_argument(
"--segments",
action="store_true",
help="include extracted text segments for debugging",
)
parser.add_argument(
"--lark-json",
action="store_true",
help="read lark-cli docs +fetch JSON and count data.document.content",
)
parser.add_argument(
"--resource-texts",
help='optional JSON object mapping resource keys to visible text, e.g. {"task:<task-id>": "title"}',
)
parser.add_argument(
"--fail-on-unsupported",
action="store_true",
help="exit with code 2 when unsupported_blocks is non-empty",
)
parser.add_argument(
"--fail-on-unknown",
action="store_true",
help="exit with code 3 when unknown XML/Markdown block types are encountered",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
source = read_input(args.input)
if args.lark_json:
try:
source = extract_lark_json_content(source)
except UserInputError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
if args.protocol == "xml":
try:
blocks = parse_xml(source)
except (ET.ParseError, UserInputError) as exc:
print(f"error: could not parse XML input: {exc}", file=sys.stderr)
return 1
else:
blocks = parse_markdown(source)
ctx = ExtractContext(resource_texts=read_resource_texts(args.resource_texts))
registry = default_registry()
segments = []
for block in blocks:
segments.extend(registry.extract(block, ctx))
stats = Counter().count_segments(segments)
payload = stats.to_dict()
payload["protocol"] = args.protocol
payload["unknown_blocks"] = [item.to_dict() for item in ctx.unknown_blocks]
payload["unsupported_blocks"] = [
item.to_dict() for item in ctx.unknown_blocks if item.action == "unsupported_resource"
]
payload["diagnostics"] = build_diagnostics(ctx.unknown_blocks)
if args.segments:
payload["segments"] = [segment.to_dict() for segment in segments]
indent = 2 if args.pretty else None
print(json.dumps(payload, ensure_ascii=False, indent=indent, sort_keys=args.pretty))
if args.fail_on_unsupported and payload["unsupported_blocks"]:
return 2
if args.fail_on_unknown and payload["diagnostics"]["has_unknown"]:
return 3
return 0
if __name__ == "__main__":
raise SystemExit(main())