├── .github └── workflows │ ├── mac-nodejs.yml │ ├── ubuntu-nodejs.yml │ └── windows-nodejs.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── README-template.md ├── README.md ├── bin ├── cli.js ├── mpv_audio.sh └── mpv_video.sh ├── build-readme.js ├── debug.js ├── dist └── .gitkeep ├── fix-parse-numbers.js ├── package.json ├── src ├── deprecated.js ├── index.js └── util.js └── test ├── stage ├── jumbled-script-tags.response-html ├── pewdiepie-thumbnail.png ├── pewdiepie-thumbnail2.png ├── pewdiepie-thumbnail3.png ├── playlist-no-views.response-html ├── playlist-updated-yesterday.response-html └── richGridRenderer.response-html ├── test-get-scripts.js └── test.js /.github/workflows/mac-nodejs.yml: -------------------------------------------------------------------------------- 1 | name: mac 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: macos-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [20.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm install 21 | - run: npm run build --if-present 22 | - run: npm test 23 | env: 24 | CI: true 25 | -------------------------------------------------------------------------------- /.github/workflows/ubuntu-nodejs.yml: -------------------------------------------------------------------------------- 1 | name: ubuntu 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [20.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm install 21 | - run: npm run build --if-present 22 | - run: npm test 23 | # - name: Setup tmate session 24 | # uses: mxschmitt/action-tmate@v3 25 | env: 26 | CI: true 27 | -------------------------------------------------------------------------------- /.github/workflows/windows-nodejs.yml: -------------------------------------------------------------------------------- 1 | name: windows 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: windows-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [20.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm install 21 | - run: npm run build --if-present 22 | - run: npm test 23 | env: 24 | CI: true 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | # vim files 4 | *.sw? 5 | 6 | # mac files 7 | *.DS_Store 8 | 9 | # dist directory 10 | dist 11 | 12 | # sample files 13 | readme-test.js 14 | 15 | # temp debugging files 16 | xxx.html 17 | dasu.response 18 | post.response 19 | 20 | # ignore lockfile 21 | package-lock.json 22 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test/ 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /README-template.md: -------------------------------------------------------------------------------- 1 | [![npm](https://img.shields.io/npm/v/yt-search.svg?maxAge=3600)](https://www.npmjs.com/package/yt-search) 2 | [![npm](https://img.shields.io/npm/dm/yt-search.svg?maxAge=3600)](https://www.npmjs.com/package/yt-search) 3 | [![npm](https://img.shields.io/npm/l/yt-search.svg?maxAge=3600)](https://www.npmjs.com/package/yt-search) 4 | ![mac](https://github.com/talmobi/yt-search/workflows/mac/badge.svg) 5 | ![ubuntu](https://github.com/talmobi/yt-search/workflows/ubuntu/badge.svg) 6 | ![windows](https://github.com/talmobi/yt-search/workflows/windows/badge.svg) 7 | 8 | # yt-search 9 | simple youtube search API and CLI 10 | 11 | ![](https://thumbs.gfycat.com/ContentShockingCuttlefish-size_restricted.gif) 12 | 13 | ## Installation 14 | ```bash 15 | npm install yt-search # local module usage 16 | ``` 17 | 18 | ## Easy to use 19 | ```javascript 20 | %f_search% 21 | ``` 22 | 23 | ###### output 24 | ```javascript 25 | %f_search_output% 26 | ``` 27 | 28 | #### single video 29 | ```javascript 30 | %f_video% 31 | ``` 32 | ###### output 33 | ```javascript 34 | %f_video_output% 35 | ``` 36 | 37 | #### single playlist 38 | ```javascript 39 | %f_playlist% 40 | ``` 41 | ###### output 42 | ```javascript 43 | %f_playlist_output% 44 | ``` 45 | 46 | ## CLI Usage (interactive) 47 | ```bash 48 | yt-search superman theme 49 | ``` 50 | 51 | If you have `mpv` installed, yt-search can directly play yt videos (or audio only) 52 | ```bash 53 | yt-search-video Dank Memes Videos 54 | yt-search-audio Wagner 55 | ``` 56 | 57 | If you don't have `mpv` installed, you can alternatively try installing `yt-play-cli` 58 | ```bash 59 | npm install -g yt-play-cli 60 | ``` 61 | 62 | see: https://github.com/talmobi/yt-play 63 | 64 | 65 | ## About 66 | Simple function to get youtube search results. 67 | 68 | ## Why 69 | Not sure.. 70 | 71 | ## How 72 | Using HTTP requests and parsing the results with [cheerio](https://github.com/cheeriojs/cheerio). 73 | 74 | CLI interactive mode with [node-fzf](https://github.com/talmobi/node-fzf) 75 | 76 | ## Options 77 | ```bash 78 | var opts = { query: 'superman theme' } 79 | yts( opts, function ( err, r ) { 80 | if ( err ) throw err 81 | console.log( r.videos ) // video results 82 | console.log( r.playlists ) // playlist results 83 | console.log( r.channels ) // channel results 84 | console.log( r.live ) // live stream results 85 | } ) 86 | 87 | var opts = { videoId: 'e9vrfEoc8_g' } 88 | yts( opts, function ( err, video ) { 89 | if ( err ) throw err 90 | console.log( video ) // single video metadata 91 | } ) 92 | 93 | var opts = { listId: 'PL7k0JFoxwvTbKL8kjGI_CaV31QxCGf1vJ' } 94 | yts( opts, function ( err, playlist ) { 95 | if ( err ) throw err 96 | console.log( playlist ) // single playlist metadata 97 | console.log( playlist.videos ) // playlist videos 98 | } ) 99 | ``` 100 | 101 | ## Alternatives 102 | [ytsr](https://www.npmjs.com/package/ytsr) 103 | 104 | ## Test 105 | ``` 106 | npm test 107 | ``` 108 | 109 | ## Development / Debugging 110 | Modify `debug.js` by adding another mXX function and calling it at the top. 111 | 112 | Run with the debug flag ex: `DEBUG=1 node debug.js` 113 | 114 | The HTML response received by yt-search is written to `dasu.response`. 115 | 116 | Prettify `dasu.response` for easier debugging ex: `prettier --parser html` 117 | -- save it as a temporary file so it's not overwritten when you call the 118 | debug fn again if necessary ex: `pewdiepie.channel` or `superman.results` 119 | 120 | Most/all relevant data for parsing is found in the results inside the 121 | `ytInitialData` object. 122 | 123 | We're using `jsonpath-plus` for resilient parsing of the `ytInitialData` 124 | object that is subject to continuous modifications by YouTube. 125 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm](https://img.shields.io/npm/v/yt-search.svg?maxAge=3600)](https://www.npmjs.com/package/yt-search) 2 | [![npm](https://img.shields.io/npm/dm/yt-search.svg?maxAge=3600)](https://www.npmjs.com/package/yt-search) 3 | [![npm](https://img.shields.io/npm/l/yt-search.svg?maxAge=3600)](https://www.npmjs.com/package/yt-search) 4 | ![mac](https://github.com/talmobi/yt-search/actions/workflows/mac-nodejs.yml/badge.svg?branch=master) 5 | ![ubuntu](https://github.com/talmobi/yt-search/actions/workflows/ubuntu-nodejs.yml/badge.svg?branch=master) 6 | ![windows](https://github.com/talmobi/yt-search/actions/workflows/windows-nodejs.yml/badge.svg?branch=master) 7 | 8 | # yt-search 9 | simple youtube search API and CLI 10 | 11 | ![](https://thumbs.gfycat.com/ContentShockingCuttlefish-size_restricted.gif) 12 | 13 | ## Installation 14 | ```bash 15 | npm install yt-search # local module usage 16 | ``` 17 | 18 | ## Easy to use 19 | ```javascript 20 | const yts = require( 'yt-search' ) 21 | const r = await yts( 'superman theme' ) 22 | 23 | const videos = r.videos.slice( 0, 3 ) 24 | videos.forEach( function ( v ) { 25 | const views = String( v.views ).padStart( 10, ' ' ) 26 | console.log( `${ views } | ${ v.title } (${ v.timestamp }) | ${ v.author.name }` ) 27 | } ) 28 | ``` 29 | 30 | ###### output 31 | ```javascript 32 | 38878009 | Superman Theme (4:13) | Super Man 33 | 8861479 | Superman • Main Theme • John Williams (4:26) | HD Film Tributes 34 | 7802473 | Superman - Main Theme (BBC Proms) (4:46) | brassbone player 35 | ``` 36 | 37 | ###### try it 38 | https://runkit.com/talmobi/runkit-npm-yt-search-basic 39 | 40 | #### single video 41 | ```javascript 42 | const video = await yts( { videoId: '_4Vt0UGwmgQ' } ) 43 | console.log( video.title + ` (${ video.duration.timestamp })` ) 44 | ``` 45 | ###### output 46 | ```javascript 47 | Philip Glass. - Koyaanisqatsi (original version) (3:29) 48 | ``` 49 | 50 | ###### try it 51 | https://runkit.com/talmobi/runkit-npm-yt-search-video 52 | 53 | #### single playlist 54 | ```javascript 55 | const list = await yts( { listId: 'PL7k0JFoxwvTbKL8kjGI_CaV31QxCGf1vJ' } ) 56 | 57 | console.log( 'playlist title: ' + list.title ) 58 | list.videos.forEach( function ( video ) { 59 | console.log( video.title ) 60 | } ) 61 | ``` 62 | ###### output 63 | ```javascript 64 | playlist title: Superman Themes 65 | The Max Fleischer Cartoon (From "Superman") 66 | [Deleted video] 67 | Superman Theme 68 | [Private video] 69 | Superman The Animated Series Full Theme 70 | Smallville theme song 71 | Reprise / Fly Away 72 | Superman Doomsday Soundtrack- Main Title 73 | Hans Zimmer - Man of Steel Theme 74 | Supergirl CW Soundtrack - Superman Theme Extended 75 | ``` 76 | 77 | ###### try it 78 | https://runkit.com/talmobi/runkit-npm-yt-search-playlist 79 | 80 | ## CLI Usage (interactive) 81 | ```bash 82 | yt-search superman theme 83 | ``` 84 | 85 | If you have `mpv` installed, yt-search can directly play yt videos (or audio only) 86 | ```bash 87 | yt-search-video Dank Memes Videos 88 | yt-search-audio Wagner 89 | ``` 90 | 91 | If you don't have `mpv` installed, you can alternatively try installing `yt-play-cli` 92 | ```bash 93 | npm install -g yt-play-cli 94 | ``` 95 | 96 | see: https://github.com/talmobi/yt-play 97 | 98 | 99 | ## About 100 | Simple function to get youtube search results. 101 | 102 | ## Why 103 | Not sure.. 104 | 105 | ## How 106 | Using HTTP requests and parsing the results with [cheerio](https://github.com/cheeriojs/cheerio). 107 | 108 | CLI interactive mode with [node-fzf](https://github.com/talmobi/node-fzf) 109 | 110 | ## Options 111 | ```bash 112 | var opts = { query: 'superman theme' } 113 | yts( opts, function ( err, r ) { 114 | if ( err ) throw err 115 | console.log( r.videos ) // video results 116 | console.log( r.playlists ) // playlist results 117 | console.log( r.channels ) // channel results 118 | console.log( r.live ) // live stream results 119 | } ) 120 | 121 | var opts = { videoId: 'e9vrfEoc8_g' } 122 | yts( opts, function ( err, video ) { 123 | if ( err ) throw err 124 | console.log( video ) // single video metadata 125 | } ) 126 | 127 | var opts = { listId: 'PL7k0JFoxwvTbKL8kjGI_CaV31QxCGf1vJ' } 128 | yts( opts, function ( err, playlist ) { 129 | if ( err ) throw err 130 | console.log( playlist ) // single playlist metadata 131 | console.log( playlist.videos ) // playlist videos 132 | } ) 133 | ``` 134 | 135 | ## Alternatives 136 | [ytsr](https://www.npmjs.com/package/ytsr) 137 | 138 | ## Test 139 | ``` 140 | npm test 141 | ``` 142 | 143 | ## Development / Debugging 144 | Modify `debug.js` by adding another mXX function and calling it at the top. 145 | 146 | Run with the debug flag ex: `DEBUG=1 node debug.js` 147 | 148 | The HTML response received by yt-search is written to `dasu.response`. 149 | 150 | Prettify `dasu.response` for easier debugging ex: `prettier --parser html` 151 | -- save it as a temporary file so it's not overwritten when you call the 152 | debug fn again if necessary ex: `pewdiepie.channel` or `superman.results` 153 | 154 | Most/all relevant data for parsing is found in the results inside the 155 | `ytInitialData` object. 156 | 157 | We're using `jsonpath-plus` for resilient parsing of the `ytInitialData` 158 | object that is subject to continuous modifications by YouTube. 159 | -------------------------------------------------------------------------------- /bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var fs = require( 'fs' ) 4 | var path = require( 'path' ) 5 | 6 | var ytSearch = require( 7 | path.join( __dirname, '../dist/yt-search.js' ) 8 | ) 9 | 10 | var argv = require( 'minimist' )( process.argv.slice( 2 ) ) 11 | 12 | var pjson = require( path.join( __dirname, '../package.json' ) ) 13 | 14 | if ( argv.v || argv.V || argv.version ) { 15 | console.log( pjson.name + ': ' + pjson.version ) 16 | process.exit() 17 | } 18 | 19 | var query = argv._.join( ' ' ) 20 | 21 | if ( !query ) { 22 | console.log( 'No search query given. Exiting.' ) 23 | return process.exit( 1 ) 24 | } 25 | 26 | ytSearch( 27 | query, 28 | function ( err, r ) { 29 | if ( err ) throw err 30 | 31 | var list = [] 32 | var videos = r.videos 33 | 34 | for ( var i = 0; i < videos.length; i++ ) { 35 | var song = videos[ i ] 36 | // console.log( song.title + ' : ' + song.duration ) 37 | 38 | var title = song.title 39 | 40 | var text = ( 41 | title + 42 | ' ($t)'.replace( '$t', song.timestamp ) + 43 | ' | ' + song.videoId + ' | ' + song.views 44 | ) 45 | 46 | list.push( text ) 47 | } 48 | 49 | var nfzf = require( 'node-fzf' ) 50 | nfzf( list, function ( r ) { 51 | if ( !r.selected ) { 52 | process.exit( 1 ) 53 | } 54 | 55 | // console.log( r.selected.value ) 56 | 57 | const video = videos[ r.selected.index ] 58 | 59 | if ( argv[ 'v' ] || argv[ '--id' ] ) { 60 | console.log( video.videoId ) 61 | } else { 62 | console.log( video.url ) 63 | } 64 | 65 | process.exit() 66 | } ) 67 | } 68 | ) 69 | -------------------------------------------------------------------------------- /bin/mpv_audio.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | QUERY="$@" 4 | mpv --no-video "$(yt-search $QUERY)" 5 | -------------------------------------------------------------------------------- /bin/mpv_video.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | QUERY="$@" 4 | mpv "$(yt-search $QUERY)" 5 | -------------------------------------------------------------------------------- /build-readme.js: -------------------------------------------------------------------------------- 1 | const fs = require( 'fs' ) 2 | const path = require( 'path' ) 3 | 4 | let template = fs.readFileSync( './README-template.md', 'utf8' ) 5 | 6 | const yts = require( './src/index.js' ) 7 | 8 | let buffer = '' 9 | const log = console.log 10 | console = { 11 | log: function ( text ) { 12 | buffer += text + '\n' 13 | } 14 | } 15 | 16 | function between ( text, a, b ) { 17 | const i = text.indexOf( a ) 18 | const j = text.lastIndexOf( b ) 19 | return text.slice( i, j ) 20 | } 21 | 22 | function parsefn ( fn, trimStart ) { 23 | const text = between( fn.toString(), '{', '}' ) 24 | const lines = text.split( '\n' ).slice( 1, -1 ) 25 | return lines.map( 26 | function ( line ) { 27 | return line.slice( trimStart ) 28 | } 29 | ).join( '\n' ) 30 | } 31 | 32 | ;( async function () { 33 | async function f_search () 34 | { 35 | const r = await yts( 'superman theme' ) 36 | 37 | const videos = r.videos.slice( 0, 3 ) 38 | videos.forEach( function ( v ) { 39 | const views = String( v.views ).padStart( 10, ' ' ) 40 | console.log( `${ views } | ${ v.title } (${ v.timestamp }) | ${ v.author.name }` ) 41 | } ) 42 | } 43 | 44 | template = template.split( '%f_search%' ).join( parsefn( f_search, 2 ) ) 45 | buffer = '' 46 | await f_search() 47 | template = template.split( '%f_search_output%' ).join( buffer.trimEnd() ) 48 | 49 | async function f_video () 50 | { 51 | const video = await yts( { videoId: '_4Vt0UGwmgQ' } ) 52 | console.log( video.title + ` (${ video.duration.timestamp })` ) 53 | } 54 | 55 | template = template.split( '%f_video%' ).join( parsefn( f_video, 2 ) ) 56 | buffer = '' 57 | await f_video() 58 | template = template.split( '%f_video_output%' ).join( buffer.trimEnd() ) 59 | 60 | async function f_playlist () 61 | { 62 | const list = await yts( { listId: 'PL7k0JFoxwvTbKL8kjGI_CaV31QxCGf1vJ' } ) 63 | 64 | console.log( 'playlist title: ' + list.title ) 65 | list.videos.forEach( function ( video ) { 66 | console.log( video.title ) 67 | } ) 68 | } 69 | 70 | template = template.split( '%f_playlist%' ).join( parsefn( f_playlist, 2 ) ) 71 | buffer = '' 72 | await f_playlist() 73 | template = template.split( '%f_playlist_output%' ).join( buffer.trimEnd() ) 74 | 75 | fs.writeFileSync( './README.md', template ) 76 | } )() 77 | -------------------------------------------------------------------------------- /debug.js: -------------------------------------------------------------------------------- 1 | const yts = require( './src/index.js' ) 2 | 3 | // console.log( yts.search ) 4 | m17() 5 | 6 | async function m17 () { 7 | const r = await yts('superman theme list'); 8 | console.log(' === VIDEOS === ') 9 | console.log(r.videos) 10 | console.log(' === CHANNELS === ') 11 | console.log(r.channels) 12 | console.log(' === PLAYLISTS === ') 13 | console.log(r.playlists) 14 | } 15 | 16 | async function m16 () { 17 | const r = await yts('pewdiepie playlist'); 18 | console.log(' === VIDEOS === ') 19 | console.log(r.videos) 20 | console.log(' === CHANNELS === ') 21 | console.log(r.channels) 22 | console.log(' === PLAYLISTS === ') 23 | console.log(r.playlists) 24 | } 25 | 26 | async function m15 () { 27 | const video = await yts({ videoId: '-ObdvMkCKws' }); 28 | console.log(video.title); 29 | console.log(video.url); 30 | console.log(video.thumbnail); 31 | 32 | console.log(video.timestamp) 33 | 34 | console.log('----') 35 | console.log(video.description) 36 | console.log(video.duration) 37 | console.log(video.views) 38 | } 39 | 40 | async function m14 () { 41 | const video = await yts({ videoId: 'e9vrfEoc8_g' }); 42 | console.log(video.title); 43 | console.log(video.url); 44 | console.log(video.thumbnail); 45 | 46 | console.log(video.timestamp) 47 | 48 | console.log('----') 49 | console.log(video.description) 50 | console.log(video.duration) 51 | console.log(video.views) 52 | } 53 | 54 | async function m13 () { 55 | const r = await yts("王菲 Faye Wong"); 56 | // const r = await yts("irregular pineapples"); 57 | // const r = await yts("pewdiepie"); 58 | const channels = r.channels 59 | 60 | const topChannel = channels[ 0 ] 61 | 62 | console.log( 'topChannel.name: ' + topChannel.name ) 63 | console.log( 'topChannel url: ' + topChannel.url ) 64 | console.log('----') 65 | console.log( 'topChannel.baseUrl: ' + topChannel.baseUrl ) 66 | console.log( 'topChannel.id: ' + topChannel.id ) 67 | console.log( 'topChannel.about: ' + topChannel.about ) 68 | console.log( 'topChannel.verified: ' + topChannel.verified ) 69 | console.log('----') 70 | console.log( 'topChannel.videoCount: ' + topChannel.videoCount ) 71 | console.log( 'topChannel.subCount: ' + topChannel.subCount ) 72 | console.log( 'topChannel.subCountLabel: ' + topChannel.subCountLabel ) 73 | } 74 | 75 | async function m12 () { 76 | const video = await yts({ videoId: '62ezXENOuIA' }); 77 | console.log(video.title); 78 | console.log(video.url); 79 | console.log(video.thumbnail); 80 | } 81 | 82 | async function m11 () { 83 | const video = await yts( { videoId: '62ezXENOuIA' } ).then(function (result) { 84 | console.log( result ) 85 | }) 86 | console.log(video.title); 87 | console.log(video.url); 88 | console.log(video.thumbnail); 89 | 90 | } 91 | 92 | async function m10 () { 93 | const id = 'z95fi3uazYA' 94 | const res = await yts(id); 95 | const videoIdSearch = await yts({ videoId: id }); 96 | 97 | console.log(videoIdSearch.videoId === res.videos[0].videoId); 98 | console.log(videoIdSearch.title); 99 | console.log(res.videos[0].title); 100 | } 101 | 102 | async function m9 () { 103 | let result = await yts({ listId: "PLQ9SiFtEqtYByscXDLGNOC8XL49BFn9tZ" }); 104 | console.log( result ) 105 | } 106 | 107 | async function m8 () { 108 | let result = await yts({ listId: "PLSwcuYF4r6MJHkUVYbDAekT7j0FvZ_B4X" }); 109 | console.log( result ) 110 | } 111 | 112 | async function m7 () { 113 | let result = await yts( 'superman theme list' ); 114 | // let result = await yts({ listId: "PL7k0JFoxwvTbKL8kjGI_CaV31QxCGf1vJ" }); 115 | console.log( result ) 116 | } 117 | 118 | async function m6 () { 119 | let result = await yts({ listId: "PL67B0C9D86F829544" }); 120 | // let result = await yts({ listId: "PL7k0JFoxwvTbKL8kjGI_CaV31QxCGf1vJ" }); 121 | console.log( result ) 122 | } 123 | 124 | async function m5 () { 125 | let result = await yts({ listId: "RDGMEM_v2KDBP3d4f8uT-ilrs8fQ" }); 126 | console.log( result ) 127 | } 128 | 129 | function m4 () { 130 | yts( { query: 'superman theme' }, function ( err, result ) { 131 | if ( err ) throw err 132 | console.log( result.videos[ 1 ].title ) 133 | } ) 134 | } 135 | 136 | async function m3 () { 137 | const r = await yts( 'live streams' ) 138 | 139 | console.log( r.videos ) 140 | console.log( r.live ) 141 | } 142 | 143 | async function m2 () { 144 | const list = await yts( { listId: 'PL7k0JFoxwvTbKL8kjGI_CaV31QxCGf1vJ' } ) 145 | console.log( list ) 146 | } 147 | 148 | async function m1 () { 149 | const r = await yts( 'live streams' ) 150 | const v = r.live.sort( 151 | function ( b, a ) { 152 | return b.watching - a.watching 153 | } 154 | )[ 0 ] 155 | console.log( v ) 156 | 157 | console.log( 158 | r.live.reduce( function ( a, c ) { 159 | return a + c.description 160 | }, '' ) 161 | ) 162 | } 163 | 164 | async function main () { 165 | const message = 'Nier: Automata - Bipolar Nightmare 【Intense Symphonic Metal Cover】' 166 | 167 | // find video id in message (url) 168 | let match = message.match( /[?&]v=(\w+)&?/ ) 169 | const videoId = match && match[ 1 ] 170 | 171 | // find list id in message (url) 172 | match = message.match( /[?&]list=(\w+)&?/ ) 173 | const listId = match && match[ 1 ] 174 | 175 | // find list index specified in message (url) 176 | match = message.match( /[?&]index=(\w+)&?/ ) 177 | const listIndex = match && match[ 1 ] 178 | 179 | if ( videoId ) { 180 | // found video id in url 181 | const opts = { videoId: videoId } 182 | const r = await yts( opts ) 183 | console.log( r ) 184 | } else if ( listId ) { 185 | // only a list id was found 186 | const opts = { listId: listId } 187 | const r = await yts( opts ) 188 | console.log( r ) 189 | 190 | // first video in playlist 191 | console.log( r.videos[ 0 ] ) 192 | 193 | if ( listIndex ) { 194 | console.log( r.videos[ listIndex ] ) 195 | } 196 | } else { 197 | const r = await yts( message ) 198 | console.log( r ) 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /dist/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/talmobi/yt-search/c25d06ab31ba7ce95fc84678aa8d195dc8a4d7dc/dist/.gitkeep -------------------------------------------------------------------------------- /fix-parse-numbers.js: -------------------------------------------------------------------------------- 1 | const yts = require('./src/index.js') 2 | 3 | console.log( yts._parseNumbers( '50 Videos 1000K users -32.5 dollars' ) ) 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yt-search", 3 | "version": "2.13.1", 4 | "description": "search youtube", 5 | "main": "dist/yt-search.js", 6 | "bin": { 7 | "yt-search": "bin/cli.js", 8 | "yt-search-video": "bin/mpv_video.sh", 9 | "yt-search-audio": "bin/mpv_audio.sh" 10 | }, 11 | "files": [ 12 | "bin/**.*", 13 | "dist/yt-search.js" 14 | ], 15 | "scripts": { 16 | "debug": "debug=1 node src/index.js --silent", 17 | "build": "npm run build:src", 18 | "build:src": "browserify --node --no-bundle-external --standalone ytSearch -t [ babelify --presets [ @babel/preset-env ] ] src/index.js -o dist/yt-search.js 2>&1 | wooster", 19 | "prepublishOnly": "npm test", 20 | "test:production": "node test/test.js | faucet", 21 | "test:util": "node test/test-get-scripts.js", 22 | "test:dev": "cross-env debug=1 test_yt_search=1 node test/test.js", 23 | "test": "npm run test:util && npm run build && npm run test:production" 24 | }, 25 | "author": "talmobi ", 26 | "license": "MIT", 27 | "private": false, 28 | "repository": { 29 | "type": "git", 30 | "url": "https://github.com/talmobi/yt-search" 31 | }, 32 | "bugs": { 33 | "url": "https://github.com/talmobi/yt-search/issues", 34 | "email": "talmo.christian@gmail.com" 35 | }, 36 | "dependencies": { 37 | "async.parallellimit": "~0.5.2", 38 | "cheerio": "^1.0.0-rc.10", 39 | "dasu": "~0.4.3", 40 | "boolstring": "~2.0.1", 41 | "human-time": "0.0.2", 42 | "jsonpath-plus": "~10.3.0", 43 | "minimist": "~1.2.5", 44 | "node-fzf": "~0.14.0" 45 | }, 46 | "devDependencies": { 47 | "@babel/core": "~7.27.1", 48 | "@babel/preset-env": "~7.27.1", 49 | "@talmobi/faucet": "0.0.3", 50 | "babelify": "~10.0.0", 51 | "browserify": "~17.0.0", 52 | "cross-env": "~7.0.2", 53 | "looks-same-plus": "~0.0.3", 54 | "spacestandard": "~0.3.0", 55 | "tape": "~5.9.0", 56 | "wooster": "~0.5.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/deprecated.js: -------------------------------------------------------------------------------- 1 | // all of these fn's DEPRECATED/EXPERIMENTAL 2 | 3 | /* The document returned by YouTube is may be 4 | * different depending on the user-agent header. 5 | * Seems like the mobile uri versions and modern user-agents 6 | * get served html documents with continuation tokens (ctoken) 7 | * that are used to get more page results when you scroll down 8 | * on the page. 9 | * We will be using these ctokens as our strategy to get more 10 | * video results in this function. 11 | * getDesktopVideos will be using an older strategy to get 12 | * more video results. 13 | * 14 | * DEPRECATED! 15 | * 16 | * The initial data of video resulsts provided for mobiles 17 | * lack certain information such as video description snippets. 18 | * I've decided not to parse mobile sites and instead attempt to 19 | * GET desktop documents with more information to parse by 20 | * settings desktop-like user-agents and using desktop url's. 21 | */ 22 | function getMobileVideos ( _options, callback ) 23 | { 24 | // querystring variables 25 | const q = _querystring.escape( _options.query ).split( /\s+/ ) 26 | const hl = _options.hl || 'en' 27 | const gl = _options.gl || 'US' 28 | const category = _options.category || '' // music 29 | 30 | const pageStart = 1 31 | const pageEnd = _options.pageEnd || 1 32 | 33 | let queryString = '?' 34 | queryString += 'search_query=' + q.join( '+' ) 35 | 36 | queryString += '&' 37 | queryString += '&hl=' + hl 38 | 39 | queryString += '&' 40 | queryString += '&gl=' + gl 41 | 42 | if ( category ) { // ex. "music" 43 | queryString += '&' 44 | queryString += '&category=' + category 45 | } 46 | 47 | const uri = TEMPLATES.SEARCH_MOBILE + queryString 48 | 49 | const params = _url.parse( uri ) 50 | 51 | params.headers = { 52 | 'user-agent': _userAgent, 53 | 'accept': 'text/html' 54 | } 55 | 56 | _dasu.req( params, function ( err, res, body ) { 57 | if ( err ) { 58 | callback( err ) 59 | } else { 60 | if ( res.status !== 200 ) { 61 | return callback( 'http status: ' + res.status ) 62 | } 63 | 64 | if ( _debugging ) { 65 | const fs = require( 'fs' ) 66 | const path = require( 'path' ) 67 | fs.writeFileSync( 'dasu.response', res.responseText, 'utf8' ) 68 | } 69 | 70 | try { 71 | parseInitialData( body, function ( err, results ) { 72 | if ( err ) return callback( err ) 73 | 74 | const list = results 75 | 76 | const videos = list.filter( videoFilter ) 77 | const playlists = list.filter( playlistFilter ) 78 | const channels = list.filter( channelFilter ) 79 | 80 | callback( null, { 81 | videos: videos, 82 | 83 | playlists: playlists, 84 | lists: playlists, 85 | 86 | accounts: channels, 87 | channels: channels 88 | } ) 89 | } ) 90 | } catch ( err ) { 91 | callback( err ) 92 | } 93 | } 94 | } ) 95 | } 96 | 97 | /* The page results received on Desktop urls vary by user-agent. 98 | * Some are given older static versions while some get newer 99 | * versions with embedded initial json data. 100 | * 101 | * We will target modern pages (using modern user-agent headers) 102 | * with embedded json data and parse them in this function. 103 | */ 104 | function getDesktopVideos ( _options, callback ) 105 | { 106 | // querystring variables 107 | const q = _querystring.escape( _options.query ).split( /\s+/ ) 108 | const hl = _options.hl || 'en' 109 | const gl = _options.gl || 'US' 110 | const category = _options.category || '' // music 111 | 112 | const pageStart = 1 113 | const pageEnd = Number( _options.pageEnd ) || 1 114 | 115 | if ( Number.isNaN( pageEnd ) ) { 116 | callback( 'error: pageEnd must be a number' ) 117 | } 118 | 119 | _options.pageStart = pageStart 120 | _options.pageEnd = pageEnd 121 | _options.currentPage = _options.currentPage || pageStart 122 | 123 | let queryString = '?' 124 | queryString += 'search_query=' + q.join( '+' ) 125 | 126 | queryString += '&' 127 | queryString += '&hl=' + hl 128 | 129 | queryString += '&' 130 | queryString += '&gl=' + gl 131 | 132 | if ( category ) { // ex. "music" 133 | queryString += '&' 134 | queryString += '&category=' + category 135 | } 136 | 137 | if ( _options.ctoken ) { // ex. "music" 138 | queryString += '&' 139 | queryString += '&ctoken=' + _options.ctoken 140 | } 141 | 142 | const uri = TEMPLATES.SEARCH_DESKTOP + queryString 143 | 144 | const params = _url.parse( uri ) 145 | 146 | params.headers = { 147 | 'user-agent': _userAgent, 148 | 'accept': 'text/html', 149 | 'accept-encoding': 'gzip', 150 | 'accept-language': 'en-US,en-GB' 151 | } 152 | 153 | debug( 'getting results: ' + _options.currentPage ) 154 | _dasu.req( params, function ( err, res, body ) { 155 | if ( err ) { 156 | callback( err ) 157 | } else { 158 | if ( res.status !== 200 ) { 159 | return callback( 'http status: ' + res.status ) 160 | } 161 | 162 | if ( _debugging ) { 163 | const fs = require( 'fs' ) 164 | const path = require( 'path' ) 165 | fs.writeFileSync( 'dasu.response', res.responseText, 'utf8' ) 166 | } 167 | 168 | try { 169 | parseInitialData( body, function ( err, results ) { 170 | if ( err ) return callback( err ) 171 | 172 | const list = results 173 | 174 | const videos = list.filter( videoFilter ) 175 | const playlists = list.filter( playlistFilter ) 176 | const channels = list.filter( channelFilter ) 177 | 178 | // keep saving results into temporary memory while 179 | // we get more results 180 | _options._data = _options._data || {} 181 | 182 | // init memory 183 | _options._data.videos = _options._data.videos || [] 184 | _options._data.playlists = _options._data.playlists || [] 185 | _options._data.channels = _options._data.channels || [] 186 | 187 | // push received results into memory 188 | videos.forEach( function ( item ) { 189 | _options._data.videos.push( item ) 190 | } ) 191 | playlists.forEach( function ( item ) { 192 | _options._data.playlists.push( item ) 193 | } ) 194 | channels.forEach( function ( item ) { 195 | _options._data.channels.push( item ) 196 | } ) 197 | 198 | _options.currentPage++ 199 | const getMoreResults = ( 200 | _options.currentPage <= _options.pageEnd 201 | ) 202 | 203 | if ( getMoreResults && results._ctoken ) { 204 | _options.ctoken = results._ctoken 205 | 206 | setTimeout( function () { 207 | getDesktopVideos( _options, callback ) 208 | }, 3000 ) // delay a bit to try and prevent throttling 209 | } else { 210 | const videos = _options._data.videos.filter( videoFilter ) 211 | const playlists = _options._data.playlists.filter( playlistFilter ) 212 | const channels = _options._data.channels.filter( channelFilter ) 213 | 214 | // return all found videos 215 | callback( null, { 216 | videos: videos, 217 | 218 | playlists: playlists, 219 | lists: playlists, 220 | 221 | accounts: channels, 222 | channels: channels 223 | } ) 224 | } 225 | } ) 226 | } catch ( err ) { 227 | callback( err ) 228 | } 229 | } 230 | } ) 231 | } 232 | 233 | 234 | /* For "modern" user-agents the html document returned from 235 | * YouTube contains initial json data that is used to populate 236 | * the page with JavaScript. This function will aim to find and 237 | * parse such data. 238 | */ 239 | function parseInitialData ( responseText, callback ) 240 | { 241 | const re = /{.*}/ 242 | const $ = _cheerio.load( responseText ) 243 | 244 | let initialData = $( 'div#initial-data' ).html() || '' 245 | initialData = re.exec( initialData ) || '' 246 | 247 | if ( !initialData ) { 248 | const scripts = $( 'script' ) 249 | 250 | for ( let i = 0; i < scripts.length; i++ ) { 251 | const script = $( scripts[ i ] ).html() 252 | 253 | const lines = script.split( '\n' ) 254 | lines.forEach( function ( line ) { 255 | let i 256 | while ( ( i = line.indexOf( 'ytInitialData' ) ) >= 0 ) { 257 | line = line.slice( i + 'ytInitialData'.length ) 258 | const match = re.exec( line ) 259 | if ( match && match.length > initialData.length ) { 260 | initialData = match 261 | } 262 | } 263 | } ) 264 | } 265 | } 266 | 267 | if ( !initialData ) { 268 | return callback( 'could not find inital data in the html document' ) 269 | } 270 | 271 | const errors = [] 272 | const results = [] 273 | 274 | const json = JSON.parse( initialData[ 0 ] ) 275 | 276 | const items = _jp.query( json, '$..itemSectionRenderer..contents.*' ) 277 | 278 | debug( 'items.length: ' + items.length ) 279 | 280 | for ( let i = 0; i < items.length; i++ ) { 281 | const item = items[ i ] 282 | 283 | let result = undefined 284 | let type = 'unknown' 285 | 286 | const hasList = ( item.compactPlaylistRenderer || item.playlistRenderer ) 287 | const hasChannel = ( item.compactChannelRenderer || item.channelRenderer ) 288 | const hasVideo = ( item.compactVideoRenderer || item.videoRenderer ) 289 | 290 | const listId = hasList && ( _jp.value( item, '$..playlistId' ) ) 291 | const channelId = hasChannel && ( _jp.value( item, '$..channelId' ) ) 292 | const videoId = hasVideo && ( _jp.value( item, '$..videoId' ) ) 293 | 294 | if ( videoId ) { 295 | type = 'video' 296 | } 297 | 298 | if ( channelId ) { 299 | type = 'channel' 300 | } 301 | 302 | if ( listId ) { 303 | type = 'list' 304 | } 305 | 306 | try { 307 | switch ( type ) { 308 | case 'video': 309 | { 310 | const thumbnail = _jp.value( item, '$..thumbnail..url' ) 311 | const title = ( 312 | _jp.value( item, '$..title..text' ) || 313 | _jp.value( item, '$..title..simpleText' ) 314 | ) 315 | 316 | const author_name = ( 317 | _jp.value( item, '$..shortBylineText..text' ) || 318 | _jp.value( item, '$..longBylineText..text' ) 319 | ) 320 | 321 | const author_url = ( 322 | _jp.value( item, '$..shortBylineText..url' ) || 323 | _jp.value( item, '$..longBylineText..url' ) 324 | ) 325 | 326 | // publish/upload date 327 | const agoText = ( 328 | _jp.value( item, '$..publishedTimeText..text' ) || 329 | _jp.value( item, '$..publishedTimeText..simpleText' ) 330 | ) 331 | 332 | const viewCountText = ( 333 | _jp.value( item, '$..viewCountText..text' ) || 334 | _jp.value( item, '$..viewCountText..simpleText' ) 335 | ) 336 | 337 | const viewsCount = Number( viewCountText.split( /\s+/ )[ 0 ].split( /[,.]/ ).join( '' ).trim() ) 338 | 339 | const lengthText = ( 340 | _jp.value( item, '$..lengthText..text' ) || 341 | _jp.value( item, '$..lengthText..simpleText' ) 342 | ) 343 | const duration = parseDuration( lengthText || '0:00' ) 344 | 345 | const description = ( 346 | _jp.value( item, '$..description..text' ) || 347 | _jp.value( item, '$..descriptionSnippet..text' ) 348 | ) 349 | 350 | // url ( playlist ) 351 | // const url = _jp.value( item, '$..navigationEndpoint..url' ) 352 | const url = TEMPLATES.YT + '/watch?v=' + videoId 353 | 354 | result = { 355 | type: 'video', 356 | 357 | videoId: videoId, 358 | url: url, 359 | 360 | title: title.trim(), 361 | description: description, 362 | 363 | thumbnail: _normalizeThumbnail( thumbnail ), 364 | 365 | seconds: Number( duration.seconds ), 366 | timestamp: duration.timestamp, 367 | duration: duration, 368 | 369 | ago: agoText, 370 | views: Number( viewsCount ), 371 | 372 | author: { 373 | name: author_name, 374 | url: TEMPLATES.YT + author_url, 375 | } 376 | } 377 | } 378 | break 379 | 380 | case 'list': 381 | { 382 | const thumbnail = _jp.value( item, '$..thumbnail..url' ) 383 | const title = ( 384 | _jp.value( item, '$..title..text' ) || 385 | _jp.value( item, '$..title..simpleText' ) 386 | ) 387 | 388 | const author_name = ( 389 | _jp.value( item, '$..shortBylineText..text' ) || 390 | _jp.value( item, '$..longBylineText..text' ) || 391 | _jp.value( item, '$..shortBylineText..simpleText' ) || 392 | _jp.value( item, '$..longBylineText..simpleTextn' ) 393 | ) || 'YouTube' 394 | 395 | const author_url = ( 396 | _jp.value( item, '$..shortBylineText..url' ) || 397 | _jp.value( item, '$..longBylineText..url' ) 398 | ) || '' 399 | 400 | const video_count = ( 401 | _jp.value( item, '$..videoCountShortText..text' ) || 402 | _jp.value( item, '$..videoCountText..text' ) || 403 | _jp.value( item, '$..videoCountShortText..simpleText' ) || 404 | _jp.value( item, '$..videoCountText..simpleText' ) || 405 | _jp.value( item, '$..thumbnailText..text' ) || 406 | _jp.value( item, '$..thumbnailText..simpleText' ) 407 | ) 408 | 409 | // url ( playlist ) 410 | // const url = _jp.value( item, '$..navigationEndpoint..url' ) 411 | const url = TEMPLATES.YT + '/playlist?list=' + listId 412 | 413 | result = { 414 | type: 'list', 415 | 416 | listId: listId, 417 | url: url, 418 | 419 | title: title.trim(), 420 | thumbnail: _normalizeThumbnail( thumbnail ), 421 | 422 | videoCount: video_count, 423 | 424 | author: { 425 | name: author_name, 426 | url: TEMPLATES.YT + author_url, 427 | } 428 | } 429 | } 430 | break 431 | 432 | case 'channel': 433 | { 434 | const thumbnail = _jp.value( item, '$..thumbnail..url' ) 435 | const title = ( 436 | _jp.value( item, '$..title..text' ) || 437 | _jp.value( item, '$..title..simpleText' ) || 438 | _jp.value( item, '$..displayName..text' ) 439 | ) 440 | 441 | const author_name = ( 442 | _jp.value( item, '$..shortBylineText..text' ) || 443 | _jp.value( item, '$..longBylineText..text' ) || 444 | _jp.value( item, '$..displayName..text' ) || 445 | _jp.value( item, '$..displayName..simpleText' ) 446 | ) 447 | 448 | const video_count_label = ( 449 | _jp.value( item, '$..videoCountText..text' ) || 450 | _jp.value( item, '$..videoCountText..simpleText' ) 451 | ) 452 | 453 | let sub_count_label = ( 454 | _jp.value( item, '$..subscriberCountText..text' ) || 455 | _jp.value( item, '$..subscriberCountText..simpleText' ) 456 | ) 457 | 458 | // first space separated word that has digits 459 | if ( typeof sub_count_label === 'string' ) { 460 | sub_count_label = ( 461 | sub_count_label.split( /\s+/ ) 462 | .filter( function ( w ) { return w.match( /\d/ ) } ) 463 | )[ 0 ] 464 | } 465 | 466 | // url ( playlist ) 467 | // const url = _jp.value( item, '$..navigationEndpoint..url' ) 468 | const url = ( 469 | _jp.value( item, '$..navigationEndpoint..url' ) || 470 | '/user/' + title 471 | ) 472 | 473 | result = { 474 | type: 'channel', 475 | 476 | name: author_name, 477 | url: TEMPLATES.YT + url, 478 | 479 | title: title.trim(), 480 | thumbnail: _normalizeThumbnail( thumbnail ), 481 | 482 | videoCount: Number( video_count_label.replace( /\D+/g, '' ) ), 483 | videoCountLabel: video_count_label, 484 | 485 | subCount: _parseSubCountLabel( sub_count_label ), 486 | subCountLabel: sub_count_label 487 | } 488 | } 489 | break 490 | 491 | default: 492 | // ignore other stuff 493 | } 494 | 495 | if ( result ) { 496 | results.push( result ) 497 | } 498 | } catch ( err ) { 499 | debug( err ) 500 | errors.push( err ) 501 | } 502 | } 503 | 504 | const ctoken = _jp.value( json, '$..continuation' ) 505 | results._ctoken = ctoken 506 | 507 | if ( errors.length ) { 508 | return callback( errors.pop(), results ) 509 | } 510 | 511 | return callback( null, results ) 512 | } 513 | 514 | function _parseVideoInitialData ( responseText, callback ) 515 | { 516 | debug( '_parseVideoInitialData' ) 517 | 518 | const re = /{.*}/ 519 | const $ = _cheerio.load( responseText ) 520 | 521 | let initialData = '' 522 | 523 | if ( !initialData ) { 524 | const scripts = $( 'script' ) 525 | 526 | for ( let i = 0; i < scripts.length; i++ ) { 527 | const script = $( scripts[ i ] ).html() 528 | 529 | const lines = script.split( '\n' ) 530 | lines.forEach( function ( line ) { 531 | let i 532 | while ( ( i = line.indexOf( 'ytInitialData' ) ) >= 0 ) { 533 | line = line.slice( i + 'ytInitialData'.length ) 534 | const match = re.exec( line ) 535 | if ( match && match.length > initialData.length ) { 536 | initialData = match 537 | } 538 | } 539 | } ) 540 | } 541 | } 542 | 543 | if ( !initialData ) { 544 | return callback( 'could not find inital data in the html document' ) 545 | } 546 | 547 | let initialPlayerData = '' 548 | 549 | if ( !initialPlayerData ) { 550 | const scripts = $( 'script' ) 551 | 552 | for ( let i = 0; i < scripts.length; i++ ) { 553 | const script = $( scripts[ i ] ).html() 554 | 555 | const lines = script.split( '\n' ) 556 | lines.forEach( function ( line ) { 557 | let i 558 | while ( ( i = line.indexOf( 'ytInitialPlayerResponse' ) ) >= 0 ) { 559 | line = line.slice( i + 'ytInitialPlayerResponse'.length ) 560 | const match = re.exec( line ) 561 | if ( match && match.length > initialPlayerData.length ) { 562 | initialPlayerData = match 563 | } 564 | } 565 | } ) 566 | } 567 | } 568 | 569 | if ( !initialPlayerData ) { 570 | return callback( 'could not find inital player data in the html document' ) 571 | } 572 | 573 | // debug( initialData[ 0 ] ) 574 | // debug( '\n------------------\n' ) 575 | // debug( initialPlayerData[ 0 ] ) 576 | 577 | const idata = JSON.parse( initialData[ 0 ] ) 578 | const ipdata = JSON.parse( initialPlayerData[ 0 ] ) 579 | 580 | const videoId = _jp.value( idata, '$..currentVideoEndpoint..videoId' ) 581 | 582 | if ( !videoId ) { 583 | return callback( 'video unavailable' ) 584 | } 585 | 586 | if ( 587 | _jp.value( ipdata, '$..status' ) === 'ERROR' || 588 | _jp.value( ipdata, '$..reason' ) === 'Video unavailable' 589 | ) { 590 | return callback( 'video unavailable' ) 591 | } 592 | 593 | const title = ( 594 | _jp.value( idata, '$..videoPrimaryInfoRenderer..title..text' ) || 595 | _jp.value( idata, '$..videoPrimaryInfoRenderer..title..simpleText' ) || 596 | _jp.value( idata, '$..videoPrimaryRenderer..title..text' ) || 597 | _jp.value( idata, '$..videoPrimaryRenderer..title..simpleText' ) || 598 | _jp.value( idata, '$..title..text' ) || 599 | _jp.value( idata, '$..title..simpleText' ) 600 | ) 601 | 602 | const description = ( 603 | _jp.value( ipdata, '$..description..text' ) || 604 | _jp.value( ipdata, '$..description..simpleText' ) || 605 | _jp.value( idata, '$..description..text' ) || 606 | _jp.value( idata, '$..description..simpleText' ) 607 | ) 608 | 609 | const author_name = ( 610 | _jp.value( idata, '$..owner..title..text' ) || 611 | _jp.value( idata, '$..owner..title..simpleText' ) 612 | ) 613 | 614 | const author_url = ( 615 | _jp.value( idata, '$..owner..navigationEndpoint..url' ) || 616 | _jp.value( idata, '$..owner..title..url' ) 617 | ) 618 | 619 | const thumbnailUrl = 'https://i.ytimg.com/vi/' + videoId + '/hqdefault.jpg' 620 | 621 | const seconds = Number( 622 | _jp.value( ipdata, '$..videoDetails..lengthSeconds' ) 623 | ) 624 | 625 | const timestamp = msToTimestamp( seconds * 1000 ) 626 | 627 | const duration = parseDuration( timestamp ) 628 | 629 | const sentimentBar = ( 630 | // ex. "tooltip": "116,701 / 8,930" 631 | _jp.value( idata, '$..sentimentBar..tooltip' ) 632 | .split( /[,.]/ ).join( '' ) 633 | .split( /\D+/ ) 634 | ) 635 | 636 | const likes = Number( sentimentBar[ 0 ] ) 637 | const dislikes = Number( sentimentBar[ 1 ] ) 638 | 639 | const uploadDate = _jp.value( idata, '$..dateText' ) 640 | 641 | const video = { 642 | title: title, 643 | description: description, 644 | 645 | url: TEMPLATES.YT + author_url, 646 | videoId: videoId, 647 | 648 | seconds: Number( duration.seconds ), 649 | timestamp: duration.timestamp, 650 | duration: duration, 651 | 652 | views: Number( 653 | _jp.value( ipdata, '$..videoDetails..viewCount' ) 654 | ), 655 | 656 | genre: ( _jp.value( ipdata, '$..category' ) || '' ).toLowerCase(), 657 | 658 | uploadDate: _jp.value( ipdata, '$..uploadDate' ), 659 | ago: _humanTime( new Date( uploadDate ) ), // ex: 10 years ago 660 | thumbnail: thumbnailUrl, 661 | 662 | author: { 663 | name: author_name, 664 | url: TEMPLATES.YT + author_url 665 | } 666 | } 667 | 668 | callback( null, video ) 669 | } 670 | 671 | function _parsePlaylistInitialData ( responseText, callback ) 672 | { 673 | debug( '_parsePlaylistInitialData' ) 674 | 675 | const re = /{.*}/ 676 | const $ = _cheerio.load( responseText ) 677 | 678 | let initialData = '' 679 | 680 | if ( !initialData ) { 681 | const scripts = $( 'script' ) 682 | 683 | for ( let i = 0; i < scripts.length; i++ ) { 684 | const script = $( scripts[ i ] ).html() 685 | 686 | const lines = script.split( '\n' ) 687 | lines.forEach( function ( line ) { 688 | let i 689 | while ( ( i = line.indexOf( 'ytInitialData' ) ) >= 0 ) { 690 | line = line.slice( i + 'ytInitialData'.length ) 691 | const match = re.exec( line ) 692 | if ( match && match.length > initialData.length ) { 693 | initialData = match 694 | } 695 | } 696 | } ) 697 | } 698 | } 699 | 700 | if ( !initialData ) { 701 | return callback( 'could not find inital data in the html document' ) 702 | } 703 | 704 | const idata = JSON.parse( initialData[ 0 ] ) 705 | 706 | const listId = _jp.value( idata, '$..playlistId' ) 707 | 708 | if ( !listId ) { 709 | return callback( 'playlist unavailable' ) 710 | } 711 | 712 | const title = ( 713 | _jp.value( idata, '$..microformat..title' ) || 714 | _jp.value( idata, '$..sidebar..title..text' ) || 715 | _jp.value( idata, '$..sidebar..title..simpleText' ) 716 | ) 717 | 718 | const url = TEMPLATES.YT + '/playlist?list=' + listId 719 | 720 | const thumbnailUrl = ( 721 | _jp.value( idata, '$..thumbnail..url' ) 722 | ) 723 | const thumbnail = _normalizeThumbnail( thumbnailUrl ) 724 | 725 | const description = ( 726 | _jp.value( idata, '$..microformat..description' ) || '' 727 | ) 728 | 729 | const author_name = ( 730 | _jp.value( idata, '$..videoOwner..title..text' ) || 731 | _jp.value( idata, '$..videoOwner..title..simpleText' ) || 732 | _jp.value( idata, '$..owner..title..text' ) || 733 | _jp.value( idata, '$..owner..title..simpleText' ) 734 | ) 735 | 736 | const author_url = ( 737 | _jp.value( idata, '$..videoOwner..title..url' ) || 738 | _jp.value( idata, '$..owner..title..url' ) 739 | ) 740 | 741 | let videos = ( 742 | _jp.value( idata, '$..sidebar..stats[0]..text' ) || 743 | _jp.value( idata, '$..sidebar..stats[0]..simpleText' ) 744 | ) 745 | 746 | let views = ( 747 | _jp.value( idata, '$..sidebar..stats[1]..text' ) || 748 | _jp.value( idata, '$..sidebar..stats[1]..simpleText' ) 749 | ) 750 | 751 | let lastUpdateLabel = ( 752 | _jp.value( idata, '$..sidebar..stats[2]..text' ) || 753 | _jp.value( idata, '$..sidebar..stats[2]..simpleText' ) 754 | ) 755 | 756 | if ( videos ) { 757 | videos = Number( videos.replace( /\D+/g, '' ) ) 758 | } 759 | 760 | if ( views ) { 761 | views = _parseSubCountLabel( views ) 762 | } 763 | 764 | console.log( lastUpdateLabel ) 765 | 766 | let lastUpdate 767 | if ( lastUpdateLabel ) { 768 | lastUpdate = _parsePlaylistLastUpdateTime( lastUpdateLabel ) 769 | } 770 | 771 | const items = _jp.query( idata, '$..playlistVideoRenderer' ) 772 | 773 | const list = [] 774 | 775 | for ( let i = 0; i < items.length; i++ ) { 776 | try { 777 | const item = items[ i ] 778 | 779 | const videoId = ( 780 | _jp.value( item, '$..videoId' ) 781 | ) 782 | 783 | const thumbnail = ( 784 | _normalizeThumbnail( _jp.value( item, '$..thumbnail..url' ) ) 785 | ) 786 | 787 | const title = ( 788 | _jp.value( item, '$..title..text' ) || 789 | _jp.value( item, '$..title..simpleText' ) 790 | ) 791 | 792 | const timestamp = ( 793 | _jp.value( item, '$..lengthText..text' ) || 794 | _jp.value( item, '$..lengthText..simpleText' ) 795 | ) 796 | 797 | const duration = parseDuration( timestamp || '0:00' ) 798 | 799 | const author_name = ( 800 | _jp.value( item, '$..shortBylineText..text' ) || 801 | _jp.value( item, '$..longBylineText..text' ) 802 | ) 803 | 804 | const author_url = ( 805 | _jp.value( item, '$..shortBylineText..url' ) || 806 | _jp.value( item, '$..longBylineText..url' ) 807 | ) 808 | 809 | list.push( { 810 | title: title, 811 | 812 | videoId: videoId, 813 | listId: listId, 814 | 815 | duration: duration, 816 | timestamp: duration.timestamp, 817 | seconds: duration.seconds, 818 | 819 | thumbnail: thumbnail, 820 | url: TEMPLATES.YT + '/watch?v=' + videoId, 821 | 822 | author: { 823 | name: author_name, 824 | url: TEMPLATES.YT + author_url 825 | } 826 | } ) 827 | } catch ( err ) { 828 | // possibly deleted videos, ignore 829 | } 830 | } 831 | 832 | const playlist = { 833 | title: title, 834 | 835 | listId: listId, 836 | 837 | url: TEMPLATES.YT + '/playlist?list=' + listId, 838 | thumbnail: thumbnail, 839 | 840 | videos: list, 841 | views: views, 842 | date: lastUpdate, 843 | 844 | author: { 845 | name: author_name, 846 | url: TEMPLATES.YT + author_url 847 | } 848 | } 849 | 850 | callback( null, playlist ) 851 | } 852 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const _cheerio = require( 'cheerio' ) 2 | const _dasu = require( 'dasu' ) 3 | const _parallel = require( 'async.parallellimit' ) 4 | 5 | // auto follow off 6 | _dasu.follow = true 7 | _dasu.debug = false 8 | 9 | const { _getScripts, _findLine, _between } = require( './util.js' ) 10 | 11 | const MAX_RETRY_ATTEMPTS = 3 12 | const RETRY_INTERVAL = 333 // ms 13 | 14 | const jpp = require( 'jsonpath-plus' ).JSONPath 15 | const _jp = {} 16 | 17 | // const items = _jp.query( json, '$..itemSectionRenderer..contents.*' ) 18 | _jp.query = function ( json, path ) { 19 | const opts = { 20 | path: path, 21 | json: json, 22 | resultType: 'value' 23 | } 24 | 25 | return jpp( opts ) 26 | } 27 | 28 | // const listId = hasList && ( _jp.value( item, '$..playlistId' ) ) 29 | _jp.value = function ( json, path ) { 30 | const opts = { 31 | path: path, 32 | json: json, 33 | resultType: 'value' 34 | } 35 | 36 | const r = jpp( opts )[ 0 ] 37 | return r 38 | } 39 | 40 | // google bot user-agent 41 | // Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html) 42 | 43 | // use fixed user-agent to get consistent html page documents as 44 | // it varies depending on the user-agent 45 | // the string "Googlebot" seems to give us pages without 46 | // warnings to update our browser, which is why we keep it in 47 | const DEFAULT_USER_AGENT = 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html) (yt-search; https://www.npmjs.com/package/yt-search)' 48 | 49 | let _userAgent = DEFAULT_USER_AGENT // mutable global user-agent 50 | 51 | const _url = require( 'url' ) 52 | 53 | const _envs = {} 54 | Object.keys( process.env ).forEach( 55 | function ( key ) { 56 | const n = process.env[ key ] 57 | if ( n == '0' || n == 'false' || !n ) { 58 | return _envs[ key ] = false 59 | } 60 | _envs[ key.toLowerCase() ] = n 61 | } 62 | ) 63 | 64 | const _debugging = _envs.debug 65 | 66 | function debug () { 67 | if ( !_debugging ) return 68 | console.log('DEBUGGING') 69 | console.log.apply( this, arguments ) 70 | } 71 | 72 | // used to escape query strings 73 | const _querystring = require( 'querystring' ) 74 | 75 | const _humanTime = require( 'human-time' ) 76 | 77 | const TEMPLATES = { 78 | YT: 'https://youtube.com', 79 | SEARCH_MOBILE: 'https://m.youtube.com/results', 80 | SEARCH_DESKTOP: 'https://www.youtube.com/results' 81 | } 82 | 83 | const ONE_SECOND = 1000 84 | const ONE_MINUTE = ONE_SECOND * 60 85 | const TIME_TO_LIVE = ONE_MINUTE * 5 86 | 87 | /** 88 | * Exports 89 | **/ 90 | module.exports = function ( query, callback ) { 91 | return search( query, callback ) 92 | } 93 | module.exports.search = search 94 | 95 | module.exports._parseSearchResultInitialData = _parseSearchResultInitialData 96 | module.exports._parseVideoInitialData = _parseVideoInitialData 97 | module.exports._parsePlaylistInitialData = _parsePlaylistInitialData 98 | 99 | module.exports._videoFilter = _videoFilter 100 | module.exports._playlistFilter = _playlistFilter 101 | module.exports._channelFilter = _channelFilter 102 | module.exports._liveFilter = _liveFilter 103 | module.exports._allFilter = _allFilter 104 | 105 | module.exports._parseNumbers = _parseNumbers 106 | 107 | module.exports._parsePlaylistLastUpdateTime = _parsePlaylistLastUpdateTime 108 | 109 | /** 110 | * Main 111 | */ 112 | function search ( query, callback ) 113 | { 114 | // support promises when no callback given 115 | if ( !callback ) { 116 | return new Promise( function ( resolve, reject ) { 117 | search( query, function ( err, data ) { 118 | if ( err ) return reject( err ) 119 | resolve( data ) 120 | } ) 121 | } ) 122 | } 123 | 124 | let _options 125 | if ( typeof query === 'string' ) { 126 | _options = { 127 | query: query 128 | } 129 | } else { 130 | _options = query 131 | } 132 | 133 | // init and increment attempts 134 | _options._attempts = ( _options._attempts || 0 ) + 1 135 | 136 | // save unmutated bare necessary options for retry 137 | const retryOptions = Object.assign( {}, _options ) 138 | 139 | function callback_with_retry ( err, data ) { 140 | if ( err ) { 141 | if ( _options._attempts > ( _options.MAX_RETRY_ATTEMPTS || MAX_RETRY_ATTEMPTS ) ) { 142 | return callback( err, data ) 143 | } else { 144 | // retry 145 | debug( ' === ' ) 146 | debug( ' RETRYING: ' + _options._attempts ) 147 | debug( ' === ' ) 148 | 149 | const n = _options._attempts 150 | const wait_ms = Math.pow( 2, n - 1 ) * (_options.RETRY_INTERVAL || RETRY_INTERVAL) 151 | 152 | setTimeout( function () { 153 | search( retryOptions, callback ) 154 | }, wait_ms ) 155 | } 156 | } else { 157 | return callback( err, data ) 158 | } 159 | } 160 | 161 | // override userAgent if set ( not recommended ) 162 | if ( _options.userAgent ) _userAgent = _options.userAgent 163 | 164 | // support common alternatives ( mutates ) 165 | _options.search = _options.query || _options.search 166 | 167 | // initial search text ( _options.search is mutated ) 168 | _options.original_search = _options.search 169 | 170 | // ignore query, only get metadata from specific video id 171 | if ( _options.videoId ) { 172 | return getVideoMetaData( _options, callback_with_retry ) 173 | } 174 | 175 | // ignore query, only get metadata from specific playlist id 176 | if ( _options.listId ) { 177 | return getPlaylistMetaData( _options, callback_with_retry ) 178 | } 179 | 180 | if ( !_options.search ) { 181 | return callback( Error( 'yt-search: no query given' ) ) 182 | } 183 | 184 | work() 185 | 186 | function work () { 187 | getSearchResults( _options, callback_with_retry ) 188 | } 189 | } 190 | 191 | function _videoFilter ( video, index, videos ) 192 | { 193 | if ( video.type !== 'video' ) return false 194 | 195 | // filter duplicates 196 | const videoId = video.videoId 197 | 198 | const firstIndex = videos.findIndex( function ( el ) { 199 | return ( videoId === el.videoId ) 200 | } ) 201 | 202 | return ( firstIndex === index ) 203 | } 204 | 205 | function _playlistFilter ( result, index, results ) 206 | { 207 | if ( result.type !== 'list' ) return false 208 | 209 | // filter duplicates 210 | const id = result.listId 211 | 212 | const firstIndex = results.findIndex( function ( el ) { 213 | return ( id === el.listId ) 214 | } ) 215 | 216 | return ( firstIndex === index ) 217 | } 218 | 219 | function _channelFilter ( result, index, results ) 220 | { 221 | if ( result.type !== 'channel' ) return false 222 | 223 | // filter duplicates 224 | const url = result.url 225 | 226 | const firstIndex = results.findIndex( function ( el ) { 227 | return ( url === el.url ) 228 | } ) 229 | 230 | return ( firstIndex === index ) 231 | } 232 | 233 | function _liveFilter ( result, index, results ) 234 | { 235 | if ( result.type !== 'live' ) return false 236 | 237 | // filter duplicates 238 | const videoId = result.videoId 239 | 240 | const firstIndex = results.findIndex( function ( el ) { 241 | return ( videoId === el.videoId ) 242 | } ) 243 | 244 | return ( firstIndex === index ) 245 | } 246 | 247 | function _allFilter ( result, index, results ) 248 | { 249 | switch ( result.type ) { 250 | case 'video': 251 | case 'list': 252 | case 'channel': 253 | case 'live': 254 | break 255 | 256 | default: 257 | // unsupported type 258 | return false 259 | } 260 | 261 | // filter duplicates 262 | const url = result.url 263 | 264 | const firstIndex = results.findIndex( function ( el ) { 265 | return ( url === el.url ) 266 | } ) 267 | 268 | return ( firstIndex === index ) 269 | } 270 | 271 | /* Request search page results with provided 272 | * search_query term 273 | */ 274 | function getSearchResults ( _options, callback ) 275 | { 276 | // querystring variables 277 | const q = _querystring.escape( _options.search ).split( /\s+/ ) 278 | const hl = _options.hl || 'en' 279 | const gl = _options.gl || 'US' 280 | const category = _options.category || '' // music 281 | 282 | let pageStart = ( 283 | Number( _options.pageStart ) || 1 284 | ) 285 | 286 | let pageEnd = ( 287 | Number( _options.pageEnd ) || 288 | Number( _options.pages ) || 1 289 | ) 290 | 291 | // handle zero-index start 292 | if ( pageStart <= 0 ) { 293 | pageStart = 1 294 | if ( pageEnd >= 1 ) { 295 | pageEnd += 1 296 | } 297 | } 298 | 299 | if ( Number.isNaN( pageEnd ) ) { 300 | callback( 'error: pageEnd must be a number' ) 301 | } 302 | 303 | _options.pageStart = pageStart 304 | _options.pageEnd = pageEnd 305 | _options.currentPage = _options.currentPage || pageStart 306 | 307 | let queryString = '?' 308 | queryString += 'search_query=' + q.join( '+' ) 309 | 310 | // language 311 | // queryString += '&' 312 | if ( queryString.indexOf( '&hl=' ) === -1 ) { 313 | queryString += '&hl=' + hl 314 | } 315 | 316 | // location 317 | // queryString += '&' 318 | if ( queryString.indexOf( '&gl=' ) === -1 ) { 319 | queryString += '&gl=' + gl 320 | } 321 | 322 | if ( category ) { // ex. "music" 323 | queryString += '&category=' + category 324 | } 325 | 326 | if ( _options.sp ) { 327 | queryString += '&sp=' + _options.sp 328 | } 329 | 330 | const uri = TEMPLATES.SEARCH_DESKTOP + queryString 331 | 332 | const params = _url.parse( uri ) 333 | 334 | params.headers = { 335 | 'user-agent': _userAgent, 336 | 'accept': 'text/html', 337 | 'accept-encoding': 'gzip', 338 | 'accept-language': 'en-US' 339 | } 340 | 341 | debug( params ) 342 | 343 | debug( 'getting results: ' + _options.currentPage ) 344 | _dasu.req( params, function ( err, res, body ) { 345 | if ( err ) { 346 | callback( err ) 347 | } else { 348 | if ( res.status !== 200 ) { 349 | return callback( 'http status: ' + res.status ) 350 | } 351 | 352 | if ( _debugging ) { 353 | const fs = require( 'fs' ) 354 | const path = require( 'path' ) 355 | fs.writeFileSync( 'dasu.response', res.responseText, 'utf8' ) 356 | } 357 | 358 | try { 359 | _parseSearchResultInitialData( body, function ( err, results ) { 360 | if ( err ) return callback( err ) 361 | 362 | const list = results 363 | 364 | const videos = list.filter( _videoFilter ) 365 | const playlists = list.filter( _playlistFilter ) 366 | const channels = list.filter( _channelFilter ) 367 | const live = list.filter( _liveFilter ) 368 | const all = list.filter( _allFilter ) 369 | 370 | // keep saving results into temporary memory while 371 | // we get more results 372 | _options._data = _options._data || {} 373 | 374 | // init memory 375 | _options._data.videos = _options._data.videos || [] 376 | _options._data.playlists = _options._data.playlists || [] 377 | _options._data.channels = _options._data.channels || [] 378 | _options._data.live = _options._data.live || [] 379 | _options._data.all = _options._data.all || [] 380 | 381 | // push received results into memory 382 | videos.forEach( function ( item ) { 383 | _options._data.videos.push( item ) 384 | } ) 385 | playlists.forEach( function ( item ) { 386 | _options._data.playlists.push( item ) 387 | } ) 388 | channels.forEach( function ( item ) { 389 | _options._data.channels.push( item ) 390 | } ) 391 | live.forEach( function ( item ) { 392 | _options._data.live.push( item ) 393 | } ) 394 | all.forEach( function ( item ) { 395 | _options._data.all.push( item ) 396 | } ) 397 | 398 | _options.currentPage++ 399 | const getMoreResults = ( 400 | _options.currentPage <= _options.pageEnd 401 | ) 402 | 403 | if ( getMoreResults && results._sp ) { 404 | _options.sp = results._sp 405 | 406 | setTimeout( function () { 407 | getSearchResults( _options, callback ) 408 | }, 2500 ) // delay a bit to try and prevent throttling 409 | } else { 410 | const videos = _options._data.videos.filter( _videoFilter ) 411 | const playlists = _options._data.playlists.filter( _playlistFilter ) 412 | const channels = _options._data.channels.filter( _channelFilter ) 413 | const live = _options._data.live.filter( _liveFilter ) 414 | const all = _options._data.all.slice( _allFilter ) 415 | 416 | // return all found videos 417 | callback( null, { 418 | all: all, 419 | 420 | videos: videos, 421 | 422 | live: live, 423 | 424 | playlists: playlists, 425 | lists: playlists, 426 | 427 | accounts: channels, 428 | channels: channels 429 | } ) 430 | } 431 | } ) 432 | } catch ( err ) { 433 | callback( err ) 434 | } 435 | } 436 | } ) 437 | } 438 | 439 | /* For "modern" user-agents the html document returned from 440 | * YouTube contains initial json data that is used to populate 441 | * the page with JavaScript. This function will aim to find and 442 | * parse such data. 443 | */ 444 | function _parseSearchResultInitialData ( responseText, callback ) 445 | { 446 | const re = /{.*}/ 447 | const $ = _cheerio.load( responseText ) 448 | 449 | let initialData = $( 'div#initial-data' ).html() || '' 450 | initialData = re.exec( initialData ) || '' 451 | 452 | if ( !initialData ) { 453 | const scripts = $( 'script' ) 454 | 455 | for ( let i = 0; i < scripts.length; i++ ) { 456 | const script = $( scripts[ i ] ).html() 457 | 458 | const lines = script.split( '\n' ) 459 | lines.forEach( function ( line ) { 460 | let i 461 | while ( ( i = line.indexOf( 'ytInitialData' ) ) >= 0 ) { 462 | line = line.slice( i + 'ytInitialData'.length ) 463 | const match = re.exec( line ) 464 | if ( match && match.length > initialData.length ) { 465 | initialData = match 466 | } 467 | } 468 | } ) 469 | } 470 | } 471 | 472 | if ( !initialData ) { 473 | return callback( 'could not find inital data in the html document' ) 474 | } 475 | 476 | const errors = [] 477 | const results = [] 478 | 479 | const json = JSON.parse( initialData[ 0 ] ) 480 | let items = _jp.query( json, '$..itemSectionRenderer..contents.*' ) 481 | 482 | // support newer richGridRenderer html structure 483 | _jp.query( json, '$..primaryContents..contents.*' ).forEach( function ( item ) { 484 | items.push( item ) 485 | }) 486 | 487 | debug( 'items.length: ' + items.length ) 488 | for ( let i = 0; i < items.length; i++ ) { 489 | const item = items[ i ] 490 | 491 | let result = undefined 492 | let type = 'unknown' 493 | 494 | const hasList = ( 495 | _jp.value( item, '$..compactPlaylistRenderer' ) || 496 | _jp.value( item, '$..playlistRenderer' ) || 497 | _jp.value( item, `$..lockupViewModel..metadata..metadataRows[0]..metadataParts[1]..text.[?(@property == "content" && @ == "Playlist")]` ) 498 | ) 499 | 500 | // https://jsonpath-plus.github.io/JSONPath/demo 501 | // $..lockupViewModel..metadata..metadataRows[0]..metadataParts[1]..text.[?(@property == "content" && @ == "Playlist")] 502 | // console.log('hasList: ' + hasList) 503 | 504 | const hasChannel = ( 505 | _jp.value( item, '$..compactChannelRenderer' ) || 506 | _jp.value( item, '$..channelRenderer' ) 507 | ) 508 | 509 | const hasVideo = ( 510 | _jp.value( item, '$..compactVideoRenderer' ) || 511 | _jp.value( item, '$..videoRenderer' ) 512 | ) 513 | 514 | const listId = hasList && ( _jp.value( item, '$..watchEndpoint..playlistId' ) ) 515 | const channelId = hasChannel && ( _jp.value( item, '$..channelId' ) ) 516 | const videoId = hasVideo && ( _jp.value( item, '$..videoId' ) ) 517 | 518 | const watchingLabel = ( _jp.query( item, '$..viewCountText..text' ) ).join( '' ) 519 | 520 | const isUpcoming = ( 521 | // if scheduled livestream (has not started yet) 522 | ( 523 | _jp.query( item, '$..thumbnailOverlayTimeStatusRenderer..style' ).join( '' ).toUpperCase().trim() === 'UPCOMING' 524 | ) 525 | ) 526 | 527 | const isLive = ( 528 | watchingLabel.indexOf( 'watching' ) >= 0 || 529 | ( 530 | _jp.query( item, '$..badges..label' ).join( '' ).toUpperCase().trim() === 'LIVE NOW' 531 | ) || 532 | ( 533 | _jp.query( item, '$..thumbnailOverlayTimeStatusRenderer..text' ).join( '' ).toUpperCase().trim() === 'LIVE' 534 | ) || isUpcoming 535 | ) 536 | 537 | if ( videoId ) { 538 | type = 'video' 539 | } 540 | 541 | if ( channelId ) { 542 | type = 'channel' 543 | } 544 | 545 | if ( listId ) { 546 | type = 'list' 547 | } 548 | 549 | if ( isLive ) { 550 | type = 'live' 551 | } 552 | 553 | try { 554 | switch ( type ) { 555 | case 'video': 556 | { 557 | const thumbnail = ( 558 | _normalizeThumbnail( _jp.value( item, '$..thumbnail..url' ) ) || 559 | _normalizeThumbnail( _jp.value( item, '$..thumbnails..url' ) ) || 560 | _normalizeThumbnail( _jp.value( item, '$..thumbnails' ) ) 561 | ) 562 | 563 | const title = ( 564 | _jp.value( item, '$..title..text' ) || 565 | _jp.value( item, '$..title..simpleText' ) 566 | ) 567 | 568 | const author_name = ( 569 | _jp.value( item, '$..shortBylineText..text' ) || 570 | _jp.value( item, '$..longBylineText..text' ) 571 | ) 572 | 573 | const author_url = ( 574 | _jp.value( item, '$..shortBylineText..url' ) || 575 | _jp.value( item, '$..longBylineText..url' ) 576 | ) 577 | 578 | // publish/upload date 579 | const agoText = ( 580 | _jp.value( item, '$..publishedTimeText..text' ) || 581 | _jp.value( item, '$..publishedTimeText..simpleText' ) 582 | ) 583 | 584 | const viewCountText = ( 585 | _jp.value( item, '$..viewCountText..text' ) || 586 | _jp.value( item, '$..viewCountText..simpleText' ) || "0" 587 | ) 588 | 589 | const viewsCount = Number( viewCountText.split( /\s+/ )[ 0 ].split( /[,.]/ ).join( '' ).trim() ) 590 | 591 | const lengthText = ( 592 | _jp.value( item, '$..lengthText..text' ) || 593 | _jp.value( item, '$..lengthText..simpleText' ) 594 | ) 595 | const duration = _parseDuration( lengthText || '0:00' ) 596 | 597 | const description = ( 598 | ( _jp.query( item, '$..detailedMetadataSnippets..snippetText..text' ) ).join( '' ) || 599 | ( _jp.query( item, '$..description..text' ) ).join( '' ) || 600 | ( _jp.query( item, '$..descriptionSnippet..text' ) ).join( '' ) 601 | ) 602 | 603 | // url ( playlist ) 604 | // const url = _jp.value( item, '$..navigationEndpoint..url' ) 605 | const url = TEMPLATES.YT + '/watch?v=' + videoId 606 | 607 | result = { 608 | type: 'video', 609 | 610 | videoId: videoId, 611 | url: url, 612 | 613 | title: title.trim(), 614 | description: description, 615 | 616 | image: thumbnail, 617 | thumbnail: thumbnail, 618 | 619 | seconds: Number( duration.seconds ), 620 | timestamp: duration.timestamp, 621 | duration: duration, 622 | 623 | ago: agoText, 624 | views: Number( viewsCount ), 625 | 626 | author: { 627 | name: author_name, 628 | url: TEMPLATES.YT + author_url, 629 | } 630 | } 631 | } 632 | break 633 | 634 | case 'list': 635 | { 636 | const thumbnail = ( 637 | _normalizeThumbnail( _jp.value( item, '$..primaryThumbnail..url' ) ) || 638 | _normalizeThumbnail( _jp.value( item, '$..thumbnail..url' ) ) || // DEPRECATED? 639 | _normalizeThumbnail( _jp.value( item, '$..thumbnails..url' ) ) || // DEPRECATED 640 | _normalizeThumbnail( _jp.value( item, '$..thumbnails' ) ) // DEPRECATED? 641 | ) 642 | 643 | const title = ( 644 | _jp.value( item, '$..metadata..title..content' ) || 645 | _jp.value( item, '$..title..text' ) || // DEPRECATED? 646 | _jp.value( item, '$..title..simpleText' ) // DEPRECATED? 647 | ) 648 | 649 | const author_name = ( 650 | _jp.value( item, '$..metadataParts[0]..text..content' ) || 651 | _jp.value( item, '$..shortBylineText..text' ) || // DEPRECATED? 652 | _jp.value( item, '$..shortBylineText..text' ) || // DEPRECATED? 653 | _jp.value( item, '$..longBylineText..text' ) || // DEPRECATED? 654 | _jp.value( item, '$..shortBylineText..simpleText' ) || // DEPRECATED? 655 | _jp.value( item, '$..longBylineText..simpleTextn' ) // DEPRECATED? 656 | ) || 'YouTube' 657 | 658 | const author_url = ( 659 | _jp.value( item, '$..metadataParts[0]..url' ) || 660 | _jp.value( item, '$..shortBylineText..url' ) || // DEPRECATED? 661 | _jp.value( item, '$..longBylineText..url' ) // DEPRECATED? 662 | ) || '' 663 | 664 | const video_count_label = ( 665 | _jp.value( item, '$..overlays..thumbnailBadges..text' ) 666 | ) 667 | // console.log('video_count_label: ' + video_count_label) 668 | 669 | const video_count = ( 670 | _jp.value( item, '$..videoCountShortText..text' ) || // DEPRECATED? 671 | _jp.value( item, '$..videoCountText..text' ) || // DEPRECATED? 672 | _jp.value( item, '$..videoCountShortText..simpleText' ) || // DEPRECATED? 673 | _jp.value( item, '$..videoCountText..simpleText' ) || // DEPRECATED? 674 | _jp.value( item, '$..thumbnailText..text' ) || // DEPRECATED? 675 | _jp.value( item, '$..thumbnailText..simpleText' ) // DEPRECATED? 676 | ) 677 | 678 | // url ( playlist ) 679 | // const url = _jp.value( item, '$..navigationEndpoint..url' ) 680 | const url = TEMPLATES.YT + '/playlist?list=' + listId 681 | 682 | result = { 683 | type: 'list', 684 | 685 | listId: listId, 686 | url: url, 687 | 688 | title: title.trim(), 689 | 690 | image: thumbnail, 691 | thumbnail: thumbnail, 692 | 693 | videoCount: ( 694 | Number( _parseNumbers( video_count_label )[0] ) || 695 | video_count 696 | ), 697 | 698 | author: { 699 | name: author_name, 700 | url: TEMPLATES.YT + author_url, 701 | } 702 | } 703 | } 704 | break 705 | 706 | case 'channel': 707 | { 708 | const thumbnail = ( 709 | _normalizeThumbnail( _jp.value( item, '$..thumbnail..url' ) ) || 710 | _normalizeThumbnail( _jp.value( item, '$..thumbnails..url' ) ) || 711 | _normalizeThumbnail( _jp.value( item, '$..thumbnails' ) ) 712 | ) 713 | 714 | const title = ( 715 | _jp.value( item, '$..title..text' ) || 716 | _jp.value( item, '$..title..simpleText' ) || 717 | _jp.value( item, '$..displayName..text' ) 718 | ) 719 | 720 | const channelId = ( 721 | _jp.value(item, '$..channelRenderer..channelId') || '' 722 | ) 723 | 724 | const author_name = ( 725 | _jp.value( item, '$..shortBylineText..text' ) || 726 | _jp.value( item, '$..longBylineText..text' ) || 727 | _jp.value( item, '$..displayName..text' ) || 728 | _jp.value( item, '$..displayName..simpleText' ) 729 | ) 730 | 731 | let about_channel = ( 732 | ( _jp.query( item, '$..channelRenderer..descriptionSnippet..text' ) ).join( '' ) || '' 733 | ) 734 | 735 | let video_count_label = ( 736 | _jp.value( item, '$..videoCountText..simpleText' ) || 737 | _jp.value( item, '$..videoCountText..label' ) || 738 | _jp.value( item, '$..videoCountText..text' ) || '0' 739 | ) 740 | 741 | let channel_verified_label = ( 742 | _jp.value(item, '$..channelRenderer..ownerBadges..style') || 743 | _jp.value(item, '$..channelRenderer..ownerBadges..tooltip') || 744 | _jp.value(item, '$..channelRenderer..ownerBadges..label') || '' 745 | ) 746 | const channel_verified = ( 747 | // has "verified" or "_verified" text in it 748 | channel_verified_label.toLowerCase().trim().search(/[\s_]?verified/) >= 0 749 | ) 750 | 751 | let sub_count_label = ( 752 | _jp.value( item, '$..subscriberCountText..simpleText' ) || 753 | _jp.value( item, '$..subscriberCountText..text' ) || '0' 754 | ) 755 | 756 | // first space separated word that has digits 757 | if ( typeof sub_count_label === 'string' ) { 758 | // handle case where subscriberCountText actually has new 759 | // @handle name and videoCountText has sub count 760 | // ref: https://github.com/talmobi/yt-search/issues/71 761 | if (sub_count_label.indexOf('subscribe') < 1) { 762 | if (video_count_label.indexOf('subscribe') > 0) { 763 | sub_count_label = video_count_label 764 | video_count_label = '-1' 765 | } 766 | } 767 | 768 | sub_count_label = ( 769 | sub_count_label.split( /\s+/ ) 770 | .filter( function ( w ) { return w.match( /\d/ ) } ) 771 | )[ 0 ] 772 | } 773 | 774 | // base url 775 | const base_url = ( 776 | _jp.value( item, '$..navigationEndpoint..url' ) || 777 | _jp.value( item, '$..browseEndpoint..canonicalBaseUrl' ) || 778 | _jp.value( item, '$..browseEndpoint..url' ) || 779 | '/user/' + title 780 | ) 781 | 782 | result = { 783 | type: 'channel', 784 | 785 | name: author_name, 786 | url: TEMPLATES.YT + base_url, 787 | 788 | baseUrl: base_url, 789 | id: channelId, 790 | 791 | title: title.trim(), 792 | about: about_channel, 793 | 794 | image: thumbnail, 795 | thumbnail: thumbnail, 796 | 797 | videoCount: Number( _parseNumbers( video_count_label )[0] ), 798 | videoCountLabel: video_count_label, 799 | 800 | verified: channel_verified, 801 | subCount: _parseSubCountLabel( sub_count_label ), 802 | subCountLabel: sub_count_label 803 | } 804 | } 805 | break 806 | 807 | case 'live': 808 | { 809 | const thumbnail = ( 810 | _normalizeThumbnail( _jp.value( item, '$..thumbnail..url' ) ) || 811 | _normalizeThumbnail( _jp.value( item, '$..thumbnails..url' ) ) || 812 | _normalizeThumbnail( _jp.value( item, '$..thumbnails' ) ) 813 | ) 814 | 815 | const title = ( 816 | _jp.value( item, '$..title..text' ) || 817 | _jp.value( item, '$..title..simpleText' ) 818 | ) 819 | 820 | const author_name = ( 821 | _jp.value( item, '$..shortBylineText..text' ) || 822 | _jp.value( item, '$..longBylineText..text' ) 823 | ) 824 | 825 | const author_url = ( 826 | _jp.value( item, '$..shortBylineText..url' ) || 827 | _jp.value( item, '$..longBylineText..url' ) 828 | ) 829 | 830 | const watchingLabel = ( 831 | ( _jp.query( item, '$..viewCountText..text' ) ).join( '' ) || 832 | ( _jp.query( item, '$..viewCountText..simpleText' ) ).join( '' ) || '0' 833 | ) 834 | 835 | const watchCount = Number( watchingLabel.split( /\s+/ )[ 0 ].split( /[,.]/ ).join( '' ).trim() ) 836 | 837 | const description = ( 838 | ( _jp.query( item, '$..detailedMetadataSnippets..snippetText..text' ) ).join( '' ) || 839 | ( _jp.query( item, '$..description..text' ) ).join( '' ) || 840 | ( _jp.query( item, '$..descriptionSnippet..text' ) ).join( '' ) 841 | ) 842 | 843 | const scheduledEpochTime = ( 844 | _jp.value( item, '$..upcomingEventData..startTime' ) 845 | ) 846 | 847 | const scheduledTime = ( 848 | ( Date.now() > scheduledEpochTime ) ? scheduledEpochTime * 1000 : scheduledEpochTime 849 | ) 850 | 851 | const scheduledDateString = _toInternalDateString( scheduledTime ) 852 | 853 | // url ( playlist ) 854 | // const url = _jp.value( item, '$..navigationEndpoint..url' ) 855 | const url = TEMPLATES.YT + '/watch?v=' + videoId 856 | 857 | result = { 858 | type: 'live', 859 | 860 | videoId: videoId, 861 | url: url, 862 | 863 | title: title.trim(), 864 | description: description, 865 | 866 | image: thumbnail, 867 | thumbnail: thumbnail, 868 | 869 | watching: Number( watchCount ), 870 | 871 | author: { 872 | name: author_name, 873 | url: TEMPLATES.YT + author_url, 874 | } 875 | } 876 | 877 | if ( scheduledTime ) { 878 | result.startTime = scheduledTime 879 | result.startDate = scheduledDateString 880 | result.status = 'UPCOMING' 881 | } else { 882 | result.status = 'LIVE' 883 | } 884 | } 885 | break 886 | 887 | default: 888 | // ignore other stuff 889 | } 890 | 891 | if ( result ) { 892 | results.push( result ) 893 | } 894 | } catch ( err ) { 895 | debug( err ) 896 | errors.push( err ) 897 | } 898 | } 899 | 900 | const ctoken = _jp.value( json, '$..continuation' ) 901 | results._ctoken = ctoken 902 | 903 | if ( errors.length ) { 904 | return callback( errors.pop(), results ) 905 | } 906 | 907 | return callback( null, results ) 908 | } 909 | 910 | /* Get metadata of a single video 911 | */ 912 | function getVideoMetaData ( opts, callback ) 913 | { 914 | debug( 'fn: getVideoMetaData' ) 915 | 916 | let videoId 917 | 918 | if ( typeof opts === 'string' ) { 919 | videoId = opts 920 | } 921 | 922 | if ( typeof opts === 'object' ) { 923 | videoId = opts.videoId 924 | } 925 | 926 | const { hl = 'en', gl = 'US' } = opts 927 | const uri = `https://www.youtube.com/watch?hl=${hl}&gl=${gl}&v=${videoId}` 928 | 929 | const params = _url.parse( uri ) 930 | 931 | params.headers = { 932 | 'user-agent': _userAgent, 933 | 'accept': 'text/html', 934 | 'accept-encoding': 'gzip', 935 | 'accept-language': `${hl}-${gl}` 936 | } 937 | 938 | params.headers[ 'user-agent' ] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Safari/605.1.15' 939 | 940 | _dasu.req( params, function ( err, res, body ) { 941 | if ( err ) { 942 | callback( err ) 943 | } else { 944 | if ( res.status !== 200 ) { 945 | return callback( 'http status: ' + res.status ) 946 | } 947 | 948 | if ( _debugging ) { 949 | const fs = require( 'fs' ) 950 | const path = require( 'path' ) 951 | fs.writeFileSync( 'dasu.response', res.responseText, 'utf8' ) 952 | } 953 | 954 | try { 955 | _parseVideoInitialData( body, callback ) 956 | } catch ( err ) { 957 | callback( err ) 958 | } 959 | } 960 | } ) 961 | } 962 | 963 | function _parseVideoInitialData ( responseText, callback ) 964 | { 965 | debug( '_parseVideoInitialData' ) 966 | 967 | // const fs = require( 'fs' ) 968 | // fs.writeFileSync( 'tmp.file', responseText ) 969 | 970 | responseText = _getScripts( responseText ) 971 | 972 | const initialData = _between( 973 | _findLine( /ytInitialData.*=\s*{/, responseText ), '{', '}' 974 | ) 975 | 976 | if ( !initialData ) { 977 | return callback( 'could not find inital data in the html document' ) 978 | } 979 | 980 | const initialPlayerData = _between( 981 | _findLine( /ytInitialPlayerResponse.*=\s*{/, responseText ), '{', '}' 982 | ) 983 | 984 | if ( !initialPlayerData ) { 985 | return callback( 'could not find inital player data in the html document' ) 986 | } 987 | 988 | // debug( initialData[ 0 ] ) 989 | // debug( '\n------------------\n' ) 990 | // debug( initialPlayerData[ 0 ] ) 991 | 992 | let idata = JSON.parse( initialData ) 993 | let ipdata = JSON.parse( initialPlayerData ) 994 | 995 | const videoId = _jp.value( idata, '$..currentVideoEndpoint..videoId' ) 996 | 997 | if ( !videoId ) { 998 | return callback( 'video unavailable' ) 999 | } 1000 | 1001 | if ( 1002 | _jp.value( ipdata, '$..status' ) === 'ERROR' || 1003 | _jp.value( ipdata, '$..reason' ) === 'Video unavailable' 1004 | ) { 1005 | return callback( 'video unavailable' ) 1006 | } 1007 | 1008 | const title = _parseVideoMetaDataTitle( idata ) 1009 | 1010 | const description = ( 1011 | ( _jp.query( idata, '$..detailedMetadataSnippets..snippetText..text' ) ).join( '' ) || 1012 | ( _jp.query( idata, '$..description..text' ) ).join( '' ) || 1013 | ( _jp.query( ipdata, '$..description..simpleText' ) ).join( '' ) || 1014 | ( _jp.query( ipdata, '$..microformat..description..simpleText' ) ).join( '' ) || 1015 | ( _jp.query( ipdata, '$..videoDetails..shortDescription' ) ).join( '' ) 1016 | ) 1017 | 1018 | const author_name = ( 1019 | _jp.value( idata, '$..owner..title..text' ) || 1020 | _jp.value( idata, '$..owner..title..simpleText' ) 1021 | ) 1022 | 1023 | const author_url = ( 1024 | _jp.value( idata, '$..owner..navigationEndpoint..url' ) || 1025 | _jp.value( idata, '$..owner..title..url' ) 1026 | ) 1027 | 1028 | const thumbnailUrl = 'https://i.ytimg.com/vi/' + videoId + '/hqdefault.jpg' 1029 | // const thumbnailUrl = ( 1030 | // _jp.value( idata, '$..thumbnail..url' ) || 1031 | // _jp.value( idata, '$..thumbnails..url' ) || 1032 | // 'https://i.ytimg.com/vi/' + videoId + '/hqdefault.jpg' 1033 | // ) 1034 | 1035 | const seconds = Number( 1036 | _jp.value( ipdata, '$..videoDetails..lengthSeconds' ) 1037 | ) 1038 | 1039 | const timestamp = _msToTimestamp( seconds * 1000 ) 1040 | 1041 | const duration = _parseDuration( timestamp ) 1042 | 1043 | // TODO some video's have likes/dislike ratio hidden (ex: 62ezXENOuIA) 1044 | // which makes this value undefined 1045 | // const sentimentBar = ( 1046 | // // ex. "tooltip": "116,701 / 8,930" 1047 | // _jp.value( idata, '$..sentimentBar..tooltip' ) 1048 | // .split( /[,.]/ ).join( '' ) 1049 | // .split( /\D+/ ) 1050 | // ) 1051 | // 1052 | // TODO currently not in use 1053 | // const likes = Number( sentimentBar[ 0 ] ) 1054 | // const dislikes = Number( sentimentBar[ 1 ] ) 1055 | 1056 | const uploadDate = ( 1057 | _jp.value( idata, '$..uploadDate' ) || 1058 | _jp.value( idata, '$..dateText..simpleText' ) 1059 | ) 1060 | 1061 | const agoText = uploadDate && _humanTime( new Date( uploadDate ) ) || '' 1062 | 1063 | const video = { 1064 | title: title, 1065 | description: description, 1066 | 1067 | url: TEMPLATES.YT + '/watch?v=' + videoId, 1068 | videoId: videoId, 1069 | 1070 | seconds: Number( duration.seconds ), 1071 | timestamp: duration.timestamp, 1072 | duration: duration, 1073 | 1074 | views: Number( 1075 | _jp.value( ipdata, '$..videoDetails..viewCount' ) 1076 | ), 1077 | 1078 | genre: ( _jp.value( ipdata, '$..category' ) || '' ).toLowerCase(), 1079 | 1080 | uploadDate: _toInternalDateString( uploadDate ), 1081 | ago: agoText, // ex: 10 years ago 1082 | 1083 | image: thumbnailUrl, 1084 | thumbnail: thumbnailUrl, 1085 | 1086 | author: { 1087 | name: author_name, 1088 | url: TEMPLATES.YT + author_url 1089 | } 1090 | } 1091 | 1092 | // some vital information like video duration and video views are no 1093 | // longer available in the ytInitialData from a direct video link -- but 1094 | // they are still available from youtube results -- we will try to fill 1095 | // in these missing information using a hack to search based on the video 1096 | // id of the video and finding the same id in the video results -- 1097 | // genre/category information seems to be lost completely 1098 | if (!video.description || !video.timestamp || !video.seconds || !video.views) { 1099 | debug('in video metadata backup to fill in missing data') 1100 | let q = `${video.title}` 1101 | 1102 | debug( 'q (before): ' + q ) 1103 | // remove characters that mess up normal behaviour from id for searching 1104 | while (q && q[0].match(/[-]/)) q = q.slice(1) 1105 | debug( 'q (after) : ' + q ) 1106 | 1107 | setTimeout(function () { 1108 | search( { 1109 | query: q, 1110 | options: { 1111 | RETRY_INTERVAL: 1000 1112 | }, 1113 | }, function (err, r) { 1114 | if (err) return callback(err) 1115 | if (!r.videos) return callback( null, video ) 1116 | for (let i = 0; i < r.videos.length; i++) { 1117 | const v = r.videos[i] 1118 | if (!v) continue; 1119 | if (video.videoId != null && video.videoId === v?.videoId) { 1120 | Object.keys(video).forEach(function (key) { 1121 | video[key] = v[key] || video[key] 1122 | }) 1123 | break 1124 | } 1125 | } 1126 | callback( err, video ) 1127 | } ) 1128 | }, 1500) // delay a bit try and circumvent throttling 1129 | } else { 1130 | callback( null, video ) 1131 | } 1132 | } 1133 | 1134 | /* Get metadata from a playlist page 1135 | */ 1136 | function getPlaylistMetaData ( opts, callback ) 1137 | { 1138 | debug( 'fn: getPlaylistMetaData' ) 1139 | 1140 | let listId 1141 | 1142 | if ( typeof opts === 'string' ) { 1143 | listId = opts 1144 | } 1145 | 1146 | if ( typeof opts === 'object' ) { 1147 | listId = opts.listId || opts.playlistId 1148 | } 1149 | 1150 | const { hl = 'en', gl = 'US' } = opts 1151 | const uri = `https://www.youtube.com/playlist?hl=${hl}&gl=${gl}&list=${listId}` 1152 | 1153 | const params = _url.parse( uri ) 1154 | 1155 | params.headers = { 1156 | 'user-agent': _userAgent, 1157 | 'accept': 'text/html', 1158 | 'accept-encoding': 'gzip', 1159 | 'accept-language': `${hl}-${gl}` 1160 | } 1161 | 1162 | _dasu.req( params, function ( err, res, body ) { 1163 | if ( err ) { 1164 | callback( err ) 1165 | } else { 1166 | if ( res.status !== 200 ) { 1167 | return callback( 'http status: ' + res.status ) 1168 | } 1169 | 1170 | if ( _debugging ) { 1171 | const fs = require( 'fs' ) 1172 | const path = require( 'path' ) 1173 | fs.writeFileSync( 'dasu.response', res.responseText, 'utf8' ) 1174 | } 1175 | 1176 | try { 1177 | _parsePlaylistInitialData( body, callback ) 1178 | } catch ( err ) { 1179 | callback( err ) 1180 | } 1181 | } 1182 | } ) 1183 | } 1184 | 1185 | function _parsePlaylistInitialData ( responseText, callback ) 1186 | { 1187 | debug( 'fn: parsePlaylistBody' ) 1188 | 1189 | responseText = _getScripts( responseText ) 1190 | 1191 | const jsonString = responseText.match( /ytInitialData.*=\s*({.*});/ )[ 1 ] 1192 | // console.log( jsonString ) 1193 | 1194 | if ( !jsonString ) { 1195 | throw new Error( 'failed to parse ytInitialData json data' ) 1196 | } 1197 | 1198 | let json = JSON.parse( jsonString ) 1199 | //console.log( json ) 1200 | 1201 | // check for errors (ex: noexist/unviewable playlist) 1202 | const plerr = _jp.value( json, '$..alerts..alertRenderer' ) 1203 | if ( plerr && ( typeof plerr.type === 'string' ) && plerr.type.toLowerCase() === 'error' ) { 1204 | let plerrtext = 'playlist error, not found?' 1205 | if ( typeof plerr.text === 'object' ) { 1206 | plerrtext = _jp.query( plerr.text, '$..text').join( '' ) 1207 | } 1208 | if ( typeof plerr.text === 'string' ) { 1209 | plerrtext = plerr.text 1210 | } 1211 | 1212 | throw new Error( 'playlist error: ' + plerrtext ) 1213 | } 1214 | 1215 | let alertInfo = '' 1216 | _jp.query( json, '$..alerts..text' ).forEach( function ( val ) { 1217 | if ( typeof val === 'string' ) alertInfo += val 1218 | if ( typeof val === 'object' ) { 1219 | // try grab simpletex 1220 | const simpleText = _jp.value( val, '$..simpleText' ) 1221 | if ( simpleText ) alertInfo += simpleText 1222 | } 1223 | } ) 1224 | 1225 | const listId = ( _jp.value( json, '$..microformat..urlCanonical' ) ).split( '=' )[ 1 ] 1226 | // console.log( 'listId: ' + listId ) 1227 | 1228 | let viewCount = 0 1229 | try { 1230 | const viewCountLabel = _jp.value( json, '$..sidebar.playlistSidebarRenderer.items[0]..stats[1].simpleText' ) 1231 | if ( viewCountLabel.toLowerCase() === 'no views' ) { 1232 | viewCount = 0 1233 | } else { 1234 | viewCount = viewCountLabel.match( /\d+/g ).join( '' ) 1235 | } 1236 | } catch ( err ) { /* ignore */ } 1237 | 1238 | const size = ( 1239 | _jp.value( json, '$..sidebar.playlistSidebarRenderer.items[0]..stats[0].simpleText' ) || 1240 | _jp.query( json, '$..sidebar.playlistSidebarRenderer.items[0]..stats[0]..text' ).join( '' ) 1241 | ).match( /\d+/g ).join( '' ) 1242 | 1243 | // playlistVideoListRenderer contents 1244 | const list = _jp.query( json, '$..playlistVideoListRenderer..contents' )[ 0 ] 1245 | 1246 | // TODO unused atm 1247 | const listHasContinuation = ( typeof list[ list.length - 1 ].continuationItemRenderer === 'object' ) 1248 | 1249 | // const list = _jp.query( json, '$..contents..tabs[0]..contents[0]..contents[0]..contents' )[ 0 ] 1250 | const videos = [] 1251 | 1252 | list.forEach( function ( item ) { 1253 | if ( !item.playlistVideoRenderer ) return // skip 1254 | 1255 | const json = item 1256 | 1257 | const duration = ( 1258 | _parseDuration( 1259 | _jp.value( json, '$..lengthText..simpleText' ) || 1260 | _jp.value( json, '$..thumbnailOverlayTimeStatusRenderer..simpleText' ) || 1261 | ( _jp.query( json, '$..lengthText..text' ) ).join( '' ) || 1262 | ( _jp.query( json, '$..thumbnailOverlayTimeStatusRenderer..text' ) ).join( '' ) 1263 | ) 1264 | ) 1265 | 1266 | const video = { 1267 | title: ( 1268 | _jp.value( json, '$..title..simpleText' ) || 1269 | _jp.value( json, '$..title..text' ) || 1270 | ( _jp.query( json, '$..title..text' ) ).join( '' ) 1271 | ), 1272 | 1273 | videoId: _jp.value( json, '$..videoId' ), 1274 | listId: listId, 1275 | 1276 | thumbnail: ( 1277 | _normalizeThumbnail( _jp.value( json, '$..thumbnail..url' ) ) || 1278 | _normalizeThumbnail( _jp.value( json, '$..thumbnails..url' ) ) || 1279 | _normalizeThumbnail( _jp.value( json, '$..thumbnails' ) ) 1280 | ), 1281 | 1282 | // ref: issue #35 https://github.com/talmobi/yt-search/issues/35 1283 | duration: duration, 1284 | 1285 | author: { 1286 | name: _jp.value( json, '$..shortBylineText..runs[0]..text' ), 1287 | url: 'https://youtube.com' + _jp.value( json, '$..shortBylineText..runs[0]..url' ), 1288 | } 1289 | } 1290 | 1291 | videos.push( video ) 1292 | } ) 1293 | 1294 | // console.log( videos ) 1295 | // console.log( 'videos.length: ' + videos.length ) 1296 | 1297 | const plthumbnail = ( 1298 | _normalizeThumbnail( _jp.value( json, '$..microformat..thumbnail..url' ) ) || 1299 | _normalizeThumbnail( _jp.value( json, '$..microformat..thumbnails..url' ) ) || 1300 | _normalizeThumbnail( _jp.value( json, '$..microformat..thumbnails' ) ) 1301 | ) 1302 | 1303 | const playlist = { 1304 | title: _jp.value( json, '$..microformat..title' ), 1305 | listId: listId, 1306 | 1307 | url: 'https://youtube.com/playlist?list=' + listId, 1308 | 1309 | size: Number( size ), 1310 | views: Number( viewCount ), 1311 | 1312 | // lastUpdate: lastUpdate, 1313 | date: _parsePlaylistLastUpdateTime( 1314 | ( _jp.value( json, '$..sidebar.playlistSidebarRenderer.items[0]..stats[2]..simpleText' ) ) || 1315 | ( _jp.query( json, '$..sidebar.playlistSidebarRenderer.items[0]..stats[2]..text' ) ).join( '' ) || 1316 | '' 1317 | ), 1318 | 1319 | image: plthumbnail || videos[ 0 ].thumbnail, 1320 | thumbnail: plthumbnail || videos[ 0 ].thumbnail, 1321 | 1322 | // playlist items/videos 1323 | videos: videos, 1324 | 1325 | alertInfo: alertInfo, 1326 | 1327 | author: { 1328 | name: _jp.value( json, '$..videoOwner..title..runs[0]..text' ), 1329 | url: 'https://youtube.com' + _jp.value( json, '$..videoOwner..navigationEndpoint..url' ) 1330 | } 1331 | } 1332 | 1333 | callback && callback( null, playlist ) 1334 | } 1335 | 1336 | function _parsePlaylistLastUpdateTime ( lastUpdateLabel ) { 1337 | debug( 'fn: _parsePlaylistLastUpdateTime' ) 1338 | const DAY_IN_MS = ( 1000 * 60 * 60 * 24 ) 1339 | 1340 | try { 1341 | // ex "Last Updated on Jun 25, 2018" 1342 | // ex: "Viimeksi päivitetty 25.6.2018" 1343 | 1344 | const words = lastUpdateLabel.toLowerCase().trim().split( /[\s.-]+/ ) 1345 | 1346 | if ( words.length > 0 ) { 1347 | const lastWord = ( words[ words.length - 1 ] ).toLowerCase() 1348 | if ( lastWord === 'yesterday' ) { 1349 | const ms = Date.now() - DAY_IN_MS 1350 | const d = new Date( ms ) // a day earlier than today 1351 | if ( d.toString() !== 'Invalid Date' ) return _toInternalDateString( d ) 1352 | } 1353 | } 1354 | 1355 | if ( words.length >= 2 ) { 1356 | // handle strings like "7 days ago" 1357 | if ( words[0] === 'updated' && words[2].slice( 0, 3 ) === 'day' ) { 1358 | const ms = Date.now() - ( DAY_IN_MS * words[1] ) 1359 | const d = new Date( ms ) // a day earlier than today 1360 | if ( d.toString() !== 'Invalid Date' ) return _toInternalDateString( d ) 1361 | } 1362 | } 1363 | 1364 | for ( let i = 0; i < words.length; i++ ) { 1365 | const slice = words.slice( i ) 1366 | const t = slice.join( ' ' ) 1367 | const r = slice.reverse().join( ' ' ) 1368 | 1369 | const a = new Date( t ) 1370 | const b = new Date( r ) 1371 | 1372 | if ( a.toString() !== 'Invalid Date' ) return _toInternalDateString( a ) 1373 | if ( b.toString() !== 'Invalid Date' ) return _toInternalDateString( b ) 1374 | } 1375 | 1376 | return '' 1377 | } catch ( err ) { return '' } 1378 | } 1379 | 1380 | function _toInternalDateString ( date ) { 1381 | date = new Date( date ) 1382 | debug( 'fn: _toInternalDateString' ) 1383 | 1384 | return ( 1385 | date.getFullYear() + '-' + 1386 | ( date.getMonth() + 1 ) + '-' + // january gives 0 1387 | date.getDate() 1388 | ) 1389 | } 1390 | 1391 | /* Helper fn to parse duration labels 1392 | * ex: Duration: 2:27, Kesto: 1.07.54 1393 | */ 1394 | function _parseDuration ( timestampText ) 1395 | { 1396 | var a = timestampText.split( /\s+/ ) 1397 | var lastword = a[ a.length - 1 ] 1398 | 1399 | // ex: Duration: 2:27, Kesto: 1.07.54 1400 | // replace all non :, non digits and non . 1401 | var timestamp = lastword.replace( /[^:.\d]/g, '' ) 1402 | 1403 | if ( !timestamp ) return { 1404 | toString: function () { return a[ 0 ] }, 1405 | seconds: 0, 1406 | timestamp: 0 1407 | } 1408 | 1409 | // remove trailing junk that are not digits 1410 | while ( timestamp[ timestamp.length - 1 ]?.match( /\D/ ) ) { 1411 | timestamp = timestamp.slice( 0, -1 ) 1412 | } 1413 | 1414 | // replaces all dots with nice ':' 1415 | timestamp = timestamp.replace( /\./g, ':' ) 1416 | 1417 | var t = timestamp.split( /[:.]/ ) 1418 | 1419 | var seconds = 0 1420 | var exp = 0 1421 | for ( var i = t.length - 1; i >= 0; i-- ) { 1422 | if ( t[ i ].length <= 0 ) continue 1423 | var number = t[ i ].replace( /\D/g, '' ) 1424 | // var exp = (t.length - 1) - i; 1425 | seconds += parseInt( number ) * ( exp > 0 ? Math.pow( 60, exp ) : 1 ) 1426 | exp++ 1427 | if ( exp > 2 ) break 1428 | }; 1429 | 1430 | return { 1431 | toString: function () { return seconds + ' seconds (' + timestamp + ')' }, 1432 | seconds: seconds, 1433 | timestamp: timestamp 1434 | } 1435 | } 1436 | 1437 | /* Parses a type of human-like timestamps found on YouTube. 1438 | * ex: "PT4M13S" -> "4:13" 1439 | */ 1440 | function _parseHumanDuration ( timestampText ) 1441 | { 1442 | debug( '_parseHumanDuration' ) 1443 | 1444 | // ex: PT4M13S 1445 | const pt = timestampText.slice( 0, 2 ) 1446 | let timestamp = timestampText.slice( 2 ).toUpperCase() 1447 | 1448 | if ( pt !== 'PT' ) return { 1449 | toString: function () { return a[ 0 ] }, 1450 | seconds: 0, 1451 | timestamp: 0 1452 | } 1453 | 1454 | let h = timestamp.match( /\d?\dH/ ) 1455 | let m = timestamp.match( /\d?\dM/ ) 1456 | let s = timestamp.match( /\d?\dS/ ) 1457 | 1458 | h = h && h[ 0 ].slice( 0, -1 ) || 0 1459 | m = m && m[ 0 ].slice( 0, -1 ) || 0 1460 | s = s && s[ 0 ].slice( 0, -1 ) || 0 1461 | 1462 | h = parseInt( h ) 1463 | m = parseInt( m ) 1464 | s = parseInt( s ) 1465 | 1466 | timestamp = '' 1467 | if ( h ) timestamp += ( h + ':' ) 1468 | if ( m ) timestamp += ( m + ':' ) 1469 | timestamp += s 1470 | 1471 | const seconds = ( h * 60 * 60 + m * 60 + s ) 1472 | 1473 | return { 1474 | toString: function () { return seconds + ' seconds (' + timestamp + ')' }, 1475 | seconds: seconds, 1476 | timestamp: timestamp 1477 | } 1478 | } 1479 | 1480 | /* Helper fn to parse sub count labels 1481 | * and turn them into Numbers. 1482 | * 1483 | * It's an estimate but can be useful for sorting etc. 1484 | * 1485 | * ex. "102M subscribers" -> 102000000 1486 | * ex. "5.33m subscribers" -> 5330000 1487 | */ 1488 | function _parseSubCountLabel ( subCountLabel ) 1489 | { 1490 | if ( !subCountLabel ) return undefined 1491 | 1492 | const label = ( 1493 | subCountLabel.split( /\s+/ ) 1494 | .filter( function ( w ) { return w.match( /\d/ ) } ) 1495 | )[ 0 ].toLowerCase() 1496 | 1497 | const m = label.match( /\d+(\.\d+)?/ ) 1498 | if ( m && m[ 0 ] ) {} else { return } 1499 | const num = Number( m[ 0 ] ) 1500 | 1501 | const THOUSAND = 1000 1502 | const MILLION = THOUSAND * THOUSAND 1503 | 1504 | if ( label.indexOf( 'm' ) >= 0 ) return MILLION * num 1505 | if ( label.indexOf( 'k' ) >= 0 ) return THOUSAND * num 1506 | return num 1507 | } 1508 | 1509 | /* Helper fn to parse first number from a label. 1510 | * 1511 | * ex. "312 videos" -> [312] 1512 | * ex. "312 videos -10k dollars" -> [312, -10000] 1513 | */ 1514 | function _parseNumbers ( label ) 1515 | { 1516 | if ( !label ) return [] 1517 | 1518 | const nums = ( 1519 | label.split( /\s+/ ) 1520 | .filter( function ( w ) { return w.match( /\d/ ) } ) 1521 | .map( function ( l ) { return l.toLowerCase() } ) 1522 | ) 1523 | 1524 | const results = [] 1525 | 1526 | nums.forEach( function ( n ) { 1527 | const m = n.match( /[-]?\d+(\.\d+)?/ ) 1528 | if ( m && m[ 0 ] ) {} else { return } 1529 | let num = Number( m[ 0 ] ) 1530 | 1531 | const THOUSAND = 1000 1532 | const MILLION = THOUSAND * THOUSAND 1533 | 1534 | if ( n.indexOf( 'm' ) >= 0 ) num = MILLION * num 1535 | if ( n.indexOf( 'k' ) >= 0 ) num = THOUSAND * num 1536 | 1537 | results.push(num) 1538 | } ) 1539 | 1540 | return results 1541 | } 1542 | 1543 | /* Helper fn to choose a good thumbnail. 1544 | */ 1545 | function _normalizeThumbnail ( thumbnails ) 1546 | { 1547 | if (!thumbnails) return undefined 1548 | 1549 | let t 1550 | if ( typeof thumbnails === 'string' ) { 1551 | t = thumbnails 1552 | } else { 1553 | // handle as array 1554 | if ( thumbnails.length ) { 1555 | t = thumbnails[ 0 ] 1556 | return _normalizeThumbnail( t ) 1557 | } 1558 | 1559 | // failed to parse thumbnail 1560 | return undefined 1561 | } 1562 | 1563 | t = t.split( '?' )[ 0 ] 1564 | 1565 | t = t.split( '/default.jpg' ).join( '/hqdefault.jpg' ) 1566 | t = t.split( '/default.jpeg' ).join( '/hqdefault.jpeg' ) 1567 | 1568 | if ( t.indexOf( '//' ) === 0 ) { 1569 | return 'https://' + t.slice( 2 ) 1570 | } 1571 | 1572 | return t.split( 'http://' ).join( 'https://' ) 1573 | } 1574 | 1575 | /* Helper fn to transform ms to timestamp 1576 | * ex: 253000 -> "4:13" 1577 | */ 1578 | function _msToTimestamp ( ms ) 1579 | { 1580 | let t = '' 1581 | 1582 | const MS_HOUR = 1000 * 60 * 60 1583 | const MS_MINUTE = 1000 * 60 1584 | const MS_SECOND = 1000 1585 | 1586 | const h = Math.floor( ms / MS_HOUR ) 1587 | const m = Math.floor( ms / MS_MINUTE ) % 60 1588 | const s = Math.floor( ms / MS_SECOND ) % 60 1589 | 1590 | if ( h ) t += h + ':' 1591 | 1592 | // pad with extra zero only if hours are set 1593 | if ( h && String( m ).length < 2 ) t += '0' 1594 | t += m + ':' 1595 | 1596 | // pad with extra zero 1597 | if ( String( s ).length < 2 ) t += '0' 1598 | t += s 1599 | 1600 | return t 1601 | } 1602 | 1603 | function _parseVideoMetaDataTitle( idata ) { 1604 | const t = ( 1605 | ( _jp.query( idata, '$..videoPrimaryInfoRenderer.title..text' ) ).join( '' ) || 1606 | ( _jp.query( idata, '$..videoPrimaryInfoRenderer.title..simpleText' ) ).join( '' ) || 1607 | ( _jp.query( idata, '$..videoPrimaryRenderer.title..text' ) ).join( '' ) || 1608 | ( _jp.query( idata, '$..videoPrimaryRenderer.title..simpleText' ) ).join( '' ) || 1609 | _jp.value( idata, '$..title..text' ) || 1610 | _jp.value( idata, '$..title..simpleText' ) 1611 | ) 1612 | 1613 | // remove zero-width chars 1614 | return t.replace( /[\u0000-\u001F\u007F-\u009F\u200b]/g, '' ) 1615 | } 1616 | 1617 | // run tests is script is run directly 1618 | if ( require.main === module ) { 1619 | // https://www.youtube.com/watch?v=e9vrfEoc8_g 1620 | // test( 'superman theme list pewdiepie channel' ) 1621 | test( '王菲 Faye Wong' ) 1622 | } 1623 | 1624 | function test ( query ) 1625 | { 1626 | console.log( 'test: doing list search' ) 1627 | 1628 | const opts = { 1629 | query: query, 1630 | pageEnd: 1 1631 | } 1632 | 1633 | search( opts, function ( error, r ) { 1634 | if ( error ) throw error 1635 | 1636 | const videos = r.videos 1637 | const playlists = r.playlists 1638 | const channels = r.channels 1639 | 1640 | const topChannel = channels[ 0 ] 1641 | 1642 | console.log( 'videos: ' + videos.length ) 1643 | console.log( 'playlists: ' + playlists.length ) 1644 | console.log( 'channels: ' + channels.length ) 1645 | 1646 | console.log( 'topChannel.name: ' + topChannel.name ) 1647 | console.log( 'topChannel.baseUrl: ' + topChannel.baseUrl ) 1648 | console.log( 'topChannel.id: ' + topChannel.id ) 1649 | console.log( 'topChannel.about: ' + topChannel.about ) 1650 | console.log( 'topChannel.verified: ' + topChannel.verified ) 1651 | console.log( 'topChannel.videoCount: ' + topChannel.videoCount ) 1652 | console.log( 'topChannel.subCount: ' + topChannel.subCount ) 1653 | console.log( 'topChannel.subCountLabel: ' + topChannel.subCountLabel ) 1654 | 1655 | /* 1656 | for ( let i = 0; i < videos.length; i++ ) { 1657 | const song = videos[ i ] 1658 | const time = ` (${ song.timestamp })` 1659 | console.log( song.title + time ) 1660 | } 1661 | 1662 | playlists.forEach( function ( p ) { 1663 | console.log( `playlist: ${ p.title } | ${ p.listId }` ) 1664 | } ) 1665 | 1666 | channels.forEach( function ( c ) { 1667 | console.log( `channel: ${ c.title } | ${ c.description }` ) 1668 | } ) 1669 | */ 1670 | } ) 1671 | } 1672 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | const _cheerio = require( 'cheerio' ) 2 | 3 | const util ={} 4 | module.exports = util 5 | util._getScripts = _getScripts 6 | util._findLine = _findLine 7 | util._between = _between 8 | 9 | function _getScripts ( text ) { 10 | // match all contents within html script tags 11 | const $ = _cheerio.load( text ) 12 | const scripts = $( 'script' ) 13 | 14 | let buffer = '' 15 | 16 | // combine all scripts 17 | for ( let i = 0; i < scripts.length; i++ ) { 18 | const el = scripts[ i ] 19 | const child = el && el.children[ 0 ] 20 | const data = child && child.data 21 | if ( data ) { 22 | buffer += data + '\n' 23 | } 24 | } 25 | 26 | return buffer 27 | } 28 | 29 | function _findLine ( regex, text ) { 30 | const cache = _findLine.cache || {} 31 | _findLine.cache = cache 32 | 33 | cache[ text ] = cache[ text ] || {} 34 | 35 | const lines = cache[ text ].lines || text.split( '\n' ) 36 | cache[ text ].lines = lines 37 | 38 | clearTimeout( cache[ text ].timeout ) 39 | cache[ text ].timeout = setTimeout( function () { 40 | delete cache[ text ] 41 | }, 100 ) 42 | 43 | for ( let i = 0; i < lines.length; i++ ) { 44 | const line = lines[ i ] 45 | if ( regex.test( line ) ) return line 46 | } 47 | 48 | return '' 49 | } 50 | 51 | function _between ( text, start, end ) { 52 | let i = text.indexOf( start ) 53 | let j = text.lastIndexOf( end ) 54 | if ( i < 0 ) return '' 55 | if ( j < 0 ) return '' 56 | return text.slice( i, j + 1 ) 57 | } 58 | -------------------------------------------------------------------------------- /test/stage/pewdiepie-thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/talmobi/yt-search/c25d06ab31ba7ce95fc84678aa8d195dc8a4d7dc/test/stage/pewdiepie-thumbnail.png -------------------------------------------------------------------------------- /test/stage/pewdiepie-thumbnail2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/talmobi/yt-search/c25d06ab31ba7ce95fc84678aa8d195dc8a4d7dc/test/stage/pewdiepie-thumbnail2.png -------------------------------------------------------------------------------- /test/stage/pewdiepie-thumbnail3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/talmobi/yt-search/c25d06ab31ba7ce95fc84678aa8d195dc8a4d7dc/test/stage/pewdiepie-thumbnail3.png -------------------------------------------------------------------------------- /test/test-get-scripts.js: -------------------------------------------------------------------------------- 1 | 2 | const fs = require( 'fs' ) 3 | const path = require( 'path' ) 4 | 5 | const test = require( 'tape' ) 6 | 7 | const cheerio = require( 'cheerio' ) 8 | 9 | const { _getScripts, _findLine, _between } = require( '../src/util.js' ) 10 | 11 | test( '_getScripts', function ( t ) { 12 | const responseText = fs.readFileSync( path.join( __dirname, 'stage', 'jumbled-script-tags.response-html' ), 'utf8' ) 13 | 14 | const scriptsText = _getScripts( responseText ) 15 | 16 | const initialData = _between( 17 | _findLine( /ytInitialData.*=\s*{/, scriptsText ), '{', '}' 18 | ) 19 | 20 | t.ok( initialData ) 21 | t.end() 22 | } ) 23 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | let yts = require( '../dist/yt-search.js' ) 2 | 3 | const lsp = require( 'looks-same-plus' ) 4 | 5 | const fs = require( 'fs' ) 6 | const path = require( 'path' ) 7 | 8 | if ( !!process.env.debug ) { 9 | yts = require( '../src/index.js' ) 10 | } 11 | 12 | // delay executions to avoid getting throttled by youtube 13 | const _yts = yts 14 | yts = async function ( o, c ) { 15 | let promise, _res, _rej 16 | 17 | if ( !c ) { 18 | promise = new Promise( function ( res, rej ) { 19 | _res = res 20 | _rej = rej 21 | } ) 22 | } 23 | 24 | await new Promise( function ( res ) { 25 | setTimeout( res, 6000 ) 26 | } ) 27 | 28 | try { 29 | const r = await _yts( o ) 30 | if ( c ) { 31 | c( undefined, r ) 32 | } else { 33 | _res( r ) 34 | } 35 | } catch ( err ) { 36 | if ( c ) { 37 | c( err ) 38 | } else { 39 | _rej( err ) 40 | } 41 | } 42 | 43 | return promise 44 | } 45 | 46 | const test = require( 'tape' ) 47 | 48 | test( 'basic search', function ( t ) { 49 | t.plan( 5 ) 50 | 51 | yts( 'philip glass koyaanisqatsi', function ( err, r ) { 52 | t.error( err, 'no errors OK!' ) 53 | 54 | const list = r.videos 55 | 56 | const koyaani = list.filter( function ( song ) { 57 | const keep = ( 58 | song.title.toLowerCase().indexOf( 'koyaani' ) >= 0 && 59 | song.title.toLowerCase().indexOf( 'glass' ) >= 0 && 60 | ( song.author.name === 'DJDumato' ) && 61 | song.seconds > 100 && 62 | song.duration.seconds > 100 && 63 | song.views > ( 100 * 1000 ) 64 | ) 65 | 66 | // console.log( song ) 67 | 68 | return keep 69 | } )[ 0 ] 70 | 71 | console.log( koyaani ) 72 | 73 | t.ok( koyaani, 'found koyaani OK!' ) 74 | t.equal( koyaani.videoId, '_4Vt0UGwmgQ', 'koyani video id equal!' ) 75 | t.equal( koyaani.timestamp, '3:29', 'koyani video timestamp equal!' ) 76 | t.ok( koyaani.description.indexOf( 77 | 'Koyaanisqatsi: Life out of balance ∞ um documentário lan°ado em 1983 dirigido' 78 | ), 'koyani video timestamp equal!' ) 79 | } ) 80 | } ) 81 | 82 | test( 'make sure CLI runs', function ( t ) { 83 | t.plan( 1 ) 84 | 85 | const pkg = require( '../package.json' ) 86 | const cp = require( 'child_process' ) 87 | const path = require( 'path' ) 88 | 89 | const prg = `${ process.execPath }` 90 | const bin = path.join( __dirname, '../bin/cli.js' ) 91 | const args = [ bin, '-v' ] 92 | 93 | const buffer = [] 94 | const spawn = cp.spawn( prg, args ); 95 | 96 | spawn.stdout.on('data', function ( data ) { 97 | buffer.push(data) 98 | } ) 99 | 100 | spawn.stderr.on('data', function ( data ) { 101 | buffer.push(data) 102 | } ) 103 | 104 | spawn.on('close', function () { 105 | const output = buffer.toString().trim() 106 | t.equal( output, 'yt-search: ' + pkg.version, 'cli -v OK' ) 107 | } ) 108 | } ) 109 | 110 | test( 'make sure no live streams show up in video results', function ( t ) { 111 | t.plan( 2 ) 112 | 113 | yts( 'minecraft LIVE', function ( err, r ) { 114 | t.error( err, 'no errors OK!' ) 115 | 116 | const list = r.videos 117 | t.plan( list.length * 2 + 1 + 1 ) // update plan count based on results 118 | list.forEach( function ( video ) { 119 | t.ok( video.views > 0, 'views OK!' ) 120 | t.ok( video.ago, 'ago OK!' ) 121 | } ) 122 | 123 | t.ok( list.length > 0, 'found some videos among live OK!' ) 124 | } ) 125 | } ) 126 | 127 | test( 'videos, playlists and users/channels', async function ( t ) { 128 | t.plan( 3 ) 129 | 130 | // Fri Oct 23 08:15:36 EEST 2020 131 | // looks like sometimes channel results don't show up, so combine a few 132 | // searches to try and ensure some show up 133 | const r1 = await yts( 'pewdiepie playlist' ) 134 | const r2 = await yts( 'valkyrae channel' ) 135 | 136 | const videos = r1.videos.concat( r2.videos ) 137 | const channels = r1.channels.concat( r2.channels ) 138 | const playlists = r1.playlists.concat( r2.playlists ) 139 | 140 | t.ok( videos.length > 0, 'videos found' ) 141 | t.ok( channels.length > 0, 'accounts/channels found' ) 142 | 143 | t.ok( playlists.length > 0, 'playlists found' ) 144 | } ) 145 | 146 | test( 'find live streams', function ( t ) { 147 | t.plan( 12 ) 148 | 149 | yts( 'live streams', function ( err, r ) { 150 | t.error( err, 'no errors OK!' ) 151 | 152 | const videos = r.videos 153 | const live = r.live 154 | 155 | t.ok( live.length > 0, 'live streams found' ) 156 | 157 | const topLiveStream = r.live.sort( function ( a, b ) { return b.watching - a.watching } )[ 0 ] 158 | 159 | const descriptions = r.live.reduce( function ( a, c ) { 160 | return a + c.description 161 | }, '' ) 162 | t.ok( descriptions.length > 3, '(all) descriptions probably OK!' ) 163 | 164 | t.equal( topLiveStream.type, 'live', 'type "live" OK!' ) 165 | t.ok( topLiveStream.videoId.length > 4, 'videoId probably OK!' ) 166 | t.ok( topLiveStream.url.indexOf( 'http' ) >= 0, 'url probably OK!' ) 167 | 168 | t.ok( topLiveStream.description.length >= 0, 'description probably OK!' ) 169 | 170 | t.ok( topLiveStream.image.indexOf( 'http' ) >= 0, 'image probably OK!' ) 171 | t.ok( topLiveStream.watching >= 1, 'watching probably OK!' ) 172 | t.ok( topLiveStream.author, 'author probably OK!' ) 173 | t.ok( topLiveStream.author.name, 'author name probably OK!' ) 174 | t.ok( topLiveStream.author.url.indexOf( 'http' ) >= 0, 'author url probably OK!' ) 175 | } ) 176 | } ) 177 | 178 | test( 'test order and relevance', function ( t ) { 179 | t.plan( 2 ) 180 | 181 | const opts = { 182 | search: "Josh A & Jake Hill - Rest in Pieces (Lyrics)" 183 | } 184 | 185 | yts( opts, function ( err, r ) { 186 | t.error( err, 'no errors OK!' ) 187 | 188 | const topVideos = r.videos.slice( 0, 5 ) 189 | 190 | let hasTitle = false 191 | topVideos.forEach( function ( v ) { 192 | if ( v.title.match( /josh.*jake.*hill.*rest.*piece.*lyric/i ) ) { 193 | hasTitle = true 194 | } 195 | } ) 196 | 197 | t.ok( hasTitle, 'relevance and order OK' ) 198 | } ) 199 | } ) 200 | 201 | test( 'test non-en same top results and duration parsing', function ( t ) { 202 | t.plan( 4 ) 203 | 204 | const opts = { 205 | search: "Josh A & Jake Hill - Rest in Pieces (Lyrics)" 206 | } 207 | 208 | yts( opts, function ( err, r ) { 209 | t.error( err, 'no errors OK!' ) 210 | 211 | const topVideo = r.videos[ 0 ] 212 | 213 | yts( { 214 | search: opts.search, 215 | hl: 'fi' 216 | }, function ( err, r ) { 217 | t.error( err, 'no errors OK!' ) 218 | 219 | const videos = r.videos 220 | 221 | t.equal( topVideo.title, videos[ 0 ].title, 'top result title the same!' ) 222 | t.equal( topVideo.duration.timestamp, videos[ 0 ].duration.timestamp, 'top result timestamp the same!' ) 223 | } ) 224 | } ) 225 | } ) 226 | 227 | test( 'search by video id', function ( t ) { 228 | t.plan( 3 ) 229 | 230 | const opts = { 231 | search: "_JzeIf1zT14" 232 | } 233 | 234 | yts( opts, function ( err, r ) { 235 | t.error( err, 'no errors OK!' ) 236 | 237 | for ( video of r.videos ) { 238 | if ( video.videoId == opts.search ) { 239 | t.ok( video.title.match( /josh.*jake.*hill.*rest.*piece.*lyric/i ), 'top result title matched!' ) 240 | t.ok( video.videoId, opts.search, 'top result video id matched!' ) 241 | break 242 | } 243 | } 244 | } ) 245 | } ) 246 | 247 | test( 'video metadata by id', function ( t ) { 248 | t.plan( 14 ) 249 | 250 | yts( { videoId: 'e9vrfEoc8_g' }, function ( err, video ) { 251 | t.error( err, 'no errors OK!' ) 252 | 253 | const MILLION = 1000 * 1000 254 | 255 | t.equal( video.title, 'Superman Theme', 'title' ) 256 | t.equal( video.videoId, 'e9vrfEoc8_g', 'videoId' ) 257 | t.equal( video.url, 'https://youtube.com/watch?v=e9vrfEoc8_g' ) 258 | 259 | t.equal( video.timestamp, '4:13', 'timestamp' ) 260 | 261 | t.equal( video.seconds, 253, 'seconds (duration)' ) 262 | 263 | t.ok( 264 | (video.description === 'The theme song from Superman: The Movie') || 265 | (video.description === 'The theme song from Superman: The Movie.') 266 | , 'description' ) 267 | 268 | t.ok( video.views > ( 35 * MILLION ), 'views over 35 Million' ) 269 | 270 | t.ok( 271 | video.genre === 'music' || video.genre === '' 272 | , 'genre is music' ) 273 | if ( 274 | (video.genre !== 'music') && 275 | (video.genre === '') 276 | ) { 277 | t.comment( 'warn: genre/category unavailable' ) 278 | } 279 | t.equal( video.uploadDate, '2009-7-27', 'uploadDate' ) 280 | 281 | // t.equal( video.ago, '14 years ago', 'agoText' ) 282 | t.equal( video.ago, 283 | `${new Date(Date.now() - new Date('2009-7-27')).getFullYear() - 1970} years ago` 284 | ,'agoText' ) 285 | 286 | // t.equal( video.author.id, 'Redmario2569', 'author id' ) 287 | // t.equal( video.author.url, 'https://youtube.com/user/Redmario2569', 'author url' ) 288 | // Sun Jan 31 13:33:55 EET 2021 it's inconsistent which url youtube servers 289 | // give, could be either at the moment, they're maybe slowly deprecating 290 | // user/xxx urls 291 | // const urlOK = ( 292 | // video.author.url === 'https://youtube.com/user/Redmario2569' || 293 | // video.author.url === 'https://youtube.com/channel/UCARqIOgzDc-UUAREIitbBwA' || 294 | // video.author.url === 'https://www.youtube.com/@Redmario2569' 295 | // ) 296 | t.equal( video.author.url, 'https://youtube.com/@Redmario2569', 'author url' ) 297 | 298 | t.comment(video.image) 299 | t.ok( video.image.indexOf('https://i.ytimg.com/vi/e9vrfEoc8_g/') >= 0, 'image' ) 300 | t.equal( video.image, video.thumbnail, 'common alternative' ) 301 | } ) 302 | } ) 303 | 304 | test( 'video metadata by faulty/non-existing id', function ( t ) { 305 | t.plan( 1 ) 306 | 307 | yts( { videoId: 'X9vrfEoc8_g' }, function ( err, video ) { 308 | t.ok( err, 'video unavailable ok' ) 309 | } ) 310 | } ) 311 | 312 | test( 'video metadata by id -ObdvMkCKws (special character "-" at beginning of id)', function ( t ) { 313 | t.plan( 7 ) 314 | 315 | yts( { videoId: '-ObdvMkCKws' }, function ( err, video ) { 316 | t.error( err, 'no errors OK!' ) 317 | 318 | const MILLION = 1000 * 1000 319 | 320 | t.ok( video.title.indexOf("Top 20 Most Popular Songs by NCS") >= 0, 'title ok' ) 321 | t.equal( video.videoId, '-ObdvMkCKws', 'videoId' ) 322 | 323 | t.equal( video.timestamp, '1:12:47', 'timestamp' ) 324 | t.equal( video.seconds, 4367, 'seconds (duration)' ) 325 | 326 | t.ok( video.description.indexOf( 'Top NCS full' ) >= 0, 'description ok' ) 327 | 328 | t.ok( video.views > ( 30 * MILLION ), 'views over 30 Million' ) 329 | } ) 330 | } ) 331 | 332 | test( 'video metadata by id _JzeIf1zT14', function ( t ) { 333 | t.plan( 13 ) 334 | 335 | yts( { videoId: '_JzeIf1zT14' }, function ( err, video ) { 336 | t.error( err, 'no errors OK!' ) 337 | 338 | const MILLION = 1000 * 1000 339 | 340 | t.comment(video.title) 341 | t.ok( 342 | (video.title === 'Josh A & Jake Hill - Rest in Pieces (Lyrics)' ) || 343 | (video.title === 'Josh A & Jake Hill - Rest in Pieces Lyrics' ) || 344 | 'title' ) 345 | t.equal( video.videoId, '_JzeIf1zT14', 'videoId' ) 346 | t.equal( video.url, 'https://youtube.com/watch?v=_JzeIf1zT14' ) 347 | 348 | t.comment(video.timestamp) 349 | t.ok( 350 | ( video.timestamp === '2:27' ) || 351 | ( video.timestamp === '2:32' ) 352 | , 'timestamp ok') 353 | t.equal( video.seconds, 147, 'seconds (duration)' ) 354 | 355 | t.ok( 356 | (video.description.indexOf( 'Produced by: Josh' ) >= 0) || ( 357 | (video.description.indexOf( 'Subscribe & turn ON' ) >= 0) && 358 | (video.description.indexOf( 'Subscribe & turn ON' ) < 10) 359 | ) 360 | , 'description' ) 361 | if ( 362 | (!video.description.indexOf( 'Produced by: Josh' ) >= 0) && 363 | (video.description.indexOf( 'Subscribe & turn ON' ) >= 0) && 364 | (video.description.indexOf( 'Subscribe & turn ON' ) < 10) 365 | ) { 366 | t.comment( 'warn: full video description not available' ) 367 | } 368 | 369 | t.ok( video.views > ( 1 * MILLION ), 'views over 1 Million' ) 370 | 371 | t.ok( 372 | video.genre === 'music' || video.genre === '' 373 | , 'genre is music' ) 374 | if ( 375 | (video.genre !== 'music') && 376 | (video.genre === '') 377 | ) { 378 | t.comment( 'warn: genre/category unavailable' ) 379 | } 380 | 381 | t.equal( video.uploadDate, '2018-10-12', 'uploadDate' ) 382 | 383 | // t.equal( video.author.id, 'UCF7YjO3SzVUGJYcXipRY0zQ', 'author id' ) 384 | t.equal( video.author.url, 'https://youtube.com/@DesazMusicYt', 'author url' ) 385 | 386 | // TODO test fails sometimes? 387 | t.comment( video.image ) 388 | t.ok( video.image.indexOf('https://i.ytimg.com/vi/_JzeIf1zT14/') >= 0, 'image' ) 389 | t.equal( video.image, video.thumbnail, 'common alternative' ) 390 | } ) 391 | } ) 392 | 393 | test( 'playlist metadata by id', function ( t ) { 394 | t.plan( 19 ) 395 | 396 | yts( { listId: 'PL7k0JFoxwvTbKL8kjGI_CaV31QxCGf1vJ' }, function ( err, playlist ) { 397 | t.error( err, 'no errors OK!' ) 398 | 399 | t.equal( playlist.title, 'Superman Themes', 'title' ) 400 | t.equal( playlist.listId, 'PL7k0JFoxwvTbKL8kjGI_CaV31QxCGf1vJ', 'listId' ) 401 | 402 | t.equal( playlist.url, 'https://youtube.com/playlist?list=PL7k0JFoxwvTbKL8kjGI_CaV31QxCGf1vJ', 'playlist url' ) 403 | 404 | t.equal( playlist.size, 8, 'total videos equal to (as of 2023-01-13)' ) 405 | t.ok( playlist.videos.length >= 5, 'visible videos equal or over 5 (as of 2023-01-13)' ) 406 | t.ok( playlist.views > 300, 'views over 300 (as of 2020-01-08)' ) 407 | 408 | const alerts = playlist.alertInfo.split( ' ' ) 409 | t.ok( alerts.shift() >= 2, '2 or more videos are hidden' ) 410 | t.equal( alerts.join( ' ' ), 'unavailable videos are hidden' ) 411 | 412 | 413 | if ( playlist.videos[ 0 ].duration.seconds === ( 60 * 1 + 37 ) ) { 414 | t.equal( playlist.videos[ 0 ].duration.seconds, 60 * 1 + 37, 'play list video 1 duration.seconds ok' ) 415 | t.equal( playlist.videos[ 0 ].duration.timestamp, '1:37', 'play list video 1 duration.timestamp ok' ) 416 | 417 | t.equal( playlist.videos[ 4 ].duration.seconds, 60 * 3 + 7, 'play list video 2 duration.seconds ok' ) 418 | t.equal( playlist.videos[ 4 ].duration.timestamp, '3:07', 'play list video 2 duration.timestamp ok' ) 419 | 420 | t.equal( playlist.image, 'https://i.ytimg.com/vi/IQtKjU_pOuw/hqdefault.jpg', 'playlist image' ) 421 | t.equal( playlist.image, playlist.thumbnail, 'common alternative' ) 422 | } else { 423 | t.equal( playlist.videos[ 0 ].duration.seconds, 60 * 4 + 13, 'play list video 1 duration.seconds ok' ) 424 | t.equal( playlist.videos[ 0 ].duration.timestamp, '4:13', 'play list video 1 duration.timestamp ok' ) 425 | 426 | // t.equal( playlist.videos[ 4 ].duration.seconds, 60 * 3 + 7, 'play list video 2 duration.seconds ok' ) 427 | // t.equal( playlist.videos[ 4 ].duration.timestamp, '3:07', 'play list video 2 duration.timestamp ok' ) 428 | t.equal( playlist.videos[ 4 ].duration.seconds, 60 * 6 + 45, 'play list video 2 duration.seconds ok' ) 429 | t.equal( playlist.videos[ 4 ].duration.timestamp, '6:45', 'play list video 2 duration.timestamp ok' ) 430 | 431 | t.equal( playlist.image, 'https://i.ytimg.com/vi/IQtKjU_pOuw/hqdefault.jpg', 'playlist image' ) 432 | // t.equal( playlist.image, 'https://i.ytimg.com/vi/e9vrfEoc8_g/hqdefault.jpg', 'playlist image' ) 433 | t.equal( playlist.image, playlist.thumbnail, 'common alternative' ) 434 | } 435 | 436 | 437 | // these no longer seem to show up in the playlist as of April 2021. An 438 | // alert if visible on page with the number of hidden videos, see playlist.alertInfo 439 | // t.equal( playlist.videos[ 1 ].duration.seconds, 0, '[deleted] play list video duration.seconds 0 OK' ) 440 | // t.equal( playlist.videos[ 3 ].duration.timestamp, 0, '[private] play list video duration.timestamp 0 OK' ) 441 | 442 | t.equal( 443 | playlist.videos.filter( v => v.title ).length, 444 | playlist.videos.length, 445 | 'no video titles are empty' 446 | ) 447 | 448 | // t.ok( playlist.videos.find( 449 | // v => v.title === '[Deleted video]' 450 | // ), 'Deleted video found' ) 451 | 452 | // t.ok( playlist.videos.find( 453 | // v => v.title === '[Private video]' 454 | // ), 'Private video found' ) 455 | 456 | // t.equal( playlist.date, '2018-6-25' , 'date' ) 457 | // Sun Jan 31 13:50:55 EET 2021 updated 458 | t.equal( playlist.date, '2022-12-15' , 'date' ) 459 | 460 | t.equal( playlist.author.name, 'Cave Spider10', 'author name' ) 461 | // t.equal( playlist.author.channelId, 'UCdwR7fIE2xyXlNRc7fb9tJg', 'author channelId' ) 462 | // t.equal( playlist.author.url, 'https://youtube.com/channel/UCdwR7fIE2xyXlNRc7fb9tJg', 'author url' ) 463 | t.equal( playlist.author.url, 'https://youtube.com/@cavespider1074', 'author url' ) 464 | } ) 465 | } ) 466 | 467 | test( 'successfully parse metadata with hidden likes/dislikes sentiment bar', async function ( t ) { 468 | // https://github.com/talmobi/yt-search/issues/68 469 | t.plan( 3 ) 470 | 471 | const video = await yts({ videoId: '62ezXENOuIA' }); 472 | 473 | t.ok( video.title, 'FINAL FANTASY XIV: Scions & Sinners – A Long Fall Music Video (THE PRIMALS)', 'title ok' ) 474 | t.ok( video.url, 'https://youtube.com/watch?v=62ezXENOuIA', 'url ok' ) 475 | t.ok( video.thumbnail, 'https://i.ytimg.com/vi/62ezXENOuIA/hqdefault.jpg', 'thumbnail ok' ) 476 | } ) 477 | 478 | test( 'parsePlaylistLastUpdateTime', function ( t ) { 479 | t.plan( 2 ) 480 | 481 | const DAY_MS = 1000 * 60 * 60 * 24 482 | const d1 = new Date( Date.now() - 2 * DAY_MS ) // 2 days ago 483 | const d2 = new Date( Date.now() - 1 * DAY_MS ) // yesterday 484 | 485 | t.equal( 486 | _yts._parsePlaylistLastUpdateTime( 'Updated 2 days ago' ), 487 | `${ d1.getFullYear() }-${ d1.getMonth() + 1 }-${ d1.getDate() }`, 488 | 'updated 2 days ago OK' 489 | ) 490 | 491 | t.equal( 492 | _yts._parsePlaylistLastUpdateTime( 'Updated yesterday' ), 493 | `${ d2.getFullYear() }-${ d2.getMonth() + 1 }-${ d2.getDate() }`, 494 | 'updated yesterday ok' 495 | ) 496 | } ) 497 | 498 | test( 'playlist metadata by id with no views', function ( t ) { 499 | t.plan( 15 ) 500 | 501 | const body = fs.readFileSync( path.join( __dirname, 'stage/playlist-no-views.response-html' ), 'utf8' ) 502 | 503 | // pre-fetched results for playlist id with no views 504 | // ref: https://www.youtube.com/playlist?list=PLSwcuYF4r6MJHkUVYbDAekT7j0FvZ_B4X 505 | _yts._parsePlaylistInitialData( body, callback ) 506 | 507 | function callback ( err, playlist ) { 508 | t.error( err, 'no errors OK!' ) 509 | 510 | t.equal( playlist.title, 'gitt', 'title' ) 511 | t.equal( playlist.listId, 'PLSwcuYF4r6MJHkUVYbDAekT7j0FvZ_B4X', 'listId' ) 512 | 513 | t.equal( playlist.url, 'https://youtube.com/playlist?list=PLSwcuYF4r6MJHkUVYbDAekT7j0FvZ_B4X', 'playlist url' ) 514 | 515 | t.equal( playlist.videos.length, 7, '7 videos ok' ) 516 | t.equal( playlist.views, 0, '0 views ( no views ) ok' ) 517 | 518 | t.equal( playlist.videos[ 0 ].duration.seconds, 60 * 25 + 17, 'play list video 1 duration.seconds ok' ) 519 | t.equal( playlist.videos[ 0 ].duration.timestamp, '25:17', 'play list video 1 duration.timestamp ok' ) 520 | 521 | t.equal( playlist.videos[ 3 ].duration.seconds, 60 * 41 + 11, 'play list video 2 duration.seconds ok' ) 522 | t.equal( playlist.videos[ 3 ].duration.timestamp, '41:11', 'play list video 2 duration.timestamp ok' ) 523 | 524 | t.equal( 525 | playlist.videos.filter( v => v.title ).length, 526 | playlist.videos.length, 527 | 'no video titles are empty' 528 | ) 529 | 530 | t.equal( playlist.author.name, 'Jangan Dikick, pls', 'author name' ) 531 | // t.equal( playlist.author.channelId, 'UCdwR7fIE2xyXlNRc7fb9tJg', 'author channelId' ) 532 | t.equal( playlist.author.url, 'https://youtube.com/channel/UCLsfC1kDr0VOvwPEsACWKvA', 'author url' ) 533 | 534 | t.equal( playlist.image, 'https://i.ytimg.com/vi/moDdhDxzg2k/hqdefault.jpg', 'playlist image' ) 535 | t.equal( playlist.image, playlist.thumbnail, 'common alternative' ) 536 | } 537 | } ) 538 | 539 | test( 'playlist metadata by id with 100+ items', function ( t ) { 540 | t.plan( 15 ) 541 | 542 | yts( { listId: 'PL67B0C9D86F829544' }, function ( err, playlist ) { 543 | t.error( err, 'no errors OK!' ) 544 | 545 | t.equal( playlist.title, 'Epic Music', 'title' ) 546 | t.equal( playlist.listId, 'PL67B0C9D86F829544', 'listId' ) 547 | 548 | t.equal( playlist.url, 'https://youtube.com/playlist?list=PL67B0C9D86F829544', 'playlist url' ) 549 | 550 | t.ok( playlist.videos.length >= 100, '100+ videos' ) 551 | t.ok( playlist.videos.length < playlist.size, 'maxed out at 100+ videos as expected' ) 552 | t.ok( playlist.size > 120, 'over 120 videos' ) 553 | t.ok( playlist.views > 1e6, 'over a million views' ) 554 | 555 | console.log( playlist.views ) 556 | 557 | t.equal( playlist.videos[ 0 ].duration.seconds, 60 * 2 + 51, 'play list video 1 duration.seconds ok' ) 558 | t.equal( playlist.videos[ 0 ].duration.timestamp, '2:51', 'play list video 1 duration.timestamp ok' ) 559 | 560 | t.equal( 561 | playlist.videos.filter( v => v.title ).length, 562 | playlist.videos.length, 563 | 'no video titles are empty' 564 | ) 565 | 566 | t.equal( playlist.author.name, 'ThePhipppy', 'author name' ) 567 | // t.equal( playlist.author.channelId, 'UCdwR7fIE2xyXlNRc7fb9tJg', 'author channelId' ) 568 | t.equal( playlist.author.url, 'https://youtube.com/@ThePhipppy', 'author url' ) 569 | 570 | t.equal( playlist.image, 'https://i.ytimg.com/vi/dJ-QLl5qjLg/hqdefault.jpg', 'playlist image' ) 571 | t.equal( playlist.image, playlist.thumbnail, 'common alternative' ) 572 | } ) 573 | } ) 574 | 575 | test( 'playlist metadata by faulty/non-existing id', function ( t ) { 576 | t.plan( 1 ) 577 | 578 | yts( { listId: 'XLhf_RSaUvUVvuJHpeiTvnk5n99rlRM' }, function ( err, playlist ) { 579 | t.ok( err, 'playlist unavailable ok' ) 580 | } ) 581 | } ) 582 | 583 | test( 'playlist metadata by unviewable id', function ( t ) { 584 | t.plan( 1 ) 585 | 586 | yts( { listId: 'RDGMEM_v2KDBP3d4f8uT-ilrs8fQ&' }, function ( err, playlist ) { 587 | t.equal( err.message, 'playlist error: This playlist type is unviewable.' ) 588 | } ) 589 | } ) 590 | 591 | test( 'search results: playlist', function ( t ) { 592 | t.plan( 6 ) 593 | 594 | yts( 'superman theme list', function ( err, r ) { 595 | t.error( err, 'no errors OK!' ) 596 | 597 | // console.log(r) 598 | 599 | const lists = r.playlists 600 | 601 | // Superman Theme Songs Playlist 602 | const sts = lists.filter( function ( playlist ) { 603 | // console.log('---------------') 604 | // console.log(playlist.title) 605 | // console.log(playlist.author) 606 | // console.log(playlist.videoCount) 607 | // console.log() 608 | const keep = ( 609 | playlist.title.toLowerCase() === 'superman theme songs' && 610 | playlist.author.name === 'AJ Lelievre' && 611 | 612 | // is exactly 21 as of now but test with some leeway 613 | playlist.videoCount >= 12 614 | ) 615 | 616 | return keep 617 | } )[ 0 ] 618 | 619 | // console.log(sts) 620 | 621 | t.equal( sts.url, 'https://youtube.com/playlist?list=PLYhKAl2FoGzC0IQkgfVtM991w3E8ro1yG', 'playlist url' ) 622 | t.equal( sts.listId, 'PLYhKAl2FoGzC0IQkgfVtM991w3E8ro1yG', 'playlist id' ) 623 | t.equal( sts.image, 'https://i.ytimg.com/vi/yCCq_6ankAI/hqdefault.jpg', 'playlist image' ) 624 | t.equal( sts.image, sts.thumbnail, 'common alternative' ) 625 | t.equal( sts.type, 'list', 'playlist type' ) 626 | } ) 627 | } ) 628 | 629 | test.skip( 'search results richGridRenderer: playlist', function ( t ) { 630 | // TODO this test is obsolete? 631 | t.plan( 6 ) 632 | 633 | const body = fs.readFileSync( path.join( __dirname, 'stage/richGridRenderer.response-html' ), 'utf8' ) 634 | 635 | // pre-fetched results for basic search 'superman theme list' in richGridRenderer format 636 | _yts._parseSearchResultInitialData( body, function ( err, results ) { 637 | const list = results 638 | 639 | const videos = list.filter( _yts._videoFilter ) 640 | const playlists = list.filter( _yts._playlistFilter ) 641 | const channels = list.filter( _yts._channelFilter ) 642 | const live = list.filter( _yts._liveFilter ) 643 | const all = list.filter( _yts._allFilter ) 644 | 645 | // return all found videos 646 | callback( null, { 647 | all: all, 648 | 649 | videos: videos, 650 | 651 | live: live, 652 | 653 | playlists: playlists, 654 | lists: playlists, 655 | 656 | accounts: channels, 657 | channels: channels 658 | } ) 659 | } ) 660 | 661 | function callback ( err, r ) { 662 | t.error( err, 'no errors OK!' ) 663 | 664 | const lists = r.playlists 665 | 666 | // Superman Theme Songs Playlist 667 | const sts = lists.filter( function ( playlist ) { 668 | // console.log('---------------') 669 | // console.log(playlist.title) 670 | // console.log(playlist.author) 671 | // console.log(playlist.videoCount) 672 | // console.log() 673 | const keep = ( 674 | playlist.title.toLowerCase() === 'superman theme songs' && 675 | playlist.author.name === 'AJ Lelievre' && 676 | 677 | // is exactly 21 as of now but test with some leeway 678 | playlist.videoCount >= 12 679 | ) 680 | 681 | return keep 682 | } )[ 0 ] 683 | 684 | t.equal( sts.url, 'https://youtube.com/playlist?list=PLYhKAl2FoGzC0IQkgfVtM991w3E8ro1yG', 'playlist url' ) 685 | t.equal( sts.listId, 'PLYhKAl2FoGzC0IQkgfVtM991w3E8ro1yG', 'playlist id' ) 686 | t.equal( sts.image, 'https://i.ytimg.com/vi/yCCq_6ankAI/hqdefault.jpg', 'playlist image' ) 687 | t.equal( sts.image, sts.thumbnail, 'common alternative' ) 688 | t.equal( sts.type, 'list', 'playlist type' ) 689 | } 690 | } ) 691 | 692 | test( 'search results: channel', function ( t ) { 693 | t.plan( 6 ) 694 | 695 | yts( 'PewDiePie', function ( err, r ) { 696 | t.error( err, 'no errors OK!' ) 697 | 698 | const channels = r.channels 699 | const topChannel = channels[ 0 ] 700 | 701 | t.ok( topChannel, 'topChannel OK' ) 702 | t.equal( topChannel.name, 'PewDiePie', 'channel name' ) 703 | t.equal( topChannel.url, 'https://youtube.com/@PewDiePie', 'channel url' ) 704 | 705 | t.ok( topChannel.videoCount === -1, 'video count unavailable' ) 706 | 707 | t.ok( topChannel.image, 'channel image exists OK' ) 708 | } ) 709 | } ) 710 | 711 | test( 'search results: channel updates (baseUrl,id,about,verified) | PR77 @TOXIC-DEVIL', function ( t ) { 712 | // https://github.com/talmobi/yt-search/pull/77 713 | t.plan( 6 + 6 ) 714 | 715 | yts( '王菲 Faye Wong', function ( err, r ) { 716 | t.error( err, 'no errors OK!' ) 717 | 718 | const channels = r.channels 719 | const topChannel = channels[ 0 ] 720 | 721 | t.equal( topChannel.name, 'Faye Wong Official Channel', 'channel name' ) 722 | t.equal( topChannel.baseUrl, '/@fayewongofficialchannel560', 'faye baseUrl OK') 723 | t.equal( topChannel.id, 'UCos8gkwQivJ_hHeoCcs6yXg', 'faye channel id OK') 724 | t.equal( topChannel.about, 'Faye Wong Official Channel.', 'faye about section OK') 725 | t.equal( topChannel.verified, true, 'verified true OK') 726 | 727 | // wait to avoid throttling 728 | setTimeout(function () { 729 | yts( 'irregular pineapples', function ( err, r ) { 730 | t.error( err, 'no errors OK!' ) 731 | 732 | const channels = r.channels 733 | const topChannel = channels[ 0 ] 734 | 735 | t.equal( topChannel.name, 'Irregular Pineapples', 'channel name' ) 736 | t.equal( topChannel.baseUrl, '/@IrregularPineapples', 'irreg pine baseUrl OK') 737 | t.equal( topChannel.id, 'UC9QpW9rzIx2nk0yBrnPQvoA', 'irreg pine channel id OK') 738 | t.equal( topChannel.about, '', 'irreg pine about section OK') 739 | t.equal( topChannel.verified, false, 'verified false OK') 740 | } ) 741 | }, 5000) 742 | } ) 743 | } ) 744 | 745 | test( 'search results: channel sub count', function ( t ) { 746 | t.plan( 5 ) 747 | 748 | yts( 'minecraft mojang channel', function ( err, r ) { 749 | t.error( err, 'no errors OK!' ) 750 | 751 | const channels = r.channels 752 | const topChannel = channels[ 0 ] 753 | 754 | t.ok( topChannel, 'topChannel OK' ) 755 | t.equal( topChannel.name, 'Minecraft', 'channel name' ) 756 | t.ok( topChannel.subCount > 5000000, 'sub count more than' ) 757 | t.ok( topChannel.subCount < 100000000, 'sub count less than' ) 758 | } ) 759 | } ) 760 | 761 | test( 'search results: all', function ( t ) { 762 | t.plan( 2 ) 763 | 764 | yts( 'minecraft', function ( err, r ) { 765 | t.error( err, 'no errors OK!' ) 766 | 767 | t.equal( 768 | r.videos.length + r.lists.length + r.channels.length + r.live.length, 769 | r.all.length, 770 | 'all length OK' 771 | ) 772 | } ) 773 | } ) 774 | 775 | test( 'search "王菲 Faye Wong"', function ( t ) { 776 | t.plan( 6 ) 777 | 778 | yts( '王菲 Faye Wong', function ( err, r ) { 779 | t.error( err, 'no errors OK!' ) 780 | 781 | const channels = r.channels 782 | const topChannel = channels[ 0 ] 783 | 784 | t.ok( topChannel, 'topChannel OK' ) 785 | t.equal( topChannel.name, 'Faye Wong Official Channel', 'channel name' ) 786 | t.equal( topChannel.url, 'https://youtube.com/@fayewongofficialchannel560', 'channel url' ) 787 | 788 | t.ok( topChannel.videoCount === -1, 'video count unavailable' ) 789 | 790 | t.ok( topChannel.image, 'channel image exists OK' ) 791 | 792 | // const channelImageUrl = ( 793 | // 'https://yt3.ggpht.com/a/AATXAJxg1ZCD6conNklSAF7wwtwlx5q4FlO7EpNRi_nSpw=s88-c-k-c0x00ffffff-no-rj-mo' 794 | // ) 795 | 796 | // lsp( 797 | // topChannel.image, 798 | // channelImageUrl, 799 | // { tolerance: 15 }, // ref: https://github.com/gemini-testing/looks-same 800 | // function ( err, r ) { 801 | // t.ok( r.equal, 'channel image OK!' ) 802 | // } 803 | // ) 804 | } ) 805 | } ) 806 | 807 | test( 'long video correct seconds & timestamp | #issue49', function ( t ) { 808 | // ref: https://github.com/talmobi/yt-search/issues/49 809 | t.plan( 3 ) 810 | 811 | yts( { videoId: 'K3dXnyQjnx8' }, function ( err, video ) { 812 | t.error( err, 'no errors OK!' ) 813 | 814 | t.ok( 815 | (video.timestamp === '12:00:00') || 816 | (video.timestamp === '12:00:01') 817 | , 'timestamp' ) 818 | t.ok( 819 | (video.seconds === 12 * 60 * 60) || 820 | (video.seconds === 12 * 60 * 60 + 1) 821 | , 'seconds (duration)' ) 822 | } ) 823 | } ) 824 | 825 | test( 'test promise support ( search by video id )', async function ( t ) { 826 | t.plan( 2 ) 827 | 828 | const opts = { 829 | search: "_JzeIf1zT14" 830 | } 831 | 832 | const r = await yts( opts ) 833 | 834 | for ( video of r.videos ) { 835 | if ( video.videoId == opts.search ) { 836 | t.ok( video.title.match( /josh.*jake.*hill.*rest.*piece.*lyric/i ), 'top result title matched!' ) 837 | t.ok( video.videoId, opts.search, 'top result video id matched!' ) 838 | break 839 | } 840 | } 841 | } ) 842 | 843 | test( 'search title and video metadata title are the same', async function ( t ) { 844 | // ref: https://github.com/talmobi/yt-search/issues/50 845 | t.plan( 2 ) 846 | 847 | const id = 'z95fi3uazYA' 848 | const videoIdSearch = await yts({ videoId: id }); 849 | await new Promise(function (resolve, reject) { setTimeout(resolve, 1000 * 2) }) 850 | const res = await yts( videoIdSearch.title ); 851 | 852 | const video = res.videos.filter(function (v) { 853 | const keep = ( 854 | v.title.toLowerCase().indexOf('dragon ball z') >= 0 && 855 | v.videoId === videoIdSearch.videoId 856 | ) 857 | return keep 858 | })[0] 859 | 860 | t.equal( video?.videoId, videoIdSearch?.videoId, 'ids equal' ) 861 | t.ok( video?.title.toLowerCase().indexOf('dragon ball z') >= 0 && videoIdSearch?.title.toLowerCase().indexOf('dragon ball z') >= 0, 'titles are similar' ) 862 | } ) 863 | --------------------------------------------------------------------------------