Files
wehub-resource-sync 1443d3fdf9
Ruff Format Check / Ruff Format & Lint (push) Failing after 7m39s
Deploy VitePress site to Pages / build (push) Failing after 9m11s
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:26 +08:00

584 lines
18 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import random
import re
from dataclasses import dataclass, field
BULLET_PATTERN = [
[
r"第[零一二三四五六七八九十百0-9]+(分?编|部分)",
r"第[零一二三四五六七八九十百0-9]+章",
r"第[零一二三四五六七八九十百0-9]+节",
r"第[零一二三四五六七八九十百0-9]+条",
r"[\(][零一二三四五六七八九十百]+[\))]",
],
[
r"第[0-9]+章",
r"第[0-9]+节",
r"[0-9]{,2}[\. 、]",
r"[0-9]{,2}\.[0-9]{,2}[^a-zA-Z/%~-]",
r"[0-9]{,2}\.[0-9]{,2}\.[0-9]{,2}",
r"[0-9]{,2}\.[0-9]{,2}\.[0-9]{,2}\.[0-9]{,2}",
],
[
r"第[零一二三四五六七八九十百0-9]+章",
r"第[零一二三四五六七八九十百0-9]+节",
r"[零一二三四五六七八九十百]+[ 、]",
r"[\(][零一二三四五六七八九十百]+[\))]",
r"[\(][0-9]{,2}[\)]",
],
[
r"PART (ONE|TWO|THREE|FOUR|FIVE|SIX|SEVEN|EIGHT|NINE|TEN)",
r"Chapter (I+V?|VI*|XI|IX|X)",
r"Section [0-9]+",
r"Article [0-9]+",
],
[
r"^#[^#]",
r"^##[^#]",
r"^###.*",
r"^####.*",
r"^#####.*",
r"^######.*",
],
]
MARKDOWN_BULLET_GROUP_INDEX = 4
def count_tokens(text: str) -> int:
"""近似 token 计数,避免引入额外依赖。"""
if not text:
return 0
# 英文单词 + 数字 + CJK 单字
parts = re.findall(r"[A-Za-z0-9_]+|[\u4e00-\u9fff]", text)
return max(1, len(parts)) if text.strip() else 0
def hard_split_by_token_limit(text: str, chunk_token_num: int, hard_limit_token_num: int | None = None) -> list[str]:
"""将文本按 token 上限硬切,用于 naive_merge 之后的兜底保护。
hard_limit_token_num 只在调用方显式传入时生效,用于允许略超目标长度的块
保持完整;默认保持严格不超过 chunk_token_num 的历史行为。
"""
token_iter = list(re.finditer(r"[A-Za-z0-9_]+|[一-鿿]", text or ""))
if not token_iter:
cleaned = (text or "").strip()
return [cleaned] if cleaned else []
max_tokens = max(int(chunk_token_num or 0), 1)
hard_limit = None
if hard_limit_token_num is not None:
hard_limit = max(int(hard_limit_token_num or 0), max_tokens)
if len(token_iter) <= hard_limit:
cleaned = (text or "").strip()
return [cleaned] if cleaned else []
spans: list[tuple[int, int]] = []
start = 0
index = 0
while index < len(token_iter):
next_index = min(index + max_tokens, len(token_iter))
if next_index < len(token_iter):
end = token_iter[next_index].start()
else:
end = len(text)
if text[start:end].strip():
spans.append((start, end))
start = end
index = next_index
tail = text[start:].strip()
if tail:
spans.append((start, len(text)))
if hard_limit is not None and len(spans) >= 2:
prev_start, _ = spans[-2]
_, tail_end = spans[-1]
candidate = text[prev_start:tail_end].strip()
if count_tokens(candidate) <= hard_limit:
spans[-2] = (prev_start, tail_end)
spans.pop()
return [text[start:end].strip() for start, end in spans if text[start:end].strip()]
def random_choices(arr: list[str], k: int) -> list[str]:
if not arr:
return []
return random.choices(arr, k=min(len(arr), k))
def is_english(texts: str | list[str]) -> bool:
if not texts:
return False
patt = re.compile(r"[`a-zA-Z0-9\s.,':;/\"?<>!\(\)\-]+")
if isinstance(texts, str):
seq = [texts]
else:
seq = [t for t in texts if isinstance(t, str) and t.strip()]
if not seq:
return False
hits = sum(1 for t in seq if patt.fullmatch(t.strip()))
return (hits / len(seq)) > 0.8
def not_bullet(line: str) -> bool:
patt = [
r"0",
r"[0-9]+ +[0-9~个只-]",
r"[0-9]+\.{2,}",
]
return any(re.match(p, line) for p in patt)
def is_probable_heading_line(line: str) -> bool:
text = (line or "").strip()
if not text:
return False
if re.match(r"^#{1,6}\s+\S", text):
return True
# 表格/HTML 残留通常不是标题。
if re.search(r"</?(table|tr|td|th|caption|tbody|thead)[^>]*>", text, flags=re.IGNORECASE):
return False
# 超长行基本是正文或条款,不是章节标题。
if len(text) > 96:
return False
if count_tokens(text) > 72:
return False
# 标题前段通常不会出现明显句号/逗号;出现则大概率是正文。
if re.search(r"[,。;!?!?:]", text[:24]):
return False
if text.endswith(("。", "", "", "!", "", "?")) and len(text) > 20:
return False
return True
def _is_mid_sentence_bullet(line: str) -> bool:
text = (line or "").strip()
if not text:
return False
if re.match(r"^#{1,6}\s+\S", text):
return False
marker = re.search(
r"([一二三四五六七八九十百]+、|[\((][一二三四五六七八九十百]+[\)]|[0-9]{1,2}[\.、])",
text,
)
if not marker:
return False
if marker.start() == 0:
return False
prev = text[marker.start() - 1]
return prev not in {"#", "\n"}
def bullets_category(sections: list[str]) -> int:
hits: list[float] = [0.0] * len(BULLET_PATTERN)
def bullet_weight(group_idx: int, line: str) -> float:
# 对 markdown 标题候选增加权重,避免“正文里的 一、/(一)”压过真正的 # 标题层级。
if group_idx != MARKDOWN_BULLET_GROUP_INDEX:
return 1.0
heading = line.strip()
if not re.match(r"^#{1,6}\s+\S", heading):
return 1.0
level = len(heading) - len(heading.lstrip("#"))
if level <= 2:
return 4.0
if level <= 4:
return 3.0
return 2.0
for i, pro in enumerate(BULLET_PATTERN):
for sec in sections:
sec = sec.strip()
for p in pro:
if re.match(p, sec) and not not_bullet(sec):
w = bullet_weight(i, sec)
if _is_mid_sentence_bullet(sec):
w *= 0.1
if i != MARKDOWN_BULLET_GROUP_INDEX and not is_probable_heading_line(sec):
w *= 0.2
hits[i] += w
break
maximum = 0
res = -1
for i, hit in enumerate(hits):
if hit <= maximum:
continue
res = i
maximum = hit
return res
def _get_text(section: str | tuple[str, str]) -> str:
if isinstance(section, str):
return section.strip()
return (section[0] or "").strip()
def remove_contents_table(sections: list[str] | list[tuple[str, str]], eng: bool = False) -> None:
i = 0
while i < len(sections):
line = re.sub(r"( | |\u3000)+", "", _get_text(sections[i]).split("@@")[0], flags=re.IGNORECASE)
if not re.match(r"(contents|目录|目次|tableofcontents|致谢|acknowledge)$", line, flags=re.IGNORECASE):
i += 1
continue
sections.pop(i)
if i >= len(sections):
break
prefix = _get_text(sections[i])[:3] if not eng else " ".join(_get_text(sections[i]).split()[:2])
while not prefix and i < len(sections):
sections.pop(i)
if i >= len(sections):
break
prefix = _get_text(sections[i])[:3] if not eng else " ".join(_get_text(sections[i]).split()[:2])
if i >= len(sections) or not prefix:
break
sections.pop(i)
if i >= len(sections):
break
for j in range(i, min(i + 128, len(sections))):
if not re.match(re.escape(prefix), _get_text(sections[j])):
continue
for _ in range(i, j):
sections.pop(i)
break
def make_colon_as_title(sections: list[str] | list[tuple[str, str]]) -> list[str] | list[tuple[str, str]]:
if not sections:
return sections
if isinstance(sections[0], str):
return sections
i = 0
while i < len(sections):
text, layout = sections[i]
i += 1
text = text.split("@")[0].strip()
if not text or text[-1] not in ":":
continue
rev = text[::-1]
arr = re.split(r"([。?!!?;]| \.)", rev)
if len(arr) < 2 or len(arr[1]) < 32:
continue
sections.insert(i - 1, (arr[0][::-1], "title"))
i += 1
return sections
def not_title(text: str) -> bool:
if re.match(r"第[零一二三四五六七八九十百0-9]+条", text):
return False
if len(text.split()) > 12 or (" " not in text and len(text) >= 32):
return True
return bool(re.search(r"[,;,。;!!]", text))
def tree_merge(bull: int, sections: list[str] | list[tuple[str, str]], depth: int) -> list[str]:
if not sections or bull < 0:
return [s if isinstance(s, str) else s[0] for s in sections]
if isinstance(sections[0], str):
typed_sections: list[tuple[str, str]] = [(s, "") for s in sections]
else:
typed_sections = sections # type: ignore[assignment]
typed_sections = [
(t, o)
for t, o in typed_sections
if t and len(t.split("@")[0].strip()) > 1 and not re.match(r"[0-9]+$", t.split("@")[0].strip())
]
def get_level(section: tuple[str, str]) -> tuple[int, str]:
text, layout = section
text = re.sub(r"\u3000", " ", text).strip()
for i, patt in enumerate(BULLET_PATTERN[bull]):
if re.match(patt, text) and is_probable_heading_line(text):
return i + 1, text
if re.search(r"(title|head)", layout) and not not_title(text):
return len(BULLET_PATTERN[bull]) + 1, text
return len(BULLET_PATTERN[bull]) + 2, text
lines: list[tuple[int, str]] = []
level_set: set[int] = set()
for section in typed_sections:
level, text = get_level(section)
if not text.strip("\n"):
continue
lines.append((level, text))
level_set.add(level)
if not lines:
return []
sorted_levels = sorted(level_set)
target_level = sorted_levels[depth - 1] if depth <= len(sorted_levels) else sorted_levels[-1]
max_body_level = len(BULLET_PATTERN[bull]) + 2
if target_level == max_body_level:
target_level = sorted_levels[-2] if len(sorted_levels) > 1 else sorted_levels[0]
root = Node(level=0, depth=target_level, texts=[])
root.build_tree(lines)
return [item for item in root.get_tree() if item]
def hierarchical_merge(bull: int, sections: list[str] | list[tuple[str, str]], depth: int) -> list[list[str]]:
if not sections or bull < 0:
return []
if isinstance(sections[0], str):
typed_sections: list[tuple[str, str]] = [(s, "") for s in sections]
else:
typed_sections = sections # type: ignore[assignment]
typed_sections = [
(t, o)
for t, o in typed_sections
if t and len(t.split("@")[0].strip()) > 1 and not re.match(r"[0-9]+$", t.split("@")[0].strip())
]
bullets_size = len(BULLET_PATTERN[bull])
levels: list[list[int]] = [[] for _ in range(bullets_size + 2)]
for i, (text, layout) in enumerate(typed_sections):
for j, patt in enumerate(BULLET_PATTERN[bull]):
if re.match(patt, text.strip()) and is_probable_heading_line(text):
levels[j].append(i)
break
else:
if re.search(r"(title|head)", layout) and not not_title(text):
levels[bullets_size].append(i)
else:
levels[bullets_size + 1].append(i)
pure_sections = [t for t, _ in typed_sections]
def binary_search(arr: list[int], target: int) -> int:
if not arr:
return -1
if target > arr[-1]:
return len(arr) - 1
if target < arr[0]:
return -1
s, e = 0, len(arr)
while e - s > 1:
mid = (e + s) // 2
if target > arr[mid]:
s = mid
elif target < arr[mid]:
e = mid
else:
return mid
return s
cks: list[list[int]] = []
readed = [False] * len(pure_sections)
levels = list(reversed(levels))
for i, arr in enumerate(levels[:depth]):
for j in arr:
if readed[j]:
continue
readed[j] = True
cks.append([j])
if i + 1 == len(levels) - 1:
continue
for ii in range(i + 1, len(levels)):
jj = binary_search(levels[ii], j)
if jj < 0:
continue
if levels[ii][jj] > cks[-1][-1]:
cks[-1].pop(-1)
cks[-1].append(levels[ii][jj])
for ii in cks[-1]:
readed[ii] = True
if not cks:
return []
for i in range(len(cks)):
cks[i] = [pure_sections[j] for j in reversed(cks[i])]
res: list[list[str]] = [[]]
num = [0]
for ck in cks:
if len(ck) == 1:
n = count_tokens(re.sub(r"@@[0-9]+.*", "", ck[0]))
if n + num[-1] < 218:
res[-1].append(ck[0])
num[-1] += n
continue
res.append(ck)
num.append(n)
continue
res.append(ck)
num.append(218)
return [chunk for chunk in res if chunk]
def _remove_pdf_tags(text: str) -> str:
return re.sub(r"@@[0-9-]+\t[0-9.\t]+##", "", text or "")
def _extract_custom_delimiters(delimiter: str) -> list[str]:
return [m.group(1) for m in re.finditer(r"`([^`]+)`", delimiter or "")]
def naive_merge(
sections: str | list[str] | list[tuple[str, str]],
chunk_token_num: int = 128,
delimiter: str = "\n。;!?",
overlapped_percent: int = 0,
) -> list[str]:
if not sections:
return []
if isinstance(sections, str):
typed_sections: list[tuple[str, str]] = [(sections, "")]
elif isinstance(sections[0], str):
typed_sections = [(s, "") for s in sections] # type: ignore[index]
else:
typed_sections = sections # type: ignore[assignment]
chunk_token_num = max(int(chunk_token_num or 0), 0)
overlap = max(0, min(int(overlapped_percent or 0), 99))
custom_delimiters = _extract_custom_delimiters(delimiter)
if custom_delimiters:
pattern = "|".join(re.escape(t) for t in sorted(set(custom_delimiters), key=len, reverse=True))
chunks: list[str] = []
for sec, pos in typed_sections:
split_sec = re.split(rf"({pattern})", sec, flags=re.DOTALL)
for sub in split_sec:
if re.fullmatch(pattern, sub or ""):
continue
text = "\n" + sub
local_pos = pos if count_tokens(text) >= 8 else ""
if local_pos and local_pos not in text:
text += local_pos
if text.strip():
chunks.append(text)
return chunks
if chunk_token_num <= 0:
merged = "\n".join(sec for sec, _ in typed_sections if sec and sec.strip())
return [merged] if merged.strip() else []
chunks = [""]
token_nums = [0]
def add_chunk(text: str, pos: str) -> None:
tnum = count_tokens(text)
local_pos = pos or ""
if tnum < 8:
local_pos = ""
threshold = chunk_token_num * (100 - overlap) / 100.0
if chunks[-1] == "" or token_nums[-1] > threshold:
if chunks:
prev = _remove_pdf_tags(chunks[-1])
start = int(len(prev) * (100 - overlap) / 100.0)
text = prev[start:] + text
if local_pos and local_pos not in text:
text += local_pos
chunks.append(text)
token_nums.append(tnum)
else:
if local_pos and local_pos not in chunks[-1]:
text += local_pos
chunks[-1] += text
token_nums[-1] += tnum
for sec, pos in typed_sections:
if not sec:
continue
add_chunk("\n" + sec, pos)
return [chunk for chunk in chunks if chunk.strip()]
@dataclass
class Node:
level: int
depth: int = -1
texts: list[str] = field(default_factory=list)
children: list[Node] = field(default_factory=list)
def add_child(self, child_node: Node) -> None:
self.children.append(child_node)
def add_text(self, text: str) -> None:
self.texts.append(text)
def build_tree(self, lines: list[tuple[int, str]]) -> Node:
stack: list[Node] = [self]
for level, text in lines:
if self.depth != -1 and level > self.depth:
stack[-1].add_text(text)
continue
while len(stack) > 1 and level <= stack[-1].level:
stack.pop()
node = Node(level=level, texts=[text])
stack[-1].add_child(node)
stack.append(node)
return self
def get_tree(self) -> list[str]:
tree_list: list[str] = []
self._dfs(self, tree_list, [])
return tree_list
def _dfs(self, node: Node, tree_list: list[str], titles: list[str]) -> None:
level = node.level
texts = node.texts
child = node.children
if level == 0 and texts:
tree_list.append("\n".join(titles + texts))
path_titles = titles + texts if 1 <= level <= self.depth else titles
if level > self.depth and texts:
tree_list.append("\n".join(path_titles + texts))
elif not child and (1 <= level <= self.depth):
tree_list.append("\n".join(path_titles))
for c in child:
self._dfs(c, tree_list, path_titles)