├── .eslintignore ├── .eslintrc.json ├── .github ├── FUNDING.yml ├── stale.yml └── workflows │ └── publish_npm.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── bun.lockb ├── lib ├── main.js ├── parseItem.js └── util.js ├── package.json └── typings └── index.d.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "parserOptions": { 4 | "ecmaVersion": 2017 5 | }, 6 | "env": { 7 | "es6": true, 8 | "node": true 9 | }, 10 | "rules": { 11 | "no-await-in-loop": "warn", 12 | "no-compare-neg-zero": "error", 13 | "no-template-curly-in-string": "error", 14 | "no-unsafe-negation": "error", 15 | "valid-jsdoc": [ 16 | "error", 17 | { 18 | "requireReturn": false, 19 | "requireReturnDescription": false, 20 | "prefer": { 21 | "return": "returns", 22 | "arg": "param" 23 | }, 24 | "preferType": { 25 | "String": "string", 26 | "Number": "number", 27 | "Boolean": "boolean", 28 | "Symbol": "symbol", 29 | "object": "Object", 30 | "function": "Function", 31 | "array": "Array", 32 | "date": "Date", 33 | "error": "Error", 34 | "null": "void" 35 | } 36 | } 37 | ], 38 | 39 | "accessor-pairs": "warn", 40 | "array-callback-return": "error", 41 | "complexity": ["warn", 25], 42 | "consistent-return": "warn", 43 | "curly": ["error", "multi-line", "consistent"], 44 | "dot-location": ["error", "property"], 45 | "dot-notation": "error", 46 | "eqeqeq": "error", 47 | "no-console": "error", 48 | "no-empty-function": "error", 49 | "no-floating-decimal": "error", 50 | "no-implied-eval": "error", 51 | "no-invalid-this": "error", 52 | "no-lone-blocks": "error", 53 | "no-multi-spaces": "error", 54 | "no-new-func": "error", 55 | "no-new-wrappers": "error", 56 | "no-new": "error", 57 | "no-octal-escape": "error", 58 | "no-return-assign": "error", 59 | "no-return-await": "error", 60 | "no-self-compare": "error", 61 | "no-sequences": "error", 62 | "no-throw-literal": "error", 63 | "no-unmodified-loop-condition": "error", 64 | "no-unused-expressions": "error", 65 | "no-useless-call": "error", 66 | "no-useless-concat": "error", 67 | "no-useless-escape": "error", 68 | "no-useless-return": "error", 69 | "no-void": "error", 70 | "no-warning-comments": "warn", 71 | "prefer-promise-reject-errors": "error", 72 | "require-await": "warn", 73 | "wrap-iife": "error", 74 | "yoda": "error", 75 | 76 | "no-label-var": "error", 77 | "no-shadow": "error", 78 | "no-undef-init": "error", 79 | 80 | "callback-return": "error", 81 | "handle-callback-err": "error", 82 | "no-mixed-requires": "error", 83 | "no-new-require": "error", 84 | "no-path-concat": "error", 85 | 86 | "array-bracket-spacing": "error", 87 | "block-spacing": "error", 88 | "brace-style": ["error", "1tbs", { "allowSingleLine": true }], 89 | "comma-dangle": ["error", "always-multiline"], 90 | "comma-spacing": "error", 91 | "comma-style": "error", 92 | "computed-property-spacing": "error", 93 | "consistent-this": ["error", "$this"], 94 | "eol-last": "error", 95 | "func-names": "error", 96 | "func-name-matching": "error", 97 | "func-style": ["error", "declaration", { "allowArrowFunctions": true }], 98 | "indent": ["error", 2, { "SwitchCase": 1, "offsetTernaryExpressions": true }], 99 | "key-spacing": "error", 100 | "keyword-spacing": "error", 101 | "max-depth": "error", 102 | "max-len": ["error", 120, 2], 103 | "max-nested-callbacks": ["error", { "max": 4 }], 104 | "max-statements-per-line": ["error", { "max": 2 }], 105 | "new-cap": "off", 106 | "newline-per-chained-call": ["error", { "ignoreChainWithDepth": 3 }], 107 | "no-array-constructor": "error", 108 | "no-inline-comments": "error", 109 | "no-lonely-if": "error", 110 | "no-mixed-operators": "error", 111 | "no-multiple-empty-lines": ["error", { "max": 2, "maxEOF": 1, "maxBOF": 0 }], 112 | "no-new-object": "error", 113 | "no-spaced-func": "error", 114 | "no-trailing-spaces": "error", 115 | "no-unneeded-ternary": "error", 116 | "no-whitespace-before-property": "error", 117 | "nonblock-statement-body-position": "error", 118 | "object-curly-spacing": ["error", "always"], 119 | "operator-assignment": "error", 120 | "padded-blocks": ["error", "never"], 121 | "quote-props": ["error", "as-needed"], 122 | "quotes": ["error", "single", { "avoidEscape": true, "allowTemplateLiterals": true }], 123 | "semi-spacing": "error", 124 | "semi": "error", 125 | "space-before-blocks": "error", 126 | "space-in-parens": "error", 127 | "space-infix-ops": "error", 128 | "space-unary-ops": "error", 129 | "spaced-comment": "error", 130 | "template-tag-spacing": "error", 131 | "unicode-bom": "error", 132 | 133 | "arrow-body-style": "error", 134 | "arrow-parens": ["error", "as-needed"], 135 | "arrow-spacing": "error", 136 | "no-duplicate-imports": "error", 137 | "no-useless-computed-key": "error", 138 | "no-useless-constructor": "error", 139 | "prefer-arrow-callback": "error", 140 | "prefer-numeric-literals": "error", 141 | "prefer-rest-params": "error", 142 | "prefer-spread": "error", 143 | "prefer-template": "error", 144 | "rest-spread-spacing": "error", 145 | "template-curly-spacing": "error", 146 | "yield-star-spacing": "error", 147 | "no-var": "error" 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: skick 2 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - bug 8 | - enhancement 9 | - help wanted 10 | # Label to use when marking an issue as stale 11 | staleLabel: stale 12 | # Comment to post when marking an issue as stale. Set to `false` to disable 13 | markComment: > 14 | This issue has been automatically marked as stale because it has not had 15 | recent activity. It will be closed if no further activity occurs. Thank you 16 | for your contributions. 17 | # Comment to post when closing a stale issue. Set to `false` to disable 18 | closeComment: false 19 | -------------------------------------------------------------------------------- /.github/workflows/publish_npm.yml: -------------------------------------------------------------------------------- 1 | name: Publish @distube/ytsr 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | jobs: 7 | publish: 8 | name: Build & Publish 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout repository 12 | uses: actions/checkout@v4 13 | 14 | - name: Install Node.js 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: 20 18 | registry-url: 'https://registry.npmjs.org' 19 | 20 | - name: Lint 21 | run: npm i && npm run lint 22 | 23 | - name: Publish 24 | run: npm publish 25 | env: 26 | NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 27 | 28 | - name: Deprecate older versions 29 | run: npm deprecate @distube/ytsr@"< ${{ github.ref_name }}" "This version is deprecated, please upgrade to the latest version." 30 | env: 31 | NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .nyc_output 4 | test.js 5 | package-lock.json 6 | dumps -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "printWidth": 120, 5 | "endOfLine": "lf", 6 | "quoteProps": "as-needed", 7 | "arrowParens": "avoid", 8 | "tabWidth": 2, 9 | "singleQuote": true 10 | } 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2017 by Tobias Kutscha 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @distube/ytsr 2 | 3 | A light-weight ytsr for [DisTube](https://distube.js.org). Original [ytsr](https://www.npmjs.com/package/ytsr). 4 | 5 | # Feature 6 | 7 | - Search for videos on YouTube 8 | - Only support `video` and `playlist` type 9 | 10 | # Usage 11 | 12 | The response is modified from the original ytsr response. See [Example Response](#example-response) for more information. 13 | 14 | ```js 15 | const ytsr = require('@distube/ytsr'); 16 | 17 | ytsr('DisTube', { safeSearch: true, limit: 1 }).then(result => { 18 | let song = result.items[0]; 19 | console.log('ID: ' + song.id); 20 | console.log('Name: ' + song.name); 21 | console.log('URL: ' + song.url); 22 | console.log('Views: ' + song.views); 23 | console.log('Duration: ' + song.duration); 24 | console.log('Live: ' + song.isLive); 25 | }); 26 | 27 | /* 28 | ID: Bk7RVw3I8eg 29 | Name: Disturbed "The Sound Of Silence" 03/28/16 30 | URL: https://www.youtube.com/watch?v=Bk7RVw3I8eg 31 | Views: 114892726 32 | Duration: 4:25 33 | Live: false 34 | */ 35 | ``` 36 | 37 | ## API 38 | 39 | ### ytsr(query, options) 40 | 41 | Searches for the given string 42 | 43 | - `searchString` 44 | - search string or url (from getFilters) to search from 45 | - `options` 46 | 47 | - object with options 48 | - possible settings: 49 | - gl[String] -> 2-Digit Code of a Country, defaults to `US` - Allows for localisation of the request 50 | - hl[String] -> 2-Digit Code for a Language, defaults to `en` - Allows for localisation of the request 51 | - utcOffsetMinutes[Number] -> Offset in minutes from UTC, defaults to `-300` - Allows for localisation of the request 52 | - safeSearch[Boolean] -> pull items in youtube restriction mode. 53 | - limit[integer] -> limits the pulled items, defaults to 100, set to Infinity to get the whole list of search results - numbers <1 result in the default being used 54 | - type[String] -> filter for a specific type of item, defaults to `video` - possible values: `video`, `playlist` 55 | - requestOptions[Object] -> Additional parameters to passed to undici's [request options](https://github.com/nodejs/undici#undicirequesturl-options-promise), which is used to do the https requests 56 | 57 | - returns a Promise 58 | - [Example response](#example-response) 59 | 60 | ## Update Checks 61 | 62 | If you'd like to disable update check, you can do so by providing the `YTSR_NO_UPDATE` env variable. 63 | 64 | ``` 65 | env YTSR_NO_UPDATE=1 node myapp.js 66 | ``` 67 | 68 | ## Example Response 69 | 70 | ```js 71 | { 72 | query: 'lofi', 73 | items: [ 74 | { 75 | type: 'video', 76 | name: 'lofi hip hop radio - beats to relax/study to', 77 | id: 'jfKfPfyJRdk', 78 | url: 'https://www.youtube.com/watch?v=jfKfPfyJRdk', 79 | thumbnail: 'https://i.ytimg.com/vi/jfKfPfyJRdk/hq720.jpg?sqp=-oaymwEXCNAFEJQDSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLD2EFON1LtcckqLkCokLTCzk0l5Jw', 80 | thumbnails: [ 81 | { 82 | url: 'https://i.ytimg.com/vi/jfKfPfyJRdk/hq720.jpg?sqp=-oaymwEXCNAFEJQDSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLD2EFON1LtcckqLkCokLTCzk0l5Jw', 83 | width: 720, 84 | height: 404 85 | }, 86 | { 87 | url: 'https://i.ytimg.com/vi/jfKfPfyJRdk/hq720.jpg?sqp=-oaymwEjCOgCEMoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBhesbAXUbF95EBkPgG26L7-14BVA', 88 | width: 360, 89 | height: 202 90 | } 91 | ], 92 | isUpcoming: false, 93 | upcoming: null, 94 | isLive: true, 95 | badges: [ 'LIVE' ], 96 | author: { 97 | name: 'Lofi Girl', 98 | channelID: 'UCSJ4gkVC6NrvII8umztf0Ow', 99 | url: 'https://www.youtube.com/@LofiGirl', 100 | bestAvatar: { 101 | url: 'https://yt3.ggpht.com/KNYElmLFGAOSZoBmxYGKKXhGHrT2e7Hmz3WsBerbam5uaDXFADAmT7htj3OcC-uK1O88lC9fQg=s68-c-k-c0x00ffffff-no-rj', 102 | width: 68, 103 | height: 68 104 | }, 105 | avatars: [ 106 | { 107 | url: 'https://yt3.ggpht.com/KNYElmLFGAOSZoBmxYGKKXhGHrT2e7Hmz3WsBerbam5uaDXFADAmT7htj3OcC-uK1O88lC9fQg=s68-c-k-c0x00ffffff-no-rj', 108 | width: 68, 109 | height: 68 110 | } 111 | ], 112 | ownerBadges: [ 'Verified' ], 113 | verified: true 114 | }, 115 | description: '', 116 | views: 20052, 117 | duration: '', 118 | uploadedAt: '' 119 | }, 120 | { 121 | type: 'video', 122 | name: 'Nhạc Lofi 2023 - Những Bản Lofi Mix Chill Nhẹ Nhàng Cực Hay - Nhạc Trẻ Lofi Gây Nghiện Hot Nhất 2023', 123 | id: 'DALtfXfRgcM', 124 | url: 'https://www.youtube.com/watch?v=DALtfXfRgcM', 125 | thumbnail: 'https://i.ytimg.com/vi/DALtfXfRgcM/hq720.jpg?sqp=-oaymwEXCNAFEJQDSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLCvkoLEsxjDDp9AYW5KoIgfwzIZPg', 126 | thumbnails: [ 127 | { 128 | url: 'https://i.ytimg.com/vi/DALtfXfRgcM/hq720.jpg?sqp=-oaymwEXCNAFEJQDSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLCvkoLEsxjDDp9AYW5KoIgfwzIZPg', 129 | width: 720, 130 | height: 404 131 | }, 132 | { 133 | url: 'https://i.ytimg.com/vi/DALtfXfRgcM/hq720.jpg?sqp=-oaymwEjCOgCEMoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLAOgaKmx3QAhmc9fkynIpZAeujk8A', 134 | width: 360, 135 | height: 202 136 | } 137 | ], 138 | isUpcoming: false, 139 | upcoming: null, 140 | isLive: false, 141 | badges: [], 142 | author: { 143 | name: 'Hạ Sang', 144 | channelID: 'UC8WHyfTblB1QhzlJEZawLQw', 145 | url: 'https://www.youtube.com/@OrinnHaSang', 146 | bestAvatar: { 147 | url: 'https://yt3.ggpht.com/ytc/AL5GRJXaQNKJ-yi0AC_HZMO0XCEGol3Z2vxayyb0A8Z9qg=s68-c-k-c0x00ffffff-no-rj', 148 | width: 68, 149 | height: 68 150 | }, 151 | avatars: [ 152 | { 153 | url: 'https://yt3.ggpht.com/ytc/AL5GRJXaQNKJ-yi0AC_HZMO0XCEGol3Z2vxayyb0A8Z9qg=s68-c-k-c0x00ffffff-no-rj', 154 | width: 68, 155 | height: 68 156 | } 157 | ], 158 | ownerBadges: [ 'Verified' ], 159 | verified: true 160 | }, 161 | description: '', 162 | views: 132032, 163 | duration: '49:10', 164 | uploadedAt: '9 days ago' 165 | }, 166 | { 167 | type: 'video', 168 | name: 'Daily Work Space 📂 Lofi Deep Forcus [chill lo-fi hip hop beats]', 169 | id: 'bHi-1Ekk3KE', 170 | url: 'https://www.youtube.com/watch?v=bHi-1Ekk3KE', 171 | thumbnail: 'https://i.ytimg.com/vi/bHi-1Ekk3KE/hq720.jpg?sqp=-oaymwEXCNAFEJQDSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLCBzHtpgMEjEU1G6WuZvXkOBH_9AA', 172 | thumbnails: [ 173 | { 174 | url: 'https://i.ytimg.com/vi/bHi-1Ekk3KE/hq720.jpg?sqp=-oaymwEXCNAFEJQDSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLCBzHtpgMEjEU1G6WuZvXkOBH_9AA', 175 | width: 720, 176 | height: 404 177 | }, 178 | { 179 | url: 'https://i.ytimg.com/vi/bHi-1Ekk3KE/hq720.jpg?sqp=-oaymwEjCOgCEMoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLAQmtYqCoZDR6frLNIyFFpAaK4FJg', 180 | width: 360, 181 | height: 202 182 | } 183 | ], 184 | isUpcoming: false, 185 | upcoming: null, 186 | isLive: false, 187 | badges: [ 'CC' ], 188 | author: { 189 | name: '𝗖𝗛𝗜𝗟𝗟 𝗩𝗜𝗟𝗟𝗔𝗚𝗘', 190 | channelID: 'UCsxTsDvh-xQnShEtN1M64zQ', 191 | url: 'https://www.youtube.com/@Chill_Village', 192 | bestAvatar: { 193 | url: 'https://yt3.ggpht.com/1sjrCoAFx_Ak9g77xLRKv5na7Uz3MlvZ1KQQs-uhCtkdxFiLLTasPHH7e_NEuBO9DmPO_m9_=s68-c-k-c0x00ffffff-no-rj', 194 | width: 68, 195 | height: 68 196 | }, 197 | avatars: [ 198 | { 199 | url: 'https://yt3.ggpht.com/1sjrCoAFx_Ak9g77xLRKv5na7Uz3MlvZ1KQQs-uhCtkdxFiLLTasPHH7e_NEuBO9DmPO_m9_=s68-c-k-c0x00ffffff-no-rj', 200 | width: 68, 201 | height: 68 202 | } 203 | ], 204 | ownerBadges: [], 205 | verified: false 206 | }, 207 | description: '', 208 | views: 277853, 209 | duration: '11:54:57', 210 | uploadedAt: 'Streamed 1 month ago' 211 | }, 212 | { 213 | type: 'video', 214 | name: 'Chill Lofi Mix [chill lo-fi hip hop beats]', 215 | id: 'CLeZyIID9Bo', 216 | url: 'https://www.youtube.com/watch?v=CLeZyIID9Bo', 217 | thumbnail: 'https://i.ytimg.com/vi/CLeZyIID9Bo/hq720.jpg?sqp=-oaymwExCNAFEJQDSFryq4qpAyMIARUAAIhCGAHwAQH4Af4JgALQBYoCDAgAEAEYRyBlKFkwDw==&rs=AOn4CLDFiqIgoPbZX2JMVNzdtcG2yaymhg', 218 | thumbnails: [ 219 | { 220 | url: 'https://i.ytimg.com/vi/CLeZyIID9Bo/hq720.jpg?sqp=-oaymwExCNAFEJQDSFryq4qpAyMIARUAAIhCGAHwAQH4Af4JgALQBYoCDAgAEAEYRyBlKFkwDw==&rs=AOn4CLDFiqIgoPbZX2JMVNzdtcG2yaymhg', 221 | width: 720, 222 | height: 404 223 | }, 224 | { 225 | url: 'https://i.ytimg.com/vi/CLeZyIID9Bo/hq720.jpg?sqp=-oaymwE9COgCEMoBSFryq4qpAy8IARUAAAAAGAElAADIQj0AgKJDeAHwAQH4Af4JgALQBYoCDAgAEAEYRyBlKFkwDw==&rs=AOn4CLCUPAL7mhZ-wVunrmOhcxcNgptn2Q', 226 | width: 360, 227 | height: 202 228 | } 229 | ], 230 | isUpcoming: false, 231 | upcoming: null, 232 | isLive: false, 233 | badges: [ '4K' ], 234 | author: { 235 | name: 'Settle', 236 | channelID: 'UCkKT4qf-TcPFOmpqhTawrGA', 237 | url: 'https://www.youtube.com/@settlefm', 238 | bestAvatar: { 239 | url: 'https://yt3.ggpht.com/dl19pP2rM_VyLyU4p70Rn05zle60B27870wKQkGvELpAiWAgBFSmL_TXX2sskD3IuCACAGb1fg=s68-c-k-c0x00ffffff-no-rj', 240 | width: 68, 241 | height: 68 242 | }, 243 | avatars: [ 244 | { 245 | url: 'https://yt3.ggpht.com/dl19pP2rM_VyLyU4p70Rn05zle60B27870wKQkGvELpAiWAgBFSmL_TXX2sskD3IuCACAGb1fg=s68-c-k-c0x00ffffff-no-rj', 246 | width: 68, 247 | height: 68 248 | } 249 | ], 250 | ownerBadges: [], 251 | verified: false 252 | }, 253 | description: '', 254 | views: 12770763, 255 | duration: '1:44:52', 256 | uploadedAt: '7 months ago' 257 | }, 258 | { 259 | type: 'video', 260 | name: 'Nhạc Chill TikTok - Những Bản Lofi Việt Nhẹ Nhàng Cực Chill - Nhạc Lofi Chill Buồn Hot TikTok 2023', 261 | id: '9L-9votMIrw', 262 | url: 'https://www.youtube.com/watch?v=9L-9votMIrw', 263 | thumbnail: 'https://i.ytimg.com/vi/9L-9votMIrw/hq720.jpg?sqp=-oaymwEXCNAFEJQDSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLAyItNyAF-lnXaNjhfi2HCPLnJEXA', 264 | thumbnails: [ 265 | { 266 | url: 'https://i.ytimg.com/vi/9L-9votMIrw/hq720.jpg?sqp=-oaymwEXCNAFEJQDSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLAyItNyAF-lnXaNjhfi2HCPLnJEXA', 267 | width: 720, 268 | height: 404 269 | }, 270 | { 271 | url: 'https://i.ytimg.com/vi/9L-9votMIrw/hq720.jpg?sqp=-oaymwEjCOgCEMoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLAeqhlXV3SXy92EJixuZYFI1IwcPg', 272 | width: 360, 273 | height: 202 274 | } 275 | ], 276 | isUpcoming: false, 277 | upcoming: null, 278 | isLive: false, 279 | badges: [ 'New' ], 280 | author: { 281 | name: 'Tiệm Nhạc Lofi', 282 | channelID: 'UCPEiJSKIdB8SeFt3YRU_fnw', 283 | url: 'https://www.youtube.com/@TiemNhacLofi', 284 | bestAvatar: { 285 | url: 'https://yt3.ggpht.com/N29QluoPr4no-Jm0QPU3B6cbt36Isx7aCOaKShsff7wgbfkoNXwoZ5dTZFXzfKzubXDrRv6ePg=s68-c-k-c0x00ffffff-no-rj', 286 | width: 68, 287 | height: 68 288 | }, 289 | avatars: [ 290 | { 291 | url: 'https://yt3.ggpht.com/N29QluoPr4no-Jm0QPU3B6cbt36Isx7aCOaKShsff7wgbfkoNXwoZ5dTZFXzfKzubXDrRv6ePg=s68-c-k-c0x00ffffff-no-rj', 292 | width: 68, 293 | height: 68 294 | } 295 | ], 296 | ownerBadges: [ 'Verified' ], 297 | verified: true 298 | }, 299 | description: '', 300 | views: 15043, 301 | duration: '1:01:19', 302 | uploadedAt: '20 hours ago' 303 | }, 304 | { 305 | type: 'video', 306 | name: '3107-2 - Sau Này Liệu Chúng Ta - Sợ Lắm 2 ft. Hẹn Yêu - Mix Freak D Mashup Lofi Sad Cực Chill - P2', 307 | id: 'HZ3XumHDDwA', 308 | url: 'https://www.youtube.com/watch?v=HZ3XumHDDwA', 309 | thumbnail: 'https://i.ytimg.com/vi/HZ3XumHDDwA/hq720.jpg?sqp=-oaymwEXCNAFEJQDSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLD6fCtWBCr8R0j99QxfweAU6hoS_g', 310 | thumbnails: [ 311 | { 312 | url: 'https://i.ytimg.com/vi/HZ3XumHDDwA/hq720.jpg?sqp=-oaymwEXCNAFEJQDSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLD6fCtWBCr8R0j99QxfweAU6hoS_g', 313 | width: 720, 314 | height: 404 315 | }, 316 | { 317 | url: 'https://i.ytimg.com/vi/HZ3XumHDDwA/hq720.jpg?sqp=-oaymwEjCOgCEMoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLC2RqK-3RZA-Uy2znlwr7WAAzSlIA', 318 | width: 360, 319 | height: 202 320 | } 321 | ], 322 | isUpcoming: false, 323 | upcoming: null, 324 | isLive: false, 325 | badges: [], 326 | author: { 327 | name: 'Pii Music', 328 | channelID: 'UCsSkCBIqzIpWGbGOKgrZ90w', 329 | url: 'https://www.youtube.com/@piimusic696', 330 | bestAvatar: { 331 | url: 'https://yt3.ggpht.com/ytc/AL5GRJUrah-GzpsEK2TgiIOuFKvctvIRZ_VIsxgxEtS6=s68-c-k-c0x00ffffff-no-rj', 332 | width: 68, 333 | height: 68 334 | }, 335 | avatars: [ 336 | { 337 | url: 'https://yt3.ggpht.com/ytc/AL5GRJUrah-GzpsEK2TgiIOuFKvctvIRZ_VIsxgxEtS6=s68-c-k-c0x00ffffff-no-rj', 338 | width: 68, 339 | height: 68 340 | } 341 | ], 342 | ownerBadges: [ 'Verified' ], 343 | verified: true 344 | }, 345 | description: '', 346 | views: 32680485, 347 | duration: '40:29', 348 | uploadedAt: '2 years ago' 349 | }, 350 | { 351 | type: 'video', 352 | name: 'Best of lofi hip hop 2021 ✨ - beats to relax/study to', 353 | id: 'n61ULEU7CO0', 354 | url: 'https://www.youtube.com/watch?v=n61ULEU7CO0', 355 | thumbnail: 'https://i.ytimg.com/vi/n61ULEU7CO0/hq720.jpg?sqp=-oaymwEXCNAFEJQDSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLABgYZhkqacI3o5_Pa56YPXWQbhRg', 356 | thumbnails: [ 357 | { 358 | url: 'https://i.ytimg.com/vi/n61ULEU7CO0/hq720.jpg?sqp=-oaymwEXCNAFEJQDSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLABgYZhkqacI3o5_Pa56YPXWQbhRg', 359 | width: 720, 360 | height: 404 361 | }, 362 | { 363 | url: 'https://i.ytimg.com/vi/n61ULEU7CO0/hq720.jpg?sqp=-oaymwEjCOgCEMoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLAg92gxOhLfxYAqrQ-MkqkeHNF6sg', 364 | width: 360, 365 | height: 202 366 | } 367 | ], 368 | isUpcoming: false, 369 | upcoming: null, 370 | isLive: false, 371 | badges: [], 372 | author: { 373 | name: 'Lofi Girl', 374 | channelID: 'UCSJ4gkVC6NrvII8umztf0Ow', 375 | url: 'https://www.youtube.com/@LofiGirl', 376 | bestAvatar: { 377 | url: 'https://yt3.ggpht.com/KNYElmLFGAOSZoBmxYGKKXhGHrT2e7Hmz3WsBerbam5uaDXFADAmT7htj3OcC-uK1O88lC9fQg=s68-c-k-c0x00ffffff-no-rj', 378 | width: 68, 379 | height: 68 380 | }, 381 | avatars: [ 382 | { 383 | url: 'https://yt3.ggpht.com/KNYElmLFGAOSZoBmxYGKKXhGHrT2e7Hmz3WsBerbam5uaDXFADAmT7htj3OcC-uK1O88lC9fQg=s68-c-k-c0x00ffffff-no-rj', 384 | width: 68, 385 | height: 68 386 | } 387 | ], 388 | ownerBadges: [ 'Verified' ], 389 | verified: true 390 | }, 391 | description: '', 392 | views: 22469974, 393 | duration: '6:32:05', 394 | uploadedAt: '1 year ago' 395 | }, 396 | { 397 | type: 'video', 398 | name: 'Nhạc Chill Nhẹ Nhàng Hot TikTok - Những Bản Nhạc Lofi Chill Nhẹ Nhàng Gây Nghiện Hay Nhất Hiện Giờ ♫', 399 | id: 'r5YZY2N6UWg', 400 | url: 'https://www.youtube.com/watch?v=r5YZY2N6UWg', 401 | thumbnail: 'https://i.ytimg.com/vi/r5YZY2N6UWg/hq720.jpg?sqp=-oaymwEXCNAFEJQDSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLBUpN6KwhdJBUHFQq7MFei77psLBA', 402 | thumbnails: [ 403 | { 404 | url: 'https://i.ytimg.com/vi/r5YZY2N6UWg/hq720.jpg?sqp=-oaymwEXCNAFEJQDSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLBUpN6KwhdJBUHFQq7MFei77psLBA', 405 | width: 720, 406 | height: 404 407 | }, 408 | { 409 | url: 'https://i.ytimg.com/vi/r5YZY2N6UWg/hq720.jpg?sqp=-oaymwEjCOgCEMoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDJ0TCSg8KQRtEDXzF6vDajQ3ISBQ', 410 | width: 360, 411 | height: 202 412 | } 413 | ], 414 | isUpcoming: false, 415 | upcoming: null, 416 | isLive: false, 417 | badges: [ 'CC' ], 418 | author: { 419 | name: 'Mặt Trời Khóc', 420 | channelID: 'UCu-o0KDtNh3zZCfwMnUnyPQ', 421 | url: 'https://www.youtube.com/@mattroikhoc1289', 422 | bestAvatar: { 423 | url: 'https://yt3.ggpht.com/aWhYbRN0K7lLsfYJb5jYWAsG0TjUua-eeS_wvQuT6TVvbDgqGzaHhTk5nSGaLz0n-MRYgk2kGg=s68-c-k-c0x00ffffff-no-rj', 424 | width: 68, 425 | height: 68 426 | }, 427 | avatars: [ 428 | { 429 | url: 'https://yt3.ggpht.com/aWhYbRN0K7lLsfYJb5jYWAsG0TjUua-eeS_wvQuT6TVvbDgqGzaHhTk5nSGaLz0n-MRYgk2kGg=s68-c-k-c0x00ffffff-no-rj', 430 | width: 68, 431 | height: 68 432 | } 433 | ], 434 | ownerBadges: [], 435 | verified: false 436 | }, 437 | description: '', 438 | views: 4008542, 439 | duration: '1:19:49', 440 | uploadedAt: '1 month ago' 441 | }, 442 | { 443 | type: 'video', 444 | name: 'Gió Mang Hương Về Giờ Em Ở Đâu ♫ Gió (Jank) ♫ Nhạc Lofi Chill Buồn Tâm Trạng 2023', 445 | id: 'yhlZooyKMYw', 446 | url: 'https://www.youtube.com/watch?v=yhlZooyKMYw', 447 | thumbnail: 'https://i.ytimg.com/vi/yhlZooyKMYw/hq720.jpg?sqp=-oaymwEXCNAFEJQDSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLCotGZ5FRy1R3o2tZoG4171FIZEcw', 448 | thumbnails: [ 449 | { 450 | url: 'https://i.ytimg.com/vi/yhlZooyKMYw/hq720.jpg?sqp=-oaymwEXCNAFEJQDSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLCotGZ5FRy1R3o2tZoG4171FIZEcw', 451 | width: 720, 452 | height: 404 453 | }, 454 | { 455 | url: 'https://i.ytimg.com/vi/yhlZooyKMYw/hq720.jpg?sqp=-oaymwEjCOgCEMoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDhBG-HWBfswLQmO6-Sw5sW5ZXLNw', 456 | width: 360, 457 | height: 202 458 | } 459 | ], 460 | isUpcoming: false, 461 | upcoming: null, 462 | isLive: false, 463 | badges: [ 'New' ], 464 | author: { 465 | name: 'Will M', 466 | channelID: 'UCbq0B-aH1rxrqvfCH9mStvg', 467 | url: 'https://www.youtube.com/@will.liuliu', 468 | bestAvatar: { 469 | url: 'https://yt3.ggpht.com/bH44QORGWRuj7DAvPfgO5LWoS5f1ZMwZQLR5BW0_mtxCPEYrXXANXVJDX6gQAHiOsjsNB6BOkLc=s68-c-k-c0x00ffffff-no-rj', 470 | width: 68, 471 | height: 68 472 | }, 473 | avatars: [ 474 | { 475 | url: 'https://yt3.ggpht.com/bH44QORGWRuj7DAvPfgO5LWoS5f1ZMwZQLR5BW0_mtxCPEYrXXANXVJDX6gQAHiOsjsNB6BOkLc=s68-c-k-c0x00ffffff-no-rj', 476 | width: 68, 477 | height: 68 478 | } 479 | ], 480 | ownerBadges: [], 481 | verified: false 482 | }, 483 | description: '', 484 | views: 30762, 485 | duration: '1:19:57', 486 | uploadedAt: '16 hours ago' 487 | }, 488 | { 489 | type: 'video', 490 | name: 'Phai Dấu Cuộc Tình (Lofi ver) Đạt Long Vinh - Một người ra đi vội vã, Mang theo những dấu yêu xa rời', 491 | id: 'gxePeTdmlgU', 492 | url: 'https://www.youtube.com/watch?v=gxePeTdmlgU', 493 | thumbnail: 'https://i.ytimg.com/vi/gxePeTdmlgU/hq720.jpg?sqp=-oaymwEXCNAFEJQDSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLBeB4HCgxU4J6pND-vg2DLe1Fpktg', 494 | thumbnails: [ 495 | { 496 | url: 'https://i.ytimg.com/vi/gxePeTdmlgU/hq720.jpg?sqp=-oaymwEXCNAFEJQDSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLBeB4HCgxU4J6pND-vg2DLe1Fpktg', 497 | width: 720, 498 | height: 404 499 | }, 500 | { 501 | url: 'https://i.ytimg.com/vi/gxePeTdmlgU/hq720.jpg?sqp=-oaymwEjCOgCEMoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLCibZbbnAwKWTYwsKZZeKEYTMKctg', 502 | width: 360, 503 | height: 202 504 | } 505 | ], 506 | isUpcoming: false, 507 | upcoming: null, 508 | isLive: false, 509 | badges: [], 510 | author: { 511 | name: 'NHẠC XƯA LOFI', 512 | channelID: 'UCTSxGcEiXDrL4OkQDS67w4g', 513 | url: 'https://www.youtube.com/@nhacxualofi', 514 | bestAvatar: { 515 | url: 'https://yt3.ggpht.com/a07ixhurnkCWjPjQ6EJZWhmrfl-cae6yKx61f1YKkIXnkiaMAO61gfrm1JhOF0IGg88xKs7X=s68-c-k-c0x00ffffff-no-rj', 516 | width: 68, 517 | height: 68 518 | }, 519 | avatars: [ 520 | { 521 | url: 'https://yt3.ggpht.com/a07ixhurnkCWjPjQ6EJZWhmrfl-cae6yKx61f1YKkIXnkiaMAO61gfrm1JhOF0IGg88xKs7X=s68-c-k-c0x00ffffff-no-rj', 522 | width: 68, 523 | height: 68 524 | } 525 | ], 526 | ownerBadges: [], 527 | verified: false 528 | }, 529 | description: '', 530 | views: 454054, 531 | duration: '1:00:34', 532 | uploadedAt: '1 month ago' 533 | } 534 | ], 535 | results: 38772312 536 | } 537 | ``` 538 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/distubejs/ytsr/8e5a65cfab0853fe778f040a20650ad8b0ae1c7a/bun.lockb -------------------------------------------------------------------------------- /lib/main.js: -------------------------------------------------------------------------------- 1 | const PARSE_ITEM = require('./parseItem.js'); 2 | const { request } = require('undici'); 3 | const UTIL = require('./util.js'); 4 | 5 | const BASE_SEARCH_URL = 'https://www.youtube.com/results'; 6 | const BASE_API_URL = 'https://www.youtube.com/youtubei/v1/search'; 7 | const CACHE = new Map([ 8 | // ['apiKey', 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8'], 9 | ['clientVersion', '2.20240606.06.00'], 10 | ['playlistParams', 'EgIQAw%3D%3D'], 11 | ]); 12 | 13 | const saveCache = (parsed, opts) => { 14 | // if (parsed.apiKey) CACHE.set('apiKey', parsed.apiKey); 15 | // else if (CACHE.has('apiKey')) parsed.apiKey = CACHE.get('apiKey'); 16 | if (parsed.context && parsed.context.client && parsed.context.client.clientVersion) { 17 | CACHE.set('clientVersion', parsed.context.client.clientVersion); 18 | } else if (CACHE.has('clientVersion')) { 19 | parsed.context = UTIL.buildPostContext(CACHE.get('clientVersion'), opts); 20 | } 21 | const plParams = 22 | UTIL.getPlaylistParams(parsed) || 23 | (parsed.body ? UTIL.betweenFromRight(parsed.body, `"params":"`, '"}},"tooltip":"Search for Playlist"') : null); 24 | if (plParams) CACHE.set('playlistParams', plParams); 25 | }; 26 | 27 | // eslint-disable-next-line complexity 28 | const main = (module.exports = async (searchString, options, rt = 3) => { 29 | UTIL.checkForUpdates(); 30 | if (rt === 2) { 31 | // CACHE.delete('apiKey'); 32 | CACHE.delete('clientVersion'); 33 | CACHE.delete('playlistParams'); 34 | } 35 | if (rt === 0) throw new Error('Unable to find JSON!'); 36 | // Set default values 37 | const opts = UTIL.checkArgs(searchString, options); 38 | 39 | let parsed = {}; 40 | if ( 41 | !opts.safeSearch || 42 | !CACHE.has('clientVersion') || 43 | !CACHE.has('playlistParams') 44 | // || !CACHE.has('apiKey') 45 | ) { 46 | const res = await request(BASE_SEARCH_URL, Object.assign({}, opts.requestOptions, { query: opts.query })); 47 | const body = await res.body.text(); 48 | parsed = UTIL.parseBody(body, opts); 49 | } 50 | saveCache(parsed, opts); 51 | if (opts.type === 'playlist') { 52 | const params = CACHE.get('playlistParams'); 53 | parsed.json = await UTIL.doPost( 54 | BASE_API_URL, 55 | Object.assign({}, opts.requestOptions, { 56 | query: { 57 | // key: parsed.apiKey, 58 | prettyPrint: false, 59 | }, 60 | }), 61 | { 62 | context: parsed.context, 63 | params, 64 | query: searchString, 65 | }, 66 | ); 67 | if (!parsed.json) throw new Error('Cannot searching for Playlist!'); 68 | } else if (opts.safeSearch || !parsed.json) { 69 | try { 70 | parsed.json = await UTIL.doPost( 71 | BASE_API_URL, 72 | Object.assign({}, opts.requestOptions, { 73 | query: { 74 | // key: parsed.apiKey, 75 | prettyPrint: false, 76 | }, 77 | }), 78 | { 79 | context: parsed.context, 80 | query: searchString, 81 | }, 82 | ); 83 | } catch (e) { 84 | if (rt === 1) throw e; 85 | } 86 | } 87 | 88 | if (!parsed.json) return main(searchString, options, rt - 1); 89 | 90 | const resp = { query: opts.search }; 91 | 92 | try { 93 | // Parse items 94 | const { rawItems, continuation } = UTIL.parseWrapper( 95 | parsed.json.contents.twoColumnSearchResultsRenderer.primaryContents, 96 | ); 97 | 98 | // Parse items 99 | resp.items = rawItems 100 | .map(a => PARSE_ITEM(a, resp)) 101 | .filter(r => r && r.type === opts.type) 102 | .filter((_, index) => index < opts.limit); 103 | 104 | // Adjust tracker 105 | opts.limit -= resp.items.length; 106 | 107 | // Get amount of results 108 | resp.results = Number(parsed.json.estimatedResults) || 0; 109 | 110 | let token = null; 111 | if (continuation) token = continuation.continuationItemRenderer.continuationEndpoint.continuationCommand.token; 112 | 113 | // We're already on last page or hit the limit 114 | if (!token || opts.limit < 1) return resp; 115 | // Recursively fetch more items 116 | const nestedResp = await parsePage2( 117 | // parsed.apiKey, 118 | token, 119 | parsed.context, 120 | opts, 121 | ); 122 | 123 | // Merge the responses 124 | resp.items.push(...nestedResp); 125 | return resp; 126 | } catch (e) { 127 | parsed.query = searchString; 128 | parsed.requestOptions = opts.requestOptions; 129 | parsed.error = UTIL.errorToObject(e); 130 | UTIL.logger(parsed); 131 | throw e; 132 | } 133 | }); 134 | 135 | const parsePage2 = async ( 136 | // apiKey, 137 | token, 138 | context, 139 | opts, 140 | ) => { 141 | const json = await UTIL.doPost( 142 | BASE_API_URL, 143 | Object.assign({}, opts.requestOptions, { 144 | query: { 145 | // key: apiKey, 146 | prettyPrint: false, 147 | }, 148 | }), 149 | { context, continuation: token }, 150 | ); 151 | if (!Array.isArray(json.onResponseReceivedCommands)) { 152 | // No more content 153 | return []; 154 | } 155 | try { 156 | const { rawItems, continuation } = UTIL.parsePage2Wrapper( 157 | json.onResponseReceivedCommands[0].appendContinuationItemsAction.continuationItems, 158 | ); 159 | const parsedItems = rawItems 160 | .map(PARSE_ITEM) 161 | .filter(r => r && r.type === opts.type) 162 | .filter((_, index) => index < opts.limit); 163 | 164 | // Adjust tracker 165 | opts.limit -= parsedItems.length; 166 | 167 | let nextToken = null; 168 | if (continuation) nextToken = continuation.continuationItemRenderer.continuationEndpoint.continuationCommand.token; 169 | 170 | // We're already on last page or hit the limit 171 | if (!nextToken || opts.limit < 1) return parsedItems; 172 | 173 | // Recursively fetch more items 174 | const nestedResp = await parsePage2( 175 | // apiKey, 176 | nextToken, 177 | context, 178 | opts, 179 | ); 180 | parsedItems.push(...nestedResp); 181 | return parsedItems; 182 | } catch (e) { 183 | json.error = UTIL.errorToObject(e); 184 | UTIL.logger(json); 185 | return []; 186 | } 187 | }; 188 | -------------------------------------------------------------------------------- /lib/parseItem.js: -------------------------------------------------------------------------------- 1 | const UTIL = require('./util'); 2 | const BASE_VIDEO_URL = 'https://www.youtube.com/watch?v='; 3 | const URL = require('url').URL; 4 | 5 | module.exports = item => { 6 | const type = Object.keys(item)[0]; 7 | switch (type) { 8 | case 'videoRenderer': 9 | return parseVideo(item[type]); 10 | case 'playlistRenderer': 11 | return parsePlaylist(item[type]); 12 | case 'gridVideoRenderer': 13 | return parseVideo(item[type]); 14 | default: 15 | return null; 16 | } 17 | }; 18 | 19 | const parseVideo = obj => { 20 | const badges = Array.isArray(obj.badges) ? obj.badges.map(a => a.metadataBadgeRenderer.label) : []; 21 | const isLive = badges.some(b => ['LIVE NOW', 'LIVE'].includes(b)); 22 | const upcoming = obj.upcomingEventData ? Number(`${obj.upcomingEventData.startTime}000`) : null; 23 | const lengthFallback = obj.thumbnailOverlays.find(x => Object.keys(x)[0] === 'thumbnailOverlayTimeStatusRenderer'); 24 | const length = obj.lengthText || (lengthFallback && lengthFallback.thumbnailOverlayTimeStatusRenderer.text); 25 | 26 | return { 27 | type: 'video', 28 | name: UTIL.parseText(obj.title), 29 | id: obj.videoId, 30 | url: BASE_VIDEO_URL + obj.videoId, 31 | thumbnail: UTIL.prepImg(obj.thumbnail.thumbnails)[0].url, 32 | thumbnails: UTIL.prepImg(obj.thumbnail.thumbnails), 33 | isUpcoming: !!upcoming, 34 | upcoming, 35 | isLive, 36 | badges, 37 | 38 | // Author can be null for shows like whBqghP5Oow 39 | author: _parseAuthor(obj), 40 | 41 | description: UTIL.parseText(obj.descriptionSnippet), 42 | 43 | views: !obj.viewCountText ? null : UTIL.parseIntegerFromText(obj.viewCountText), 44 | // Duration not provided for live & sometimes with upcoming & sometimes randomly 45 | duration: UTIL.parseText(length), 46 | // UploadedAt not provided for live & upcoming & sometimes randomly 47 | uploadedAt: UTIL.parseText(obj.publishedTimeText), 48 | }; 49 | }; 50 | 51 | const _parseAuthor = obj => { 52 | const ctsr = obj.channelThumbnailSupportedRenderers; 53 | const authorImg = !ctsr ? { thumbnail: { thumbnails: [] } } : ctsr.channelThumbnailWithLinkRenderer; 54 | const ownerBadgesString = obj.ownerBadges && JSON.stringify(obj.ownerBadges); 55 | const isOfficial = !!(ownerBadgesString && ownerBadgesString.includes('OFFICIAL')); 56 | const isVerified = !!(ownerBadgesString && ownerBadgesString.includes('VERIFIED')); 57 | const author = obj.ownerText && obj.ownerText.runs[0]; 58 | if (!author || !author.navigationEndpoint) return null; 59 | const authorUrl = 60 | author.navigationEndpoint.browseEndpoint.canonicalBaseUrl || 61 | author.navigationEndpoint.commandMetadata.webCommandMetadata.url; 62 | return { 63 | name: author.text, 64 | channelID: author.navigationEndpoint.browseEndpoint.browseId, 65 | url: new URL(authorUrl, BASE_VIDEO_URL).toString(), 66 | bestAvatar: UTIL.prepImg(authorImg.thumbnail.thumbnails)[0] || null, 67 | avatars: UTIL.prepImg(authorImg.thumbnail.thumbnails), 68 | ownerBadges: Array.isArray(obj.ownerBadges) ? obj.ownerBadges.map(a => a.metadataBadgeRenderer.tooltip) : [], 69 | verified: isOfficial || isVerified, 70 | }; 71 | }; 72 | 73 | const parsePlaylist = obj => ({ 74 | type: 'playlist', 75 | id: obj.playlistId, 76 | name: UTIL.parseText(obj.title), 77 | url: `https://www.youtube.com/playlist?list=${obj.playlistId}`, 78 | 79 | owner: _parseOwner(obj), 80 | 81 | publishedAt: UTIL.parseText(obj.publishedTimeText), 82 | length: Number(obj.videoCount), 83 | }); 84 | 85 | const _parseOwner = obj => { 86 | // Auto generated playlists (starting with OL) only provide a simple string 87 | // Eg: https://www.youtube.com/playlist?list=OLAK5uy_nCItxg-iVIgQUZnPViEyd8xTeRAIr0y5I 88 | 89 | if (obj.shortBylineText.simpleText) return null; 90 | // Or return { name: obj.shortBylineText.simpleText }; 91 | 92 | const owner = 93 | (obj.shortBylineText && obj.shortBylineText.runs[0]) || (obj.longBylineText && obj.longBylineText.runs[0]); 94 | 95 | if (!owner.navigationEndpoint) return null; 96 | // Or return { name: owner.text }; 97 | 98 | const ownerUrl = 99 | owner.navigationEndpoint.browseEndpoint.canonicalBaseUrl || 100 | owner.navigationEndpoint.commandMetadata.webCommandMetadata.url; 101 | const ownerBadgesString = obj.ownerBadges && JSON.stringify(obj.ownerBadges); 102 | const isOfficial = !!(ownerBadgesString && ownerBadgesString.includes('OFFICIAL')); 103 | const isVerified = !!(ownerBadgesString && ownerBadgesString.includes('VERIFIED')); 104 | const fallbackURL = owner.navigationEndpoint.commandMetadata.webCommandMetadata.url; 105 | 106 | return { 107 | name: owner.text, 108 | channelID: owner.navigationEndpoint.browseEndpoint.browseId, 109 | url: new URL(ownerUrl || fallbackURL, BASE_VIDEO_URL).toString(), 110 | ownerBadges: Array.isArray(obj.ownerBadges) ? obj.ownerBadges.map(a => a.metadataBadgeRenderer.tooltip) : [], 111 | verified: isOfficial || isVerified, 112 | }; 113 | }; 114 | -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | const { request } = require('undici'); 2 | const PATH = require('path'); 3 | const FS = require('fs'); 4 | 5 | const BASE_URL = 'https://www.youtube.com/'; 6 | const DEFAULT_OPTIONS = { limit: 10, safeSearch: false }; 7 | const DEFAULT_QUERY = { gl: 'US', hl: 'en' }; 8 | const DEFAULT_CONTEXT = { 9 | client: { 10 | utcOffsetMinutes: -300, 11 | gl: 'US', 12 | hl: 'en', 13 | clientName: 'WEB', 14 | clientVersion: '', 15 | }, 16 | user: {}, 17 | }; 18 | const CONSENT_COOKIE = 'SOCS=CAI'; 19 | 20 | const tryParseBetween = (body, left, right, addEndCurly = false) => { 21 | try { 22 | let data = between(body, left, right); 23 | if (!data) return null; 24 | if (addEndCurly) data += '}'; 25 | return JSON.parse(data); 26 | } catch (e) { 27 | return null; 28 | } 29 | }; 30 | 31 | const getClientVersion = json => { 32 | try { 33 | const { serviceTrackingParams } = json.responseContext; 34 | for (const service of serviceTrackingParams) { 35 | if (!Array.isArray(service.params)) continue; 36 | const param = service.params.find(p => p.key === 'cver'); 37 | if (!param || !param.value) continue; 38 | return param.value; 39 | } 40 | } catch (e) { 41 | // noop 42 | } 43 | return null; 44 | }; 45 | 46 | exports.parseBody = (body, options = {}) => { 47 | const json = 48 | tryParseBetween(body, 'var ytInitialData = ', '};', true) || 49 | tryParseBetween(body, 'window["ytInitialData"] = ', '};', true) || 50 | tryParseBetween(body, 'var ytInitialData = ', ';') || 51 | tryParseBetween(body, 'window["ytInitialData"] = ', ';'); 52 | const apiKey = undefined; 53 | // between(body, 'INNERTUBE_API_KEY":"', '"') || between(body, 'innertubeApiKey":"', '"'); 54 | const clientVersion = 55 | getClientVersion(json) || 56 | between(body, 'INNERTUBE_CONTEXT_CLIENT_VERSION":"', '"') || 57 | between(body, 'innertube_context_client_version":"', '"'); 58 | const context = buildPostContext(clientVersion, options); 59 | // Return multiple values 60 | return { json, apiKey, context }; 61 | }; 62 | 63 | const buildPostContext = (exports.buildPostContext = (clientVersion, options = {}) => { 64 | // Make deep copy and set clientVersion 65 | const context = clone(DEFAULT_CONTEXT); 66 | context.client.clientVersion = clientVersion; 67 | // Add params to context 68 | if (options.gl) context.client.gl = options.gl; 69 | if (options.hl) context.client.hl = options.hl; 70 | if (options.utcOffsetMinutes) context.client.utcOffsetMinutes = options.utcOffsetMinutes; 71 | if (options.safeSearch) context.user.enableSafetyMode = true; 72 | return context; 73 | }); 74 | 75 | // Parsing utility 76 | const parseText = (exports.parseText = txt => 77 | typeof txt === 'object' ? txt.simpleText || (Array.isArray(txt.runs) ? txt.runs.map(a => a.text).join('') : '') : ''); 78 | 79 | exports.parseIntegerFromText = x => (typeof x === 'string' ? Number(x) : Number(parseText(x).replace(/\D+/g, ''))); 80 | 81 | // Request Utility 82 | exports.doPost = async (url, opts, payload) => { 83 | if (!opts) opts = {}; 84 | const reqOpts = Object.assign({}, opts, { method: 'POST', body: JSON.stringify(payload) }); 85 | const r = await request(url, reqOpts); 86 | return r.body.json(); 87 | }; 88 | 89 | // Guarantee that all arguments are valid 90 | exports.checkArgs = (searchString, options = {}) => { 91 | // Validation 92 | if (!searchString) { 93 | throw new Error('search string is mandatory'); 94 | } 95 | if (typeof searchString !== 'string') { 96 | throw new Error('search string must be of type string'); 97 | } 98 | 99 | if (typeof options.type !== 'string' || !['video', 'playlist'].includes(options.type)) { 100 | options.type = 'video'; 101 | } 102 | 103 | // Normalization 104 | let obj = Object.assign({}, DEFAULT_OPTIONS, options); 105 | // Other optional params 106 | if (isNaN(obj.limit) || obj.limit <= 0) obj.limit = DEFAULT_OPTIONS.limit; 107 | if (typeof obj.safeSearch !== 'boolean') obj.safeSearch = DEFAULT_OPTIONS.safeSearch; 108 | // Default requestOptions 109 | if (!options.requestOptions) options.requestOptions = {}; 110 | // Unlink requestOptions 111 | obj.requestOptions = Object.assign({}, options.requestOptions); 112 | // Unlink requestOptions#headers 113 | obj.requestOptions.headers = obj.requestOptions.headers ? clone(obj.requestOptions.headers) : {}; 114 | const cookie = getPropInsensitive(obj.requestOptions.headers, 'cookie'); 115 | if (!cookie) { 116 | setPropInsensitive(obj.requestOptions.headers, 'cookie', CONSENT_COOKIE); 117 | } else if (!cookie.includes('SOCS=')) { 118 | setPropInsensitive(obj.requestOptions.headers, 'cookie', `${cookie}; ${CONSENT_COOKIE}`); 119 | } 120 | // Set required parameter: query 121 | const inputURL = new URL(searchString, BASE_URL); 122 | if (searchString.startsWith(BASE_URL) && inputURL.pathname === '/results' && inputURL.searchParams.has('sp')) { 123 | // Watch out for requests with a set filter 124 | // in such a case searchString would be an url including `sp` & `search_query` querys 125 | if (!inputURL.searchParams.get('search_query')) { 126 | throw new Error('filter links have to include a "search_string" query'); 127 | } 128 | // Object.fromEntries not supported in nodejs < v12 129 | obj.query = {}; 130 | for (const key of inputURL.searchParams.keys()) { 131 | obj.query[key] = inputURL.searchParams.get(key); 132 | } 133 | } else { 134 | // If no filter-link default to passing it all as query 135 | obj.query = { search_query: searchString }; 136 | } 137 | // Save the search term itself for potential later use 138 | obj.search = obj.query.search_query; 139 | 140 | // Add additional information 141 | obj.query = Object.assign({}, DEFAULT_QUERY, obj.query); 142 | if (options && options.gl) obj.query.gl = options.gl; 143 | if (options && options.hl) obj.query.hl = options.hl; 144 | return obj; 145 | }; 146 | 147 | /** 148 | * Extract string between another. 149 | * Property of https://github.com/fent/node-ytdl-core/blob/master/lib/utils.js 150 | * 151 | * @param {string} haystack haystack 152 | * @param {string} left left 153 | * @param {string} right right 154 | * @returns {string} 155 | */ 156 | const between = (haystack, left, right) => { 157 | let pos; 158 | pos = haystack.indexOf(left); 159 | if (pos === -1) { 160 | return ''; 161 | } 162 | pos += left.length; 163 | haystack = haystack.slice(pos); 164 | pos = haystack.indexOf(right); 165 | if (pos === -1) { 166 | return ''; 167 | } 168 | haystack = haystack.slice(0, pos); 169 | return haystack; 170 | }; 171 | 172 | /** 173 | * Extract string between another. (search from right to left) 174 | * Property of https://github.com/fent/node-ytdl-core/blob/master/lib/utils.js 175 | * 176 | * @param {string} haystack haystack 177 | * @param {string} left left 178 | * @param {string} right right 179 | * @returns {string} 180 | */ 181 | exports.betweenFromRight = (haystack, left, right) => { 182 | let pos; 183 | pos = haystack.indexOf(right); 184 | if (pos === -1) { 185 | return ''; 186 | } 187 | haystack = haystack.slice(0, pos); 188 | pos = haystack.lastIndexOf(left); 189 | if (pos === -1) { 190 | return ''; 191 | } 192 | pos += left.length; 193 | haystack = haystack.slice(pos); 194 | return haystack; 195 | }; 196 | 197 | // Sorts Images in descending order & normalizes the url's 198 | exports.prepImg = img => { 199 | // Resolve url 200 | img.forEach(x => { 201 | x.url = x.url ? new URL(x.url, BASE_URL).toString() : null; 202 | }); 203 | // Sort 204 | return img.sort((a, b) => b.width - a.width); 205 | }; 206 | 207 | exports.parseWrapper = primaryContents => { 208 | let rawItems = []; 209 | let continuation = null; 210 | 211 | // Older Format 212 | if (primaryContents.sectionListRenderer) { 213 | rawItems = primaryContents.sectionListRenderer.contents.find(x => Object.keys(x)[0] === 'itemSectionRenderer') 214 | .itemSectionRenderer.contents; 215 | continuation = primaryContents.sectionListRenderer.contents.find( 216 | x => Object.keys(x)[0] === 'continuationItemRenderer', 217 | ); 218 | // Newer Format 219 | } else if (primaryContents.richGridRenderer) { 220 | rawItems = primaryContents.richGridRenderer.contents 221 | .filter(x => !Object.prototype.hasOwnProperty.call(x, 'continuationItemRenderer')) 222 | .map(x => (x.richItemRenderer || x.richSectionRenderer).content); 223 | continuation = primaryContents.richGridRenderer.contents.find(x => 224 | Object.prototype.hasOwnProperty.call(x, 'continuationItemRenderer'), 225 | ); 226 | } 227 | 228 | return { rawItems, continuation }; 229 | }; 230 | 231 | exports.parsePage2Wrapper = continuationItems => { 232 | let rawItems = []; 233 | let continuation = null; 234 | 235 | for (const ci of continuationItems) { 236 | // Older Format 237 | if (Object.prototype.hasOwnProperty.call(ci, 'itemSectionRenderer')) { 238 | rawItems.push(...ci.itemSectionRenderer.contents); 239 | // Newer Format 240 | } else if (Object.prototype.hasOwnProperty.call(ci, 'richItemRenderer')) { 241 | rawItems.push(ci.richItemRenderer.content); 242 | } else if (Object.prototype.hasOwnProperty.call(ci, 'richSectionRenderer')) { 243 | rawItems.push(ci.richSectionRenderer.content); 244 | // Continuation 245 | } else if (Object.prototype.hasOwnProperty.call(ci, 'continuationItemRenderer')) { 246 | continuation = ci; 247 | } 248 | } 249 | 250 | return { rawItems, continuation }; 251 | }; 252 | 253 | const clone = obj => 254 | Object.keys(obj).reduce( 255 | (v, d) => 256 | Object.assign(v, { 257 | [d]: obj[d].constructor === Object ? clone(obj[d]) : obj[d], 258 | }), 259 | {}, 260 | ); 261 | 262 | const findPropKeyInsensitive = (obj, prop) => 263 | Object.keys(obj).find(p => p.toLowerCase() === prop.toLowerCase()) || null; 264 | 265 | const getPropInsensitive = (obj, prop) => { 266 | const key = findPropKeyInsensitive(obj, prop); 267 | return key && obj[key]; 268 | }; 269 | 270 | const setPropInsensitive = (obj, prop, value) => { 271 | const key = findPropKeyInsensitive(obj, prop); 272 | obj[key || prop] = value; 273 | return key; 274 | }; 275 | 276 | const dumpDir = PATH.resolve(__dirname, '../dumps/'); 277 | if (FS.existsSync(dumpDir)) FS.rmdirSync(dumpDir, { recursive: true }); 278 | 279 | exports.logger = content => { 280 | const file = PATH.resolve(dumpDir, `${Date.now()}-${Math.random().toString(36).slice(3)}.txt`); 281 | const cfg = PATH.resolve(__dirname, '../package.json'); 282 | const bugsRef = require(cfg).bugs.url; 283 | 284 | if (!FS.existsSync(dumpDir)) FS.mkdirSync(dumpDir); 285 | FS.writeFileSync(file, JSON.stringify(content)); 286 | /* eslint-disable no-console */ 287 | console.error('*'.repeat(200)); 288 | console.error('Unsupported YouTube Search response.'); 289 | console.error(`Please post the the file "${file}" to ${bugsRef} or DisTube support server. Thanks!`); 290 | console.error('*'.repeat(200)); 291 | /* eslint-enable no-console */ 292 | return null; 293 | }; 294 | 295 | const pkg = require('../package.json'); 296 | const UPDATE_INTERVAL = 1000 * 60 * 60 * 12; 297 | let updateWarnTimes = 0; 298 | let lastUpdateCheck = 0; 299 | exports.checkForUpdates = async () => { 300 | if (process.env.YTSR_NO_UPDATE || Date.now() - lastUpdateCheck < UPDATE_INTERVAL) return; 301 | 302 | try { 303 | lastUpdateCheck = Date.now(); 304 | const res = await request('https://api.github.com/repos/distubejs/ytsr/contents/package.json', { 305 | headers: { 'User-Agent': 'Chromium";v="112", "Microsoft Edge";v="112", "Not:A-Brand";v="99' }, 306 | }); 307 | if (res.statusCode !== 200) throw new Error(`Status code: ${res.statusCode}`); 308 | const data = await res.body.json(); 309 | const buf = Buffer.from(data.content, data.encoding); 310 | const pkgFile = JSON.parse(buf.toString('ascii')); 311 | if (pkgFile.version !== pkg.version && updateWarnTimes++ < 5) { 312 | // eslint-disable-next-line no-console 313 | console.warn( 314 | '\x1b[33mWARNING:\x1B[0m @distube/ytsr is out of date! Update with "npm install @distube/ytsr@latest".', 315 | ); 316 | } 317 | } catch (err) { 318 | /* eslint-disable no-console */ 319 | console.warn('Error checking for updates:', err.message); 320 | console.warn('You can disable this check by setting the `YTSR_NO_UPDATE` env variable.'); 321 | /* eslint-enable no-console */ 322 | } 323 | }; 324 | 325 | exports.errorToObject = err => { 326 | const obj = {}; 327 | Object.getOwnPropertyNames(err).forEach(key => { 328 | obj[key] = err[key]; 329 | }); 330 | return obj; 331 | }; 332 | 333 | exports.getPlaylistParams = parsed => { 334 | try { 335 | return parsed.json.header.searchHeaderRenderer.searchFilterButton.buttonRenderer.command.openPopupAction.popup 336 | .searchFilterOptionsDialogRenderer.groups[1].searchFilterGroupRenderer.filters[2].searchFilterRenderer 337 | .navigationEndpoint.searchEndpoint.params; 338 | } catch (e) { 339 | return null; 340 | } 341 | }; 342 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@distube/ytsr", 3 | "version": "2.0.4", 4 | "description": "A ytsr fork. Made for DisTube.js.org.", 5 | "keywords": [ 6 | "youtube", 7 | "search" 8 | ], 9 | "bugs": { 10 | "url": "https://github.com/distubejs/ytsr/issues" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git://github.com/distubejs/ytsr.git" 15 | }, 16 | "license": "MIT", 17 | "author": "Skick (https://github.com/skick1234)", 18 | "contributors": [ 19 | "Tobias Kutscha (https://github.com/TimeForANinja)" 20 | ], 21 | "main": "./lib/main.js", 22 | "types": "./typings/index.d.ts", 23 | "files": [ 24 | "lib", 25 | "typings" 26 | ], 27 | "scripts": { 28 | "prettier": "prettier --write \"**/*.{ts,json,yml,yaml,md}\"", 29 | "lint": "eslint ./", 30 | "lint:fix": "eslint --fix ./" 31 | }, 32 | "dependencies": { 33 | "undici": "^6.18.2" 34 | }, 35 | "devDependencies": { 36 | "eslint": "^8.57.0", 37 | "prettier": "^3.3.1" 38 | }, 39 | "engines": { 40 | "node": ">=14.0" 41 | }, 42 | "homepage": "https://github.com/distubejs/ytsr#readme", 43 | "funding": "https://github.com/distubejs/ytsr?sponsor" 44 | } 45 | -------------------------------------------------------------------------------- /typings/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@distube/ytsr' { 2 | namespace ytsr { 3 | interface Options { 4 | safeSearch?: boolean; 5 | limit?: number; 6 | continuation?: string; 7 | hl?: string; 8 | gl?: string; 9 | utcOffsetMinutes?: number; 10 | type?: 'video' | 'playlist'; 11 | requestOptions?: { [key: string]: object } & { headers?: { [key: string]: string } }; 12 | } 13 | 14 | interface Image { 15 | url: string | null; 16 | width: number; 17 | height: number; 18 | } 19 | 20 | interface Video { 21 | type: 'video'; 22 | id: string; 23 | name: string; 24 | url: string; 25 | thumbnail: string; 26 | thumbnails: Image[]; 27 | isUpcoming: boolean; 28 | upcoming: number | null; 29 | isLive: boolean; 30 | badges: string[]; 31 | views: number; 32 | duration: string; 33 | author: { 34 | name: string; 35 | channelID: string; 36 | url: string; 37 | bestAvatar: Image; 38 | avatars: Image[]; 39 | ownerBadges: string[]; 40 | verified: boolean; 41 | } | null; 42 | } 43 | 44 | interface Playlist { 45 | type: 'playlist'; 46 | id: string; 47 | name: string; 48 | url: string; 49 | length: number; 50 | owner: { 51 | name: string; 52 | channelID: string; 53 | url: string; 54 | ownerBadges: string[]; 55 | verified: boolean; 56 | } | null; 57 | publishedAt: string | null; 58 | } 59 | 60 | interface VideoResult { 61 | query: string; 62 | items: Video[]; 63 | results: number; 64 | } 65 | 66 | interface PlaylistResult { 67 | query: string; 68 | items: Playlist[]; 69 | results: number; 70 | } 71 | } 72 | 73 | function ytsr(id: string): Promise; 74 | function ytsr(id: string, options: ytsr.Options & { type: 'video' }): Promise; 75 | function ytsr(id: string, options: ytsr.Options & { type: 'playlist' }): Promise; 76 | function ytsr( 77 | id: string, 78 | options: ytsr.Options & { type: 'video' | 'playlist' }, 79 | ): Promise; 80 | function ytsr(id: string, options: ytsr.Options): Promise; 81 | 82 | export = ytsr; 83 | } 84 | --------------------------------------------------------------------------------