Current section

86 Versions

Jump to

Compare versions

16 files changed
+519 additions
-399 deletions
  @@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
7 7
8 8 ## [Unreleased][unreleased]
9 9
10 + ## [0.35.3] - 2024-01-25
11 +
12 + This release has great performance improvements, thanks to the PRs
13 + from [@ypconstante](https://github.com/ypconstante)!
14 +
15 + Most of the main functions, such as `Floki.raw_html/2` and `Floki.find/2` are
16 + faster and are using less memory. It's something like twice as fast, and half
17 + usage of memory for `find/2`, for example.
18 +
19 + ### Fixed
20 +
21 + - Add `:leex` to Mix compilers. Fixes the build when running with dev version of Elixir.
22 + Thanks [@wojtekmach](https://github.com/wojtekmach).
23 +
24 + - Fix `Floki.raw_html/2` when a tree using attributes as maps is given.
25 + Thanks [@SupaMic](https://github.com/SupaMic).
26 +
27 + - Add a guard to `Floki.find/2` so people can have a better error message when an
28 + invalid input is given. Thanks [@Hajto](https://github.com/Hajto).
29 +
30 + - Fix parsers to consider IO data as inputs. This may change in the next version
31 + of Floki, as I plan to drop support for IO data.
32 + Thanks [@ypconstante](https://github.com/ypconstante).
33 +
34 + ### Removed
35 +
36 + - Remove outdated Gleam wrapper code. The external functions syntax in Gleam
37 + has [changed](https://gleam.run/book/tour/external-functions.html). So now
38 + the wrapper is not needed anymore.
39 + Thanks [@michallepicki](https://github.com/michallepicki).
40 +
10 41 ## [0.35.2] - 2023-10-25
11 42
12 43 ### Fixed
  @@ -705,7 +736,8 @@ of the parent element inside HTML.
705 736
706 737 - Elixir version requirement from "~> 1.0.0" to ">= 1.0.0".
707 738
708 - [unreleased]: https://github.com/philss/floki/compare/v0.35.2...HEAD
739 + [unreleased]: https://github.com/philss/floki/compare/v0.35.3...HEAD
740 + [0.35.3]: https://github.com/philss/floki/compare/v0.35.2...v0.35.3
709 741 [0.35.2]: https://github.com/philss/floki/compare/v0.35.1...v0.35.2
710 742 [0.35.1]: https://github.com/philss/floki/compare/v0.35.0...v0.35.1
711 743 [0.35.0]: https://github.com/philss/floki/compare/v0.34.3...v0.35.0
  @@ -1,36 +1,37 @@
1 - {<<"app">>,<<"floki">>}.
2 - {<<"build_tools">>,[<<"mix">>]}.
3 - {<<"description">>,
4 - <<"Floki is a simple HTML parser that enables search for nodes using CSS selectors.">>}.
5 - {<<"elixir">>,<<"~> 1.12">>}.
6 - {<<"files">>,
7 - [<<"lib/floki">>,<<"lib/floki/html_tree.ex">>,<<"lib/floki/entities.ex">>,
8 - <<"lib/floki/parse_error.ex">>,<<"lib/floki/deep_text.ex">>,
9 - <<"lib/floki/finder.ex">>,<<"lib/floki/flat_text.ex">>,
10 - <<"lib/floki/selector">>,<<"lib/floki/selector/attribute_selector.ex">>,
11 - <<"lib/floki/selector/pseudo_class.ex">>,<<"lib/floki/selector/parser.ex">>,
12 - <<"lib/floki/selector/functional.ex">>,
13 - <<"lib/floki/selector/combinator.ex">>,
14 - <<"lib/floki/selector/tokenizer.ex">>,<<"lib/floki/raw_html.ex">>,
15 - <<"lib/floki/filter_out.ex">>,<<"lib/floki/html">>,
16 - <<"lib/floki/html/numeric_charref.ex">>,<<"lib/floki/html/tokenizer.ex">>,
17 - <<"lib/floki/html_tree">>,<<"lib/floki/html_tree/comment.ex">>,
18 - <<"lib/floki/html_tree/id_seeder.ex">>,<<"lib/floki/html_tree/text.ex">>,
19 - <<"lib/floki/html_tree/html_node.ex">>,<<"lib/floki/html_parser.ex">>,
20 - <<"lib/floki/entities">>,<<"lib/floki/entities/codepoints.ex">>,
21 - <<"lib/floki/text_extractor.ex">>,<<"lib/floki/selector.ex">>,
22 - <<"lib/floki/traversal.ex">>,<<"lib/floki/html_parser">>,
23 - <<"lib/floki/html_parser/html5ever.ex">>,
24 - <<"lib/floki/html_parser/fast_html.ex">>,
25 - <<"lib/floki/html_parser/mochiweb.ex">>,<<"lib/floki.ex">>,
26 - <<"src/floki_selector_lexer.xrl">>,<<"src/floki_mochi_html.erl">>,
27 - <<"src/floki.gleam">>,<<"mix.exs">>,<<"README.md">>,<<"LICENSE">>,
28 - <<"CODE_OF_CONDUCT.md">>,<<"CONTRIBUTING.md">>,<<"CHANGELOG.md">>]}.
29 - {<<"licenses">>,[<<"MIT">>]}.
30 1 {<<"links">>,
31 2 [{<<"Changelog">>,<<"https://hexdocs.pm/floki/changelog.html">>},
32 3 {<<"GitHub">>,<<"https://github.com/philss/floki">>},
33 4 {<<"Sponsor">>,<<"https://github.com/sponsors/philss">>}]}.
34 5 {<<"name">>,<<"floki">>}.
6 + {<<"version">>,<<"0.35.3">>}.
7 + {<<"description">>,
8 + <<"Floki is a simple HTML parser that enables search for nodes using CSS selectors.">>}.
9 + {<<"elixir">>,<<"~> 1.12">>}.
10 + {<<"app">>,<<"floki">>}.
11 + {<<"licenses">>,[<<"MIT">>]}.
12 + {<<"files">>,
13 + [<<"lib/floki">>,<<"lib/floki/deep_text.ex">>,<<"lib/floki/entities">>,
14 + <<"lib/floki/entities/codepoints.ex">>,<<"lib/floki/filter_out.ex">>,
15 + <<"lib/floki/flat_text.ex">>,<<"lib/floki/html">>,
16 + <<"lib/floki/html/numeric_charref.ex">>,<<"lib/floki/html/tokenizer.ex">>,
17 + <<"lib/floki/html_parser">>,<<"lib/floki/html_parser/fast_html.ex">>,
18 + <<"lib/floki/html_parser/html5ever.ex">>,
19 + <<"lib/floki/html_parser/mochiweb.ex">>,<<"lib/floki/html_tree">>,
20 + <<"lib/floki/html_tree/comment.ex">>,<<"lib/floki/html_tree/html_node.ex">>,
21 + <<"lib/floki/html_tree/id_seeder.ex">>,<<"lib/floki/html_tree/text.ex">>,
22 + <<"lib/floki/parse_error.ex">>,<<"lib/floki/selector">>,
23 + <<"lib/floki/selector/combinator.ex">>,
24 + <<"lib/floki/selector/functional.ex">>,
25 + <<"lib/floki/selector/pseudo_class.ex">>,
26 + <<"lib/floki/selector/tokenizer.ex">>,
27 + <<"lib/floki/selector/attribute_selector.ex">>,
28 + <<"lib/floki/selector/parser.ex">>,<<"lib/floki/text_extractor.ex">>,
29 + <<"lib/floki/traversal.ex">>,<<"lib/floki/html_parser.ex">>,
30 + <<"lib/floki/entities.ex">>,<<"lib/floki/finder.ex">>,
31 + <<"lib/floki/html_tree.ex">>,<<"lib/floki/raw_html.ex">>,
32 + <<"lib/floki/selector.ex">>,<<"lib/floki.ex">>,
33 + <<"src/floki_selector_lexer.xrl">>,<<"src/floki_mochi_html.erl">>,
34 + <<"mix.exs">>,<<"README.md">>,<<"LICENSE">>,<<"CODE_OF_CONDUCT.md">>,
35 + <<"CONTRIBUTING.md">>,<<"CHANGELOG.md">>]}.
35 36 {<<"requirements">>,[]}.
36 - {<<"version">>,<<"0.35.2">>}.
37 + {<<"build_tools">>,[<<"mix">>]}.
  @@ -77,6 +77,11 @@ defmodule Floki do
77 77
78 78 @type css_selector :: String.t() | %Floki.Selector{} | [%Floki.Selector{}]
79 79
80 + defguard is_html_node(value)
81 + when is_binary(value) or tuple_size(value) == 3 or
82 + (tuple_size(value) == 2 and elem(value, 0) in [:pi, :comment]) or
83 + (tuple_size(value) == 4 and elem(value, 0) == :doctype)
84 +
80 85 @doc """
81 86 Parses a HTML Document from a String.
82 87
  @@ -168,7 +173,7 @@ defmodule Floki do
168 173 ## Options
169 174
170 175 * `:attributes_as_maps` - Change the behaviour of the parser to return the attributes
171 - as maps, instead of a list of `{"key", "value"}`. Remember that maps are no longer
176 + as maps, instead of a list of `{"key", "value"}`. Remember that maps are no longer
172 177 ordered since OTP 26. Default to `false`.
173 178
174 179 * `:html_parser` - The module of the backend that is responsible for parsing
  @@ -212,7 +217,7 @@ defmodule Floki do
212 217 ## Options
213 218
214 219 * `:encode` - A boolean option to control if special HTML characters
215 - should be encoded as HTML entities. Defaults to `true`.
220 + should be encoded as HTML entities. Defaults to `true`.
216 221
217 222 You can also control the encoding behaviour at the application level via
218 223 `config :floki, :encode_raw_html, false`
  @@ -269,7 +274,6 @@ defmodule Floki do
269 274 """
270 275
271 276 @spec find(binary() | html_tree() | html_node(), css_selector()) :: html_tree
272 -
273 277 def find(html, selector) when is_binary(html) do
274 278 Logger.info(
275 279 "deprecation: parse the HTML with parse_document or parse_fragment before using find/2"
  @@ -282,7 +286,8 @@ defmodule Floki do
282 286 end
283 287 end
284 288
285 - def find(html_tree_as_tuple, selector) do
289 + def find(html_tree_as_tuple, selector)
290 + when is_list(html_tree_as_tuple) or is_html_node(html_tree_as_tuple) do
286 291 {tree, results} = Finder.find(html_tree_as_tuple, selector)
287 292
288 293 Enum.map(results, fn html_node -> HTMLTree.to_tuple(tree, html_node) end)
  @@ -511,7 +516,7 @@ defmodule Floki do
511 516
512 517 ## Options
513 518
514 - * `:deep` - A boolean option to control how deep the search for
519 + * `:deep` - A boolean option to control how deep the search for
515 520 text is going to be. If `false`, only the level of the HTML node
516 521 or the first level of the HTML document is going to be considered.
517 522 Defaults to `true`.
  @@ -626,11 +631,19 @@ defmodule Floki do
626 631 iex> Floki.attribute([{"a", [{"href", "https://google.com"}], ["Google"]}], "a", "href")
627 632 ["https://google.com"]
628 633
629 - iex> Floki.attribute([{"a", [{"class", "foo"}, {"href", "https://google.com"}], ["Google"]}], "a", "class")
634 + iex> Floki.attribute(
635 + iex> [{"a", [{"class", "foo"}, {"href", "https://google.com"}], ["Google"]}],
636 + iex> "a",
637 + iex> "class"
638 + iex> )
630 639 ["foo"]
631 640
632 - iex> Floki.attribute([{"a", [{"href", "https://google.com"}, {"data-name", "google"}], ["Google"]}], "a[data-name]", "data-name")
633 - ["google"]
641 + iex> Floki.attribute(
642 + iex> [{"a", [{"href", "https://e.corp.com"}, {"data-name", "e.corp"}], ["E.Corp"]}],
643 + iex> "a[data-name]",
644 + iex> "data-name"
645 + iex> )
646 + ["e.corp"]
634 647 """
635 648
636 649 @spec attribute(binary | html_tree | html_node, binary, binary) :: list
  @@ -62,15 +62,51 @@ defmodule Floki.Entities do
62 62 * greater-than sign - > - is replaced by "&gt;".
63 63
64 64 All other simbols are going to remain the same.
65 +
66 + Optimized IO data implementation from Plug.HTML
65 67 """
66 - @spec encode(String.t()) :: String.t()
67 - def encode(string) when is_binary(string) do
68 - String.replace(string, ["'", "\"", "&", "<", ">"], fn
69 - "'" -> "&#39;"
70 - "\"" -> "&quot;"
71 - "&" -> "&amp;"
72 - "<" -> "&lt;"
73 - ">" -> "&gt;"
74 - end)
68 + @spec encode(iodata()) :: iodata()
69 + def encode(string) when is_binary(string), do: encode(string, 0, string, [])
70 + def encode(data), do: encode(IO.iodata_to_binary(data))
71 +
72 + escapes = [
73 + {?<, "&lt;"},
74 + {?>, "&gt;"},
75 + {?&, "&amp;"},
76 + {?", "&quot;"},
77 + {?', "&#39;"}
78 + ]
79 +
80 + for {match, insert} <- escapes do
81 + defp encode(<<unquote(match), rest::bits>>, skip, original, acc) do
82 + encode(rest, skip + 1, original, [acc | unquote(insert)])
83 + end
84 + end
85 +
86 + defp encode(<<_char, rest::bits>>, skip, original, acc) do
87 + encode(rest, skip, original, acc, 1)
88 + end
89 +
90 + defp encode(<<>>, _skip, _original, acc) do
91 + acc
92 + end
93 +
94 + for {match, insert} <- escapes do
95 + defp encode(<<unquote(match), rest::bits>>, skip, original, acc, len) do
96 + part = binary_part(original, skip, len)
97 + encode(rest, skip + len + 1, original, [acc, part | unquote(insert)])
98 + end
99 + end
100 +
101 + defp encode(<<_char, rest::bits>>, skip, original, acc, len) do
102 + encode(rest, skip, original, acc, len + 1)
103 + end
104 +
105 + defp encode(<<>>, 0, original, _acc, _len) do
106 + original
107 + end
108 +
109 + defp encode(<<>>, skip, original, acc, len) do
110 + [acc | binary_part(original, skip, len)]
75 111 end
76 112 end
  @@ -16,137 +16,91 @@ defmodule Floki.Finder do
16 16 def find(html_as_string, _) when is_binary(html_as_string), do: {%HTMLTree{}, []}
17 17
18 18 def find(html_tree, selector_as_string) when is_binary(selector_as_string) do
19 - selectors = get_selectors(selector_as_string)
20 - find_selectors(html_tree, selectors)
21 - end
22 -
23 - def find(html_tree, selectors) when is_list(selectors) do
24 - find_selectors(html_tree, selectors)
19 + selectors = Selector.Parser.parse(selector_as_string)
20 + find(html_tree, selectors)
25 21 end
26 22
27 23 def find(html_tree, selector = %Selector{}) do
28 - find_selectors(html_tree, [selector])
24 + find(html_tree, [selector])
29 25 end
30 26
31 - @spec map(Floki.html_tree() | Floki.html_node(), function()) ::
32 - Floki.html_tree() | Floki.html_node()
27 + def find(html_tree, selectors) when is_list(selectors) do
28 + tree = HTMLTree.build(html_tree)
33 29
34 - def map({name, attrs, rest}, fun) do
35 - {new_name, new_attrs} = fun.({name, attrs})
36 -
37 - {new_name, new_attrs, Enum.map(rest, &map(&1, fun))}
38 - end
39 -
40 - def map(other, _fun), do: other
41 -
42 - defp find_selectors(html_tuple_or_list, selectors) do
43 - tree = HTMLTree.build(html_tuple_or_list)
30 + node_ids = Enum.reverse(tree.node_ids)
31 + stack = Enum.map(selectors, fn s -> {s, node_ids} end)
44 32
45 33 results =
46 - tree.node_ids
34 + traverse_with(:cont, tree, [], stack)
47 35 |> Enum.reverse()
48 - |> get_nodes(tree)
49 - |> Enum.flat_map(fn html_node -> get_matches_for_selectors(tree, html_node, selectors) end)
50 36 |> Enum.uniq()
51 37
52 38 {tree, results}
53 39 end
54 40
55 - defp get_selectors(selector_as_string) do
56 - selector_as_string
57 - |> Selector.Tokenizer.tokenize()
58 - |> Selector.Parser.parse()
59 - end
60 -
61 - defp get_matches_for_selectors(tree, html_node, selectors) do
62 - Enum.flat_map(selectors, fn selector -> get_matches(tree, html_node, selector) end)
63 - end
64 -
65 - defp get_matches(tree, html_node, selector = %Selector{combinator: nil}) do
66 - if selector_match?(tree, html_node, selector) do
67 - [html_node]
68 - else
69 - []
70 - end
71 - end
72 -
73 - defp get_matches(tree, html_node, selector = %Selector{combinator: combinator}) do
74 - if selector_match?(tree, html_node, selector) do
75 - traverse_with(combinator, tree, [html_node])
76 - else
77 - []
78 - end
79 - end
80 -
81 - defp selector_match?(tree, html_node, selector) do
82 - Selector.match?(html_node, selector, tree)
83 - end
84 -
85 41 # The stack serves as accumulator when there is another combinator to traverse.
86 42 # So the scope of one combinator is the stack (or acc) or the parent one.
87 - defp traverse_with(_, _, []), do: []
88 - defp traverse_with(nil, _, results), do: results
89 -
90 - defp traverse_with(%Selector.Combinator{match_type: :child, selector: s}, tree, stack) do
91 - results =
92 - Enum.flat_map(stack, fn html_node ->
93 - nodes =
94 - html_node.children_nodes_ids
95 - |> Enum.reverse()
96 - |> get_nodes(tree)
97 -
98 - Enum.filter(nodes, fn html_node -> selector_match?(tree, html_node, s) end)
99 - end)
100 -
101 - traverse_with(s.combinator, tree, results)
43 + defp traverse_with(:cont, _, acc, []) do
44 + acc
102 45 end
103 46
104 - defp traverse_with(%Selector.Combinator{match_type: :sibling, selector: s}, tree, stack) do
105 - results =
106 - Enum.flat_map(stack, fn html_node ->
107 - # It treats sibling as list to easily ignores those that didn't match
108 - sibling_id =
109 - html_node
110 - |> get_siblings(tree)
111 - |> Enum.take(1)
112 -
113 - nodes = get_nodes(sibling_id, tree)
114 -
115 - # Finally, try to match those siblings with the selector
116 - Enum.filter(nodes, fn html_node -> selector_match?(tree, html_node, s) end)
117 - end)
118 -
119 - traverse_with(s.combinator, tree, results)
47 + defp traverse_with(:cont, tree, acc, [next | rest]) do
48 + traverse_with(next, tree, acc, rest)
120 49 end
121 50
122 - defp traverse_with(%Selector.Combinator{match_type: :general_sibling, selector: s}, tree, stack) do
123 - results =
124 - Enum.flat_map(stack, fn html_node ->
125 - sibling_ids = get_siblings(html_node, tree)
126 -
127 - nodes = get_nodes(sibling_ids, tree)
128 -
129 - # Finally, try to match those siblings with the selector
130 - Enum.filter(nodes, fn html_node -> selector_match?(tree, html_node, s) end)
131 - end)
132 -
133 - traverse_with(s.combinator, tree, results)
51 + defp traverse_with({selector, [node_id]}, tree, acc, stack) do
52 + traverse_with({selector, node_id}, tree, acc, stack)
134 53 end
135 54
136 - defp traverse_with(%Selector.Combinator{match_type: :descendant, selector: s}, tree, stack) do
137 - results =
138 - Enum.flat_map(stack, fn html_node ->
139 - ids_to_match = get_descendant_ids(html_node.node_id, tree)
140 - nodes = get_nodes(ids_to_match, tree)
141 -
142 - Enum.filter(nodes, fn html_node -> selector_match?(tree, html_node, s) end)
143 - end)
144 -
145 - traverse_with(s.combinator, tree, results)
55 + defp traverse_with({selector, [next | rest]}, tree, acc, stack) do
56 + stack = [{selector, rest} | stack]
57 + traverse_with({selector, next}, tree, acc, stack)
146 58 end
147 59
148 - defp get_nodes(ids, tree) do
149 - Enum.map(ids, fn id -> Map.get(tree.nodes, id) end)
60 + defp traverse_with({%Selector{combinator: nil} = selector, node_id}, tree, acc, stack) do
61 + html_node = get_node(node_id, tree)
62 +
63 + acc =
64 + if Selector.match?(html_node, selector, tree) do
65 + [html_node | acc]
66 + else
67 + acc
68 + end
69 +
70 + traverse_with(:cont, tree, acc, stack)
71 + end
72 +
73 + defp traverse_with({%Selector{combinator: combinator} = selector, node_id}, tree, acc, stack) do
74 + html_node = get_node(node_id, tree)
75 +
76 + stack =
77 + if Selector.match?(html_node, selector, tree) do
78 + nodes = get_selector_nodes(combinator, html_node, tree)
79 + [{combinator.selector, nodes} | stack]
80 + else
81 + stack
82 + end
83 +
84 + traverse_with(:cont, tree, acc, stack)
85 + end
86 +
87 + defp get_selector_nodes(%Selector.Combinator{match_type: :child}, html_node, _tree) do
88 + Enum.reverse(html_node.children_nodes_ids)
89 + end
90 +
91 + defp get_selector_nodes(%Selector.Combinator{match_type: :sibling}, html_node, tree) do
92 + case get_siblings(html_node, tree) do
93 + [sibling_id | _] -> sibling_id
94 + _ -> nil
95 + end
96 + end
97 +
98 + defp get_selector_nodes(%Selector.Combinator{match_type: :general_sibling}, html_node, tree) do
99 + get_siblings(html_node, tree)
100 + end
101 +
102 + defp get_selector_nodes(%Selector.Combinator{match_type: :descendant}, html_node, tree) do
103 + get_descendant_ids(html_node.node_id, tree)
150 104 end
151 105
152 106 defp get_node(id, tree) do
  @@ -191,4 +145,15 @@ defmodule Floki.Finder do
191 145 []
192 146 end
193 147 end
148 +
149 + @spec map(Floki.html_tree() | Floki.html_node(), function()) ::
150 + Floki.html_tree() | Floki.html_node()
151 +
152 + def map({name, attrs, rest}, fun) do
153 + {new_name, new_attrs} = fun.({name, attrs})
154 +
155 + {new_name, new_attrs, Enum.map(rest, &map(&1, fun))}
156 + end
157 +
158 + def map(other, _fun), do: other
194 159 end
Loading more files…