Repository: yt-dlp/yt-dlp
Pull request #13711 (2025-07-12 22:59:37)

[ie] Rework `_search_nextjs_v13_data` helper
Considering the [Patreon next.js v13 data](https://www.patreon.com/cw/anythingelse) and how its campaign `id`s are not accessible with the current implementation of `_search_nextjs_v13_data`, it's apparent that we need to extract the flight segments that are objects/dicts, not only the arrays/lists.

Also, the `prefix` of the flight segment (e.g. `1a`) can be referenced in another data object (e.g. `$1a`), and these seem to be in the same namespace as the flight segment `name` (e.g. `$L1a`).

Conclusion: we should flatten to a dict instead of a list, with the `prefix`es/`name`s as the keys, and return that. (And the keys should have the `$` and `$L` prefixes stripped)

Fixes 5245231e4a39ecd5595d4337d46d85e150e2430a

### Description of your *pull request* and other information

### Before submitting a *pull request* make sure you have:
- [x] At least skimmed through [contributing guidelines](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#developer-instructions) including [yt-dlp coding conventions](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#yt-dlp-coding-conventions)
- [x] [Searched](https://github.com/yt-dlp/yt-dlp/search?q=is%3Apr&type=Issues) the bugtracker for similar pull requests

### In order to be accepted and merged into yt-dlp each piece of code must be in public domain or released under [Unlicense](http://unlicense.org/). Check those that apply and remove the others:
- [x] I am the original author of the code in this PR, and I am willing to release it under [Unlicense](http://unlicense.org/)

### What is the purpose of your *pull request*? Check those that apply and remove the others:
- [x] Core bug fix/improvement

</details>



Patch:
diff --git a/yt_dlp/extractor/common.py b/yt_dlp/extractor/common.py
index a3ff5a1c0..d601e1751 100644
--- a/yt_dlp/extractor/common.py
+++ b/yt_dlp/extractor/common.py
@@ -1785,7 +1785,7 @@ def _search_nextjs_data(self, webpage, video_id, *, fatal=True, default=NO_DEFAU
 
     def _search_nextjs_v13_data(self, webpage, video_id, fatal=True):
         """Parses Next.js app router flight data that was introduced in Next.js v13"""
-        nextjs_data = []
+        nextjs_data = {}
         if not fatal and not isinstance(webpage, str):
             return nextjs_data
 
@@ -1797,9 +1797,9 @@ def flatten(flight_data):
                 if not isinstance(data, dict):
                     return
                 children = data.pop('children', None)
-                if data and isinstance(name, str) and name.startswith('$'):
+                if data and isinstance(name, str) and re.fullmatch(r'\$L[0-9a-f]+', name):
                     # It is useful hydration JSON data
-                    nextjs_data.append(data)
+                    nextjs_data[name[2:]] = data
                 flatten(children)
                 return
             for f in flight_data:
@@ -1823,10 +1823,16 @@ def flatten(flight_data):
                 flight_text += chunk
 
         for f in flight_text.splitlines():
-            prefix, _, body = f.partition(':')
-            if body.startswith('[') and body.endswith(']') and re.fullmatch(r'[0-9a-f]{1,3}', prefix.lstrip()):
-                # The body isn't necessarily valid JSON, so this should always be non-fatal
+            prefix, _, body = f.lstrip().partition(':')
+            if not re.fullmatch(r'[0-9a-f]+', prefix):
+                continue
+            # The body still isn't guaranteed to be valid JSON, so parsing should always be non-fatal
+            if body.startswith('[') and body.endswith(']'):
                 flatten(self._parse_json(body, video_id, fatal=False, errnote=False))
+            elif body.startswith('{') and body.endswith('}'):
+                data = self._parse_json(body, video_id, fatal=False, errnote=False)
+                if data is not None:
+                    nextjs_data[prefix] = data
 
         return nextjs_data
 


Test patch:
diff --git a/test/test_InfoExtractor.py b/test/test_InfoExtractor.py
index 7c3825f77..40dd05e13 100644
--- a/test/test_InfoExtractor.py
+++ b/test/test_InfoExtractor.py
@@ -1969,21 +1969,26 @@ def test_search_nextjs_v13_data(self):
             <script>self.__next_f.push([1,"df:[\"$undefined\",[\"$\",\"div\",null,{\"children\":[\"$\",\"$L17\",null,{}],\"do_not_include_this_field\":\"fail\"}],[\"$\",\"div\",null,{\"children\":[[\"$\",\"$L19\",null,{\"duplicated_field_name\":{\"x\":1}}],[\"$\",\"$L20\",null,{\"duplicated_field_name\":{\"y\":2}}]]}],\"$undefined\"]\n"])</script>
             <script>self.__next_f.push([3,"MzM6WyIkIiwiJEwzMiIsbnVsbCx7ImRlY29kZWQiOiJzdWNjZXNzIn1d"])</script>
             '''
-        EXPECTED = [{
-            'foo': 'bar',
-        }, {
-            'meta': {
-                'dateCreated': 1730489700,
-                'uuid': '40cac41d-8d29-4ef5-aa11-75047b9f0907',
+        EXPECTED = {
+            '18': {
+                'foo': 'bar',
             },
-        }, {
-            'duplicated_field_name': {'x': 1},
-        }, {
-            'duplicated_field_name': {'y': 2},
-        }]
+            '16': {
+                'meta': {
+                    'dateCreated': 1730489700,
+                    'uuid': '40cac41d-8d29-4ef5-aa11-75047b9f0907',
+                },
+            },
+            '19': {
+                'duplicated_field_name': {'x': 1},
+            },
+            '20': {
+                'duplicated_field_name': {'y': 2},
+            },
+        }
         self.assertEqual(self.ie._search_nextjs_v13_data(HTML, None), EXPECTED)
-        self.assertEqual(self.ie._search_nextjs_v13_data('', None, fatal=False), [])
-        self.assertEqual(self.ie._search_nextjs_v13_data(None, None, fatal=False), [])
+        self.assertEqual(self.ie._search_nextjs_v13_data('', None, fatal=False), {})
+        self.assertEqual(self.ie._search_nextjs_v13_data(None, None, fatal=False), {})
 
     def test_search_nuxt_json(self):
         HTML_TMPL = '<script data-ssr="true" id="__NUXT_DATA__" type="application/json">[{}]</script>'