diff --git a/src/crawlee/_utils/forms.py b/src/crawlee/_utils/forms.py
new file mode 100644
index 0000000000..3a0d569df0
--- /dev/null
+++ b/src/crawlee/_utils/forms.py
@@ -0,0 +1,451 @@
+from __future__ import annotations
+
+import codecs
+import re
+from contextlib import suppress
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, NamedTuple, TypedDict
+from urllib.parse import urlencode
+
+from lxml.html import HTMLParser, document_fromstring
+from yarl import URL
+
+from crawlee._request import Request
+from crawlee._types import HttpHeaders
+from crawlee._utils.crypto import compute_short_hash
+from crawlee._utils.urls import convert_to_absolute_url
+
+if TYPE_CHECKING:
+ from collections.abc import Iterable, Mapping, Sequence
+
+ from lxml.html import HtmlElement
+ from typing_extensions import NotRequired, Unpack
+
+ from crawlee._types import EnqueueStrategy, HttpMethod, JsonSerializable
+
+_CHARSET_PATTERN = re.compile(r'charset\s*=\s*["\']?([^"\'\s;]+)', re.IGNORECASE)
+
+# Legacy charsets browsers replace with a superset, per the WHATWG Encoding Standard.
+_ENCODING_SUPERSETS = {
+ 'ascii': 'cp1252',
+ 'iso8859-1': 'cp1252',
+ 'iso8859-9': 'cp1254',
+ 'iso8859-11': 'cp874',
+ 'tis-620': 'cp874',
+ 'gb2312': 'gbk',
+ 'shift_jis': 'cp932',
+ 'euc_kr': 'cp949',
+}
+
+_FIELD_TAGS = ('input', 'button', 'select', 'textarea')
+_BUTTON_INPUT_TYPES = ('submit', 'image', 'reset', 'button')
+
+# Browsers percent-encode these characters in multipart field names.
+_MULTIPART_NAME_ESCAPES = str.maketrans({'"': '%22', '\r': '%0D', '\n': '%0A'})
+
+
+class FormRequestOptions(TypedDict):
+ """Options for the `Request` created from a form, other than the URL, method and payload taken from the form."""
+
+ label: NotRequired[str | None]
+ session_id: NotRequired[str | None]
+ unique_key: NotRequired[str | None]
+ keep_url_fragment: NotRequired[bool]
+ use_extended_unique_key: NotRequired[bool]
+ always_enqueue: NotRequired[bool]
+ user_data: NotRequired[Mapping[str, JsonSerializable]]
+ no_retry: NotRequired[bool]
+ enqueue_strategy: NotRequired[EnqueueStrategy]
+ max_retries: NotRequired[int | None]
+
+
+def forms_to_requests(
+ forms: Iterable[HtmlElement],
+ page_url: str,
+ content_type: str | None,
+ *,
+ form_data: Mapping[str, str | Sequence[str] | None] | None = None,
+ click_data: Mapping[str, str] | None = None,
+ dont_click: bool = False,
+ headers: HttpHeaders | dict[str, str] | None = None,
+ **kwargs: Unpack[FormRequestOptions],
+) -> list[Request]:
+ """Create a `Request` submitting each form the way a browser does.
+
+ Forms with no submit button matching `click_data`, dialog forms and forms not submitting over HTTP(S) are skipped.
+
+ Args:
+ forms: The form elements, each within the lxml tree of the whole page.
+ page_url: The URL of the page.
+ content_type: The `Content-Type` header of the page response, used to find the page encoding.
+ form_data: Field values overriding those in the form. A `None` value drops the field.
+ click_data: Attributes identifying the submit button to click. Defaults to the first one.
+ dont_click: Submit the form without clicking any button.
+ headers: The HTTP headers of the request. The `Content-Type` of the form is added to them.
+ **kwargs: Additional options passed to `Request.from_url`.
+ """
+ forms = list(forms)
+ if not forms:
+ return []
+
+ page = _analyze_page(forms[0].getroottree().getroot(), page_url, content_type)
+
+ requests = []
+ for form in forms:
+ request = _form_to_request(
+ form,
+ page,
+ form_data=form_data,
+ click_data=click_data,
+ dont_click=dont_click,
+ headers=headers,
+ **kwargs,
+ )
+ if request is not None:
+ requests.append(request)
+ return requests
+
+
+def parse_html(body: bytes, encoding: str | None) -> HtmlElement:
+ """Parse a page with lxml, decoding it with the given encoding, or the one lxml detects if it's `None`."""
+ try:
+ parser = HTMLParser(encoding=encoding)
+ except LookupError:
+ # A codec Python knows but libxml2 doesn't, so let lxml detect the encoding itself.
+ parser = HTMLParser()
+ return document_fromstring(body, parser=parser)
+
+
+def response_charset(content_type: str | None) -> str | None:
+ """Get the charset from a `Content-Type` header, if it names a known one."""
+ charset = _find_charset(content_type)
+ if charset is None:
+ return None
+ with suppress(LookupError):
+ return codecs.lookup(charset).name
+ return None
+
+
+class _Field(NamedTuple):
+ """A single entry the form submits."""
+
+ name: str
+ value: str
+ is_file: bool = False
+
+
+@dataclass
+class _Page:
+ """The parts of a page shared by all its forms, computed in a single pass over the document."""
+
+ url: str
+ base_url: str
+ charsets: list[str]
+ """The page encoding candidates, from the `Content-Type` header and then the `` tags."""
+ elements_by_form: dict[HtmlElement, list[HtmlElement]]
+ """The fields and buttons belonging to each form, in document order."""
+
+
+def _analyze_page(root: HtmlElement, page_url: str, content_type: str | None) -> _Page:
+ """Resolve the base URL, encoding candidates and form owners of all fields on the page."""
+ try:
+ base_url = convert_to_absolute_url(page_url, root.xpath('string(//base[@href][1]/@href)'))
+ except ValueError:
+ base_url = page_url
+
+ # The first element with a given ID wins, as in `getElementById`.
+ elements_by_id: dict[str, HtmlElement] = {}
+ for element in root.xpath('//*[@id]'):
+ elements_by_id.setdefault(element.get('id'), element)
+
+ elements_by_form: dict[HtmlElement, list[HtmlElement]] = {}
+ for element in root.iter(*_FIELD_TAGS):
+ owner = _form_owner(element, elements_by_id)
+ if owner is not None:
+ elements_by_form.setdefault(owner, []).append(element)
+
+ return _Page(
+ url=page_url,
+ base_url=base_url,
+ charsets=_page_charsets(root, content_type),
+ elements_by_form=elements_by_form,
+ )
+
+
+def _page_charsets(root: HtmlElement, content_type: str | None) -> list[str]:
+ """Collect the charsets the page declares, in the order browsers trust them."""
+ charsets: list[str] = []
+
+ header_charset = _find_charset(content_type)
+ if header_charset is not None:
+ charsets.append(header_charset)
+
+ charsets.extend(root.xpath('//meta[@charset]/@charset'))
+
+ http_equiv_contents = root.xpath(
+ '//meta[translate(@http-equiv, "CONTENT-TYP", "content-typ")="content-type"]/@content'
+ )
+ for content in http_equiv_contents:
+ meta_charset = _find_charset(content)
+ if meta_charset is not None:
+ charsets.append(meta_charset)
+
+ return charsets
+
+
+def _find_charset(value: str | None) -> str | None:
+ """Find the `charset=` parameter in a `Content-Type` value."""
+ if not value:
+ return None
+ match = _CHARSET_PATTERN.search(value)
+ return match.group(1) if match else None
+
+
+def _form_to_request(
+ form: HtmlElement,
+ page: _Page,
+ *,
+ form_data: Mapping[str, str | Sequence[str] | None] | None,
+ click_data: Mapping[str, str] | None,
+ dont_click: bool,
+ headers: HttpHeaders | dict[str, str] | None,
+ **kwargs: Unpack[FormRequestOptions],
+) -> Request | None:
+ """Create a `Request` submitting a single form, or `None` if a browser wouldn't send one."""
+ elements = page.elements_by_form.get(form, [])
+
+ if dont_click:
+ button = None
+ else:
+ button = _find_clickable(elements, click_data)
+ if click_data and button is None:
+ return None
+
+ method = (_submission_attribute(form, button, 'method') or 'get').upper()
+ enctype = _submission_attribute(form, button, 'enctype').lower()
+ action = _submission_attribute(form, button, 'action').strip()
+
+ # A dialog form only closes its `