Projects STRLCPY snscrape Commits 9af1f190
🤬
Revision indexing in progress... (symbol navigation in revisions will be accurate after indexed)
  • ■ ■ ■ ■ ■ ■
    snscrape/modules/twitter.py
    skipped 98 lines
    99 99  class Video(Medium):
    100 100   thumbnailUrl: str
    101 101   variants: typing.List[VideoVariant]
    102  - duration: float
     102 + duration: typing.Optional[float] = None
    103 103   views: typing.Optional[int] = None
    104 104   
    105 105   
    skipped 26 lines
    132 132   countryCode: str
    133 133   
    134 134   
     135 +class Card:
     136 + pass
     137 + 
     138 + 
    135 139  @dataclasses.dataclass
    136  -class Card:
     140 +class SummaryCard(Card):
    137 141   title: str
    138 142   url: str
    139 143   description: typing.Optional[str] = None
    140 144   thumbnailUrl: typing.Optional[str] = None
     145 + siteUser: typing.Optional['User'] = None
     146 + creatorUser: typing.Optional['User'] = None
     147 + 
     148 + 
     149 +@dataclasses.dataclass
     150 +class AppCard(SummaryCard):
     151 + pass
     152 + 
     153 + 
     154 +@dataclasses.dataclass
     155 +class PollCard(Card):
     156 + options: typing.List['PollOption']
     157 + endDate: datetime.datetime
     158 + duration: int
     159 + finalResults: bool
     160 + lastUpdateDate: typing.Optional[datetime.datetime] = None
     161 + medium: typing.Optional[Medium] = None
     162 + 
     163 + 
     164 +@dataclasses.dataclass
     165 +class PollOption:
     166 + label: str
     167 + count: typing.Optional[int] = None
     168 + 
     169 + 
     170 +@dataclasses.dataclass
     171 +class PlayerCard(Card):
     172 + title: str
     173 + url: str
     174 + description: typing.Optional[str] = None
     175 + imageUrl: typing.Optional[str] = None
     176 + siteUser: typing.Optional['User'] = None
     177 + 
     178 + 
     179 +@dataclasses.dataclass
     180 +class PromoConvoCard(Card):
     181 + actions: typing.List['PromoConvoAction']
     182 + thankYouText: str
     183 + medium: Medium
     184 + thankYouUrl: typing.Optional[str] = None
     185 + thankYouTcoUrl: typing.Optional[str] = None
     186 + cover: typing.Optional['Photo'] = None
     187 + 
     188 + 
     189 +@dataclasses.dataclass
     190 +class PromoConvoAction:
     191 + label: str
     192 + tweet: str
     193 + 
     194 + 
     195 +@dataclasses.dataclass
     196 +class BroadcastCard(Card):
     197 + id: str
     198 + url: str
     199 + title: str
     200 + state: str
     201 + source: str
     202 + thumbnailUrl: str
     203 + broadcaster: 'User'
     204 + siteUser: typing.Optional['User'] = None
     205 + 
     206 + 
     207 +@dataclasses.dataclass
     208 +class PeriscopeBroadcastCard(Card):
     209 + id: str
     210 + url: str
     211 + title: str
     212 + description: str
     213 + state: str
     214 + source: str
     215 + totalParticipants: int
     216 + thumbnailUrl: str
     217 + broadcaster: 'User'
     218 + siteUser: typing.Optional['User'] = None
     219 + 
     220 + 
     221 +@dataclasses.dataclass
     222 +class EventCard(Card):
     223 + event: 'Event'
     224 + 
     225 + 
     226 +@dataclasses.dataclass
     227 +class Event:
     228 + id: int
     229 + title: str
     230 + category: str
     231 + photo: Photo
     232 + description: typing.Optional[str] = None
     233 + 
     234 + @property
     235 + def url(self):
     236 + return f'https://twitter.com/i/events/{self.id}'
     237 + 
     238 + 
     239 +@dataclasses.dataclass
     240 +class NewsletterCard(Card):
     241 + title: str
     242 + description: str
     243 + imageUrl: str
     244 + url: str
     245 + revueAccountId: int
     246 + issueCount: int
     247 + 
     248 + 
     249 +@dataclasses.dataclass
     250 +class NewsletterIssueCard(Card):
     251 + newsletterTitle: str
     252 + newsletterDescription: str
     253 + issueTitle: str
     254 + issueDescription: str
     255 + issueNumber: int
     256 + url: str
     257 + revueAccountId: int
     258 + imageUrl: typing.Optional[str] = None
     259 + 
     260 + 
     261 +@dataclasses.dataclass
     262 +class AmplifyCard(Card):
     263 + id: str
     264 + video: Video
     265 + 
     266 + 
     267 +@dataclasses.dataclass
     268 +class AppPlayerCard(Card):
     269 + title: str
     270 + video: Video
     271 + appCategory: str
     272 + playerOwnerId: int
     273 + siteUser: typing.Optional['User'] = None
     274 + 
     275 + 
     276 +@dataclasses.dataclass
     277 +class SpacesCard(Card):
     278 + url: str
     279 + id: str
     280 + 
     281 + 
     282 +UnifiedCardComponentKey = str
     283 +UnifiedCardDestinationKey = str
     284 +UnifiedCardMediumKey = str
     285 +UnifiedCardAppKey = str
     286 + 
     287 + 
     288 +@dataclasses.dataclass
     289 +class UnifiedCard(Card):
     290 + componentObjects: typing.Dict[UnifiedCardComponentKey, 'UnifiedCardComponentObject']
     291 + destinations: typing.Dict[UnifiedCardDestinationKey, 'UnifiedCardDestination']
     292 + media: typing.Dict[UnifiedCardMediumKey, Medium]
     293 + apps: typing.Optional[typing.Dict[UnifiedCardAppKey, typing.List['UnifiedCardApp']]] = None
     294 + components: typing.Optional[typing.List[UnifiedCardComponentKey]] = None
     295 + swipeableLayoutSlides: typing.Optional[typing.List['UnifiedCardSwipeableLayoutSlide']] = None
     296 + type: typing.Optional[str] = None
     297 + 
     298 + def __post_init__(self):
     299 + if (self.components is None) == (self.swipeableLayoutSlides is None):
     300 + raise ValueError('did not get exactly one of components or swipeableLayoutSlides')
     301 + if self.components and not all(k in self.componentObjects for k in self.components):
     302 + raise ValueError('missing components')
     303 + if self.swipeableLayoutSlides and not all(s.mediumComponentKey in self.componentObjects and s.componentKey in self.componentObjects for s in self.swipeableLayoutSlides):
     304 + raise ValueError('missing components')
     305 + if any(c.destinationKey not in self.destinations for c in self.componentObjects.values() if hasattr(c, 'destinationKey')):
     306 + raise ValueError('missing destinations')
     307 + if any(b.destinationKey not in self.destinations for c in self.componentObjects.values() if isinstance(c, UnifiedCardButtonGroupComponentObject) for b in c.buttons):
     308 + raise ValueError('missing destinations')
     309 + mediaKeys = []
     310 + for c in self.componentObjects.values():
     311 + if isinstance(c, UnifiedCardMediumComponentObject):
     312 + mediaKeys.append(c.mediumKey)
     313 + elif isinstance(c, UnifiedCardSwipeableMediaComponentObject):
     314 + mediaKeys.extend(x.mediumKey for x in c.media)
     315 + mediaKeys.extend(d.mediumKey for d in self.destinations.values() if d.mediumKey is not None)
     316 + mediaKeys.extend(a.iconMediumKey for l in (self.apps.values() if self.apps is not None else []) for a in l if a.iconMediumKey is not None)
     317 + if any(k not in self.media for k in mediaKeys):
     318 + raise ValueError('missing media')
     319 + if any(c.appKey not in self.apps for c in self.componentObjects.values() if hasattr(c, 'appKey')):
     320 + raise ValueError('missing apps')
     321 + if any(d.appKey not in self.apps for d in self.destinations.values() if d.appKey is not None):
     322 + raise ValueError('missing apps')
     323 + 
     324 + 
     325 +class UnifiedCardComponentObject:
     326 + pass
     327 + 
     328 + 
     329 +@dataclasses.dataclass
     330 +class UnifiedCardDetailComponentObject(UnifiedCardComponentObject):
     331 + content: str
     332 + destinationKey: UnifiedCardDestinationKey
     333 + 
     334 + 
     335 +@dataclasses.dataclass
     336 +class UnifiedCardMediumComponentObject(UnifiedCardComponentObject):
     337 + mediumKey: UnifiedCardMediumKey
     338 + destinationKey: UnifiedCardDestinationKey
     339 + 
     340 + 
     341 +@dataclasses.dataclass
     342 +class UnifiedCardButtonGroupComponentObject(UnifiedCardComponentObject):
     343 + buttons: typing.List['UnifiedCardButton']
     344 + 
     345 + 
     346 +@dataclasses.dataclass
     347 +class UnifiedCardButton:
     348 + text: str
     349 + destinationKey: UnifiedCardDestinationKey
     350 + 
     351 + 
     352 +@dataclasses.dataclass
     353 +class UnifiedCardSwipeableMediaComponentObject(UnifiedCardComponentObject):
     354 + media: typing.List['UnifiedCardSwipeableMediaMedium']
     355 + 
     356 + 
     357 +@dataclasses.dataclass
     358 +class UnifiedCardSwipeableMediaMedium:
     359 + mediumKey: UnifiedCardMediumKey
     360 + destinationKey: UnifiedCardDestinationKey
     361 + 
     362 + 
     363 +@dataclasses.dataclass
     364 +class UnifiedCardAppStoreComponentObject(UnifiedCardComponentObject):
     365 + appKey: UnifiedCardAppKey
     366 + destinationKey: UnifiedCardDestinationKey
     367 + 
     368 + 
     369 +@dataclasses.dataclass
     370 +class UnifiedCardTwitterListDetailsComponentObject(UnifiedCardComponentObject):
     371 + name: str
     372 + memberCount: int
     373 + subscriberCount: int
     374 + user: 'User'
     375 + destinationKey: UnifiedCardDestinationKey
     376 + 
     377 + 
     378 +@dataclasses.dataclass
     379 +class UnifiedCardDestination:
     380 + url: typing.Optional[str] = None
     381 + appKey: typing.Optional[UnifiedCardAppKey] = None
     382 + mediumKey: typing.Optional[UnifiedCardMediumKey] = None
     383 + 
     384 + def __post_init__(self):
     385 + if (self.url is None) == (self.appKey is None):
     386 + raise ValueError('did not get exactly one of url and appKey')
     387 + 
     388 + 
     389 +@dataclasses.dataclass
     390 +class UnifiedCardApp:
     391 + type: str
     392 + id: str
     393 + title: str
     394 + category: str
     395 + countryCode: str
     396 + url: str
     397 + description: typing.Optional[str] = None
     398 + iconMediumKey: typing.Optional[UnifiedCardMediumKey] = None
     399 + size: typing.Optional[int] = None
     400 + installs: typing.Optional[int] = None
     401 + ratingAverage: typing.Optional[float] = None
     402 + ratingCount: typing.Optional[int] = None
     403 + isFree: typing.Optional[bool] = None
     404 + isEditorsChoice: typing.Optional[bool] = None
     405 + hasInAppPurchases: typing.Optional[bool] = None
     406 + hasInAppAds: typing.Optional[bool] = None
     407 + 
     408 + 
     409 +@dataclasses.dataclass
     410 +class UnifiedCardSwipeableLayoutSlide:
     411 + mediumComponentKey: UnifiedCardComponentKey
     412 + componentKey: UnifiedCardComponentKey
    141 413   
    142 414   
    143 415  @dataclasses.dataclass
    skipped 46 lines
    190 462   url: typing.Optional[str] = None
    191 463   badgeUrl: typing.Optional[str] = None
    192 464   longDescription: typing.Optional[str] = None
     465 + 
     466 + 
     467 +@dataclasses.dataclass
     468 +class UserRef:
     469 + id: int
    193 470   
    194 471   
    195 472  @dataclasses.dataclass
    skipped 314 lines
    510 787   raise snscrape.base.ScraperException(f'Unable to handle entry {entryId!r}')
    511 788   yield self._tweet_to_tweet(tweet, obj)
    512 789   
     790 + def _get_tweet_id(self, tweet):
     791 + return tweet['id'] if 'id' in tweet else int(tweet['id_str'])
     792 + 
    513 793   def _make_tweet(self, tweet, user, retweetedTweet = None, quotedTweet = None, card = None):
    514 794   kwargs = {}
    515  - kwargs['id'] = tweet['id'] if 'id' in tweet else int(tweet['id_str'])
     795 + kwargs['id'] = self._get_tweet_id(tweet)
    516 796   kwargs['content'] = tweet['full_text']
    517 797   kwargs['renderedContent'] = self._render_text_with_urls(tweet['full_text'], tweet['entities'].get('urls'))
    518 798   kwargs['user'] = user
    skipped 16 lines
    535 815   if 'extended_entities' in tweet and 'media' in tweet['extended_entities']:
    536 816   media = []
    537 817   for medium in tweet['extended_entities']['media']:
    538  - if medium['type'] == 'photo':
    539  - if '.' not in medium['media_url_https']:
    540  - _logger.warning(f'Skipping malformed medium URL on tweet {kwargs["id"]}: {medium["media_url_https"]!r} contains no dot')
    541  - continue
    542  - baseUrl, format = medium['media_url_https'].rsplit('.', 1)
    543  - if format not in ('jpg', 'png'):
    544  - _logger.warning(f'Skipping photo with unknown format on tweet {kwargs["id"]}: {format!r}')
    545  - continue
    546  - media.append(Photo(
    547  - previewUrl = f'{baseUrl}?format={format}&name=small',
    548  - fullUrl = f'{baseUrl}?format={format}&name=large',
    549  - ))
    550  - elif medium['type'] == 'video' or medium['type'] == 'animated_gif':
    551  - variants = []
    552  - for variant in medium['video_info']['variants']:
    553  - variants.append(VideoVariant(contentType = variant['content_type'], url = variant['url'], bitrate = variant.get('bitrate')))
    554  - mKwargs = {
    555  - 'thumbnailUrl': medium['media_url_https'],
    556  - 'variants': variants,
    557  - }
    558  - if medium['type'] == 'video':
    559  - mKwargs['duration'] = medium['video_info']['duration_millis'] / 1000
    560  - if (ext := medium.get('ext')) and (mediaStats := ext['mediaStats']) and isinstance(r := mediaStats['r'], dict) and 'ok' in r and isinstance(r['ok'], dict):
    561  - mKwargs['views'] = int(r['ok']['viewCount'])
    562  - elif (mediaStats := medium.get('mediaStats')):
    563  - mKwargs['views'] = mediaStats['viewCount']
    564  - cls = Video
    565  - elif medium['type'] == 'animated_gif':
    566  - cls = Gif
    567  - media.append(cls(**mKwargs))
     818 + if (mediumO := self._make_medium(medium, kwargs['id'])):
     819 + media.append(mediumO)
    568 820   if media:
    569 821   kwargs['media'] = media
    570 822   if retweetedTweet:
    skipped 34 lines
    605 857   kwargs['cashtags'] = [o['text'] for o in tweet['entities']['symbols']]
    606 858   if card:
    607 859   kwargs['card'] = card
    608  - # Try to convert the URL to the non-shortened/t.co one
    609  - try:
    610  - i = kwargs['tcooutlinks'].index(card.url)
    611  - except ValueError:
    612  - _logger.warning('Could not find card URL in tcooutlinks')
    613  - else:
    614  - card.url = kwargs['outlinks'][i]
     860 + if hasattr(card, 'url') and '//t.co/' in card.url and 'tcooutlinks' in kwargs:
     861 + # Try to convert the URL to the non-shortened/t.co one
     862 + try:
     863 + i = kwargs['tcooutlinks'].index(card.url)
     864 + except ValueError:
     865 + _logger.warning('Could not find card URL in tcooutlinks')
     866 + else:
     867 + card.url = kwargs['outlinks'][i]
    615 868   return Tweet(**kwargs)
    616 869   
    617  - def _make_card(self, card, apiType):
    618  - cardKwargs = {}
    619  - for key, kwarg in [('title', 'title'), ('description', 'description'), ('card_url', 'url'), ('thumbnail_image_original', 'thumbnailUrl')]:
    620  - if apiType is _TwitterAPIType.V2:
    621  - value = card['binding_values'].get(key)
    622  - elif apiType is _TwitterAPIType.GRAPHQL:
    623  - value = next((o['value'] for o in card['legacy']['binding_values'] if o['key'] == key), None)
    624  - if not value:
     870 + def _make_medium(self, medium, tweetId):
     871 + if medium['type'] == 'photo':
     872 + if '?format=' in medium['media_url_https'] or '&format=' in medium['media_url_https']:
     873 + return Photo(previewUrl = medium['media_url_https'], fullUrl = medium['media_url_https'])
     874 + if '.' not in medium['media_url_https']:
     875 + _logger.warning(f'Skipping malformed medium URL on tweet {tweetId}: {medium["media_url_https"]!r} contains no dot')
     876 + return
     877 + baseUrl, format = medium['media_url_https'].rsplit('.', 1)
     878 + if format not in ('jpg', 'png'):
     879 + _logger.warning(f'Skipping photo with unknown format on tweet {tweetId}: {format!r}')
     880 + return
     881 + return Photo(
     882 + previewUrl = f'{baseUrl}?format={format}&name=small',
     883 + fullUrl = f'{baseUrl}?format={format}&name=large',
     884 + )
     885 + elif medium['type'] == 'video' or medium['type'] == 'animated_gif':
     886 + variants = []
     887 + for variant in medium['video_info']['variants']:
     888 + variants.append(VideoVariant(contentType = variant['content_type'], url = variant['url'], bitrate = variant.get('bitrate')))
     889 + mKwargs = {
     890 + 'thumbnailUrl': medium['media_url_https'],
     891 + 'variants': variants,
     892 + }
     893 + if medium['type'] == 'video':
     894 + mKwargs['duration'] = medium['video_info']['duration_millis'] / 1000
     895 + if (ext := medium.get('ext')) and (mediaStats := ext.get('mediaStats')) and isinstance(r := mediaStats['r'], dict) and 'ok' in r and isinstance(r['ok'], dict):
     896 + mKwargs['views'] = int(r['ok']['viewCount'])
     897 + elif (mediaStats := medium.get('mediaStats')):
     898 + mKwargs['views'] = mediaStats['viewCount']
     899 + cls = Video
     900 + elif medium['type'] == 'animated_gif':
     901 + cls = Gif
     902 + return cls(**mKwargs)
     903 + else:
     904 + _logger.warning(f'Unsupported medium type on tweet {tweetId}: {medium["type"]!r}')
     905 + 
     906 + def _make_card(self, card, apiType, tweetId):
     907 + bindingValues = {}
     908 + 
     909 + def _kwargs_from_map(keyKwargMap):
     910 + nonlocal bindingValues
     911 + return {kwarg: bindingValues[key] for key, kwarg in keyKwargMap.items() if key in bindingValues}
     912 + 
     913 + userRefs = {}
     914 + if apiType is _TwitterAPIType.V2:
     915 + for o in card.get('users', {}).values():
     916 + userId = o['id']
     917 + assert userId not in userRefs
     918 + userRefs[userId] = self._user_to_user(o)
     919 + elif apiType is _TwitterAPIType.GRAPHQL:
     920 + for o in card['legacy'].get('user_refs', {}):
     921 + userId = int(o['rest_id'])
     922 + if userId in userRefs:
     923 + _logger.warning(f'Duplicate user {userId} in card on tweet {tweetId}')
     924 + continue
     925 + if 'legacy' in o:
     926 + userRefs[userId] = self._user_to_user(o['legacy'], id_ = userId)
     927 + else:
     928 + userRefs[userId] = UserRef(id = userId)
     929 + 
     930 + if apiType is _TwitterAPIType.V2:
     931 + messyBindingValues = card['binding_values'].items()
     932 + elif apiType is _TwitterAPIType.GRAPHQL:
     933 + messyBindingValues = ((x['key'], x['value']) for x in card['legacy']['binding_values'])
     934 + for key, value in messyBindingValues:
     935 + if 'type' not in value:
     936 + # Silently ignore creator/site entries since they frequently appear like this.
     937 + if key not in ('creator', 'site'):
     938 + _logger.warning(f'Skipping type-less card value {key!r} on tweet {tweetId}')
    625 939   continue
    626 940   if value['type'] == 'STRING':
    627  - cardKwargs[kwarg] = value['string_value']
     941 + bindingValues[key] = value['string_value']
     942 + if key.endswith('_datetime_utc'):
     943 + bindingValues[key] = datetime.datetime.strptime(bindingValues[key], '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo = datetime.timezone.utc)
    628 944   elif value['type'] == 'IMAGE':
    629  - cardKwargs[kwarg] = value['image_value']['url']
     945 + bindingValues[key] = value['image_value']['url']
     946 + elif value['type'] == 'IMAGE_COLOR':
     947 + # Silently discard this.
     948 + pass
     949 + elif value['type'] == 'BOOLEAN':
     950 + bindingValues[key] = value['boolean_value']
     951 + elif value['type'] == 'USER':
     952 + bindingValues[key] = userRefs[int(value['user_value']['id_str'])]
    630 953   else:
    631  - raise snscrape.base.ScraperError(f'Unknown card value type: {value["type"]!r}')
    632  - return Card(**cardKwargs)
     954 + _logger.warning(f'Unsupported card value type on {key!r} on tweet {tweetId}: {value["type"]!r}')
     955 + 
     956 + if apiType is _TwitterAPIType.V2:
     957 + cardName = card['name']
     958 + elif apiType is _TwitterAPIType.GRAPHQL:
     959 + cardName = card['legacy']['name']
     960 + 
     961 + if cardName in ('summary', 'summary_large_image', 'app', 'direct_store_link_app'):
     962 + keyKwargMap = {
     963 + 'title': 'title',
     964 + 'description': 'description',
     965 + 'card_url': 'url',
     966 + 'site': 'siteUser',
     967 + 'creator': 'creatorUser',
     968 + }
     969 + if cardName in ('app', 'direct_store_link_app'):
     970 + keyKwargMap['thumbnail_original'] = 'thumbnailUrl'
     971 + return AppCard(**_kwargs_from_map(keyKwargMap))
     972 + else:
     973 + keyKwargMap['thumbnail_image_original'] = 'thumbnailUrl'
     974 + return SummaryCard(**_kwargs_from_map(keyKwargMap))
     975 + elif any(cardName.startswith(x) for x in ('poll2choice_', 'poll3choice_', 'poll4choice_')) and cardName.split('_', 1)[1] in ('text_only', 'image', 'video'):
     976 + kwargs = _kwargs_from_map({'end_datetime_utc': 'endDate', 'last_updated_datetime_utc': 'lastUpdateDate', 'duration_minutes': 'duration', 'counts_are_final': 'finalResults'})
     977 + 
     978 + options = []
     979 + for key in sorted(bindingValues):
     980 + if key.startswith('choice') and key.endswith('_label'):
     981 + optKwargs = {'label': bindingValues[key]}
     982 + if (count := bindingValues.get(f'{key[:-5]}count')):
     983 + optKwargs['count'] = int(count)
     984 + options.append(PollOption(**optKwargs))
     985 + kwargs['options'] = options
     986 + kwargs['duration'] = int(kwargs['duration'])
     987 + 
     988 + if cardName.endswith('_image'):
     989 + kwargs['medium'] = Photo(previewUrl = bindingValues['image_small'], fullUrl = bindingValues['image_original'])
     990 + elif cardName.endswith('_video'):
     991 + variants = []
     992 + variants.append(VideoVariant(contentType = 'application/x-mpegurl', url = bindingValues['player_hls_url'], bitrate = None))
     993 + if 'vmap' not in bindingValues['player_stream_url']:
     994 + _logger.warning(f'Non-VMAP URL in {cardName} player_stream_url on tweet {tweetId}')
     995 + variants.append(VideoVariant(contentType = 'text/xml', url = bindingValues['player_stream_url'], bitrate = None))
     996 + kwargs['medium'] = Video(thumbnailUrl = bindingValues['player_image_original'], variants = variants, duration = int(bindingValues['content_duration_seconds']))
     997 + 
     998 + return PollCard(**kwargs)
     999 + elif cardName == 'player':
     1000 + return PlayerCard(**_kwargs_from_map({'title': 'title', 'description': 'description', 'card_url': 'url', 'player_image_original': 'imageUrl', 'site': 'siteUser'}))
     1001 + elif cardName in ('promo_image_convo', 'promo_video_convo'):
     1002 + kwargs = _kwargs_from_map({'thank_you_text': 'thankYouText', 'thank_you_url': 'thankYouUrl', 'thank_you_shortened_url': 'thankYouTcoUrl'})
     1003 + kwargs['actions'] = []
     1004 + for l in ('one', 'two', 'three', 'four'):
     1005 + if f'cta_{l}' in bindingValues:
     1006 + kwargs['actions'].append(PromoConvoAction(label = bindingValues[f'cta_{l}'], tweet = bindingValues[f'cta_{l}_tweet']))
     1007 + if 'image' in cardName:
     1008 + kwargs['medium'] = Photo(previewUrl = bindingValues['promo_image_small'], fullUrl = bindingValues['promo_image_original'])
     1009 + if 'cover_promo_image' in bindingValues:
     1010 + kwargs['cover'] = Photo(previewUrl = bindingValues['cover_promo_image_small'], fullUrl = bindingValues['cover_promo_image_original'])
     1011 + elif 'video' in cardName:
     1012 + variants = []
     1013 + variants.append(VideoVariant(contentType = bindingValues['player_stream_content_type'], url = bindingValues['player_stream_url'], bitrate = None))
     1014 + if bindingValues['player_stream_url'] != bindingValues['player_url']:
     1015 + if 'vmap' not in bindingValues['player_url']:
     1016 + _logger.warning(f'Non-VMAP URL in {cardName} player_url on tweet {tweetId}')
     1017 + variants.append(VideoVariant(contentType = 'text/xml', url = bindingValues['player_url'], bitrate = None))
     1018 + kwargs['medium'] = Video(thumbnailUrl = bindingValues['player_image_original'], variants = variants, duration = int(bindingValues['content_duration_seconds']))
     1019 + return PromoConvoCard(**kwargs)
     1020 + elif cardName in ('745291183405076480:broadcast', '3691233323:periscope_broadcast'):
     1021 + keyKwargMap = {'broadcast_state': 'state', 'broadcast_source': 'source', 'site': 'siteUser'}
     1022 + if cardName == '745291183405076480:broadcast':
     1023 + keyKwargMap = {**keyKwargMap, 'broadcast_id': 'id', 'broadcast_url': 'url', 'broadcast_title': 'title', 'broadcast_thumbnail_original': 'thumbnailUrl'}
     1024 + else:
     1025 + keyKwargMap = {**keyKwargMap, 'id': 'id', 'url': 'url', 'title': 'title', 'description': 'description', 'total_participants': 'totalParticipants', 'thumbnail_original': 'thumbnailUrl'}
     1026 + kwargs = _kwargs_from_map(keyKwargMap)
     1027 + kwargs['broadcaster'] = User(id = int(bindingValues['broadcaster_twitter_id']), username = bindingValues['broadcaster_username'], displayname = bindingValues['broadcaster_display_name'])
     1028 + if 'siteUser' not in kwargs:
     1029 + kwargs['siteUser'] = None
     1030 + if cardName == '745291183405076480:broadcast':
     1031 + return BroadcastCard(**kwargs)
     1032 + else:
     1033 + kwargs['totalParticipants'] = int(kwargs['totalParticipants'])
     1034 + return PeriscopeBroadcastCard(**kwargs)
     1035 + elif cardName == '745291183405076480:live_event':
     1036 + kwargs = _kwargs_from_map({'event_id': 'id', 'event_title': 'title', 'event_category': 'category', 'event_subtitle': 'description'})
     1037 + kwargs['id'] = int(kwargs['id'])
     1038 + kwargs['photo'] = Photo(previewUrl = bindingValues['event_thumbnail_small'], fullUrl = bindingValues['event_thumbnail_original'])
     1039 + return EventCard(event = Event(**kwargs))
     1040 + elif cardName == '3337203208:newsletter_publication':
     1041 + kwargs = _kwargs_from_map({'newsletter_title': 'title', 'newsletter_description': 'description', 'newsletter_image_original': 'imageUrl', 'card_url': 'url', 'revue_account_id': 'revueAccountId', 'issue_count': 'issueCount'})
     1042 + kwargs['revueAccountId'] = int(kwargs['revueAccountId'])
     1043 + kwargs['issueCount'] = int(kwargs['issueCount'])
     1044 + return NewsletterCard(**kwargs)
     1045 + elif cardName == '3337203208:newsletter_issue':
     1046 + kwargs = _kwargs_from_map({
     1047 + 'newsletter_title': 'newsletterTitle',
     1048 + 'newsletter_description': 'newsletterDescription',
     1049 + 'issue_title': 'issueTitle',
     1050 + 'issue_description': 'issueDescription',
     1051 + 'issue_number': 'issueNumber',
     1052 + 'issue_image_original': 'imageUrl',
     1053 + 'card_url': 'url',
     1054 + 'revue_account_id': 'revueAccountId'
     1055 + })
     1056 + kwargs['issueNumber'] = int(kwargs['issueNumber'])
     1057 + kwargs['revueAccountId'] = int(kwargs['revueAccountId'])
     1058 + return NewsletterIssueCard(**kwargs)
     1059 + elif cardName == 'amplify':
     1060 + return AmplifyCard(
     1061 + id = bindingValues['amplify_content_id'],
     1062 + video = Video(
     1063 + thumbnailUrl = bindingValues['player_image'],
     1064 + variants = [VideoVariant(contentType = bindingValues['player_stream_content_type'], url = bindingValues['amplify_url_vmap'], bitrate = None)],
     1065 + ),
     1066 + )
     1067 + elif cardName == 'appplayer':
     1068 + kwargs = _kwargs_from_map({'title': 'title', 'app_category': 'appCategory', 'player_owner_id': 'playerOwnerId', 'site': 'siteUser'})
     1069 + kwargs['playerOwnerId'] = int(kwargs['playerOwnerId'])
     1070 + variants = []
     1071 + variants.append(VideoVariant(contentType = 'application/x-mpegurl', url = bindingValues['player_hls_url'], bitrate = None))
     1072 + if 'vmap' not in bindingValues['player_url']:
     1073 + _logger.warning(f'Non-VMAP URL in {cardName} player_url on tweet {tweetId}')
     1074 + variants.append(VideoVariant(contentType = 'text/xml', url = bindingValues['player_url'], bitrate = None))
     1075 + kwargs['video'] = Video(thumbnailUrl = bindingValues['player_image_original'], variants = variants, duration = int(bindingValues['content_duration_seconds']))
     1076 + return AppPlayerCard(**kwargs)
     1077 + elif cardName == '3691233323:audiospace':
     1078 + return SpacesCard(**_kwargs_from_map({'card_url': 'url', 'id': 'id'}))
     1079 + elif cardName == 'unified_card':
     1080 + o = json.loads(bindingValues['unified_card'])
     1081 + kwargs = {}
     1082 + if 'type' in o:
     1083 + unifiedCardType = o.get('type')
     1084 + if unifiedCardType not in (
     1085 + 'image_app',
     1086 + 'image_carousel_app',
     1087 + 'image_carousel_website',
     1088 + 'image_multi_dest_carousel_website',
     1089 + 'image_website',
     1090 + 'mixed_media_multi_dest_carousel_website',
     1091 + 'mixed_media_single_dest_carousel_app',
     1092 + 'mixed_media_single_dest_carousel_website',
     1093 + 'video_app',
     1094 + 'video_carousel_app',
     1095 + 'video_carousel_website',
     1096 + 'video_multi_dest_carousel_website',
     1097 + 'video_website',
     1098 + ):
     1099 + _logger.warning(f'Unsupported unified_card type on tweet {tweetId}: {unifiedCardType!r}')
     1100 + return
     1101 + kwargs['type'] = unifiedCardType
     1102 + elif set(c['type'] for c in o['component_objects'].values()) != {'media', 'twitter_list_details'}:
     1103 + _logger.warning(f'Unsupported unified_card type on tweet {tweetId}')
     1104 + return
     1105 + 
     1106 + kwargs['componentObjects'] = {}
     1107 + for k, v in o['component_objects'].items():
     1108 + if v['type'] == 'details':
     1109 + co = UnifiedCardDetailComponentObject(content = v['data']['title']['content'], destinationKey = v['data']['destination'])
     1110 + elif v['type'] == 'media':
     1111 + co = UnifiedCardMediumComponentObject(mediumKey = v['data']['id'], destinationKey = v['data']['destination'])
     1112 + elif v['type'] == 'button_group':
     1113 + if not all(b['type'] == 'cta' for b in v['data']['buttons']):
     1114 + _logger.warning(f'Unsupported unified_card button_group button type on tweet {tweetId}')
     1115 + return
     1116 + buttons = [UnifiedCardButton(text = b['action'][0].upper() + re.sub('[A-Z]', lambda x: f' {x[0]}', b['action'][1:]), destinationKey = b['destination']) for b in v['data']['buttons']]
     1117 + co = UnifiedCardButtonGroupComponentObject(buttons = buttons)
     1118 + elif v['type'] == 'swipeable_media':
     1119 + media = [UnifiedCardSwipeableMediaMedium(mediumKey = m['id'], destinationKey = m['destination']) for m in v['data']['media_list']]
     1120 + co = UnifiedCardSwipeableMediaComponentObject(media = media)
     1121 + elif v['type'] == 'app_store_details':
     1122 + co = UnifiedCardAppStoreComponentObject(appKey = v['data']['app_id'], destinationKey = v['data']['destination'])
     1123 + elif v['type'] == 'twitter_list_details':
     1124 + co = UnifiedCardTwitterListDetailsComponentObject(
     1125 + name = v['data']['name']['content'],
     1126 + memberCount = v['data']['member_count'],
     1127 + subscriberCount = v['data']['subscriber_count'],
     1128 + user = self._user_to_user(o['users'][v['data']['user_id']]),
     1129 + destinationKey = v['data']['destination'],
     1130 + )
     1131 + else:
     1132 + _logger.warning(f'Unsupported unified_card component type on tweet {tweetId}: {v["type"]!r}')
     1133 + return
     1134 + kwargs['componentObjects'][k] = co
     1135 + 
     1136 + kwargs['destinations'] = {}
     1137 + for k, v in o['destination_objects'].items():
     1138 + dKwargs = {}
     1139 + if 'url_data' in v['data']:
     1140 + dKwargs['url'] = v['data']['url_data']['url']
     1141 + if 'app_id' in v['data']:
     1142 + dKwargs['appKey'] = v['data']['app_id']
     1143 + if 'media_id' in v['data']:
     1144 + dKwargs['mediumKey'] = v['data']['media_id']
     1145 + kwargs['destinations'][k] = UnifiedCardDestination(**dKwargs)
     1146 + 
     1147 + kwargs['media'] = {}
     1148 + for k, v in o['media_entities'].items():
     1149 + if (medium := self._make_medium(v, tweetId)):
     1150 + kwargs['media'][k] = medium
     1151 + 
     1152 + if 'app_store_data' in o:
     1153 + kwargs['apps'] = {}
     1154 + for k, v in o['app_store_data'].items():
     1155 + variants = []
     1156 + for var in v:
     1157 + vKwargsMap = {
     1158 + 'type': 'type',
     1159 + 'id': 'id',
     1160 + 'icon_media_key': 'iconMediumKey',
     1161 + 'country_code': 'countryCode',
     1162 + 'num_installs': 'installs',
     1163 + 'size_bytes': 'size',
     1164 + 'is_free': 'isFree',
     1165 + 'is_editors_choice': 'isEditorsChoice',
     1166 + 'has_in_app_purchases': 'hasInAppPurchases',
     1167 + 'has_in_app_ads': 'hasInAppAds',
     1168 + }
     1169 + vKwargs = {kwarg: var[key] for key, kwarg in vKwargsMap.items() if key in var}
     1170 + vKwargs['title'] = var['title']['content']
     1171 + if 'description' in var:
     1172 + vKwargs['description'] = var['description']['content']
     1173 + vKwargs['category'] = var['category']['content']
     1174 + if (ratings := var['ratings']):
     1175 + vKwargs['ratingAverage'] = var['ratings']['star']
     1176 + vKwargs['ratingCount'] = var['ratings']['count']
     1177 + vKwargs['url'] = f'https://play.google.com/store/apps/details?id={var["id"]}' if var['type'] == 'android_app' else f'https://itunes.apple.com/app/id{var["id"]}'
     1178 + variants.append(UnifiedCardApp(**vKwargs))
     1179 + kwargs['apps'][k] = variants
     1180 + 
     1181 + if o['components']:
     1182 + kwargs['components'] = o['components']
     1183 + 
     1184 + if 'layout' in o:
     1185 + if o['layout']['type'] != 'swipeable':
     1186 + _logger.warning(f'Unsupported unified_card layout type on tweet {tweetId}: {o["layout"]["type"]!r}')
     1187 + return
     1188 + kwargs['swipeableLayoutSlides'] = [UnifiedCardSwipeableLayoutSlide(mediumComponentKey = v[0], componentKey = v[1]) for v in o['layout']['data']['slides']]
     1189 + 
     1190 + return UnifiedCard(**kwargs)
     1191 + 
     1192 + _logger.warning(f'Unsupported card type on tweet {tweetId}: {cardName!r}')
    633 1193   
    634 1194   def _tweet_to_tweet(self, tweet, obj):
    635 1195   user = self._user_to_user(obj['globalObjects']['users'][tweet['user_id_str']])
    skipped 3 lines
    639 1199   if 'quoted_status_id_str' in tweet and tweet['quoted_status_id_str'] in obj['globalObjects']['tweets']:
    640 1200   kwargs['quotedTweet'] = self._tweet_to_tweet(obj['globalObjects']['tweets'][tweet['quoted_status_id_str']], obj)
    641 1201   if 'card' in tweet:
    642  - kwargs['card'] = self._make_card(tweet['card'], _TwitterAPIType.V2)
     1202 + kwargs['card'] = self._make_card(tweet['card'], _TwitterAPIType.V2, self._get_tweet_id(tweet))
    643 1203   return self._make_tweet(tweet, user, **kwargs)
    644 1204   
    645 1205   def _graphql_timeline_tweet_item_result_to_tweet(self, result):
    skipped 23 lines
    669 1229   elif 'quoted_status_id_str' in tweet:
    670 1230   kwargs['quotedTweet'] = TweetRef(id = int(tweet['quoted_status_id_str']))
    671 1231   if 'card' in result:
    672  - kwargs['card'] = self._make_card(result['card'], _TwitterAPIType.GRAPHQL)
     1232 + kwargs['card'] = self._make_card(result['card'], _TwitterAPIType.GRAPHQL, self._get_tweet_id(tweet))
    673 1233   return self._make_tweet(tweet, user, **kwargs)
    674 1234   
    675 1235   def _graphql_timeline_instructions_to_tweets(self, instructions, includeConversationThreads = False):
    skipped 435 lines
Please wait...
Page is in error, reload to recover