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:
zhifu gao
2026-07-25 00:04:08 +08:00
committed by GitHub
parent 8a34247dc5
commit a28ee6b39d
2 changed files with 395 additions and 49 deletions
+201 -30
View File
@@ -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