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