fix(firmware): parse P2S/X2D wiki anchors without dash and full-width parens (#1030)

The wiki scraper silently returned no versions for P2S and X2D, causing
  Bambuddy to fall back to the Bambu Lab download page, which still listed
  01.01.01.00 as "latest" even though 01.02.00.00 shipped on 2026-04-09.

  Two regex mismatches in _fetch_all_versions_from_wiki():

  1. Heading anchor ids require an optional dash between version bytes and
     date. H2D/X1/H2C/H2S use "h-01020000-20260409"; P2S and X2D publish
     "h-0102000020260409" (no dash).
  2. The text fallback only matched ASCII parens around release dates, but
     P2S, X2D, A1 and A1-mini render dates in full-width parens (YYYYMMDD)
     (U+FF08/U+FF09).

  Anchor regex now accepts an optional dash; fallback accepts both paren
  styles. Added regression tests for both shapes.
This commit is contained in:
maziggy
2026-04-19 12:27:06 +02:00
parent 2bf397e33e
commit c7ad449e4e
3 changed files with 63 additions and 6 deletions
+15 -6
View File
@@ -161,8 +161,11 @@ class FirmwareCheckService:
Fetch all firmware versions from the wiki release history page.
Only extracts versions that appear in section-heading anchors
(e.g. `id="h-01030000-20260303"`) — this excludes version-like
numbers mentioned incidentally in release-note text.
(e.g. `id="h-01030000-20260303"` or `id="h-0102000020260409"`) —
this excludes version-like numbers mentioned incidentally in
release-note text. The dash separator between version and date is
optional: H2D/X1/H2C/H2S still use it, but P2S and X2D publish
anchors without the dash.
Returns list of (version, release_date_YYYYMMDD | None) tuples, newest first.
"""
@@ -176,8 +179,9 @@ class FirmwareCheckService:
if response.status_code != 200:
return []
# Primary: heading anchor ids like id="h-01030000-20260303"
anchor_matches = re.findall(r'id="h-(\d{2})(\d{2})(\d{2})(\d{2})-(\d{8})"', response.text)
# Primary: heading anchor ids like id="h-01030000-20260303" (dash)
# or id="h-0102000020260409" (no dash, P2S/X2D-style).
anchor_matches = re.findall(r'id="h-(\d{2})(\d{2})(\d{2})(\d{2})-?(\d{8})"', response.text)
seen: set[str] = set()
versions: list[tuple[str, str | None]] = []
for a, b, c, d, date in anchor_matches:
@@ -190,8 +194,13 @@ class FirmwareCheckService:
if versions:
return versions
# Fallback: heading text with "XX.XX.XX.XX (YYYYMMDD)"
text_matches = re.findall(r"(\d{2}\.\d{2}\.\d{2}\.\d{2})\s*\((\d{8})\)", response.text)
# Fallback: heading text with "XX.XX.XX.XX (YYYYMMDD)" —
# accept both ASCII "()" and full-width "()" (U+FF08/U+FF09)
# which some pages (A1, A1-mini, P2S) use.
text_matches = re.findall(
r"(\d{2}\.\d{2}\.\d{2}\.\d{2})\s*[(\uff08](\d{8})[)\uff09]",
response.text,
)
for v, date in text_matches:
if v in seen:
continue
@@ -50,6 +50,53 @@ async def test_wiki_extraction_returns_empty_for_unknown_api_key():
assert await svc._fetch_all_versions_from_wiki("no-such-key") == []
# P2S and X2D wiki pages publish anchor ids without a dash between the
# version bytes and the date (e.g. h-0102000020260409). Regression for #1030
# where the anchor regex required a dash and silently returned no versions,
# causing the UI to fall back to the stale download-page "Latest" value.
P2S_NODASH_ANCHOR_SAMPLE = """
<h2 id="h-0102000020260409" class="toc-header">01.02.00.00(20260409)</h2>
<h2 id="h-0101030020260209" class="toc-header">01.01.03.00(20260209)</h2>
<h2 id="h-0101010020251208" class="toc-header">01.01.01.00(20251208)</h2>
"""
@pytest.mark.asyncio
async def test_wiki_extraction_accepts_nodash_anchors():
"""P2S/X2D anchors concatenate version+date with no dash — must still parse."""
svc = FirmwareCheckService()
mock_resp = AsyncMock()
mock_resp.status_code = 200
mock_resp.text = P2S_NODASH_ANCHOR_SAMPLE
with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)):
versions = await svc._fetch_all_versions_from_wiki("p2s")
assert [v for v, _ in versions] == ["01.02.00.00", "01.01.03.00", "01.01.01.00"]
assert versions[0][1] == "20260409"
# A1, A1-mini and P2S pages render dates in full-width parens (YYYYMMDD)
# rather than ASCII parens (YYYYMMDD). Pages without version-anchors fall
# through to the text-based regex, so it must accept both paren styles.
FULLWIDTH_PAREN_FALLBACK_SAMPLE = """
<h2>01.04.00.01 (20260401)</h2>
<h2>01.03.00.00 (20260101)</h2>
"""
@pytest.mark.asyncio
async def test_wiki_extraction_fallback_accepts_fullwidth_parens():
svc = FirmwareCheckService()
mock_resp = AsyncMock()
mock_resp.status_code = 200
mock_resp.text = FULLWIDTH_PAREN_FALLBACK_SAMPLE
with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)):
versions = await svc._fetch_all_versions_from_wiki("a1")
assert [v for v, _ in versions] == ["01.04.00.01", "01.03.00.00"]
assert versions[0][1] == "20260401"
@pytest.mark.asyncio
async def test_get_available_versions_merges_sources():
"""