mirror of
https://github.com/modelscope/FunASR.git
synced 2026-10-12 02:19:18 +00:00
ci: guard FunClip v2.1.0 launch pages (#3417)
Add robust live-site contracts for the bilingual FunClip v2.1.0 launch pages, release routes, donor navigation, and interface image. Hidden evidence and zero-area links cannot satisfy the contract.
This commit is contained in:
@@ -22,11 +22,13 @@ class PageContract:
|
||||
visible_required: tuple[str, ...] = ()
|
||||
visible_patterns: tuple[tuple[str, str], ...] = ()
|
||||
required_links: tuple[str, ...] = ()
|
||||
required_images: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StaticAssetContract:
|
||||
signature: bytes
|
||||
media_type: str
|
||||
min_bytes: int = 128
|
||||
|
||||
|
||||
@@ -59,9 +61,7 @@ PAGE_CONTRACTS: dict[str, PageContract] = {
|
||||
("FunASR 官方插件 0.1.1", r"FunASR 官方插件 0\.1\.1(?![\w.+-])"),
|
||||
("最大 25 MB 音频上传", r"最大 (?<!\d)25 MB 音频上传"),
|
||||
),
|
||||
required_links=(
|
||||
"https://marketplace.dify.ai/plugin/langgenius/funasr",
|
||||
),
|
||||
required_links=("https://marketplace.dify.ai/plugin/langgenius/funasr",),
|
||||
forbidden=("16K+",),
|
||||
),
|
||||
f"{BASE_URL}/en/ecosystem.html": PageContract(
|
||||
@@ -76,9 +76,7 @@ PAGE_CONTRACTS: dict[str, PageContract] = {
|
||||
("FunASR plugin 0.1.1", r"FunASR plugin 0\.1\.1(?![\w.+-])"),
|
||||
("25 MB uploads", r"(?<!\d)25 MB uploads"),
|
||||
),
|
||||
required_links=(
|
||||
"https://marketplace.dify.ai/plugin/langgenius/funasr",
|
||||
),
|
||||
required_links=("https://marketplace.dify.ai/plugin/langgenius/funasr",),
|
||||
forbidden=("16K+",),
|
||||
),
|
||||
f"{BASE_URL}/donors.html": PageContract(
|
||||
@@ -275,48 +273,216 @@ PAGE_CONTRACTS: dict[str, PageContract] = {
|
||||
),
|
||||
forbidden=("funasr==1.3.27", "runtime-llamacpp-v0.1.1"),
|
||||
),
|
||||
f"{BASE_URL}/blog/funclip-v2-1-0-video-clipping-release.html": PageContract(
|
||||
required=(
|
||||
"FunClip v2.1.0",
|
||||
"FunClip-2.1.0.tar.gz",
|
||||
"FunClip-2.1.0.zip",
|
||||
"SHA256SUMS",
|
||||
"funasr>=1.3.29",
|
||||
"TwelveLabs Pegasus",
|
||||
),
|
||||
visible_required=(
|
||||
"FunClip v2.1.0",
|
||||
"FunClip-2.1.0.tar.gz",
|
||||
"FunClip-2.1.0.zip",
|
||||
"SHA256SUMS",
|
||||
"funasr>=1.3.29",
|
||||
"TwelveLabs Pegasus",
|
||||
"为什么发布的是源码归档,而不是 wheel",
|
||||
"v2.1.0 的精确合并源码通过 50 项测试,另有 1 项跳过",
|
||||
"干净 Python 3.12 环境中的 6 项发布契约测试",
|
||||
"GitHub Actions 首次发布和主动重跑均成功",
|
||||
"Star FunClip 仓库",
|
||||
),
|
||||
required_links=(
|
||||
"https://github.com/modelscope/FunClip",
|
||||
"https://github.com/modelscope/FunClip/releases/tag/v2.1.0",
|
||||
"https://github.com/modelscope/FunClip/releases/download/v2.1.0/FunClip-2.1.0.tar.gz",
|
||||
"https://github.com/modelscope/FunClip/releases/download/v2.1.0/FunClip-2.1.0.zip",
|
||||
"https://github.com/modelscope/FunClip/releases/download/v2.1.0/SHA256SUMS",
|
||||
"https://huggingface.co/spaces/FunAudioLLM/FunClip",
|
||||
"https://github.com/modelscope/FunASR/releases/tag/v1.3.29",
|
||||
"/donors.html",
|
||||
),
|
||||
required_images=("/img/funclip-v2-1-0-interface.jpg",),
|
||||
forbidden=("funasr>=1.3.28",),
|
||||
),
|
||||
f"{BASE_URL}/en/blog/funclip-v2-1-0-video-clipping-release.html": PageContract(
|
||||
required=(
|
||||
"FunClip v2.1.0",
|
||||
"FunClip-2.1.0.tar.gz",
|
||||
"FunClip-2.1.0.zip",
|
||||
"SHA256SUMS",
|
||||
"funasr>=1.3.29",
|
||||
"TwelveLabs Pegasus",
|
||||
),
|
||||
visible_required=(
|
||||
"FunClip v2.1.0",
|
||||
"FunClip-2.1.0.tar.gz",
|
||||
"FunClip-2.1.0.zip",
|
||||
"SHA256SUMS",
|
||||
"funasr>=1.3.29",
|
||||
"TwelveLabs Pegasus",
|
||||
"Why these are source archives, not a wheel",
|
||||
"The exact v2.1.0 merge passed 50 tests with 1 skipped",
|
||||
"Six release-contract tests also passed in a clean Python 3.12 environment",
|
||||
"Both the initial GitHub Actions release and a deliberate rerun succeeded",
|
||||
"star the FunClip repository",
|
||||
),
|
||||
required_links=(
|
||||
"https://github.com/modelscope/FunClip",
|
||||
"https://github.com/modelscope/FunClip/releases/tag/v2.1.0",
|
||||
"https://github.com/modelscope/FunClip/releases/download/v2.1.0/FunClip-2.1.0.tar.gz",
|
||||
"https://github.com/modelscope/FunClip/releases/download/v2.1.0/FunClip-2.1.0.zip",
|
||||
"https://github.com/modelscope/FunClip/releases/download/v2.1.0/SHA256SUMS",
|
||||
"https://huggingface.co/spaces/FunAudioLLM/FunClip",
|
||||
"https://github.com/modelscope/FunASR/releases/tag/v1.3.29",
|
||||
"/en/donors.html",
|
||||
),
|
||||
required_images=("/img/funclip-v2-1-0-interface.jpg",),
|
||||
forbidden=("funasr>=1.3.28",),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
JPEG_SIGNATURE = b"\xff\xd8\xff"
|
||||
|
||||
STATIC_ASSET_CONTRACTS: dict[str, StaticAssetContract] = {
|
||||
f"{BASE_URL}/img/banner.4f436d19.png": StaticAssetContract(PNG_SIGNATURE),
|
||||
f"{BASE_URL}/logo.png": StaticAssetContract(PNG_SIGNATURE),
|
||||
f"{BASE_URL}/img/banner.4f436d19.png": StaticAssetContract(
|
||||
PNG_SIGNATURE,
|
||||
"PNG",
|
||||
),
|
||||
f"{BASE_URL}/logo.png": StaticAssetContract(PNG_SIGNATURE, "PNG"),
|
||||
f"{BASE_URL}/img/funclip-v2-1-0-interface.jpg": StaticAssetContract(
|
||||
JPEG_SIGNATURE,
|
||||
"JPEG",
|
||||
min_bytes=100_000,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def extract_visible_text(html: str) -> str:
|
||||
body = html.split("<body", 1)[-1]
|
||||
body = re.sub(
|
||||
r"<script[\s\S]*?</script>|<style[\s\S]*?</style>",
|
||||
" ",
|
||||
body,
|
||||
flags=re.IGNORECASE,
|
||||
_HIDDEN_TAGS = frozenset(("head", "script", "style", "template", "noscript"))
|
||||
_VOID_TAGS = frozenset(
|
||||
(
|
||||
"area",
|
||||
"base",
|
||||
"br",
|
||||
"col",
|
||||
"embed",
|
||||
"hr",
|
||||
"img",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
)
|
||||
body = re.sub(r"<[^>]+>", " ", body)
|
||||
return re.sub(r"\s+", " ", body).strip()
|
||||
)
|
||||
|
||||
|
||||
class _LinkCollector(HTMLParser):
|
||||
@dataclass
|
||||
class _ElementState:
|
||||
tag: str
|
||||
hidden: bool
|
||||
link: str | None = None
|
||||
has_visible_content: bool = False
|
||||
|
||||
|
||||
class _VisibleContentCollector(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.text: list[str] = []
|
||||
self.links: set[str] = set()
|
||||
self.images: set[str] = set()
|
||||
self._stack: list[_ElementState] = []
|
||||
|
||||
def handle_starttag(
|
||||
self, tag: str, attrs: list[tuple[str, str | None]]
|
||||
) -> None:
|
||||
if tag.lower() != "a":
|
||||
return
|
||||
for name, value in attrs:
|
||||
if name.lower() == "href" and value is not None:
|
||||
self.links.add(value)
|
||||
@property
|
||||
def _parent_hidden(self) -> bool:
|
||||
return bool(self._stack and self._stack[-1].hidden)
|
||||
|
||||
def _mark_link_content(self) -> None:
|
||||
for state in reversed(self._stack):
|
||||
if state.link is not None:
|
||||
state.has_visible_content = True
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def _has_hidden_attribute(attrs: dict[str, str | None]) -> bool:
|
||||
style = re.sub(r"\s+", "", attrs.get("style") or "").lower()
|
||||
return (
|
||||
"hidden" in attrs
|
||||
or (attrs.get("aria-hidden") or "").lower() == "true"
|
||||
or "display:none" in style
|
||||
or "visibility:hidden" in style
|
||||
or "opacity:0" in style
|
||||
)
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
tag = tag.lower()
|
||||
attributes = {name.lower(): value for name, value in attrs}
|
||||
hidden = (
|
||||
self._parent_hidden
|
||||
or tag in _HIDDEN_TAGS
|
||||
or self._has_hidden_attribute(attributes)
|
||||
)
|
||||
if not hidden:
|
||||
if tag == "img" and attributes.get("src"):
|
||||
self.images.add(attributes["src"])
|
||||
self._mark_link_content()
|
||||
if tag not in _VOID_TAGS:
|
||||
link = attributes.get("href") if tag == "a" and not hidden else None
|
||||
self._stack.append(_ElementState(tag=tag, hidden=hidden, link=link))
|
||||
|
||||
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
tag = tag.lower()
|
||||
attributes = {name.lower(): value for name, value in attrs}
|
||||
hidden = (
|
||||
self._parent_hidden
|
||||
or tag in _HIDDEN_TAGS
|
||||
or self._has_hidden_attribute(attributes)
|
||||
)
|
||||
if not hidden and tag == "img" and attributes.get("src"):
|
||||
self.images.add(attributes["src"])
|
||||
self._mark_link_content()
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
tag = tag.lower()
|
||||
for index in range(len(self._stack) - 1, -1, -1):
|
||||
if self._stack[index].tag == tag:
|
||||
state = self._stack[index]
|
||||
if state.link is not None and state.has_visible_content:
|
||||
self.links.add(state.link)
|
||||
del self._stack[index:]
|
||||
return
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if not self._parent_hidden:
|
||||
self.text.append(data)
|
||||
if data.strip():
|
||||
self._mark_link_content()
|
||||
|
||||
|
||||
def _collect_visible_content(html: str) -> _VisibleContentCollector:
|
||||
parser = _VisibleContentCollector()
|
||||
parser.feed(html)
|
||||
parser.close()
|
||||
return parser
|
||||
|
||||
|
||||
def extract_visible_text(html: str) -> str:
|
||||
parser = _collect_visible_content(html)
|
||||
return re.sub(r"\s+", " ", " ".join(parser.text)).strip()
|
||||
|
||||
|
||||
def extract_links(html: str) -> set[str]:
|
||||
parser = _LinkCollector()
|
||||
parser.feed(html)
|
||||
return parser.links
|
||||
return _collect_visible_content(html).links
|
||||
|
||||
|
||||
def extract_images(html: str) -> set[str]:
|
||||
return _collect_visible_content(html).images
|
||||
|
||||
|
||||
def validate_pages(pages: dict[str, str]) -> list[str]:
|
||||
@@ -345,6 +511,11 @@ def validate_pages(pages: dict[str, str]) -> list[str]:
|
||||
for target in contract.required_links:
|
||||
if target not in links:
|
||||
failures.append(f"{url}: missing link `{target}`")
|
||||
if contract.required_images:
|
||||
images = extract_images(text)
|
||||
for target in contract.required_images:
|
||||
if target not in images:
|
||||
failures.append(f"{url}: missing image `{target}`")
|
||||
for needle in contract.forbidden:
|
||||
if needle in text:
|
||||
failures.append(f"{url}: forbidden `{needle}`")
|
||||
@@ -358,7 +529,7 @@ def validate_assets(assets: dict[str, bytes]) -> list[str]:
|
||||
if body is None:
|
||||
failures.append(f"{url}: asset was not fetched")
|
||||
elif not body.startswith(contract.signature) or len(body) < contract.min_bytes:
|
||||
failures.append(f"{url}: response is not a valid PNG")
|
||||
failures.append(f"{url}: response is not a valid {contract.media_type}")
|
||||
return failures
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user