nextflight 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nextflight/__init__.py +68 -0
- nextflight/cli.py +86 -0
- nextflight/extractor.py +556 -0
- nextflight-0.3.0.dist-info/METADATA +244 -0
- nextflight-0.3.0.dist-info/RECORD +9 -0
- nextflight-0.3.0.dist-info/WHEEL +5 -0
- nextflight-0.3.0.dist-info/entry_points.txt +2 -0
- nextflight-0.3.0.dist-info/licenses/LICENSE +21 -0
- nextflight-0.3.0.dist-info/top_level.txt +1 -0
nextflight/__init__.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
nextflight
|
|
3
|
+
==========
|
|
4
|
+
|
|
5
|
+
Generic parser for the React Server Components "Flight" wire format that
|
|
6
|
+
Next.js 13+ (App Router) embeds in ``<script>self.__next_f.push([...])</script>``
|
|
7
|
+
tags. Works on any Next.js App Router site.
|
|
8
|
+
|
|
9
|
+
from nextflight import extract
|
|
10
|
+
|
|
11
|
+
page = extract(html_text)
|
|
12
|
+
listing = page.find_by_keys({"sections", "meta"})
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import warnings
|
|
16
|
+
|
|
17
|
+
from .extractor import (
|
|
18
|
+
FlightExtractor,
|
|
19
|
+
FlightParseError,
|
|
20
|
+
extract,
|
|
21
|
+
find_json_ld,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"FlightExtractor",
|
|
26
|
+
"FlightParseError",
|
|
27
|
+
"extract",
|
|
28
|
+
"find_json_ld",
|
|
29
|
+
"NextFlightExtractor", # deprecated alias, see below
|
|
30
|
+
"extract_json_ld", # deprecated alias, see below
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
__version__ = "0.3.0"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------- #
|
|
37
|
+
# Backwards-compatible aliases for the pre-rename API (nextjs_flight_extractor
|
|
38
|
+
# 0.1.x). These will be removed in a future major version -- switch to
|
|
39
|
+
# FlightExtractor / find_json_ld when convenient.
|
|
40
|
+
# ---------------------------------------------------------------------- #
|
|
41
|
+
class NextFlightExtractor(FlightExtractor):
|
|
42
|
+
"""Deprecated alias for :class:`FlightExtractor`. Use ``FlightExtractor`` instead."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, *args, **kwargs):
|
|
45
|
+
warnings.warn(
|
|
46
|
+
"NextFlightExtractor is deprecated, use nextflight.FlightExtractor instead",
|
|
47
|
+
DeprecationWarning,
|
|
48
|
+
stacklevel=2,
|
|
49
|
+
)
|
|
50
|
+
super().__init__(*args, **kwargs)
|
|
51
|
+
|
|
52
|
+
def find_first(self, *args, **kwargs):
|
|
53
|
+
warnings.warn(
|
|
54
|
+
"find_first() is deprecated, use find_one() instead",
|
|
55
|
+
DeprecationWarning,
|
|
56
|
+
stacklevel=2,
|
|
57
|
+
)
|
|
58
|
+
return self.find_one(*args, **kwargs)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def extract_json_ld(html: str, schema_type=None) -> list:
|
|
62
|
+
"""Deprecated alias for :func:`find_json_ld`. Use ``find_json_ld`` instead."""
|
|
63
|
+
warnings.warn(
|
|
64
|
+
"extract_json_ld() is deprecated, use nextflight.find_json_ld() instead",
|
|
65
|
+
DeprecationWarning,
|
|
66
|
+
stacklevel=2,
|
|
67
|
+
)
|
|
68
|
+
return find_json_ld(html, type_=schema_type)
|
nextflight/cli.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line entry point for quick exploration:
|
|
3
|
+
|
|
4
|
+
nextflight page.html --keys sections,meta
|
|
5
|
+
nextflight https://example.com/product/123 --keys price,title
|
|
6
|
+
nextflight page.html --all > everything.json
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from .extractor import FlightExtractor
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _load_html(source: str) -> str:
|
|
19
|
+
if source.startswith("http://") or source.startswith("https://"):
|
|
20
|
+
extractor = FlightExtractor.from_url(source)
|
|
21
|
+
return extractor.html
|
|
22
|
+
with open(source, "r", encoding="utf-8") as f:
|
|
23
|
+
return f.read()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main(argv=None) -> int:
|
|
27
|
+
parser = argparse.ArgumentParser(
|
|
28
|
+
prog="nextflight",
|
|
29
|
+
description="Extract Next.js Flight (__next_f.push) data from a page.",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument("source", help="Path to an HTML file, or a URL to fetch.")
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--keys", help="Comma-separated keys: find the first object containing all of them."
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--all-by-keys", dest="all_by_keys",
|
|
37
|
+
help="Comma-separated keys: find EVERY object containing all of them (not just the first).",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--type", dest="type_value",
|
|
41
|
+
help='Find every object whose "@type" (or --type-key) equals this value.',
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument("--type-key", default="@type", help='Key to match --type against (default "@type").')
|
|
44
|
+
parser.add_argument("--text", dest="text_pattern", help="Regex: list every distinct string value on the page that matches it.")
|
|
45
|
+
parser.add_argument("--get", dest="get_path", help='Dotted path into the resolved page, e.g. "3f.props.price".')
|
|
46
|
+
parser.add_argument("--all", action="store_true", help="Dump every resolved chunk.")
|
|
47
|
+
parser.add_argument("--stats", action="store_true", help="Print a quick diagnostic summary instead of data.")
|
|
48
|
+
parser.add_argument("--save", dest="save_path", help="Write output to this file instead of stdout.")
|
|
49
|
+
parser.add_argument("--indent", type=int, default=2, help="JSON indent for output (default 2).")
|
|
50
|
+
args = parser.parse_args(argv)
|
|
51
|
+
|
|
52
|
+
html = _load_html(args.source)
|
|
53
|
+
extractor = FlightExtractor(html)
|
|
54
|
+
|
|
55
|
+
if args.keys:
|
|
56
|
+
keys = {k.strip() for k in args.keys.split(",") if k.strip()}
|
|
57
|
+
result = extractor.find_by_keys(keys)
|
|
58
|
+
elif args.all_by_keys:
|
|
59
|
+
keys = {k.strip() for k in args.all_by_keys.split(",") if k.strip()}
|
|
60
|
+
result = extractor.find_all_by_keys(keys)
|
|
61
|
+
elif args.type_value:
|
|
62
|
+
result = extractor.find_by_type(args.type_value, key=args.type_key)
|
|
63
|
+
elif args.text_pattern:
|
|
64
|
+
result = extractor.find_text(args.text_pattern)
|
|
65
|
+
elif args.get_path:
|
|
66
|
+
result = extractor.get(args.get_path)
|
|
67
|
+
elif args.stats:
|
|
68
|
+
result = extractor.stats()
|
|
69
|
+
elif args.all:
|
|
70
|
+
result = extractor.resolve_all()
|
|
71
|
+
else:
|
|
72
|
+
parser.error("Provide one of --keys, --all-by-keys, --type, --text, --get, --stats, or --all.")
|
|
73
|
+
return 2
|
|
74
|
+
|
|
75
|
+
output = json.dumps(result, indent=args.indent, ensure_ascii=False, default=str)
|
|
76
|
+
if args.save_path:
|
|
77
|
+
with open(args.save_path, "w", encoding="utf-8") as f:
|
|
78
|
+
f.write(output)
|
|
79
|
+
else:
|
|
80
|
+
sys.stdout.write(output)
|
|
81
|
+
sys.stdout.write("\n")
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
raise SystemExit(main())
|
nextflight/extractor.py
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
"""
|
|
2
|
+
nextflight.extractor
|
|
3
|
+
|
|
4
|
+
Core parser for the React Server Components "Flight" wire format that
|
|
5
|
+
Next.js 13+ (App Router) embeds in ``<script>self.__next_f.push([...])</script>``
|
|
6
|
+
tags. Works on any Next.js App Router site -- nothing here is tied to a
|
|
7
|
+
specific project.
|
|
8
|
+
|
|
9
|
+
Why not just str.split('\n') on the payload?
|
|
10
|
+
---------------------------------------------
|
|
11
|
+
Because two of the row kinds break that assumption:
|
|
12
|
+
|
|
13
|
+
* Text rows: `id:T<hexByteLen>,<raw text of exactly hexByteLen bytes>`
|
|
14
|
+
The raw text is a byte-length-prefixed blob, not newline-terminated.
|
|
15
|
+
It can legitimately CONTAIN literal newlines, and it can run directly
|
|
16
|
+
into the NEXT row's id with zero separator.
|
|
17
|
+
* Module rows: `id:I[...]` / anonymous preload rows: `:HL[...]`
|
|
18
|
+
|
|
19
|
+
And even once rows are split correctly, the values are full of `$`-sigil
|
|
20
|
+
references Next.js uses to dedupe repeated subtrees (`$3`, `$L41`, `$@20`,
|
|
21
|
+
`$Sreact.fragment`, and path-suffixed refs like
|
|
22
|
+
`$3f:props:children:0:props:sections:...`). Hardcoding array indices like
|
|
23
|
+
`data[3]["children"][0][3]["children"][3][3]` breaks the moment the
|
|
24
|
+
surrounding component tree reshuffles on a redeploy. This module resolves
|
|
25
|
+
those references and lets you *search* for the shape of data you want
|
|
26
|
+
instead.
|
|
27
|
+
|
|
28
|
+
Quick start
|
|
29
|
+
-----------
|
|
30
|
+
from nextflight import extract
|
|
31
|
+
|
|
32
|
+
page = extract(html_text)
|
|
33
|
+
|
|
34
|
+
# Find whatever object looks like the data you need, wherever
|
|
35
|
+
# Next.js decided to put it in this particular build:
|
|
36
|
+
listing = page.find_by_keys({"sections", "meta"})
|
|
37
|
+
|
|
38
|
+
# Or with a custom predicate:
|
|
39
|
+
products = page.find_all(lambda n: isinstance(n, dict) and n.get("@type") == "Product")
|
|
40
|
+
|
|
41
|
+
# Or just get everything, fully dereferenced:
|
|
42
|
+
everything = page.resolve_all()
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import json
|
|
48
|
+
import re
|
|
49
|
+
import urllib.request
|
|
50
|
+
from typing import Any, Callable, Iterable, Iterator, Optional
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class FlightParseError(Exception):
|
|
54
|
+
"""Raised only when ``strict=True`` and a row cannot be parsed at all."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _coerce_html(source: Any) -> str:
|
|
58
|
+
"""Accept a raw HTML string/bytes, or a response-like object (Scrapy's
|
|
59
|
+
``Response``, ``requests.Response``, httpx, etc.) and return plain text.
|
|
60
|
+
|
|
61
|
+
This means both of these just work:
|
|
62
|
+
|
|
63
|
+
FlightExtractor(response) # Scrapy / requests response
|
|
64
|
+
FlightExtractor(response.text) # or the plain string, as before
|
|
65
|
+
"""
|
|
66
|
+
if isinstance(source, str):
|
|
67
|
+
return source
|
|
68
|
+
if isinstance(source, (bytes, bytearray)):
|
|
69
|
+
return bytes(source).decode("utf-8", errors="replace")
|
|
70
|
+
text_attr = getattr(source, "text", None)
|
|
71
|
+
if isinstance(text_attr, str):
|
|
72
|
+
return text_attr
|
|
73
|
+
body_attr = getattr(source, "body", None)
|
|
74
|
+
if isinstance(body_attr, (bytes, bytearray)):
|
|
75
|
+
return bytes(body_attr).decode("utf-8", errors="replace")
|
|
76
|
+
raise TypeError(
|
|
77
|
+
"Expected an HTML string, bytes, or a response-like object with a "
|
|
78
|
+
".text or .body attribute (e.g. a Scrapy or requests Response); "
|
|
79
|
+
f"got {type(source).__name__}"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class FlightExtractor:
|
|
84
|
+
"""Parses and searches the Next.js Flight payloads embedded in a page.
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
html:
|
|
89
|
+
The full HTML of a server-rendered Next.js App Router page. Also
|
|
90
|
+
accepts bytes, or a response-like object with a `.text`/`.body`
|
|
91
|
+
attribute (Scrapy's `Response`, `requests.Response`, etc.).
|
|
92
|
+
strict:
|
|
93
|
+
If True, raise :class:`FlightParseError` when a row's value isn't
|
|
94
|
+
valid JSON and doesn't look like a bare `$`-reference marker.
|
|
95
|
+
Default False: such rows are kept as raw strings so a handful of
|
|
96
|
+
odd rows never take down extraction of everything else on the page.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
_REF_RE = re.compile(r"^\$(?P<sigil>[A-Z@]{0,2})(?P<id>[^:\s]+)(?::(?P<path>.+))?$")
|
|
100
|
+
_PUSH_CALL_RE = re.compile(r"self\.__next_f\.push\(")
|
|
101
|
+
_ROW_START_RE = re.compile(r"[0-9a-zA-Z_\-]*:")
|
|
102
|
+
|
|
103
|
+
def __init__(self, html: Any, *, strict: bool = False):
|
|
104
|
+
self.html = _coerce_html(html)
|
|
105
|
+
self.strict = strict
|
|
106
|
+
self.raw_chunks: dict[str, Any] = {}
|
|
107
|
+
self._resolved_cache: dict[str, Any] = {}
|
|
108
|
+
self._resolving: set[str] = set()
|
|
109
|
+
self._extract_all()
|
|
110
|
+
|
|
111
|
+
def __repr__(self) -> str:
|
|
112
|
+
return f"<FlightExtractor chunks={len(self.raw_chunks)}>"
|
|
113
|
+
|
|
114
|
+
def __len__(self) -> int:
|
|
115
|
+
return len(self.raw_chunks)
|
|
116
|
+
|
|
117
|
+
def __iter__(self):
|
|
118
|
+
return iter(self.raw_chunks)
|
|
119
|
+
|
|
120
|
+
def __contains__(self, key: str) -> bool:
|
|
121
|
+
return key in self.raw_chunks
|
|
122
|
+
|
|
123
|
+
def __getitem__(self, key: str) -> Any:
|
|
124
|
+
"""``page[key]`` is shorthand for ``page.resolve_chunk(key)``, but
|
|
125
|
+
raises KeyError (like a normal dict) instead of returning None for
|
|
126
|
+
a key that was never pushed onto the page at all."""
|
|
127
|
+
if key not in self.raw_chunks:
|
|
128
|
+
raise KeyError(key)
|
|
129
|
+
return self.resolve_chunk(key)
|
|
130
|
+
|
|
131
|
+
def keys(self) -> list:
|
|
132
|
+
"""Every chunk id found on the page, in the order they were pushed.
|
|
133
|
+
This is 'step 1': see what's there before deciding what to resolve."""
|
|
134
|
+
return list(self.raw_chunks.keys())
|
|
135
|
+
|
|
136
|
+
# ------------------------------------------------------------------ #
|
|
137
|
+
# Construction helpers
|
|
138
|
+
# ------------------------------------------------------------------ #
|
|
139
|
+
@classmethod
|
|
140
|
+
def from_url(cls, url: str, *, timeout: float = 15.0, headers: Optional[dict] = None,
|
|
141
|
+
strict: bool = False) -> "FlightExtractor":
|
|
142
|
+
"""Fetch a URL with the stdlib (no extra dependencies) and parse it.
|
|
143
|
+
|
|
144
|
+
For anything beyond quick, one-off exploration -- retries, proxies,
|
|
145
|
+
rendering JS, respecting robots.txt -- fetch the page with your own
|
|
146
|
+
HTTP client / Scrapy / Zyte and pass ``response.text`` to the
|
|
147
|
+
normal constructor instead.
|
|
148
|
+
"""
|
|
149
|
+
req = urllib.request.Request(
|
|
150
|
+
url,
|
|
151
|
+
headers=headers or {"User-Agent": "Mozilla/5.0 (nextflight)"},
|
|
152
|
+
)
|
|
153
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
154
|
+
charset = resp.headers.get_content_charset() or "utf-8"
|
|
155
|
+
html = resp.read().decode(charset, errors="replace")
|
|
156
|
+
return cls(html, strict=strict)
|
|
157
|
+
|
|
158
|
+
# ------------------------------------------------------------------ #
|
|
159
|
+
# Step 1 -- find every push([...]) call, bracket/quote aware, so it
|
|
160
|
+
# doesn't matter how many <script> tags they're spread across.
|
|
161
|
+
# ------------------------------------------------------------------ #
|
|
162
|
+
def _iter_push_payloads(self) -> Iterator[str]:
|
|
163
|
+
for m in self._PUSH_CALL_RE.finditer(self.html):
|
|
164
|
+
array_text, _end = self._read_balanced(self.html, m.end(), "[", "]")
|
|
165
|
+
if array_text is None:
|
|
166
|
+
continue
|
|
167
|
+
try:
|
|
168
|
+
value = json.loads(array_text)
|
|
169
|
+
except json.JSONDecodeError:
|
|
170
|
+
continue
|
|
171
|
+
# push([0]) is an init call with no payload string; the ones we
|
|
172
|
+
# want look like push([1, "...rows..."])
|
|
173
|
+
if isinstance(value, list) and len(value) > 1 and isinstance(value[1], str):
|
|
174
|
+
yield value[1]
|
|
175
|
+
|
|
176
|
+
@staticmethod
|
|
177
|
+
def _read_balanced(text: str, start: int, open_ch: str, close_ch: str):
|
|
178
|
+
"""Find `open_ch` at/after `start`, then return (substring, end_idx)
|
|
179
|
+
where substring spans to its matching `close_ch`, respecting quoted
|
|
180
|
+
strings and backslash escapes inside them. (None, start) if no
|
|
181
|
+
balanced match exists (e.g. truncated HTML)."""
|
|
182
|
+
i = start
|
|
183
|
+
n = len(text)
|
|
184
|
+
while i < n and text[i] != open_ch:
|
|
185
|
+
i += 1
|
|
186
|
+
if i >= n:
|
|
187
|
+
return None, start
|
|
188
|
+
begin = i
|
|
189
|
+
depth = 0
|
|
190
|
+
in_str = False
|
|
191
|
+
esc = False
|
|
192
|
+
while i < n:
|
|
193
|
+
ch = text[i]
|
|
194
|
+
if in_str:
|
|
195
|
+
if esc:
|
|
196
|
+
esc = False
|
|
197
|
+
elif ch == "\\":
|
|
198
|
+
esc = True
|
|
199
|
+
elif ch == '"':
|
|
200
|
+
in_str = False
|
|
201
|
+
else:
|
|
202
|
+
if ch == '"':
|
|
203
|
+
in_str = True
|
|
204
|
+
elif ch == open_ch:
|
|
205
|
+
depth += 1
|
|
206
|
+
elif ch == close_ch:
|
|
207
|
+
depth -= 1
|
|
208
|
+
if depth == 0:
|
|
209
|
+
return text[begin:i + 1], i + 1
|
|
210
|
+
i += 1
|
|
211
|
+
return None, n
|
|
212
|
+
|
|
213
|
+
# ------------------------------------------------------------------ #
|
|
214
|
+
# Step 2 -- split a payload string into (chunk_id, row_type, raw_value)
|
|
215
|
+
# honouring the real row grammar (this is what a '\n'.split() breaks).
|
|
216
|
+
# ------------------------------------------------------------------ #
|
|
217
|
+
def _split_rows(self, payload: str) -> Iterator[tuple]:
|
|
218
|
+
i = 0
|
|
219
|
+
n = len(payload)
|
|
220
|
+
while i < n:
|
|
221
|
+
if payload[i] == "\n":
|
|
222
|
+
i += 1
|
|
223
|
+
continue
|
|
224
|
+
m = self._ROW_START_RE.match(payload, i)
|
|
225
|
+
if not m:
|
|
226
|
+
break
|
|
227
|
+
chunk_id = m.group(0)[:-1]
|
|
228
|
+
i = m.end()
|
|
229
|
+
if i >= n:
|
|
230
|
+
# Truncated payload: an id: was matched but nothing follows
|
|
231
|
+
# it (e.g. the row got cut off at a chunk boundary, which
|
|
232
|
+
# happens with proxies/CDNs that truncate responses).
|
|
233
|
+
# Nothing more to parse.
|
|
234
|
+
break
|
|
235
|
+
if payload[i] == "T":
|
|
236
|
+
j = payload.index(",", i + 1)
|
|
237
|
+
hex_len = int(payload[i + 1:j], 16)
|
|
238
|
+
body_start = j + 1
|
|
239
|
+
remaining_bytes = payload[body_start:].encode("utf-8")
|
|
240
|
+
text_bytes = remaining_bytes[:hex_len]
|
|
241
|
+
text_str = text_bytes.decode("utf-8")
|
|
242
|
+
i = body_start + len(text_str)
|
|
243
|
+
yield chunk_id, "text", text_str
|
|
244
|
+
elif payload[i:i + 2] == "HL":
|
|
245
|
+
val, end = self._read_balanced(payload, i + 2, "[", "]")
|
|
246
|
+
if val is None:
|
|
247
|
+
break
|
|
248
|
+
i = end
|
|
249
|
+
yield chunk_id, "preload", val
|
|
250
|
+
elif payload[i] == "I":
|
|
251
|
+
val, end = self._read_balanced(payload, i + 1, "[", "]")
|
|
252
|
+
if val is None:
|
|
253
|
+
break
|
|
254
|
+
i = end
|
|
255
|
+
yield chunk_id, "module", val
|
|
256
|
+
else:
|
|
257
|
+
if payload[i] in "[{":
|
|
258
|
+
close_ch = "]" if payload[i] == "[" else "}"
|
|
259
|
+
val, end = self._read_balanced(payload, i, payload[i], close_ch)
|
|
260
|
+
if val is None:
|
|
261
|
+
val, i = payload[i:], n
|
|
262
|
+
else:
|
|
263
|
+
i = end
|
|
264
|
+
elif payload[i] == '"':
|
|
265
|
+
val = self._read_quoted_string(payload, i)
|
|
266
|
+
i += len(val)
|
|
267
|
+
else:
|
|
268
|
+
nxt = re.search(r"\n[0-9a-zA-Z_\-]*:", payload[i:])
|
|
269
|
+
end = i + nxt.start() if nxt else n
|
|
270
|
+
val, i = payload[i:end], end
|
|
271
|
+
yield chunk_id, "json", val
|
|
272
|
+
|
|
273
|
+
@staticmethod
|
|
274
|
+
def _read_quoted_string(text: str, start: int) -> str:
|
|
275
|
+
i, n, esc = start + 1, len(text), False
|
|
276
|
+
while i < n:
|
|
277
|
+
ch = text[i]
|
|
278
|
+
if esc:
|
|
279
|
+
esc = False
|
|
280
|
+
elif ch == "\\":
|
|
281
|
+
esc = True
|
|
282
|
+
elif ch == '"':
|
|
283
|
+
return text[start:i + 1]
|
|
284
|
+
i += 1
|
|
285
|
+
return text[start:]
|
|
286
|
+
|
|
287
|
+
def _extract_all(self) -> None:
|
|
288
|
+
for payload in self._iter_push_payloads():
|
|
289
|
+
for chunk_id, row_type, raw_value in self._split_rows(payload):
|
|
290
|
+
if row_type == "text":
|
|
291
|
+
self.raw_chunks[chunk_id] = raw_value
|
|
292
|
+
elif row_type in ("module", "preload"):
|
|
293
|
+
try:
|
|
294
|
+
self.raw_chunks[chunk_id] = json.loads(raw_value)
|
|
295
|
+
except json.JSONDecodeError:
|
|
296
|
+
if self.strict:
|
|
297
|
+
raise FlightParseError(
|
|
298
|
+
f"chunk {chunk_id!r}: invalid {row_type} JSON: {raw_value[:80]!r}"
|
|
299
|
+
)
|
|
300
|
+
self.raw_chunks[chunk_id] = raw_value
|
|
301
|
+
else:
|
|
302
|
+
if raw_value == "$undefined":
|
|
303
|
+
self.raw_chunks[chunk_id] = None
|
|
304
|
+
continue
|
|
305
|
+
try:
|
|
306
|
+
self.raw_chunks[chunk_id] = json.loads(raw_value)
|
|
307
|
+
except json.JSONDecodeError:
|
|
308
|
+
if self.strict and not raw_value.startswith("$"):
|
|
309
|
+
raise FlightParseError(
|
|
310
|
+
f"chunk {chunk_id!r}: invalid JSON: {raw_value[:80]!r}"
|
|
311
|
+
)
|
|
312
|
+
self.raw_chunks[chunk_id] = raw_value # bare marker e.g. "X"
|
|
313
|
+
|
|
314
|
+
# ------------------------------------------------------------------ #
|
|
315
|
+
# Step 3 -- resolve '$'-sigil references into real values, recursively.
|
|
316
|
+
# ------------------------------------------------------------------ #
|
|
317
|
+
def resolve_chunk(self, chunk_id: str) -> Any:
|
|
318
|
+
"""Resolve a single chunk (by its id) with all `$`-refs dereferenced."""
|
|
319
|
+
if chunk_id in self._resolved_cache:
|
|
320
|
+
return self._resolved_cache[chunk_id]
|
|
321
|
+
if chunk_id in self._resolving or chunk_id not in self.raw_chunks:
|
|
322
|
+
return None
|
|
323
|
+
self._resolving.add(chunk_id)
|
|
324
|
+
resolved = self._resolve_value(self.raw_chunks[chunk_id])
|
|
325
|
+
self._resolving.discard(chunk_id)
|
|
326
|
+
self._resolved_cache[chunk_id] = resolved
|
|
327
|
+
return resolved
|
|
328
|
+
|
|
329
|
+
def _resolve_value(self, value: Any) -> Any:
|
|
330
|
+
if isinstance(value, str):
|
|
331
|
+
return self._resolve_ref_string(value)
|
|
332
|
+
if isinstance(value, list):
|
|
333
|
+
return [self._resolve_value(v) for v in value]
|
|
334
|
+
if isinstance(value, dict):
|
|
335
|
+
return {k: self._resolve_value(v) for k, v in value.items()}
|
|
336
|
+
return value
|
|
337
|
+
|
|
338
|
+
def _resolve_ref_string(self, s: str) -> Any:
|
|
339
|
+
if s == "$undefined":
|
|
340
|
+
return None
|
|
341
|
+
if not s.startswith("$"):
|
|
342
|
+
return s
|
|
343
|
+
if s.startswith("$$"): # escaped literal '$...'
|
|
344
|
+
return s[1:]
|
|
345
|
+
m = self._REF_RE.match(s)
|
|
346
|
+
if not m:
|
|
347
|
+
return s
|
|
348
|
+
sigil, ref_id, path = m.group("sigil"), m.group("id"), m.group("path")
|
|
349
|
+
if sigil == "S":
|
|
350
|
+
return {"__symbol__": ref_id}
|
|
351
|
+
if ref_id not in self.raw_chunks:
|
|
352
|
+
return s
|
|
353
|
+
value = self.resolve_chunk(ref_id)
|
|
354
|
+
if path:
|
|
355
|
+
value = self._walk_path(value, path.split(":"))
|
|
356
|
+
return value
|
|
357
|
+
|
|
358
|
+
@staticmethod
|
|
359
|
+
def _walk_path(value: Any, parts: list) -> Any:
|
|
360
|
+
for part in parts:
|
|
361
|
+
if isinstance(value, list):
|
|
362
|
+
try:
|
|
363
|
+
value = value[int(part)]
|
|
364
|
+
except (ValueError, IndexError):
|
|
365
|
+
return None
|
|
366
|
+
elif isinstance(value, dict):
|
|
367
|
+
value = value.get(part)
|
|
368
|
+
else:
|
|
369
|
+
return None
|
|
370
|
+
return value
|
|
371
|
+
|
|
372
|
+
def resolve_all(self) -> dict:
|
|
373
|
+
"""Every chunk on the page, fully dereferenced."""
|
|
374
|
+
return {cid: self.resolve_chunk(cid) for cid in list(self.raw_chunks)}
|
|
375
|
+
|
|
376
|
+
# ------------------------------------------------------------------ #
|
|
377
|
+
# Step 4 -- schema-free search over the fully resolved data.
|
|
378
|
+
# ------------------------------------------------------------------ #
|
|
379
|
+
def find_all(self, predicate: Callable[[Any], bool], root: Any = None,
|
|
380
|
+
max_results: Optional[int] = None) -> list:
|
|
381
|
+
"""Walk the whole resolved tree and collect every node matching `predicate`."""
|
|
382
|
+
data = self.resolve_all() if root is None else root
|
|
383
|
+
results: list = []
|
|
384
|
+
seen: set = set()
|
|
385
|
+
|
|
386
|
+
def walk(node: Any):
|
|
387
|
+
if max_results is not None and len(results) >= max_results:
|
|
388
|
+
return
|
|
389
|
+
if id(node) in seen:
|
|
390
|
+
return
|
|
391
|
+
if isinstance(node, (dict, list)):
|
|
392
|
+
seen.add(id(node))
|
|
393
|
+
if predicate(node):
|
|
394
|
+
results.append(node)
|
|
395
|
+
if max_results is not None and len(results) >= max_results:
|
|
396
|
+
return
|
|
397
|
+
if isinstance(node, dict):
|
|
398
|
+
for v in node.values():
|
|
399
|
+
walk(v)
|
|
400
|
+
elif isinstance(node, list):
|
|
401
|
+
for v in node:
|
|
402
|
+
walk(v)
|
|
403
|
+
|
|
404
|
+
if isinstance(data, dict):
|
|
405
|
+
for v in data.values():
|
|
406
|
+
walk(v)
|
|
407
|
+
else:
|
|
408
|
+
walk(data)
|
|
409
|
+
return results
|
|
410
|
+
|
|
411
|
+
def find_one(self, predicate: Callable[[Any], bool], root: Any = None) -> Any:
|
|
412
|
+
"""Like :meth:`find_all` but returns just the first match, or None."""
|
|
413
|
+
r = self.find_all(predicate, root=root, max_results=1)
|
|
414
|
+
return r[0] if r else None
|
|
415
|
+
|
|
416
|
+
def find_by_keys(self, required_keys: Iterable[str], root: Any = None) -> Any:
|
|
417
|
+
"""Find the first dict containing ALL of `required_keys` -- the
|
|
418
|
+
pattern you almost always want: 'give me whatever object looks
|
|
419
|
+
like the data I need', regardless of where this build's component
|
|
420
|
+
tree happened to put it."""
|
|
421
|
+
required_keys = set(required_keys)
|
|
422
|
+
return self.find_one(
|
|
423
|
+
lambda n: isinstance(n, dict) and required_keys <= n.keys(), root=root
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
def find_all_by_keys(self, required_keys: Iterable[str], root: Any = None) -> list:
|
|
427
|
+
"""Like :meth:`find_by_keys` but returns every matching dict, not
|
|
428
|
+
just the first -- useful for pages with repeated cards/listings
|
|
429
|
+
that all share the same shape (product cards, search results, ...)."""
|
|
430
|
+
required_keys = set(required_keys)
|
|
431
|
+
return self.find_all(
|
|
432
|
+
lambda n: isinstance(n, dict) and required_keys <= n.keys(), root=root
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
def find_by_type(self, type_value: str, *, key: str = "@type", root: Any = None) -> list:
|
|
436
|
+
"""Find every dict whose `key` field equals `type_value` (default key
|
|
437
|
+
"@type", matching schema.org-style typed objects Next.js often embeds
|
|
438
|
+
e.g. {"@type": "Product", ...})."""
|
|
439
|
+
return self.find_all(
|
|
440
|
+
lambda n: isinstance(n, dict) and n.get(key) == type_value, root=root
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
def find_text(self, pattern, root: Any = None) -> list:
|
|
444
|
+
"""Regex-search every string value in the resolved tree and return
|
|
445
|
+
the distinct whole string values that contain a match (this is a
|
|
446
|
+
substring search, like `re.search`, not an exact-match filter), in
|
|
447
|
+
the order first seen. Handy for pulling emails, phone numbers,
|
|
448
|
+
prices, or SKUs out of a page without having to know which object
|
|
449
|
+
they live on.
|
|
450
|
+
|
|
451
|
+
page.find_text(r"^\\$[\\d,]+(\\.\\d{2})?$") # dollar amounts
|
|
452
|
+
page.find_text(re.compile(r"[\\w.+-]+@[\\w-]+\\.\\w+")) # emails
|
|
453
|
+
"""
|
|
454
|
+
compiled = re.compile(pattern) if isinstance(pattern, str) else pattern
|
|
455
|
+
data = self.resolve_all() if root is None else root
|
|
456
|
+
matches: list = []
|
|
457
|
+
seen: set = set()
|
|
458
|
+
|
|
459
|
+
def walk(node: Any):
|
|
460
|
+
if isinstance(node, str):
|
|
461
|
+
if node not in seen and compiled.search(node):
|
|
462
|
+
seen.add(node)
|
|
463
|
+
matches.append(node)
|
|
464
|
+
elif isinstance(node, dict):
|
|
465
|
+
for v in node.values():
|
|
466
|
+
walk(v)
|
|
467
|
+
elif isinstance(node, list):
|
|
468
|
+
for v in node:
|
|
469
|
+
walk(v)
|
|
470
|
+
|
|
471
|
+
walk(data)
|
|
472
|
+
return matches
|
|
473
|
+
|
|
474
|
+
def get(self, path: str, default: Any = None, sep: str = ".") -> Any:
|
|
475
|
+
"""Navigate the fully resolved page with a dotted path of dict keys
|
|
476
|
+
and/or list indices, e.g. ``page.get("3f.props.product.price")`` or
|
|
477
|
+
``page.get("items.0.name")``. Returns `default` if any segment is
|
|
478
|
+
missing, instead of raising -- meant for quick, tolerant lookups
|
|
479
|
+
once you already know roughly where something lives on this site."""
|
|
480
|
+
current: Any = self.resolve_all()
|
|
481
|
+
for part in path.split(sep):
|
|
482
|
+
if isinstance(current, dict):
|
|
483
|
+
if part not in current:
|
|
484
|
+
return default
|
|
485
|
+
current = current[part]
|
|
486
|
+
elif isinstance(current, list):
|
|
487
|
+
try:
|
|
488
|
+
current = current[int(part)]
|
|
489
|
+
except (ValueError, IndexError):
|
|
490
|
+
return default
|
|
491
|
+
else:
|
|
492
|
+
return default
|
|
493
|
+
return current
|
|
494
|
+
|
|
495
|
+
def stats(self) -> dict:
|
|
496
|
+
"""A quick diagnostic snapshot -- handy the first time you point
|
|
497
|
+
this at a new site and want a feel for what's on the page before
|
|
498
|
+
writing search predicates."""
|
|
499
|
+
row_types: dict[str, int] = {}
|
|
500
|
+
for value in self.raw_chunks.values():
|
|
501
|
+
kind = type(value).__name__
|
|
502
|
+
row_types[kind] = row_types.get(kind, 0) + 1
|
|
503
|
+
return {
|
|
504
|
+
"chunk_count": len(self.raw_chunks),
|
|
505
|
+
"chunk_ids": self.keys(),
|
|
506
|
+
"value_type_counts": row_types,
|
|
507
|
+
"html_size_bytes": len(self.html.encode("utf-8")),
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
def to_json(self, path: Optional[str] = None, *, indent: int = 2) -> Optional[str]:
|
|
511
|
+
"""Dump the fully resolved page as JSON. Writes to `path` if given
|
|
512
|
+
(returns None), otherwise returns the JSON string."""
|
|
513
|
+
text = json.dumps(self.resolve_all(), indent=indent, ensure_ascii=False, default=str)
|
|
514
|
+
if path is None:
|
|
515
|
+
return text
|
|
516
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
517
|
+
f.write(text)
|
|
518
|
+
return None
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def find_json_ld(html: Any, type_: Optional[str] = None) -> list:
|
|
522
|
+
"""Parse any <script type="application/ld+json"> blocks on the page,
|
|
523
|
+
independent of Flight data and often more stable across redesigns --
|
|
524
|
+
worth trying first for structured product/article/breadcrumb data.
|
|
525
|
+
|
|
526
|
+
`html` accepts a raw string/bytes, or a response-like object (Scrapy's
|
|
527
|
+
`Response`, `requests.Response`, etc.). `type_` optionally filters
|
|
528
|
+
results by their "@type" (e.g. "Product")."""
|
|
529
|
+
html = _coerce_html(html)
|
|
530
|
+
blocks = re.findall(
|
|
531
|
+
r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
|
|
532
|
+
html, re.DOTALL,
|
|
533
|
+
)
|
|
534
|
+
out = []
|
|
535
|
+
for b in blocks:
|
|
536
|
+
try:
|
|
537
|
+
parsed = json.loads(b)
|
|
538
|
+
except json.JSONDecodeError:
|
|
539
|
+
continue
|
|
540
|
+
for c in (parsed if isinstance(parsed, list) else [parsed]):
|
|
541
|
+
if not isinstance(c, dict):
|
|
542
|
+
continue
|
|
543
|
+
types = c.get("@type")
|
|
544
|
+
if type_ is None or types == type_ or (
|
|
545
|
+
isinstance(types, list) and type_ in types
|
|
546
|
+
):
|
|
547
|
+
out.append(c)
|
|
548
|
+
return out
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def extract(html: Any, *, strict: bool = False) -> FlightExtractor:
|
|
552
|
+
"""Shorthand for ``FlightExtractor(html)``. `html` accepts a raw
|
|
553
|
+
string/bytes, or a response-like object (Scrapy's `Response`,
|
|
554
|
+
`requests.Response`, etc.) -- you can pass `response` straight from a
|
|
555
|
+
Scrapy `parse()` method without writing `response.text` yourself."""
|
|
556
|
+
return FlightExtractor(html, strict=strict)
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nextflight
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Parse Next.js App Router 'Flight' (__next_f.push) payloads embedded in server-rendered HTML -- works on any Next.js 13+ site.
|
|
5
|
+
Author: Aly Reda
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Aly-Reda/nextflight
|
|
8
|
+
Project-URL: Issues, https://github.com/Aly-Reda/nextflight/issues
|
|
9
|
+
Keywords: nextjs,scraping,scrapy,flight,rsc,react-server-components,web-scraping,zyte
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# nextflight
|
|
25
|
+
|
|
26
|
+
A general-purpose parser for the data Next.js (App Router) embeds in
|
|
27
|
+
`<script>self.__next_f.push([...])</script>` tags — the React Server
|
|
28
|
+
Components "Flight" wire format. Works on **any** Next.js 13+ App Router
|
|
29
|
+
site, not just one particular project.
|
|
30
|
+
|
|
31
|
+
Instead of hardcoding array indices like `data[3]["children"][0][3]...`,
|
|
32
|
+
which break the moment a site's component tree reshuffles on redeploy,
|
|
33
|
+
`nextflight` resolves the `$`-sigil references Next.js uses internally
|
|
34
|
+
and lets you *search* for the shape of data you want.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install nextflight
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
The core workflow is two steps: hand it any HTML, see what keys are on
|
|
45
|
+
the page, then fetch the resolved JSON for whichever key you want.
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from nextflight import extract
|
|
49
|
+
|
|
50
|
+
# Step 1: send any HTML, get the list of keys (one per __next_f.push chunk)
|
|
51
|
+
page = extract(html_text)
|
|
52
|
+
print(page.keys()) # e.g. ['0', '1', '3f', '20', ...]
|
|
53
|
+
|
|
54
|
+
# Step 2: fetch the resolved JSON for a specific key
|
|
55
|
+
data = page["3f"] # same as page.resolve_chunk("3f")
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Chunk ids are arbitrary per build though (a redeploy can renumber them),
|
|
59
|
+
so in practice you'll usually skip straight to *searching* for the shape
|
|
60
|
+
of data you want instead of a specific id:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from nextflight import extract
|
|
64
|
+
|
|
65
|
+
page = extract(html_text)
|
|
66
|
+
|
|
67
|
+
# Find the first object anywhere in the page that has all of these keys,
|
|
68
|
+
# wherever this build's component tree happened to put it:
|
|
69
|
+
listing = page.find_by_keys({"sections", "meta"})
|
|
70
|
+
|
|
71
|
+
# Find every node with a given @type (or any custom key):
|
|
72
|
+
products = page.find_by_type("Product")
|
|
73
|
+
|
|
74
|
+
# Or search with a fully custom predicate:
|
|
75
|
+
items = page.find_all(lambda n: isinstance(n, dict) and "price" in n)
|
|
76
|
+
|
|
77
|
+
# Or grab everything, fully dereferenced, and inspect by hand:
|
|
78
|
+
everything = page.resolve_all()
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Command line
|
|
82
|
+
|
|
83
|
+
For quick, no-script exploration of a page you've already saved (or a live URL):
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
nextflight page.html --keys sections,meta
|
|
87
|
+
nextflight https://example.com/product/123 --type Product
|
|
88
|
+
nextflight page.html --all > everything.json
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### In a Scrapy / Zyte spider
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
import scrapy
|
|
95
|
+
from nextflight import extract
|
|
96
|
+
|
|
97
|
+
class MySpider(scrapy.Spider):
|
|
98
|
+
name = "my_spider"
|
|
99
|
+
|
|
100
|
+
def parse(self, response):
|
|
101
|
+
page = extract(response.text)
|
|
102
|
+
|
|
103
|
+
items = page.find_all(
|
|
104
|
+
lambda n: isinstance(n, dict) and "price" in n and "title" in n
|
|
105
|
+
)
|
|
106
|
+
for item in items:
|
|
107
|
+
yield {
|
|
108
|
+
"title": item.get("title"),
|
|
109
|
+
"price": item.get("price"),
|
|
110
|
+
"url": response.url,
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Fetching a URL directly (no Scrapy needed)
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from nextflight import FlightExtractor
|
|
118
|
+
|
|
119
|
+
page = FlightExtractor.from_url("https://example.com/product/123")
|
|
120
|
+
product = page.find_by_keys({"price", "title"})
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
(`from_url` uses only the stdlib for quick one-off exploration. For
|
|
124
|
+
production crawling — retries, proxies, JS rendering, robots.txt — fetch
|
|
125
|
+
the page with your own HTTP client / Scrapy / Zyte and pass
|
|
126
|
+
`response.text` to `FlightExtractor(...)` / `extract(...)` instead.)
|
|
127
|
+
|
|
128
|
+
## API
|
|
129
|
+
|
|
130
|
+
- **`extract(html) -> FlightExtractor`** — shorthand constructor. `html`
|
|
131
|
+
accepts a plain string, bytes, or a response-like object (Scrapy's
|
|
132
|
+
`Response`, `requests.Response`, etc.) — pass `response` straight from a
|
|
133
|
+
`parse()` method without writing `response.text` yourself.
|
|
134
|
+
- **`FlightExtractor(html, *, strict: bool = False)`**
|
|
135
|
+
- `.keys() -> list[str]` — every chunk id found on the page, in order.
|
|
136
|
+
- `page["3f"]` / `.resolve_chunk("3f")` — the resolved JSON for one
|
|
137
|
+
specific chunk id (`page[...]` raises `KeyError` if it doesn't exist;
|
|
138
|
+
`resolve_chunk` returns `None`). `"3f" in page` and `for k in page`
|
|
139
|
+
also work, like a dict.
|
|
140
|
+
- `.resolve_all() -> dict` — every chunk, fully dereferenced.
|
|
141
|
+
- `.find_all(predicate, root=None, max_results=None) -> list` — walk the
|
|
142
|
+
resolved tree and collect every node matching `predicate`.
|
|
143
|
+
- `.find_one(predicate, root=None) -> Any | None`
|
|
144
|
+
- `.find_by_keys(required_keys, root=None) -> dict | None` — find the
|
|
145
|
+
first dict containing all of `required_keys`.
|
|
146
|
+
- `.find_all_by_keys(required_keys, root=None) -> list` — like
|
|
147
|
+
`find_by_keys` but returns every match, for pages with repeated
|
|
148
|
+
cards/listings that share the same shape.
|
|
149
|
+
- `.find_by_type(type_value, key="@type", root=None) -> list` — find
|
|
150
|
+
every dict whose `key` field equals `type_value`.
|
|
151
|
+
- `.find_text(pattern, root=None) -> list` — regex-search every string
|
|
152
|
+
value on the page and return the distinct whole values that contain a
|
|
153
|
+
match (emails, prices, phone numbers, SKUs, ...) without needing to
|
|
154
|
+
know which object they live on.
|
|
155
|
+
- `.get("path.to.value", default=None) -> Any` — tolerant dotted-path
|
|
156
|
+
lookup into the resolved page (dict keys and/or list indices), once
|
|
157
|
+
you already know roughly where something lives on this site.
|
|
158
|
+
- `.stats() -> dict` — quick diagnostic snapshot (chunk count, ids,
|
|
159
|
+
value type counts, page size) for exploring a new site.
|
|
160
|
+
- `.to_json(path=None, indent=2) -> str | None` — dump the fully
|
|
161
|
+
resolved page to a file, or return it as a JSON string.
|
|
162
|
+
- `.from_url(url, timeout=15.0, headers=None) -> FlightExtractor`
|
|
163
|
+
(classmethod) — fetch and parse a URL using only the stdlib.
|
|
164
|
+
- `strict=True` raises `FlightParseError` on a row that's neither valid
|
|
165
|
+
JSON nor a recognizable `$`-reference marker, instead of silently
|
|
166
|
+
keeping it as a raw string (useful while developing a new scraper;
|
|
167
|
+
leave off in production so a handful of odd rows never take down
|
|
168
|
+
extraction of everything else on the page).
|
|
169
|
+
- **`find_json_ld(html, type_=None) -> list`** — parse any
|
|
170
|
+
`<script type="application/ld+json">` blocks on the page, optionally
|
|
171
|
+
filtered by `@type`. Also accepts response-like objects.
|
|
172
|
+
- **CLI**: `nextflight <file-or-url> [--keys a,b | --all-by-keys a,b | --type Product | --text PATTERN | --get path.to.value | --stats | --all] [--save out.json]`
|
|
173
|
+
|
|
174
|
+
No runtime dependencies — stdlib only (`json`, `re`, `urllib`, `argparse`)
|
|
175
|
+
— so it's safe to drop into any existing Scrapy/Zyte project without
|
|
176
|
+
touching the rest of your dependency tree.
|
|
177
|
+
|
|
178
|
+
### Upgrading from `nextjs-flight-extractor` / `NextFlightExtractor`
|
|
179
|
+
|
|
180
|
+
The old names still work but emit a `DeprecationWarning`:
|
|
181
|
+
|
|
182
|
+
| Old (0.1.x) | New (0.2.x+) |
|
|
183
|
+
|---------------------------------------|----------------------------------|
|
|
184
|
+
| `from nextjs_flight_extractor import NextFlightExtractor` | `from nextflight import FlightExtractor` |
|
|
185
|
+
| `extractor.find_first(...)` | `page.find_one(...)` |
|
|
186
|
+
| `extract_json_ld(html, schema_type=…)`| `find_json_ld(html, type_=…)` |
|
|
187
|
+
|
|
188
|
+
## Why not just `str.split('\n')`?
|
|
189
|
+
|
|
190
|
+
Two of the Flight row kinds break that assumption:
|
|
191
|
+
|
|
192
|
+
- **Text rows** (`id:T<hexByteLen>,<raw text>`) are byte-length-prefixed
|
|
193
|
+
blobs, not newline-terminated, and can contain literal newlines or run
|
|
194
|
+
directly into the next row's id with zero separator.
|
|
195
|
+
- **Module / preload rows** (`id:I[...]` / `:HL[...]`) need bracket-aware
|
|
196
|
+
parsing.
|
|
197
|
+
|
|
198
|
+
`nextflight` implements the real row grammar, quote/escape aware, so it
|
|
199
|
+
holds up on both well-formed and truncated payloads (e.g. from a proxy
|
|
200
|
+
that cuts a response off mid-chunk).
|
|
201
|
+
|
|
202
|
+
## Building / publishing
|
|
203
|
+
|
|
204
|
+
### Manual (twine)
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
pip install build twine
|
|
208
|
+
python -m build # produces dist/*.whl and dist/*.tar.gz
|
|
209
|
+
twine check dist/* # validate metadata before uploading
|
|
210
|
+
twine upload dist/* # publish to PyPI (or use --repository testpypi for a dry run)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Automatic (GitHub Actions + PyPI Trusted Publishing)
|
|
214
|
+
|
|
215
|
+
This repo ships with `.github/workflows/ci.yml`, which:
|
|
216
|
+
- runs the test suite on every push/PR across Python 3.9–3.12,
|
|
217
|
+
- builds and validates the sdist/wheel,
|
|
218
|
+
- publishes to PyPI automatically whenever a tag like `v0.2.1` is pushed.
|
|
219
|
+
|
|
220
|
+
Publishing uses PyPI's **Trusted Publisher** flow — no API token stored in
|
|
221
|
+
GitHub secrets. One-time setup:
|
|
222
|
+
|
|
223
|
+
1. On [pypi.org](https://pypi.org), go to your project → *Publishing* →
|
|
224
|
+
*Add a new publisher* (or, for a brand-new project name, do this from
|
|
225
|
+
your PyPI account's "Trusted Publishers" management page before the
|
|
226
|
+
project exists yet).
|
|
227
|
+
2. Fill in: Owner = your GitHub username/org, Repository = this repo's
|
|
228
|
+
name, Workflow name = `ci.yml`, Environment name = `pypi`.
|
|
229
|
+
3. In your GitHub repo, go to *Settings → Environments*, create an
|
|
230
|
+
environment named `pypi` (optionally require a manual approval before
|
|
231
|
+
deploys, for extra safety).
|
|
232
|
+
4. Release a new version:
|
|
233
|
+
```bash
|
|
234
|
+
# bump version in pyproject.toml and src/nextflight/__init__.py first
|
|
235
|
+
git commit -am "Release v0.2.2"
|
|
236
|
+
git tag v0.2.2
|
|
237
|
+
git push origin main --tags
|
|
238
|
+
```
|
|
239
|
+
The workflow builds, tests, and publishes automatically.
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
## License
|
|
243
|
+
|
|
244
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
nextflight/__init__.py,sha256=YMtSkaAYlkPMSooKGapYJSr9_eaqQ5-jr57SyU38uU8,2052
|
|
2
|
+
nextflight/cli.py,sha256=itGnfTs8saWByxcw3gOLQi3A2vVe2h_8uGleJ-CkRKA,3330
|
|
3
|
+
nextflight/extractor.py,sha256=Xng1skJTVrt97Q8mCs15W2385Z4p5XRisITo91kv2b4,22965
|
|
4
|
+
nextflight-0.3.0.dist-info/licenses/LICENSE,sha256=j9lAneLMy0MUiZBPpVcj7Jr284CZIpMjq-Z9yO82410,1065
|
|
5
|
+
nextflight-0.3.0.dist-info/METADATA,sha256=fC8Zjw5QQVc-uJqnVRJrsYZm_Ep5pBjIv0g1A4mCRZQ,9862
|
|
6
|
+
nextflight-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
+
nextflight-0.3.0.dist-info/entry_points.txt,sha256=px-_KjhkGmUfBR6qlnjvcbsZbBeLQ7PvfgyKDFRXxk4,51
|
|
8
|
+
nextflight-0.3.0.dist-info/top_level.txt,sha256=IlFQ8rgdkbLdLtcoCDcFLLxUMnz_1RBLDhYyJzWHraE,11
|
|
9
|
+
nextflight-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Aly Reda
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nextflight
|