├── .gitattributes
├── .yo-rc.json
├── .bowerrc
├── .babelrc
├── app
├── robots.txt
├── favicon.ico
├── apple-touch-icon.png
├── scripts
│ └── main.js
├── styles
│ └── main.scss
└── index.html
├── .gitignore
├── bower.json
├── test
├── spec
│ └── test.js
└── index.html
├── .editorconfig
├── search-config.yml
├── package.json
├── LICENSE.txt
├── README.md
├── get-links
└── gulpfile.babel.js
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto
--------------------------------------------------------------------------------
/.yo-rc.json:
--------------------------------------------------------------------------------
1 | {
2 | "generator-mocha": {}
3 | }
--------------------------------------------------------------------------------
/.bowerrc:
--------------------------------------------------------------------------------
1 | {
2 | "directory": "bower_components"
3 | }
4 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "es2015"
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/app/robots.txt:
--------------------------------------------------------------------------------
1 | # robotstxt.org/
2 |
3 | User-agent: *
4 | Disallow:
5 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DrummerHead/youtube-search-by-duration/master/app/favicon.ico
--------------------------------------------------------------------------------
/app/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DrummerHead/youtube-search-by-duration/master/app/apple-touch-icon.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | .tmp
4 | .sass-cache
5 | bower_components
6 | test/bower_components
7 | /app/scripts/videos.js
8 |
--------------------------------------------------------------------------------
/bower.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "youtube-search-by-duration",
3 | "private": true,
4 | "dependencies": {},
5 | "devDependencies": {
6 | "chai": "^3.5.0",
7 | "mocha": "^2.5.3"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/test/spec/test.js:
--------------------------------------------------------------------------------
1 | (function () {
2 | 'use strict';
3 |
4 | describe('Give it some context', function () {
5 | describe('maybe a bit more context here', function () {
6 | it('should run here few assertions', function () {
7 |
8 | });
9 | });
10 | });
11 | })();
12 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig helps developers define and maintain consistent
2 | # coding styles between different editors and IDEs
3 | # editorconfig.org
4 |
5 | root = true
6 |
7 |
8 | [*]
9 |
10 | # change these settings to your own preference
11 | indent_style = space
12 | indent_size = 2
13 |
14 | # we recommend you to keep these unchanged
15 | end_of_line = lf
16 | charset = utf-8
17 | trim_trailing_whitespace = true
18 | insert_final_newline = true
19 |
20 | [*.md]
21 | trim_trailing_whitespace = false
22 |
23 | [{package,bower}.json]
24 | indent_style = space
25 | indent_size = 2
26 |
--------------------------------------------------------------------------------
/search-config.yml:
--------------------------------------------------------------------------------
1 | min_time: '15:45'
2 | max_time: '18:00'
3 | keywords:
4 | - algorithms
5 | - arduino
6 | - back end development
7 | - big data
8 | - computer science
9 | - darpa
10 | - elm language
11 | - ember js
12 | - front end development
13 | - functional programming
14 | - game development
15 | - haskell
16 | - information security
17 | - investing
18 | - javascript
19 | - jsconf
20 | - linux
21 | - lua language
22 | - machine learning
23 | - node js
24 | - procedural generation
25 | - programming
26 | - robotics
27 | - ruby language -pokemon -knights
28 | - siggraph
29 | - speedrun
30 | - table tennis
31 | - talks at google
32 | - tool assisted speedrun
33 | - vim editor
34 | - web development
--------------------------------------------------------------------------------
/test/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Mocha Spec Runner
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
18 |
19 |
20 |
21 |
22 |
23 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "engines": {
4 | "node": ">=0.12.0"
5 | },
6 | "devDependencies": {
7 | "babel-core": "^6.4.0",
8 | "babel-preset-es2015": "^6.3.13",
9 | "browser-sync": "^2.2.1",
10 | "del": "^1.1.1",
11 | "gulp": "^3.9.0",
12 | "gulp-autoprefixer": "^3.0.1",
13 | "gulp-babel": "^6.1.1",
14 | "gulp-cache": "^0.2.8",
15 | "gulp-cssnano": "^2.0.0",
16 | "gulp-eslint": "^0.13.2",
17 | "gulp-htmlmin": "^1.3.0",
18 | "gulp-if": "^1.2.5",
19 | "gulp-imagemin": "^2.2.1",
20 | "gulp-load-plugins": "^0.10.0",
21 | "gulp-plumber": "^1.0.1",
22 | "gulp-sass": "^2.0.0",
23 | "gulp-size": "^1.2.1",
24 | "gulp-sourcemaps": "^1.5.0",
25 | "gulp-uglify": "^1.1.0",
26 | "gulp-useref": "^3.0.0",
27 | "main-bower-files": "^2.5.0",
28 | "wiredep": "^2.2.2"
29 | },
30 | "eslintConfig": {
31 | "env": {
32 | "es6": true,
33 | "node": true,
34 | "browser": true
35 | },
36 | "rules": {
37 | "quotes": [
38 | 2,
39 | "single"
40 | ]
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 DrummerHead
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of
6 | this software and associated documentation files (the "Software"), to deal in
7 | the Software without restriction, including without limitation the rights to
8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
9 | of the Software, and to permit persons to whom the Software is furnished to do
10 | so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/app/scripts/main.js:
--------------------------------------------------------------------------------
1 | (function(window, document){
2 | 'use strict';
3 |
4 | var videos = window.videos;
5 |
6 | var pad = function(num){
7 | return num < 10 ? '0' + num : num;
8 | };
9 |
10 | var stringToHue = function(string){
11 | return string.split('')
12 | .map((letter) => letter.charCodeAt())
13 | .reduce((prev, curr) => prev + curr) % 360;
14 | };
15 |
16 | var durationToString = function(duration){
17 | var hours = duration.hours > 0 ? `${duration.hours}:` : '';
18 | return `${hours}${pad(duration.minutes)}:${pad(duration.seconds)}`;
19 | };
20 |
21 | var videosHtml = videos.map((video) => {
22 | return `
23 |
24 |
25 |
26 |
31 |
32 |
37 |
38 | `;
39 | }).reduce((prev, curr) => prev + curr);
40 |
41 | document.getElementById('videos').insertAdjacentHTML('beforeend', videosHtml);
42 |
43 | })(window, document);
44 |
--------------------------------------------------------------------------------
/app/styles/main.scss:
--------------------------------------------------------------------------------
1 | // bower:scss
2 | // endbower
3 |
4 | html {
5 | text-size-adjust: 100%;
6 | line-height: 1.2;
7 | font-family: sans-serif;
8 | }
9 | body {
10 | margin: 0;
11 | }
12 | ul {
13 | margin: 0;
14 | padding: 0;
15 | list-style-type: none;
16 | }
17 | .clearfix:after {
18 | display: table;
19 | content: '';
20 | clear: both;
21 | }
22 | #videos {
23 | float: left;
24 | padding: .5em;
25 | @extend .clearfix;
26 | }
27 | .video {
28 | float: left;
29 | width: 20em;
30 | margin: .5em;
31 | }
32 | .video-thumb {
33 | display: block;
34 | position: relative;
35 | }
36 | .video-thumb img {
37 | display: block;
38 | }
39 | .metadata {
40 | color: blue;
41 | }
42 | .metadata li {
43 | color: red;
44 | position: absolute;
45 | background-color: rgba(#000, .5);
46 | color: #fff;
47 | padding: .32em .75em;
48 | text-shadow: .1em .1em 0 #000;
49 | }
50 | .duration {
51 | bottom: 0;
52 | right: 0;
53 | }
54 | .views {
55 | top: 0;
56 | right: 0;
57 | }
58 | .keyword {
59 | bottom: 0;
60 | left: 0;
61 | }
62 | .title {
63 | $line-height: 1.2;
64 | display: block;
65 | margin: .5em 0;
66 | font-size: 1.1em;
67 | line-height: $line-height;
68 | height: #{$line-height * 3}em;
69 | overflow: hidden;
70 | text-overflow: ellipsis;
71 | white-space: normal;
72 | word-wrap: break-word;
73 | }
74 | .title a {
75 | display: block;
76 | }
77 |
78 | img {
79 | max-width: 100%;
80 | }
81 | a {
82 | color: #222;
83 | text-decoration: none;
84 | &:hover {
85 | text-decoration: underline;
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/app/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | youtube search by duration
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Youtube search by duration
2 |
3 | With this handy script you can filter many youtube searches and get videos of a specific length you choose (between a minimum and maximum duration) and then display the results in an HTML document.
4 |
5 | ## The Why
6 |
7 | I wanted to do some indoor bike for a specific amount of time and watch a video of roughly that same length. I searched for a tool to do this and I couldn't find results even related to what I was looking for. Youtube's current filter of "short <4 minutes" and "long >20 minutes" was not specific enough, so it was time to go to the drawing board and create my own solution!
8 |
9 | This will be useful if you have an activity that lasts a specific time and want to find videos of that length according to your interests.
10 |
11 | ## Requirements
12 |
13 | - Ruby
14 | - NPM
15 |
16 | Run these commands:
17 |
18 | ```
19 | gem install nokogiri
20 | npm install
21 | npm install -g gulp-cli
22 | ```
23 |
24 | ## Instructions
25 |
26 | Edit `search-config.yml` to your liking, the parameters are:
27 | - `min_time`: Minimum video duration
28 | - `max_time`: Maximum video duration
29 | - `keywords`: List of all the search queries for which to find videos
30 |
31 | After you have set your preferences, run:
32 |
33 | ```
34 | ./get-links
35 | ```
36 |
37 | And wait for the script to scrape youtube for the information. It will look like this:
38 |
39 | [](https://asciinema.org/a/8cs7cacqnvc1x874c5dk1c8jn)
40 |
41 | After it's ready, run:
42 |
43 | ```
44 | gulp serve
45 | ```
46 |
47 | To see the results in your browser! Which will roughly look like this:
48 |
49 | 
50 |
51 | If you'd like to upload the results to your server, run `gulp build` and check the `./dist` folder
52 |
--------------------------------------------------------------------------------
/get-links:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | require 'nokogiri'
4 | require 'open-uri'
5 | require 'yaml'
6 | require 'json'
7 |
8 | settings = YAML.load_file('search-config.yml')
9 |
10 | puts "\nSearching for videos between #{settings["min_time"]} and #{settings["max_time"]} about:"
11 | settings["keywords"].each { |keyword| puts " - #{keyword}"}
12 | puts "\n"
13 |
14 | def time_hash time_string
15 | time = time_string.split(':')
16 | {
17 | hours: time[-3].to_i,
18 | minutes: time[-2].to_i,
19 | seconds: time[-1].to_i
20 | }
21 | end
22 |
23 | def get_seconds time
24 | time = time.is_a?(String) ? time_hash(time) : time
25 | time[:hours] * 60 * 60 + time[:minutes] * 60 + time[:seconds]
26 | end
27 |
28 | def parse_views views_string
29 | views_string.gsub(',','').to_i
30 | end
31 |
32 | def get_video_info video, keyword
33 | {
34 | id: video.attr('data-context-item-id'),
35 | title: video.css('.yt-lockup-title a').inner_text,
36 | duration: time_hash(video.css('.video-time').inner_text),
37 | views: parse_views(video.css('.yt-lockup-meta-info li:last-child').inner_text),
38 | keyword: keyword
39 | }
40 | end
41 |
42 | def get_next_page_sp query_page
43 | last_link = query_page.css('.search-pager a:last-child')
44 | if last_link.length > 0
45 | last_link.attr('href').to_s.match(/sp=([^&]*)/)[1]
46 | else
47 | nil
48 | end
49 | end
50 |
51 | def search(query, min_time="16:00", max_time="18:00", pages=40, sp='', results=[])
52 | query_page = Nokogiri::HTML(open("https://www.youtube.com/results?sp=#{sp}&q=#{query}"), nil, 'utf-8')
53 |
54 | videos = query_page.css('.yt-lockup-video:not([data-ad-impressions])')
55 | next_page_sp = get_next_page_sp(query_page)
56 |
57 | min_time = min_time.is_a?(String) ? get_seconds(min_time) : min_time
58 | max_time = max_time.is_a?(String) ? get_seconds(max_time) : max_time
59 |
60 | videos.each do |video|
61 | video_info = get_video_info(video, query)
62 | video_duration = get_seconds(video_info[:duration])
63 |
64 | if min_time <= video_duration && video_duration <= max_time
65 | results << video_info
66 | end
67 | end
68 |
69 | if pages > 1 && !next_page_sp.nil?
70 | print '.'
71 | search(query, min_time, max_time, pages - 1, next_page_sp, results)
72 | else
73 | results
74 | end
75 | end
76 |
77 |
78 | final_result = []
79 |
80 | settings["keywords"].each do |keyword|
81 | print "\nsearching #{keyword}"
82 | result = search(keyword, settings["min_time"], settings["max_time"])
83 | puts "\n#{result.length} videos found\n"
84 | final_result += result
85 | end
86 |
87 | sorted_final_result = final_result.sort do |a, b|
88 | b[:views] <=> a[:views]
89 | end
90 |
91 | puts "\n\nSearch complete!\n\n"
92 |
93 | File.write('app/scripts/videos.js', "/* eslint-disable */\n\nwindow.videos = " + sorted_final_result.uniq.to_json)
--------------------------------------------------------------------------------
/gulpfile.babel.js:
--------------------------------------------------------------------------------
1 | // generated on 2016-07-09 using generator-gulp-webapp 1.1.1
2 | import gulp from 'gulp';
3 | import gulpLoadPlugins from 'gulp-load-plugins';
4 | import browserSync from 'browser-sync';
5 | import del from 'del';
6 | import {stream as wiredep} from 'wiredep';
7 |
8 | const $ = gulpLoadPlugins();
9 | const reload = browserSync.reload;
10 |
11 | gulp.task('styles', () => {
12 | return gulp.src('app/styles/*.scss')
13 | .pipe($.plumber())
14 | .pipe($.sourcemaps.init())
15 | .pipe($.sass.sync({
16 | outputStyle: 'expanded',
17 | precision: 10,
18 | includePaths: ['.']
19 | }).on('error', $.sass.logError))
20 | .pipe($.autoprefixer({browsers: ['> 1%', 'last 2 versions', 'Firefox ESR']}))
21 | .pipe($.sourcemaps.write())
22 | .pipe(gulp.dest('.tmp/styles'))
23 | .pipe(reload({stream: true}));
24 | });
25 |
26 | gulp.task('scripts', () => {
27 | return gulp.src('app/scripts/**/*.js')
28 | .pipe($.plumber())
29 | .pipe($.sourcemaps.init())
30 | .pipe($.babel())
31 | .pipe($.sourcemaps.write('.'))
32 | .pipe(gulp.dest('.tmp/scripts'))
33 | .pipe(reload({stream: true}));
34 | });
35 |
36 | function lint(files, options) {
37 | return () => {
38 | return gulp.src(files)
39 | .pipe(reload({stream: true, once: true}))
40 | .pipe($.eslint(options))
41 | .pipe($.eslint.format())
42 | .pipe($.if(!browserSync.active, $.eslint.failAfterError()));
43 | };
44 | }
45 | const testLintOptions = {
46 | env: {
47 | mocha: true
48 | }
49 | };
50 |
51 | gulp.task('lint', lint('app/scripts/**/*.js'));
52 | gulp.task('lint:test', lint('test/spec/**/*.js', testLintOptions));
53 |
54 | gulp.task('html', ['styles', 'scripts'], () => {
55 | return gulp.src('app/*.html')
56 | .pipe($.useref({searchPath: ['.tmp', 'app', '.']}))
57 | .pipe($.if('*.js', $.uglify()))
58 | .pipe($.if('*.css', $.cssnano()))
59 | .pipe($.if('*.html', $.htmlmin({collapseWhitespace: true})))
60 | .pipe(gulp.dest('dist'));
61 | });
62 |
63 | gulp.task('images', () => {
64 | return gulp.src('app/images/**/*')
65 | .pipe($.if($.if.isFile, $.cache($.imagemin({
66 | progressive: true,
67 | interlaced: true,
68 | // don't remove IDs from SVGs, they are often used
69 | // as hooks for embedding and styling
70 | svgoPlugins: [{cleanupIDs: false}]
71 | }))
72 | .on('error', function (err) {
73 | console.log(err);
74 | this.end();
75 | })))
76 | .pipe(gulp.dest('dist/images'));
77 | });
78 |
79 | gulp.task('fonts', () => {
80 | return gulp.src(require('main-bower-files')('**/*.{eot,svg,ttf,woff,woff2}', function (err) {})
81 | .concat('app/fonts/**/*'))
82 | .pipe(gulp.dest('.tmp/fonts'))
83 | .pipe(gulp.dest('dist/fonts'));
84 | });
85 |
86 | gulp.task('extras', () => {
87 | return gulp.src([
88 | 'app/*.*',
89 | '!app/*.html'
90 | ], {
91 | dot: true
92 | }).pipe(gulp.dest('dist'));
93 | });
94 |
95 | gulp.task('clean', del.bind(null, ['.tmp', 'dist']));
96 |
97 | gulp.task('serve', ['styles', 'scripts', 'fonts'], () => {
98 | browserSync({
99 | notify: false,
100 | port: 9000,
101 | server: {
102 | baseDir: ['.tmp', 'app'],
103 | routes: {
104 | '/bower_components': 'bower_components'
105 | }
106 | }
107 | });
108 |
109 | gulp.watch([
110 | 'app/*.html',
111 | '.tmp/scripts/**/*.js',
112 | 'app/images/**/*',
113 | '.tmp/fonts/**/*'
114 | ]).on('change', reload);
115 |
116 | gulp.watch('app/styles/**/*.scss', ['styles']);
117 | gulp.watch('app/scripts/**/*.js', ['scripts']);
118 | gulp.watch('app/fonts/**/*', ['fonts']);
119 | gulp.watch('bower.json', ['wiredep', 'fonts']);
120 | });
121 |
122 | gulp.task('serve:dist', () => {
123 | browserSync({
124 | notify: false,
125 | port: 9000,
126 | server: {
127 | baseDir: ['dist']
128 | }
129 | });
130 | });
131 |
132 | gulp.task('serve:test', ['scripts'], () => {
133 | browserSync({
134 | notify: false,
135 | port: 9000,
136 | ui: false,
137 | server: {
138 | baseDir: 'test',
139 | routes: {
140 | '/scripts': '.tmp/scripts',
141 | '/bower_components': 'bower_components'
142 | }
143 | }
144 | });
145 |
146 | gulp.watch('app/scripts/**/*.js', ['scripts']);
147 | gulp.watch('test/spec/**/*.js').on('change', reload);
148 | gulp.watch('test/spec/**/*.js', ['lint:test']);
149 | });
150 |
151 | // inject bower components
152 | gulp.task('wiredep', () => {
153 | gulp.src('app/styles/*.scss')
154 | .pipe(wiredep({
155 | ignorePath: /^(\.\.\/)+/
156 | }))
157 | .pipe(gulp.dest('app/styles'));
158 |
159 | gulp.src('app/*.html')
160 | .pipe(wiredep({
161 | ignorePath: /^(\.\.\/)*\.\./
162 | }))
163 | .pipe(gulp.dest('app'));
164 | });
165 |
166 | gulp.task('build', ['lint', 'html', 'images', 'fonts', 'extras'], () => {
167 | return gulp.src('dist/**/*').pipe($.size({title: 'build', gzip: true}));
168 | });
169 |
170 | gulp.task('default', ['clean'], () => {
171 | gulp.start('build');
172 | });
173 |
--------------------------------------------------------------------------------