instagram-archiver 0.2.1__py3-none-any.whl → 0.3.1__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.

Potentially problematic release.


This version of instagram-archiver might be problematic. Click here for more details.

@@ -1,107 +1,63 @@
1
- from typing import TYPE_CHECKING, Final, Mapping
1
+ """Constants."""
2
+ from __future__ import annotations
2
3
 
3
- from .utils import YoutubeDLLogger
4
+ __all__ = ('API_HEADERS', 'BROWSER_CHOICES', 'PAGE_FETCH_HEADERS', 'SHARED_HEADERS', 'USER_AGENT')
4
5
 
5
- __all__ = ('BROWSER_CHOICES', 'LOG_SCHEMA', 'RETRY_ABORT_NUM', 'SHARED_HEADERS',
6
- 'SHARED_YT_DLP_OPTIONS', 'YT_DLP_SLEEP_INTERVAL', 'USER_AGENT')
6
+ USER_AGENT = ('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) '
7
+ 'Chrome/137.0.0.0 Safari/537.36')
8
+ """
9
+ User agent.
7
10
 
8
- if TYPE_CHECKING:
9
- from yt_dlp import YDLOpts
10
-
11
- USER_AGENT: Final[str] = ('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) '
12
- 'Chrome/112.0.0.0 Safari/537.36')
13
- # Do not set the x-ig-d header as this will cause API calls to return 404
14
- SHARED_HEADERS: Final[Mapping[str, str]] = {
15
- 'accept': ('text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,'
16
- 'image/avif,image/webp,image/apng,*/*;q=0.8,'
17
- 'application/signed-exchange;v=b3;q=0.9'),
18
- 'accept-language': 'en,en-GB;q=0.9,en-US;q=0.8',
11
+ :meta hide-value:
12
+ """
13
+ SHARED_HEADERS = {
14
+ 'accept': '*/*',
19
15
  'authority': 'www.instagram.com',
20
16
  'cache-control': 'no-cache',
21
17
  'dnt': '1',
22
18
  'pragma': 'no-cache',
23
- 'referer': 'https://www.instagram.com',
24
- 'upgrade-insecure-requests': '1',
25
19
  'user-agent': USER_AGENT,
26
- 'viewport-width': '2560',
27
- 'x-ig-app-id': '936619743392459'
20
+ # 'x-asbd-id': '359341',
21
+ # 'x-ig-app-id': '936619743392459',
22
+ }
23
+ """
24
+ Headers to use for requests.
25
+
26
+ :meta hide-value:
27
+ """
28
+ API_HEADERS = {
29
+ 'x-asbd-id': '359341',
30
+ 'x-ig-app-id': '936619743392459',
31
+ }
32
+ """
33
+ Headers to use for API requests.
34
+
35
+ :meta hide-value:
36
+ """
37
+ PAGE_FETCH_HEADERS = {
38
+ 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,'
39
+ 'image/apng,*/*;q=0.8',
40
+ 'dpr': '1.5',
41
+ 'sec-fetch-mode': 'navigate', # Definitely required.
42
+ 'viewport-width': '3840',
28
43
  }
