Fix logbook fetch/download returning 0 records (#3) - #4
Conversation
QRZ HTML-escapes the ADIF payload it returns, so the payload's own "&"
characters ("<", ">", "&") were treated as key=value delimiters
by _parse_kv, which split the body on every "&". That shredded the ADIF
value into fragments with no "=", all of which were dropped, leaving
ADIF as the empty string before the ADIF field regex ever ran.
_parse_kv now splits only at an "&" that introduces a new "KEY=" pair and
unescapes each value individually. Per-value unescaping matters: decoding
the whole body up front would turn an "&" inside a QSO comment into a
live delimiter, truncating the record and silently dropping every QSO
after it.
Also fix a latent bug in _parse_adif_records where a declared field length
overrunning the buffer left pos before the marker just consumed, stalling
the scan.
Mock fixtures previously bypassed _parse_kv entirely and used unescaped
ADIF, so the broken function was never exercised in mock mode. They now
use the real wire format and decode through the same path as live
responses.
Verified against a live 30-QSO logbook: status/fetch/download now all
report 30; before this change fetch and download reported 0.
Live testing against a real logbook (inserting QSOs with adversarial
comments, reading them back) showed the previous fix was still wrong, and
that the wire format differs from what both it and the issue assumed.
QRZ escapes only the ADIF *markers* ("<call:6>"). Field values are
passed through verbatim, so a value may contain raw "&", "=", "+", "%"
and newlines, none of which are delimiters or encodings:
<comment:7>R&R net bare "&", not "&"
<comment:10>A+B 50%20C not URL-encoded
<comment:23>& < ... entity-like text, kept as typed
The previous approach unescaped and URL-decoded each value, which
corrupted all three: "A+B 50%20C" became "A B 50 C", and operator text
containing "&" was silently mutated. Values are now consumed by their
declared length and never rewritten; markers are matched in either literal
or escaped form. Lengths are counted in characters, matching QRZ (a
12-character accented comment declares 12, not its 16 UTF-8 bytes).
ADIF is the final key in a FETCH response, so it is taken as the entire
remainder of the body rather than split on "&".
Also fixes qrz_download emitting escaped markers into its ".adi" output,
which no logger could import. Records are parsed and re-serialised with
literal markers, so record_count and the file agree. A literal "<eor>"
inside a comment no longer inflates the count.
Mock fixtures are transcribed from live responses so these cases are
covered in CI. Verified on Python 3.10-3.13.
Correction: my first fix was also wrongI inserted QSOs with adversarial comments into a live logbook and read them back. That disproved a premise both the issue and my original patch relied on, so I've pushed a rewrite. QRZ escapes only the ADIF markers. Field values are passed through verbatim. Live wire output, verbatim: In every case the declared length equals the raw returned value length. So values must be consumed by length, never by scanning for delimiters or entities. My previous patch HTML-unescaped and URL-decoded each value. Against real data that corrupted three of six cases:
Scoreboard on the live fetch — old code, the issue's suggested The rewrite now round-trips all six recoverable cases. (The seventh, What changed
Second bug this surfaced
VerificationLive: Tests: 55 passing on Python 3.10–3.13. One caveat on my earlier matrix claim in this PR: AI disclosure: written by an AI agent (Claude, via OpenCode) on a human-directed prompt. All measurements quoted were produced by running against the live QRZ API, not asserted from model knowledge. |
Fixes #3.
Verified against a live 30-QSO QRZ logbook (
PU2UMK— the same book from the issue report).The failure is in
_parse_kv, not the ADIF regexThe issue attributes this to
_ADIF_FIELD_REnot matching<call:6>. That regex does fail on escaped input, but it is never reached. The payload is already destroyed one step earlier.QRZ HTML-escapes the ADIF payload, so its value legitimately contains
&characters._parse_kvsplit the body on every&:The fragments contain no
=, so they were dropped andADIFresolved to the empty string. Measured against the live response:The suggested one-line fix is unsafe
html.unescape()on the whole body before splitting promotes any&inside a QSO comment into a real delimiter, truncating that record and discarding every QSO after it:This is not hypothetical — the live logbook contains comments such as
PY2KAC Repeater, and&in comments is common. That change would swap a loud 0-record bug for a quiet data-loss bug.Changes
_parse_kv— splits only at an&that introduces a newKEY=pair, then URL-decodes and HTML-unescapes each value individually. Order is the point: per-value unescaping stops a decoded&from ever being seen as a delimiter._unescape_adif— safety net for ADIF reaching the parser by another path. Unescapes only when literal field markers are absent but escaped ones are present, so an already-decoded&is not decoded twice._parse_adif_records— fixes a latent bug where a declared field length overrunning the buffer leftposbefore the marker just consumed, stalling the scan.poscan no longer rewind._ADIF_HEADERand_count_records.Mock fixtures
Escaping the fixture string alone would not have caught this: the old mock bypassed
_parse_kventirely, so the broken function was never exercised in mock mode. Mockfetch/download_adifnow decode_MOCK_FETCH_BODYthrough the same_parse_kvpath as live responses. The fixture includes aR&R netcomment to pin the&behaviour.Verification
Live, against the real logbook:
All three MCP tools now agree at 30. Test suite: 49 passed (43 existing + 6 new, QRZ-L2-049..054). 4 of the 6 new tests fail against the pre-fix source, confirming they actually pin the regression rather than just passing.
Out of scope — separate bug found while verifying
The live
ACTION=STATUSresponse uses different field names thanstatus()reads, so several stats silently return0:dxcc,us_states,start_dateandend_dateare affected (count,confirmedandcallsignwork, sinceOWNERandCOUNTdo exist). The mock fixture encodes the wrong names too, which is why tests pass. Unrelated to the ADIF parsing bug, so I left it out of this PR — happy to file it separately or fold it in, whichever you prefer.AI disclosure
This PR — code, tests, commit messages and description — was written by an AI agent (Claude, via OpenCode) working from a human-directed prompt. The human maintainer supplied QRZ credentials and directed the live-testing step.
Every factual claim above was verified by executing it against the live QRZ API and the test suite, not asserted from model knowledge. The measurements are reproducible from the descriptions given.
Reviewer note: please apply the same scrutiny you would to any other contribution, and more to the reasoning than the diff. The first revision of this PR was confidently wrong — it corrupted comment values containing
+,%and&— and was caught only because a human suggested testing against a real contact. Plausible-sounding analysis is this tool's characteristic failure mode.