nitter.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. import random
  2. import re
  3. import urllib.parse
  4. from .common import InfoExtractor
  5. from ..utils import (
  6. determine_ext,
  7. parse_count,
  8. remove_end,
  9. unified_timestamp,
  10. )
  11. class NitterIE(InfoExtractor):
  12. # Taken from https://github.com/zedeus/nitter/wiki/Instances
  13. NON_HTTP_INSTANCES = (
  14. '3nzoldnxplag42gqjs23xvghtzf6t6yzssrtytnntc6ppc7xxuoneoad.onion',
  15. 'nitter.l4qlywnpwqsluw65ts7md3khrivpirse744un3x7mlskqauz5pyuzgqd.onion',
  16. 'nitter7bryz3jv7e3uekphigvmoyoem4al3fynerxkj22dmoxoq553qd.onion',
  17. 'npf37k3mtzwxreiw52ccs5ay4e6qt2fkcs2ndieurdyn2cuzzsfyfvid.onion',
  18. 'nitter.v6vgyqpa7yefkorazmg5d5fimstmvm2vtbirt6676mt7qmllrcnwycqd.onion',
  19. 'i23nv6w3juvzlw32xzoxcqzktegd4i4fu3nmnc2ewv4ggiu4ledwklad.onion',
  20. '26oq3gioiwcmfojub37nz5gzbkdiqp7fue5kvye7d4txv4ny6fb4wwid.onion',
  21. 'vfaomgh4jxphpbdfizkm5gbtjahmei234giqj4facbwhrfjtcldauqad.onion',
  22. 'iwgu3cv7ywf3gssed5iqtavmrlszgsxazkmwwnt4h2kdait75thdyrqd.onion',
  23. 'erpnncl5nhyji3c32dcfmztujtl3xaddqb457jsbkulq24zqq7ifdgad.onion',
  24. 'ckzuw5misyahmg7j5t5xwwuj3bwy62jfolxyux4brfflramzsvvd3syd.onion',
  25. 'jebqj47jgxleaiosfcxfibx2xdahjettuydlxbg64azd4khsxv6kawid.onion',
  26. 'nttr2iupbb6fazdpr2rgbooon2tzbbsvvkagkgkwohhodjzj43stxhad.onion',
  27. 'nitraeju2mipeziu2wtcrqsxg7h62v5y4eqgwi75uprynkj74gevvuqd.onion',
  28. 'nitter.lqs5fjmajyp7rvp4qvyubwofzi6d4imua7vs237rkc4m5qogitqwrgyd.onion',
  29. 'ibsboeui2im5o7dxnik3s5yghufumgy5abevtij5nbizequfpu4qi4ad.onion',
  30. 'ec5nvbycpfa5k6ro77blxgkyrzbkv7uy6r5cngcbkadtjj2733nm3uyd.onion',
  31. 'nitter.i2p',
  32. 'u6ikd6zndl3c4dsdq4mmujpntgeevdk5qzkfb57r4tnfeccrn2qa.b32.i2p',
  33. 'nitterlgj3n5fgwesu3vxc5h67ruku33nqaoeoocae2mvlzhsu6k7fqd.onion',
  34. )
  35. HTTP_INSTANCES = (
  36. 'nitter.lacontrevoie.fr',
  37. 'nitter.fdn.fr',
  38. 'nitter.1d4.us',
  39. 'nitter.kavin.rocks',
  40. 'nitter.unixfox.eu',
  41. 'nitter.domain.glass',
  42. 'nitter.namazso.eu',
  43. 'birdsite.xanny.family',
  44. 'nitter.moomoo.me',
  45. 'bird.trom.tf',
  46. 'nitter.it',
  47. 'twitter.censors.us',
  48. 'nitter.grimneko.de',
  49. 'twitter.076.ne.jp',
  50. 'nitter.fly.dev',
  51. 'notabird.site',
  52. 'nitter.weiler.rocks',
  53. 'nitter.sethforprivacy.com',
  54. 'nitter.cutelab.space',
  55. 'nitter.nl',
  56. 'nitter.mint.lgbt',
  57. 'nitter.bus-hit.me',
  58. 'nitter.esmailelbob.xyz',
  59. 'tw.artemislena.eu',
  60. 'nitter.winscloud.net',
  61. 'nitter.tiekoetter.com',
  62. 'nitter.spaceint.fr',
  63. 'nitter.privacy.com.de',
  64. 'nitter.poast.org',
  65. 'nitter.bird.froth.zone',
  66. 'nitter.dcs0.hu',
  67. 'twitter.dr460nf1r3.org',
  68. 'nitter.garudalinux.org',
  69. 'twitter.femboy.hu',
  70. 'nitter.cz',
  71. 'nitter.privacydev.net',
  72. 'nitter.evil.site',
  73. 'tweet.lambda.dance',
  74. 'nitter.kylrth.com',
  75. 'nitter.foss.wtf',
  76. 'nitter.priv.pw',
  77. 'nitter.tokhmi.xyz',
  78. 'nitter.catalyst.sx',
  79. 'unofficialbird.com',
  80. 'nitter.projectsegfau.lt',
  81. 'nitter.eu.projectsegfau.lt',
  82. 'singapore.unofficialbird.com',
  83. 'canada.unofficialbird.com',
  84. 'india.unofficialbird.com',
  85. 'nederland.unofficialbird.com',
  86. 'uk.unofficialbird.com',
  87. 'n.l5.ca',
  88. 'nitter.slipfox.xyz',
  89. 'nitter.soopy.moe',
  90. 'nitter.qwik.space',
  91. 'read.whatever.social',
  92. 'nitter.rawbit.ninja',
  93. 'nt.vern.cc',
  94. 'ntr.odyssey346.dev',
  95. 'nitter.ir',
  96. 'nitter.privacytools.io',
  97. 'nitter.sneed.network',
  98. 'n.sneed.network',
  99. 'nitter.manasiwibi.com',
  100. 'nitter.smnz.de',
  101. 'nitter.twei.space',
  102. 'nitter.inpt.fr',
  103. 'nitter.d420.de',
  104. 'nitter.caioalonso.com',
  105. 'nitter.at',
  106. 'nitter.drivet.xyz',
  107. 'nitter.pw',
  108. 'nitter.nicfab.eu',
  109. 'bird.habedieeh.re',
  110. 'nitter.hostux.net',
  111. 'nitter.adminforge.de',
  112. 'nitter.platypush.tech',
  113. 'nitter.mask.sh',
  114. 'nitter.pufe.org',
  115. 'nitter.us.projectsegfau.lt',
  116. 'nitter.arcticfoxes.net',
  117. 't.com.sb',
  118. 'nitter.kling.gg',
  119. 'nitter.ktachibana.party',
  120. 'nitter.riverside.rocks',
  121. 'nitter.girlboss.ceo',
  122. 'nitter.lunar.icu',
  123. 'twitter.moe.ngo',
  124. 'nitter.freedit.eu',
  125. 'ntr.frail.duckdns.org',
  126. 'nitter.librenode.org',
  127. 'n.opnxng.com',
  128. 'nitter.plus.st',
  129. )
  130. DEAD_INSTANCES = (
  131. # maintenance
  132. 'nitter.ethibox.fr',
  133. # official, rate limited
  134. 'nitter.net',
  135. # offline
  136. 'is-nitter.resolv.ee',
  137. 'lu-nitter.resolv.ee',
  138. 'nitter.13ad.de',
  139. 'nitter.40two.app',
  140. 'nitter.cattube.org',
  141. 'nitter.cc',
  142. 'nitter.dark.fail',
  143. 'nitter.himiko.cloud',
  144. 'nitter.koyu.space',
  145. 'nitter.mailstation.de',
  146. 'nitter.mastodont.cat',
  147. 'nitter.tedomum.net',
  148. 'nitter.tokhmi.xyz',
  149. 'nitter.weaponizedhumiliation.com',
  150. 'nitter.vxempire.xyz',
  151. 'tweet.lambda.dance',
  152. 'nitter.ca',
  153. 'nitter.42l.fr',
  154. 'nitter.pussthecat.org',
  155. 'nitter.nixnet.services',
  156. 'nitter.eu',
  157. 'nitter.actionsack.com',
  158. 'nitter.hu',
  159. 'twitr.gq',
  160. 'nittereu.moomoo.me',
  161. 'bird.from.tf',
  162. 'twitter.grimneko.de',
  163. 'nitter.alefvanoon.xyz',
  164. 'n.hyperborea.cloud',
  165. 'twitter.mstdn.social',
  166. 'nitter.silkky.cloud',
  167. 'nttr.stream',
  168. 'fuckthesacklers.network',
  169. 'nitter.govt.land',
  170. 'nitter.datatunnel.xyz',
  171. 'de.nttr.stream',
  172. 'twtr.bch.bar',
  173. 'nitter.exonip.de',
  174. 'nitter.mastodon.pro',
  175. 'nitter.notraxx.ch',
  176. 'nitter.skrep.in',
  177. 'nitter.snopyta.org',
  178. )
  179. INSTANCES = NON_HTTP_INSTANCES + HTTP_INSTANCES + DEAD_INSTANCES
  180. _INSTANCES_RE = f'(?:{"|".join(map(re.escape, INSTANCES))})'
  181. _VALID_URL = fr'https?://{_INSTANCES_RE}/(?P<uploader_id>.+)/status/(?P<id>[0-9]+)(#.)?'
  182. current_instance = random.choice(HTTP_INSTANCES)
  183. _TESTS = [
  184. {
  185. # GIF (wrapped in mp4)
  186. 'url': f'https://{current_instance}/firefox/status/1314279897502629888#m',
  187. 'info_dict': {
  188. 'id': '1314279897502629888',
  189. 'ext': 'mp4',
  190. 'title': 'md5:7890a9277da4639ab624dd899424c5d8',
  191. 'description': 'md5:5fea96a4d3716c350f8b95b21b3111fe',
  192. 'thumbnail': r're:^https?://.*\.jpg$',
  193. 'uploader': 'Firefox 🔥',
  194. 'uploader_id': 'firefox',
  195. 'uploader_url': f'https://{current_instance}/firefox',
  196. 'upload_date': '20201008',
  197. 'timestamp': 1602183720,
  198. 'like_count': int,
  199. 'repost_count': int,
  200. 'comment_count': int,
  201. },
  202. }, { # normal video
  203. 'url': f'https://{current_instance}/Le___Doc/status/1299715685392756737#m',
  204. 'info_dict': {
  205. 'id': '1299715685392756737',
  206. 'ext': 'mp4',
  207. 'title': 're:^.* - "Je ne prédis jamais rien"\nD Raoult, Août 2020...',
  208. 'description': '"Je ne prédis jamais rien"\nD Raoult, Août 2020...',
  209. 'thumbnail': r're:^https?://.*\.jpg$',
  210. 'uploader': 're:^Le *Doc',
  211. 'uploader_id': 'Le___Doc',
  212. 'uploader_url': f'https://{current_instance}/Le___Doc',
  213. 'upload_date': '20200829',
  214. 'timestamp': 1598711340,
  215. 'view_count': int,
  216. 'like_count': int,
  217. 'repost_count': int,
  218. 'comment_count': int,
  219. },
  220. }, { # video embed in a "Streaming Political Ads" box
  221. 'url': f'https://{current_instance}/mozilla/status/1321147074491092994#m',
  222. 'info_dict': {
  223. 'id': '1321147074491092994',
  224. 'ext': 'mp4',
  225. 'title': 'md5:8290664aabb43b9189145c008386bf12',
  226. 'description': 'md5:9cf2762d49674bc416a191a689fb2aaa',
  227. 'thumbnail': r're:^https?://.*\.jpg$',
  228. 'uploader': 'Mozilla',
  229. 'uploader_id': 'mozilla',
  230. 'uploader_url': f'https://{current_instance}/mozilla',
  231. 'upload_date': '20201027',
  232. 'timestamp': 1603820940,
  233. 'view_count': int,
  234. 'like_count': int,
  235. 'repost_count': int,
  236. 'comment_count': int,
  237. },
  238. 'expected_warnings': ['Ignoring subtitle tracks found in the HLS manifest'],
  239. }, { # not the first tweet but main-tweet
  240. 'url': f'https://{current_instance}/firefox/status/1354848277481414657#m',
  241. 'info_dict': {
  242. 'id': '1354848277481414657',
  243. 'ext': 'mp4',
  244. 'title': 'md5:bef647f03bd1c6b15b687ea70dfc9700',
  245. 'description': 'md5:5efba25e2f9dac85ebcd21160cb4341f',
  246. 'thumbnail': r're:^https?://.*\.jpg$',
  247. 'uploader': 'Firefox 🔥',
  248. 'uploader_id': 'firefox',
  249. 'uploader_url': f'https://{current_instance}/firefox',
  250. 'upload_date': '20210128',
  251. 'timestamp': 1611855960,
  252. 'view_count': int,
  253. 'like_count': int,
  254. 'repost_count': int,
  255. 'comment_count': int,
  256. },
  257. }, { # no OpenGraph title
  258. 'url': f'https://{current_instance}/LocalBateman/status/1678455464038735895#m',
  259. 'info_dict': {
  260. 'id': '1678455464038735895',
  261. 'ext': 'mp4',
  262. 'title': 'Your Typical Local Man - Local man, what did Romanians ever do to you?',
  263. 'description': 'Local man, what did Romanians ever do to you?',
  264. 'thumbnail': r're:^https?://.*\.jpg$',
  265. 'uploader': 'Your Typical Local Man',
  266. 'uploader_id': 'LocalBateman',
  267. 'uploader_url': f'https://{current_instance}/LocalBateman',
  268. 'upload_date': '20230710',
  269. 'timestamp': 1689009900,
  270. 'view_count': int,
  271. 'like_count': int,
  272. 'repost_count': int,
  273. 'comment_count': int,
  274. },
  275. 'expected_warnings': ['Ignoring subtitle tracks found in the HLS manifest'],
  276. 'params': {'skip_download': 'm3u8'},
  277. },
  278. ]
  279. def _real_extract(self, url):
  280. video_id, uploader_id = self._match_valid_url(url).group('id', 'uploader_id')
  281. parsed_url = urllib.parse.urlparse(url)
  282. base_url = f'{parsed_url.scheme}://{parsed_url.netloc}'
  283. self._set_cookie(parsed_url.netloc, 'hlsPlayback', 'on')
  284. full_webpage = webpage = self._download_webpage(url, video_id)
  285. main_tweet_start = full_webpage.find('class="main-tweet"')
  286. if main_tweet_start > 0:
  287. webpage = full_webpage[main_tweet_start:]
  288. video_url = '{}{}'.format(base_url, self._html_search_regex(
  289. r'(?:<video[^>]+data-url|<source[^>]+src)="([^"]+)"', webpage, 'video url'))
  290. ext = determine_ext(video_url)
  291. if ext == 'unknown_video':
  292. formats = self._extract_m3u8_formats(video_url, video_id, ext='mp4')
  293. else:
  294. formats = [{
  295. 'url': video_url,
  296. 'ext': ext,
  297. }]
  298. title = description = self._og_search_description(full_webpage, default=None) or self._html_search_regex(
  299. r'<div class="tweet-content[^>]+>([^<]+)</div>', webpage, 'title', fatal=False)
  300. uploader_id = self._html_search_regex(
  301. r'<a class="username"[^>]+title="@([^"]+)"', webpage, 'uploader id', fatal=False) or uploader_id
  302. uploader = self._html_search_regex(
  303. r'<a class="fullname"[^>]+title="([^"]+)"', webpage, 'uploader name', fatal=False)
  304. if uploader:
  305. title = f'{uploader} - {title}'
  306. counts = {
  307. f'{x[0]}_count': self._html_search_regex(
  308. fr'<span[^>]+class="icon-{x[1]}[^>]*></span>([^<]*)</div>',
  309. webpage, f'{x[0]} count', fatal=False)
  310. for x in (('view', 'play'), ('like', 'heart'), ('repost', 'retweet'), ('comment', 'comment'))
  311. }
  312. counts = {field: 0 if count == '' else parse_count(count) for field, count in counts.items()}
  313. thumbnail = (
  314. self._html_search_meta('og:image', full_webpage, 'thumbnail url')
  315. or remove_end('{}{}'.format(base_url, self._html_search_regex(
  316. r'<video[^>]+poster="([^"]+)"', webpage, 'thumbnail url', fatal=False)), '%3Asmall'))
  317. thumbnails = [
  318. {'id': id_, 'url': f'{thumbnail}%3A{id_}'}
  319. for id_ in ('thumb', 'small', 'large', 'medium', 'orig')
  320. ]
  321. date = self._html_search_regex(
  322. r'<span[^>]+class="tweet-date"[^>]*><a[^>]+title="([^"]+)"',
  323. webpage, 'upload date', default='').replace('·', '')
  324. return {
  325. 'id': video_id,
  326. 'title': title,
  327. 'description': description,
  328. 'uploader': uploader,
  329. 'timestamp': unified_timestamp(date),
  330. 'uploader_id': uploader_id,
  331. 'uploader_url': f'{base_url}/{uploader_id}',
  332. 'formats': formats,
  333. 'thumbnails': thumbnails,
  334. 'thumbnail': thumbnail,
  335. **counts,
  336. }