29
- LOG_SCHEMA: Final[str] = '''
30
- CREATE TABLE log (
44
+ """
45
+ Headers to use for fetching HTML pages.
46
+
47
+ :meta hide-value:
48
+ """
49
+ LOG_SCHEMA = """CREATE TABLE log (
31
50
  url TEXT PRIMARY KEY NOT NULL,
32
51
  date TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL
33
- );
34
- '''
35
- #: Calls per minute allowed.
36
- CALLS_PER_MINUTE: Final[int] = 10
37
- #: yt-dlp sleep interval.
38
- YT_DLP_SLEEP_INTERVAL: Final[int] = 60 // CALLS_PER_MINUTE
39
- #: Value taken from Instagram's JS under BootloaderConfig
40
- RETRY_ABORT_NUM: Final[int] = 2
41
- SHARED_YT_DLP_OPTIONS: 'YDLOpts' = {
42
- 'allowed_extractors': ['Instagram.*'],
43
- 'allsubtitles': True,
44
- 'cookiesfrombrowser': None,
45
- 'geo_bypass': True,
46
- 'getcomments': False,
47
- 'hls_use_mpegts': True,
48
- 'http_headers': SHARED_HEADERS,
49
- 'ignore_no_formats_error': True,
50
- 'ignoreerrors': True,
51
- 'logger': YoutubeDLLogger(),
52
- 'outtmpl': {
53
- 'default': '%(title).128s___src=%(extractor)s___id=%(id)s.%(ext)s',
54
- 'pl_thumbnail': ''
55
- },
56
- 'overwrites': False,
57
- 'max_sleep_interval': 6,
58
- 'merge_output_format': 'mkv',
59
- 'postprocessors': [{
60
- 'api': 'https://sponsor.ajay.app',
61
- 'categories': [
62
- 'preview', 'selfpromo', 'interaction', 'music_offtopic', 'sponsor', 'poi_highlight',
63
- 'intro', 'outro', 'filler', 'chapter'
64
- ],
65
- 'key': 'SponsorBlock',
66
- 'when': 'after_filter'
67
- }, {
68
- 'format': 'srt',
69
- 'key': 'FFmpegSubtitlesConvertor',
70
- 'when': 'before_dl'
71
- }, {
72
- 'already_have_subtitle': True,
73
- 'key': 'FFmpegEmbedSubtitle'
74
- }, {
75
- 'force_keyframes': False,
76
- 'key': 'ModifyChapters',
77
- 'remove_chapters_patterns': [],
78
- 'remove_ranges': [],
79
- 'remove_sponsor_segments': [],
80
- 'sponsorblock_chapter_title': '[SponsorBlock]: %(category_names)'
81
- }, {
82
- 'add_chapters': True,
83
- 'add_infojson': 'if_exists',
84
- 'add_metadata': True,
85
- 'key': 'FFmpegMetadata'
86
- }, {
87
- 'already_have_thumbnail': False,
88
- 'key': 'EmbedThumbnail'
89
- }, {
90
- 'key': 'FFmpegConcat',
91
- 'only_multi_video': True,
92
- 'when': 'playlist'
93
- }],
94
- 'restrictfilenames': True,
95
- 'skip_unavailable_fragments': True,
96
- 'sleep_interval': YT_DLP_SLEEP_INTERVAL,
97
- 'sleep_interval_requests': YT_DLP_SLEEP_INTERVAL,
98
- 'sleep_interval_subtitles': YT_DLP_SLEEP_INTERVAL,
99
- 'subtitleslangs': ['all'],
100
- 'verbose': False,
101
- 'writeautomaticsub': True,
102
- 'writesubtitles': True,
103
- 'writeinfojson': True,
104
- 'writethumbnail': True,
105
- }
106
- #: Possible browser choices to get cookies from.
52
+ );"""
53
+ """
54
+ Schema for log database.
55
+
56
+ :meta hide-value:
57
+ """
107
58
  BROWSER_CHOICES = ('brave', 'chrome', 'chromium', 'edge', 'opera', 'vivaldi', 'firefox', 'safari')
59
+ """
60
+ Possible browser choices to get cookies from.
61
+
62
+ :meta hide-value:
63
+ """
@@ -1,82 +1,105 @@
1
+ """Main application."""
2
+ from __future__ import annotations
3
+
1
4
  from pathlib import Path
2
- import sys
5
+ from typing import TYPE_CHECKING
3
6
 
4
- from loguru import logger
5
- from requests.exceptions import RetryError
6
7
  import click
7
8
 
8
- from .client import AuthenticationError, InstagramClient
9
+ from .client import UnexpectedRedirect
9
10
  from .constants import BROWSER_CHOICES
10
- from .find_query_hashes import find_query_hashes
11
- from .ig_typing import BrowserName
11
+ from .profile_scraper import ProfileScraper
12
+ from .saved_scraper import SavedScraper
12
13
  from .utils import setup_logging
13
14
 
15
+ if TYPE_CHECKING:
16
+ from .typing import BrowserName
17
+
14
18
  __all__ = ('main',)
15
19
 
16
20
 
17
- @click.command()
21
+ @click.command(context_settings={'help_option_names': ('-h', '--help')})
18
22
  @click.option('-o',
19
23
  '--output-dir',
20
- default=None,
21
- help='Output directory',
22
- type=click.Path(file_okay=False, path_type=Path, resolve_path=True, writable=True))
24
+ default='%(username)s',
25
+ help='Output directory.',
26
+ type=click.Path(file_okay=False, writable=True))
23
27
  @click.option('-b',
24
28
  '--browser',
25
29
  default='chrome',
26
30
  type=click.Choice(BROWSER_CHOICES),
27
- help='Browser to read cookies from')
28
- @click.option('-p', '--profile', default='Default', help='Browser profile')
29
- @click.option('-d', '--debug', is_flag=True, help='Enable debug output')
30
- @click.option('--no-log', is_flag=True, help='Ignore log (re-fetch everything)')
31
+ help='Browser to read cookies from.')
32
+ @click.option('-p', '--profile', default='Default', help='Browser profile.')
33
+ @click.option('-d', '--debug', is_flag=True, help='Enable debug output.')
34
+ @click.option('--no-log', is_flag=True, help='Ignore log (re-fetch everything).')
31
35
  @click.option('-C',
32
36
  '--include-comments',
33
37
  is_flag=True,
34
38
  help='Also download all comments (extends download time significantly).')
35
- @click.option('--print-query-hashes',
36
- is_flag=True,
37
- help='Print current query hashes and exit.',
38
- hidden=True)
39
- @click.argument('username', required=False)
40
- def main(output_dir: Path | None,
41
- browser: BrowserName,
42
- profile: str,
39
+ @click.argument('username')
40
+ def main(output_dir: str,
41
+ username: str,
42
+ browser: BrowserName = 'chrome',
43
+ profile: str = 'Default',
44
+ *,
43
45
  debug: bool = False,
44
46
  include_comments: bool = False,
45
- no_log: bool = False,
46
- print_query_hashes: bool = False,
47
- username: str | None = None) -> None:
48
- """Archive a profile's posts."""
49
- setup_logging(debug)
50
- if print_query_hashes:
51
- for query_hash in sorted(find_query_hashes(browser, profile)):
52
- click.echo(query_hash)
53
- return
54
- if not username:
55
- raise click.UsageError('Username is required')
47
+ no_log: bool = False) -> None:
48
+ """Archive a profile's posts.""" # noqa: DOC501
49
+ setup_logging(debug=debug)
56
50
  try:
