From 84ac6cb87574cdfae5e3921cec97441086865b5d Mon Sep 17 00:00:00 2001 From: Moises Macia Date: Wed, 16 Sep 2026 22:53:50 +0000 Subject: [PATCH] add Recovery.Attributes module --- lib/feed_reader/recovery/attributes.ex | 98 +++++++++++++++++++ test/feed_reader/recovery/attributes_test.exs | 48 +++++++++ 2 files changed, 146 insertions(+) create mode 100644 lib/feed_reader/recovery/attributes.ex create mode 100644 test/feed_reader/recovery/attributes_test.exs diff --git a/lib/feed_reader/recovery/attributes.ex b/lib/feed_reader/recovery/attributes.ex new file mode 100644 index 0000000..b48eb70 --- /dev/null +++ b/lib/feed_reader/recovery/attributes.ex @@ -0,0 +1,98 @@ +defmodule FeedReader.Recovery.Attributes do + @moduledoc """ + Repairs malformed quotes in XML attributes. + + A small number of feeds place literal quotes inside a quoted attribute value. + This module repairs those quotes only within one complete markup element, so + valid quotes in neighboring elements cannot be rewritten accidentally. + Incomplete elements are retained as state for the next input chunk. + """ + + @doc "Repairs literal quotes in streamed XML attributes." + def normalize(input) do + Stream.transform( + input, + fn -> <<>> end, + fn chunk, pending -> + {output, pending} = scan(pending <> chunk, []) + {if(output == [], do: [], else: [IO.iodata_to_binary(Enum.reverse(output))]), pending} + end, + fn + <<>> -> [] + pending -> [pending] + end + ) + end + + defp scan(<<>>, output), do: {output, <<>>} + + defp scan(data, output) do + case :binary.match(data, "<") do + :nomatch -> + {[data | output], <<>>} + + {position, 1} when position > 0 -> + {prefix, rest} = :erlang.split_binary(data, position) + scan(rest, [prefix | output]) + + {0, 1} -> + case markup_end(data) do + :incomplete -> {output, data} + {markup, rest} -> scan(rest, [repair(markup) | output]) + end + end + end + + defp markup_end(<<"> = data), do: special_end(data, "]]>") + defp markup_end(<<"") + + defp markup_end(data) do + case :binary.match(data, ">") do + :nomatch -> :incomplete + {position, 1} -> :erlang.split_binary(data, position + 1) + end + end + + defp special_end(data, closing) do + case :binary.match(data, closing) do + :nomatch -> :incomplete + {position, length} -> :erlang.split_binary(data, position + length) + end + end + + defp repair(<<"> = markup), do: markup + defp repair(<<") + + assert normalize([xml]) == xml + end + + test "does not repair across adjacent elements" do + xml = ~s(SourceNews) + + assert normalize([xml]) == xml + end + + test "preserves text outside markup" do + assert normalize(["before ", ~s(), " after"]) == + ~s(before after) + end + + defp normalize(chunks), do: chunks |> Attributes.normalize() |> Enum.join() +end