57
- with InstagramClient(browser=browser,
58
- browser_profile=profile,
59
- comments=include_comments,
60
- debug=debug,
61
- disable_log=no_log,
62
- output_dir=output_dir,
63
- username=username) as client:
51
+ with ProfileScraper(browser=browser,
52
+ browser_profile=profile,
53
+ comments=include_comments,
54
+ disable_log=no_log,
55
+ output_dir=(Path(output_dir % {'username': username})
56
+ if '%(username)s' in output_dir else Path(output_dir)),
57
+ username=username) as client:
64
58
  client.process()
65
- except RetryError as e:
66
- click.echo(
67
- 'Open your browser and login if necessary. If you are logged in and this continues, '
68
- 'try waiting at least 12 hours.',
69
- file=sys.stderr)
70
- raise click.Abort() from e
71
- except AuthenticationError as e:
72
- click.echo(
73
- 'You are probably not logged into Instagram in this browser profile or your '
74
- 'session has expired.',
75
- file=sys.stderr)
76
- raise click.Abort() from e
59
+ except UnexpectedRedirect as e:
60
+ click.echo('Unexpected redirect. Assuming request limit has been reached.', err=True)
61
+ raise click.Abort from e
62
+ except Exception as e:
63
+ if isinstance(e, KeyboardInterrupt) or debug:
64
+ raise
65
+ click.echo('Run with --debug for more information.', err=True)
66
+ raise click.Abort from e
67
+
68
+
69
+ @click.command(context_settings={'help_option_names': ('-h', '--help')})
70
+ @click.option('-o',
71
+ '--output-dir',
72
+ default='.',
73
+ help='Output directory.',
74
+ type=click.Path(file_okay=False, writable=True))
75
+ @click.option('-b',
76
+ '--browser',
77
+ default='chrome',
78
+ type=click.Choice(BROWSER_CHOICES),
79
+ help='Browser to read cookies from.')
80
+ @click.option('-p', '--profile', default='Default', help='Browser profile.')
81
+ @click.option('-d', '--debug', is_flag=True, help='Enable debug output.')
82
+ @click.option('-C',
83
+ '--include-comments',
84
+ is_flag=True,
85
+ help='Also download all comments (extends download time significantly).')
86
+ @click.option('-u', '--unsave', is_flag=True, help='Unsave posts after successful archive.')
87
+ def save_saved_main(output_dir: str,
88
+ browser: BrowserName = 'chrome',
89
+ profile: str = 'Default',
90
+ *,
91
+ debug: bool = False,
92
+ include_comments: bool = False,
93
+ unsave: bool = False) -> None:
94
+ """Archive your saved posts.""" # noqa: DOC501
95
+ setup_logging(debug=debug)
96
+ try:
97
+ SavedScraper(browser, profile, output_dir, comments=include_comments).process(unsave=unsave)
98
+ except UnexpectedRedirect as e:
99
+ click.echo('Unexpected redirect. Assuming request limit has been reached.', err=True)
100
+ raise click.Abort from e
77
101
  except Exception as e:
78
- if debug:
79
- logger.exception(e)
80
- else:
81
- click.echo('Run with --debug for more information')
82
- raise click.Abort(f'{e} (run with --debug for more information)') from e
102
+ if isinstance(e, KeyboardInterrupt) or debug:
103
+ raise
104
+ click.echo('Run with --debug for more information.', err=True)
105
+ raise click.Abort from e
@@ -0,0 +1,194 @@
1
+ """Instagram client."""
2
+ from __future__ import annotations
3
+
4
+ from contextlib import chdir
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, TypeVar, override
7
+ from urllib.parse import urlparse
8
+ import json
9
+ import logging
10
+ import sqlite3
11
+
12
+ from requests import HTTPError
13
+ from yt_dlp_utils import get_configured_yt_dlp
14
+
15
+ from .client import InstagramClient
16
+ from .constants import LOG_SCHEMA
17
+ from .typing import (
18
+ BrowserName,
19
+ WebProfileInfo,
20
+ XDTAPIV1FeedUserTimelineGraphQLConnectionContainer,
21
+ )
22
+ from .utils import SaveCommentsCheckDisabledMixin
23
+
24
+ if TYPE_CHECKING:
25
+ from types import TracebackType
26
+
27
+ __all__ = ('ProfileScraper',)
28
+
29
+ T = TypeVar('T')
30
+ log = logging.getLogger(__name__)
31
+
32
+
33
+ def _clean_url(url: str) -> str:
34
+ parsed = urlparse(url)
35
+ return f'https://{parsed.netloc}{parsed.path}'
36
+
37
+
38
+ class ProfileScraper(SaveCommentsCheckDisabledMixin, InstagramClient):
39
+ """The scraper."""
40
+ def __init__(self,
41
+ username: str,
42
+ *,
43
+ log_file: str | Path | None = None,
44
+ output_dir: str | Path | None = None,
45
+ disable_log: bool = False,
46
+ browser: BrowserName = 'chrome',
47
+ browser_profile: str = 'Default',
48
+ comments: bool = False) -> None:
49
+ """
50
+ Initialise ``ProfileScraper``.
51
+
52
+ Parameters
53
+ ----------
54
+ username : str
55
+ The username to scrape.
56
+ log_file : str | Path | None
57
+ The log file to use.
58
+ output_dir : str | Path | None
59
+ The output directory to save the posts to.
60
+ disable_log : bool
61
+ Whether to disable logging or not.
62
+ browser : BrowserName
63
+ The browser to use.
64
+ browser_profile : str
65
+ The browser profile to use.
66
+ comments : bool
67
+ Whether to save comments or not.
68
+ """
69
+ super().__init__(browser, browser_profile)
70
+ self._no_log = disable_log
71
+ self._output_dir = Path(output_dir or Path.cwd() / username)
72
+ self._output_dir.mkdir(parents=True, exist_ok=True)
73
+ self._log_db = Path(log_file or self._output_dir / '.log.db')
74
+ self._connection = sqlite3.connect(self._log_db)
75
+ self._cursor = self._connection.cursor()
76
+ self._setup_db()
77
+ self._username = username
78
+ self.should_save_comments = comments
79
+
80
+ def _setup_db(self) -> None:
81
+ if self._no_log:
82
+ return
83
+ existed = self._log_db.exists()
84
+ if not existed or (existed and self._log_db.stat().st_size == 0):
85
+ log.debug('Creating schema.')
86
+ self._cursor.execute(LOG_SCHEMA)
87
+
88
+ @override
89
+ def save_to_log(self, url: str) -> None:
90
+ if self._no_log:
91
+ return
92
+ self._cursor.execute('INSERT INTO log (url) VALUES (?)', (_clean_url(url),))
93
+ self._connection.commit()
94
+
95
+ @override
96
+ def is_saved(self, url: str) -> bool:
97
+ if self._no_log:
98
+ return False
99
+ self._cursor.execute('SELECT COUNT(url) FROM log WHERE url = ?', (_clean_url(url),))
100
+ count: int
101
+ count, = self._cursor.fetchone()
102
+ return count == 1
103
+
104
+ @override
105
+ def __exit__(self, _: type[BaseException] | None, __: BaseException | None,
106
+ ___: TracebackType | None) -> None:
107
+ """Clean up."""
108
+ self._cursor.close()
109
+ self._connection.close()
110
+
111
+ def process(self) -> None:
112
+ """Process posts."""
113
+ with chdir(self._output_dir):
114
+ self.get_text(f'https://www.instagram.com/{self._username}/')
115
+ self.add_csrf_token_header()
116
+ r = self.get_json('https://i.instagram.com/api/v1/users/web_profile_info/',
117
+ params={'username': self._username},
118
+ cast_to=WebProfileInfo)
119
+ with Path('web_profile_info.json').open('w', encoding='utf-8') as f:
120
+ json.dump(r, f, indent=2, sort_keys=True)
121
+ user_info = r['data']['user']
122
+ if not self.is_saved(user_info['profile_pic_url_hd']):
123
+ with Path('profile_pic.jpg').open('wb') as f:
124
+ f.writelines(
125
+ self.session.get(user_info['profile_pic_url_hd'],
126
+ stream=True).iter_content(chunk_size=512))
127
+ self.save_to_log(user_info['profile_pic_url_hd'])
128
+ try:
129
+ for item in self.highlights_tray(user_info['id'])['tray']:
130
+ self.add_video_url('https://www.instagram.com/stories/highlights/'
131
+ f'{item["id"].split(":")[-1]}/')
132
+ except HTTPError:
133
+ log.exception('Failed to get highlights data.')
134
+ self.save_edges(user_info['edge_owner_to_timeline_media']['edges'])
135
+ d = self.graphql_query(
136
+ {
137
+ 'data': {
138
+ 'count': 12,
139
+ 'include_reel_media_seen_timestamp': True,
140
+ 'include_relationship_info': True,
141
+ 'latest_besties_reel_media': True,
142
+ 'latest_reel_media': True
143
+ },
144
+ 'username': self._username,
145
+ '__relay_internal__pv__PolarisIsLoggedInrelayprovider': True,
146
+ '__relay_internal__pv__PolarisShareSheetV3relayprovider': True,
147
+ },
148
+ cast_to=XDTAPIV1FeedUserTimelineGraphQLConnectionContainer)
149
+ if not d:
150
+ log.error('First GraphQL query failed.')
151
+ else:
152
+ self.save_edges(d['xdt_api__v1__feed__user_timeline_graphql_connection']['edges'])
153
+ page_info = d['xdt_api__v1__feed__user_timeline_graphql_connection']['page_info']
154
+ while page_info['has_next_page']:
155
+ d = self.graphql_query(
156
+ {
157
+ 'after': page_info['end_cursor'],
158
+ 'before': None,
159
+ 'data': {
160
+ 'count': 12,
161
+ 'include_reel_media_seen_timestamp': True,
162
+ 'include_relationship_info': True,
163
+ 'latest_besties_reel_media': True,
164
+ 'latest_reel_media': True,
165
+ },
166
+ 'first': 12,
167
+ 'last': None,
168
+ 'username': self._username,
169
+ '__relay_internal__pv__PolarisIsLoggedInrelayprovider': True,
170
+ '__relay_internal__pv__PolarisShareSheetV3relayprovider': True,
171
+ },
172
+ cast_to=XDTAPIV1FeedUserTimelineGraphQLConnectionContainer)
173
+ if not d:
174
+ break
175
+ page_info = d['xdt_api__v1__feed__user_timeline_graphql_connection'][
176
+ 'page_info']
177
+ self.save_edges(
178
+ d['xdt_api__v1__feed__user_timeline_graphql_connection']['edges'])
179
+ if self.video_urls:
180
+ with get_configured_yt_dlp() as ydl:
181
+ while self.video_urls and (url := self.video_urls.pop()):
182
+ if self.is_saved(url):
183
+ log.info('`%s` is already saved.', url)
184
+ continue
185
+ if ydl.extract_info(url):
186
+ log.info('Extracting `%s`.', url)
187
+ self.save_to_log(url)
188
+ else:
189
+ self.failed_urls.add(url)
190
+ if self.failed_urls:
191
+ log.warning('Some video URIs failed. Check failed.txt.')
192
+ with Path('failed.txt').open('w', encoding='utf-8') as f:
193
+ for url in self.failed_urls:
194
+ f.write(f'{url}\n')
@@ -0,0 +1,79 @@
1
+ """Saved posts scraper."""
2
+ from __future__ import annotations
3
+
4
+ from contextlib import chdir
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Any
7
+ import logging
8
+
9
+ from .client import InstagramClient
10
+ from .constants import API_HEADERS, PAGE_FETCH_HEADERS
11
+ from .utils import SaveCommentsCheckDisabledMixin
12
+
13
+ if TYPE_CHECKING:
14
+
15
+ from collections.abc import Iterable
16
+
17
+ from .typing import BrowserName
18
+
19
+ __all__ = ('SavedScraper',)
20
+ log = logging.getLogger(__name__)
21
+
22
+
23
+ class SavedScraper(SaveCommentsCheckDisabledMixin, InstagramClient):
24
+ """Scrape saved posts."""
25
+ def __init__(
26
+ self,
27
+ browser: BrowserName = 'chrome',
28
+ browser_profile: str = 'Default',
29
+ output_dir: str | Path | None = None,
30
+ *,
31
+ comments: bool = False,
32
+ ) -> None:
33
+ """
34
+ Initialise ``SavedScraper``.
35
+
36
+ Parameters
37
+ ----------
38
+ browser : BrowserName
39
+ The browser to use.
40
+ browser_profile : str
41
+ The browser profile to use.
42
+ output_dir : str | Path | None
43
+ The output directory to save the posts to.
44
+ comments : bool
45
+ Whether to save comments or not.
46
+ """
47
+ super().__init__(browser, browser_profile)
48
+ self._output_dir = Path(output_dir or Path.cwd() / '@@saved-posts@@')
49
+ Path(self._output_dir).mkdir(parents=True, exist_ok=True)
50
+ self.should_save_comments = comments
51
+
52
+ def unsave(self, items: Iterable[str]) -> None:
53
+ """Unsave saved posts."""
54
+ for item in items:
55
+ log.info('Unsaving %s.', item)
56
+ self.session.post(f'https://www.instagram.com/web/save/{item}/unsave/',
57
+ headers=API_HEADERS)
58
+
59
+ def process(self, *, unsave: bool = False) -> None:
60
+ """Process the saved posts."""
61
+ with chdir(self._output_dir):
62
+ self.add_csrf_token_header()
63
+ self.session.get('https://www.instagram.com/', headers=PAGE_FETCH_HEADERS)
64
+ feed = self.get_json('https://www.instagram.com/api/v1/feed/saved/posts/',
65
+ cast_to=dict[str, Any])
66
+ self.save_edges({
67
+ 'node': {
68
+ '__typename': 'XDTMediaDict',
69
+ 'id': item['media']['id'],
70
+ 'code': item['media']['code'],
71
+ 'owner': item['media']['owner'],
72
+ 'pk': item['media']['pk'],
73
+ 'video_dash_manifest': item['media'].get('video_dash_manifest')
74
+ }
75
+ } for item in feed['items'])
76
+ if unsave:
77
+ self.unsave(item['media']['code'] for item in feed['items'])
78
+ if feed.get('more_available'):
79
+ log.warning('Unhandled pagination.')