structables 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
structables/main.py ADDED
@@ -0,0 +1,1282 @@
1
+ #!/usr/bin/env python
2
+
3
+ from flask import (
4
+ Flask,
5
+ render_template,
6
+ request,
7
+ redirect,
8
+ Response,
9
+ )
10
+
11
+ from urllib.parse import quote, unquote, urlencode
12
+ from urllib.request import Request, urlopen
13
+ from urllib.error import HTTPError
14
+ from traceback import print_exc
15
+ from urllib.parse import urlparse
16
+ from argparse import ArgumentParser
17
+
18
+ from werkzeug.exceptions import BadRequest, abort, InternalServerError, NotFound
19
+ from bs4 import BeautifulSoup
20
+
21
+ import os
22
+ import json
23
+ import re
24
+ import logging
25
+ import pathlib
26
+
27
+ logging.basicConfig(level=logging.DEBUG)
28
+
29
+ global_ibles = {}
30
+
31
+
32
+ def proxy(url):
33
+ logging.debug(f"Generating proxy URL for {url}")
34
+ return f"/proxy/?url={url}"
35
+
36
+
37
+ def get_typesense_api_key():
38
+ logging.debug("Getting Typesense API key...")
39
+
40
+ data = urlopen("https://www.instructables.com/")
41
+ soup = BeautifulSoup(data.read().decode(), "html.parser")
42
+ scripts = soup.select("script")
43
+
44
+ for script in scripts:
45
+ if "typesense" in script.text and (
46
+ matches := re.search(r'"typesenseApiKey":\s?"(.*?)"', script.text)
47
+ ):
48
+ api_key = matches.group(1)
49
+ logging.debug(f"Identified Typesense API key as {api_key}")
50
+ return api_key
51
+
52
+ logging.error("Failed to get Typesense API key")
53
+
54
+
55
+ TYPESENSE_API_KEY = get_typesense_api_key()
56
+
57
+ debugmode = False
58
+ invidious = None
59
+ unsafe = False
60
+
61
+
62
+ def projects_search(
63
+ query="*",
64
+ category="",
65
+ channel="",
66
+ filter_by="",
67
+ page=1,
68
+ per_page=50,
69
+ query_by="title,stepBody,screenName",
70
+ sort_by="publishDate:desc",
71
+ timeout=5,
72
+ ):
73
+ if category:
74
+ if filter_by:
75
+ filter_by += " && "
76
+ filter_by += f"category:={category}"
77
+
78
+ if channel:
79
+ if filter_by:
80
+ filter_by += " && "
81
+ filter_by += f"channel:={channel}"
82
+
83
+ query = quote(query)
84
+ filter_by = quote(filter_by)
85
+
86
+ logging.debug(
87
+ f"Searching projects with query {query} and filter {filter_by}, page {page}"
88
+ )
89
+
90
+ projects_headers = {"x-typesense-api-key": TYPESENSE_API_KEY}
91
+
92
+ request_args = {
93
+ "q": query,
94
+ "query_by": query_by,
95
+ "page": page,
96
+ "sort_by": sort_by,
97
+ "include_fields": "title,urlString,coverImageUrl,screenName,favorites,views,primaryClassification,featureFlag,prizeLevel,IMadeItCount",
98
+ "filter_by": filter_by,
99
+ "per_page": per_page,
100
+ }
101
+
102
+ args_str = "&".join([f"{key}={value}" for key, value in request_args.items()])
103
+
104
+ projects_request = Request(
105
+ f"https://www.instructables.com/api_proxy/search/collections/projects/documents/search?{args_str}",
106
+ headers=projects_headers,
107
+ )
108
+
109
+ projects_data = urlopen(projects_request, timeout=timeout)
110
+ project_obj = json.loads(projects_data.read().decode())
111
+ project_ibles = project_obj["hits"]
112
+
113
+ logging.debug(f"Got {len(project_ibles)} projects")
114
+
115
+ return project_ibles, project_obj["out_of"]
116
+
117
+
118
+ def update_data():
119
+ logging.debug("Updating data...")
120
+
121
+ channels = []
122
+
123
+ sitemap_data = urlopen("https://www.instructables.com/sitemap/")
124
+ sitemap_soup = BeautifulSoup(sitemap_data.read().decode(), "html.parser")
125
+ main = sitemap_soup.select("div.sitemap-content")[0]
126
+
127
+ for group in main.select("div.group-section"):
128
+ channels.append(group.select("h2 a")[0].text.lower())
129
+
130
+ global_ibles["/projects"] = []
131
+ project_ibles, total = projects_search(filter_by="featureFlag:=true")
132
+
133
+ while len(global_ibles["/projects"]) <= 0:
134
+ for ible in project_ibles:
135
+ link = f"/{ible['document']['urlString']}"
136
+ img = proxy(ible["document"]["coverImageUrl"])
137
+
138
+ title = ible["document"]["title"]
139
+ author = ible["document"]["screenName"]
140
+ author_link = f"/member/{author}"
141
+
142
+ channel = ible["document"]["primaryClassification"]
143
+ channel_link = f"/channel/{channel}"
144
+
145
+ views = ible["document"]["views"]
146
+ favorites = ible["document"]["favorites"]
147
+
148
+ global_ibles["/projects"].append(
149
+ {
150
+ "link": link,
151
+ "img": img,
152
+ "title": title,
153
+ "author": author,
154
+ "author_link": author_link,
155
+ "channel": channel,
156
+ "channel_link": channel_link,
157
+ "views": views,
158
+ "favorites": favorites,
159
+ }
160
+ )
161
+
162
+
163
+ app = Flask(__name__, template_folder="templates", static_folder="static")
164
+
165
+
166
+ @app.route("/cron/")
167
+ def cron():
168
+ update_data()
169
+ return "OK"
170
+
171
+
172
+ def explore_lists(soup):
173
+ list_ = []
174
+ for ible in soup.select(".home-content-explore-ible"):
175
+ link = ible.a["href"]
176
+ img = proxy(ible.select("a img")[0].get("data-src"))
177
+ alt = ible.select("a img")[0].get("alt")
178
+ title = ible.select("div strong a")[0].text
179
+ author = ible.select("div span.ible-author a")[0].text
180
+ author_link = ible.select("div span.ible-author a")[0].get("href")
181
+ channel = ible.select("div span.ible-channel a")[0].text
182
+ channel_link = ible.select("div span.ible-channel a")[0].get("href")
183
+ views = 0
184
+ if ible.select("span.ible-views") != []:
185
+ views = ible.select("span.ible-views")[0].text
186
+ favorites = 0
187
+ if ible.select("span.ible-favorites") != []:
188
+ favorites = ible.select("span.ible-favorites")[0].text
189
+ list_.append(
190
+ {
191
+ "link": link,
192
+ "img": img,
193
+ "alt": alt,
194
+ "title": title,
195
+ "author": author,
196
+ "author_link": author_link,
197
+ "channel": channel,
198
+ "channel_link": channel_link,
199
+ "favorites": favorites,
200
+ "views": views,
201
+ }
202
+ )
203
+ return list_
204
+
205
+
206
+ def member_header(header):
207
+ avatar = proxy(
208
+ header.select("div.profile-avatar-container img.profile-avatar")[0].get("src")
209
+ )
210
+ title = header.select("div.profile-top div.profile-headline h1.profile-title")[
211
+ 0
212
+ ].text
213
+
214
+ header.select("div.profile-top")[0]
215
+
216
+ # stats_text = profile_top.select("div.profile-header-stats")[0]
217
+ # stats_num = header.select("div.profile-top div.profile-header-stats")[1]
218
+
219
+ location = header.select("span.member-location")
220
+ if location != []:
221
+ location = location[0].text
222
+ else:
223
+ location = 0
224
+
225
+ signup = header.select("span.member-signup-date")
226
+ if signup != []:
227
+ signup = signup[0].text
228
+ else:
229
+ signup = 0
230
+
231
+ instructables = header.select("span.ible-count")
232
+ if instructables != []:
233
+ instructables = instructables[0].text
234
+ else:
235
+ instructables = 0
236
+
237
+ views = header.select("span.total-views")
238
+ if views != []:
239
+ views = views[0].text
240
+ else:
241
+ views = 0
242
+
243
+ comments = header.select("span.total-comments")
244
+ if comments != []:
245
+ comments = comments[0].text
246
+ else:
247
+ comments = 0
248
+
249
+ followers = header.select("span.follower-count")
250
+ if followers != []:
251
+ followers = followers[0].text
252
+ else:
253
+ followers = 0
254
+
255
+ bio = header.select("span.member-bio")
256
+ if bio != []:
257
+ bio = bio[0].text
258
+ else:
259
+ bio = ""
260
+
261
+ return {
262
+ "avatar": avatar,
263
+ "title": title,
264
+ "location": location,
265
+ "signup": signup,
266
+ "instructables": instructables,
267
+ "views": views,
268
+ "comments": comments,
269
+ "followers": followers,
270
+ "bio": bio,
271
+ }
272
+
273
+
274
+ def category_page(name, teachers=False):
275
+ path = urlparse(request.path).path
276
+ page = request.args.get("page", 1, type=int)
277
+
278
+ ibles = []
279
+
280
+ channels = []
281
+ contests = []
282
+
283
+ for channel in global_ibles["/projects"]:
284
+ if (
285
+ channel["channel"].startswith(name.lower())
286
+ and channel["channel"] not in channels
287
+ ):
288
+ channels.append(channel["channel"])
289
+
290
+ category_ibles, total = projects_search(
291
+ category=name, page=page, filter_by="featureFlag:=true"
292
+ )
293
+
294
+ for ible in category_ibles:
295
+ link = f"/{ible['document']['urlString']}"
296
+ img = proxy(ible["document"]["coverImageUrl"])
297
+
298
+ title = ible["document"]["title"]
299
+ author = ible["document"]["screenName"]
300
+ author_link = f"/member/{author}"
301
+
302
+ channel = ible["document"]["primaryClassification"]
303
+ channel_link = f"/channel/{channel}"
304
+
305
+ views = ible["document"]["views"]
306
+ favorites = ible["document"]["favorites"]
307
+
308
+ ibles.append(
309
+ {
310
+ "link": link,
311
+ "img": img,
312
+ "title": title,
313
+ "author": author,
314
+ "author_link": author_link,
315
+ "channel": channel,
316
+ "channel_link": channel_link,
317
+ "views": views,
318
+ "favorites": favorites,
319
+ }
320
+ )
321
+
322
+ return render_template(
323
+ "category.html",
324
+ title=name,
325
+ channels=channels,
326
+ ibles=ibles,
327
+ contests=contests,
328
+ path=path,
329
+ )
330
+
331
+
332
+ def get_pagination(request, total, per_page=1):
333
+ pagination = []
334
+
335
+ args = request.args.copy()
336
+ current = int(args.pop("page", 1))
337
+
338
+ query_string = urlencode(args)
339
+
340
+ total_pages = int(total / per_page)
341
+
342
+ if query_string:
343
+ query_string = "&" + query_string
344
+
345
+ if current > 1:
346
+ pagination.append(
347
+ {
348
+ "link": f"?page={current - 1}{query_string}",
349
+ "text": "Previous",
350
+ "disabled": False,
351
+ "active": False,
352
+ }
353
+ )
354
+
355
+ for page in range(max(current - 5, 1), min(current + 5, total_pages)):
356
+ if page == current:
357
+ pagination.append(
358
+ {
359
+ "link": f"?page={page}{query_string}",
360
+ "text": page,
361
+ "disabled": False,
362
+ "active": True,
363
+ }
364
+ )
365
+ else:
366
+ pagination.append(
367
+ {
368
+ "link": f"?page={page}{query_string}",
369
+ "text": page,
370
+ "disabled": False,
371
+ "active": False,
372
+ }
373
+ )
374
+
375
+ if current < total_pages:
376
+ pagination.append(
377
+ {
378
+ "link": f"?page={current + 1}{query_string}",
379
+ "text": "Next",
380
+ "disabled": False,
381
+ "active": False,
382
+ }
383
+ )
384
+
385
+ return pagination
386
+
387
+
388
+ def project_list(head, sort="", per_page=20):
389
+ head = f"{head + ' ' if head != '' else ''}Projects" + sort
390
+ path = urlparse(request.path).path
391
+
392
+ page = request.args.get("page", 1, type=int)
393
+
394
+ if path in ("/projects/", "/projects"):
395
+ ibles = global_ibles["/projects"]
396
+ total = len(ibles)
397
+ else:
398
+ if "projects" in path.split("/"):
399
+ ibles = []
400
+
401
+ parts = path.split("/")
402
+ category = parts[1]
403
+ channel = "" if parts[2] == "projects" else parts[2]
404
+
405
+ project_ibles, total = projects_search(
406
+ category=category, channel=channel, per_page=per_page, page=page
407
+ )
408
+
409
+ elif "search" in path.split("/"):
410
+ ibles = []
411
+ query = (
412
+ request.args.get("q") if request.method == "GET" else request.form["q"]
413
+ )
414
+
415
+ project_ibles, total = projects_search(
416
+ query=query,
417
+ filter_by="",
418
+ per_page=per_page,
419
+ page=page,
420
+ query_by="title,screenName",
421
+ )
422
+
423
+ else:
424
+ abort(404)
425
+
426
+ for ible in project_ibles:
427
+ link = f"/{ible['document']['urlString']}"
428
+ img = proxy(ible["document"]["coverImageUrl"])
429
+
430
+ title = ible["document"]["title"]
431
+ author = ible["document"]["screenName"]
432
+ author_link = f"/member/{author}"
433
+
434
+ channel = ible["document"]["primaryClassification"]
435
+ channel_link = f"/channel/{channel}"
436
+
437
+ views = ible["document"]["views"]
438
+ favorites = ible["document"]["favorites"]
439
+
440
+ ibles.append(
441
+ {
442
+ "link": link,
443
+ "img": img,
444
+ "title": title,
445
+ "author": author,
446
+ "author_link": author_link,
447
+ "channel": channel,
448
+ "channel_link": channel_link,
449
+ "views": views,
450
+ "favorites": favorites,
451
+ }
452
+ )
453
+
454
+ return render_template(
455
+ "projects.html",
456
+ title=head,
457
+ ibles=ibles,
458
+ path=path,
459
+ pagination=get_pagination(request, total, per_page),
460
+ )
461
+
462
+
463
+ @app.route("/sitemap/")
464
+ @app.route("/sitemap/<path:path>")
465
+ def route_sitemap(path=""):
466
+ try:
467
+ data = urlopen("https://www.instructables.com/sitemap/" + path)
468
+ except HTTPError as e:
469
+ abort(e.code)
470
+
471
+ soup = BeautifulSoup(data.read().decode(), "html.parser")
472
+
473
+ main = soup.select("div.sitemap-content")[0]
474
+
475
+ group_section = main.select("div.group-section")
476
+
477
+ if group_section:
478
+ groups = []
479
+ for group in group_section:
480
+ category = group.select("h2 a")[0].text
481
+ category_link = group.select("h2 a")[0].get("href")
482
+ channels = []
483
+ for li in group.select("ul.sitemap-listing li"):
484
+ channel = li.a.text
485
+ channel_link = li.a["href"]
486
+ channels.append([channel, channel_link])
487
+ groups.append([category, category_link, channels])
488
+
489
+ else:
490
+ groups = []
491
+ channels = []
492
+ for li in main.select("ul.sitemap-listing li"):
493
+ channel = li.a.text
494
+ channel_link = li.a["href"]
495
+ channels.append([channel, channel_link])
496
+ groups.append(["", "", channels])
497
+
498
+ return render_template("sitemap.html", title="Sitemap", groups=groups)
499
+
500
+
501
+ @app.route("/contest/archive/")
502
+ def route_contest_archive():
503
+ page = 1
504
+ if request.args.get("page") is not None:
505
+ page = request.args.get("page")
506
+
507
+ try:
508
+ data = urlopen(f"https://www.instructables.com/contest/archive/?page={page}")
509
+ except HTTPError as e:
510
+ abort(e.code)
511
+
512
+ soup = BeautifulSoup(data.read().decode(), "html.parser")
513
+
514
+ main = soup.select("div#contest-archive-wrapper")[0]
515
+
516
+ contest_count = main.select("p.contest-count")[0].text
517
+
518
+ contest_list = []
519
+ for index, year in enumerate(main.select("div.contest-archive-list h2")):
520
+ year_list = main.select(
521
+ "div.contest-archive-list div.contest-archive-list-year"
522
+ )[index]
523
+ year_name = year.text
524
+ month_list = []
525
+ for month in year_list.select("div.contest-archive-list-month"):
526
+ month_name = month.select("h3")[0].text
527
+ month_contest_list = []
528
+ for p in month.select("p"):
529
+ date = p.select("span")[0].text
530
+ link = p.select("a")[0].get("href")
531
+ title = p.select("a")[0].text
532
+ month_contest_list.append([date, link, title])
533
+ month_list.append([month_name, month_contest_list])
534
+ contest_list.append([year_name, month_list])
535
+
536
+ pagination = main.select("nav.pagination ul.pagination")[0]
537
+
538
+ return render_template(
539
+ "archives.html",
540
+ title=f"Contest Archives (Page {page})",
541
+ page=page,
542
+ contest_count=contest_count,
543
+ pagination=pagination,
544
+ contest_list=contest_list,
545
+ )
546
+
547
+
548
+ @app.route("/contest/<contest>/")
549
+ def route_contest(contest):
550
+ try:
551
+ data = urlopen(f"https://www.instructables.com/contest/{contest}/")
552
+ except HTTPError as e:
553
+ abort(e.code)
554
+
555
+ soup = BeautifulSoup(data.read().decode(), "html.parser")
556
+
557
+ title = soup.select('meta[property="og:title"]')[0].get("content")
558
+
559
+ body = soup.select("div#contest-wrapper")[0]
560
+
561
+ img = proxy(body.select("div#contest-masthead img")[0].get("src"))
562
+
563
+ entry_count = body.select("li.entries-nav-btn")[0].text.split(" ")[0]
564
+ prizes = body.select("li.prizes-nav-btn")[0].text.split(" ")[0]
565
+
566
+ info = body.select("div.contest-body-column-left")[0]
567
+ info.select("div#site-announcements-page")[0].decompose()
568
+ info.select("h3")[0].decompose()
569
+ info.select("div#contest-body-nav")[0].decompose()
570
+ info = str(info).replace("https://www.instructables.com", "/")
571
+
572
+ body.select("span.contest-entity-count")[0].text
573
+
574
+ entry_list = []
575
+ for entry in body.select("div.contest-entries-list div.contest-entries-list-ible"):
576
+ link = entry.a["href"]
577
+ entry_img = proxy(entry.select("a noscript img")[0].get("src"))
578
+ entry_title = entry.select("a.ible-title")[0].text
579
+ author = entry.select("div span.ible-author a")[0].text
580
+ author_link = entry.select("div span.ible-author a")[0].get("href")
581
+ channel = entry.select("div span.ible-channel a")[0].text
582
+ channel_link = entry.select("div span.ible-channel a")[0].get("href")
583
+ views = entry.select(".ible-views")[0].text
584
+
585
+ entry_list.append(
586
+ {
587
+ "link": link,
588
+ "entry_img": entry_img,
589
+ "entry_title": entry_title,
590
+ "author": author,
591
+ "author_link": author_link,
592
+ "channel": channel,
593
+ "channel_link": channel_link,
594
+ "views": views,
595
+ }
596
+ )
597
+
598
+ return render_template(
599
+ "contest.html",
600
+ title=title,
601
+ img=img,
602
+ entry_count=entry_count,
603
+ prizes=prizes,
604
+ info=info,
605
+ entry_list=entry_list,
606
+ )
607
+
608
+
609
+ @app.route("/contest/")
610
+ def route_contests():
611
+ try:
612
+ data = urlopen("https://www.instructables.com/contest/")
613
+ except HTTPError as e:
614
+ abort(e.code)
615
+
616
+ soup = BeautifulSoup(data.read().decode(), "html.parser")
617
+
618
+ contest_count = str(soup.select("p.contest-count")[0])
619
+
620
+ contests = []
621
+ for contest in soup.select("div#cur-contests div.row-fluid div.contest-banner"):
622
+ link = contest.select("div.contest-banner-inner a")[0].get("href")
623
+ img = proxy(contest.select("div.contest-banner-inner a img")[0].get("src"))
624
+ alt = contest.select("div.contest-banner-inner a img")[0].get("alt")
625
+ deadline = contest.select("span.contest-meta-deadline")[0].get("data-deadline")
626
+ prizes = contest.select("span.contest-meta-count")[0].text
627
+ entries = contest.select("span.contest-meta-count")[1].text
628
+
629
+ contests.append(
630
+ {
631
+ "link": link,
632
+ "img": img,
633
+ "alt": alt,
634
+ "deadline": deadline,
635
+ "prizes": prizes,
636
+ "entries": entries,
637
+ }
638
+ )
639
+
640
+ closed = []
641
+ for display in soup.select("div.contest-winner-display"):
642
+ link = display.select("div.contest-banner-inner a")[0].get("href")
643
+ img = proxy(display.select("div.contest-banner-inner a img")[0].get("src"))
644
+ alt = display.select("div.contest-banner-inner a img")[0].get("alt")
645
+ featured_items = []
646
+ for featured_item in display.select("ul.featured-items li"):
647
+ item_link = featured_item.select("div.ible-thumb a")[0].get("href")
648
+ item_img = proxy(featured_item.select("div.ible-thumb a img")[0].get("src"))
649
+ item_title = featured_item.select("a.title")[0].text
650
+ item_author = featured_item.select("a.author")[0].text
651
+ item_author_link = featured_item.select("a.author")[0].get("href")
652
+
653
+ featured_items.append(
654
+ {
655
+ "link": item_link,
656
+ "img": item_img,
657
+ "title": item_title,
658
+ "author": item_author,
659
+ "author_link": item_author_link,
660
+ }
661
+ )
662
+ closed.append(
663
+ {"link": link, "img": img, "alt": alt, "featured_items": featured_items}
664
+ )
665
+
666
+ return render_template(
667
+ "contests.html",
668
+ title="Contests",
669
+ contest_count=contest_count,
670
+ contests=contests,
671
+ closed=closed,
672
+ )
673
+
674
+
675
+ @app.route("/<category>/<channel>/projects/")
676
+ def route_channel_projects(category, channel):
677
+ return project_list(channel.title())
678
+
679
+
680
+ @app.route("/<category>/<channel>/projects/<sort>/")
681
+ def route_channel_projects_sort(category, channel, sort):
682
+ return project_list(
683
+ channel.title(),
684
+ " Sorted by " + sort.title(),
685
+ )
686
+
687
+
688
+ @app.route("/<category>/projects/")
689
+ def route_category_projects(category):
690
+ return project_list(category.title())
691
+
692
+
693
+ @app.route("/<category>/projects/<sort>/")
694
+ def route_category_projects_sort(category, sort):
695
+ return project_list(category.title(), " Sorted by " + sort.title())
696
+
697
+
698
+ @app.route("/projects/")
699
+ def route_projects():
700
+ return project_list("")
701
+
702
+
703
+ @app.route("/search", methods=["POST", "GET"])
704
+ def route_search():
705
+ return project_list("Search")
706
+
707
+
708
+ @app.route("/projects/<sort>/")
709
+ def route_projects_sort(sort):
710
+ return project_list("", " Sorted by " + sort.title())
711
+
712
+
713
+ @app.route("/circuits/")
714
+ def route_circuits():
715
+ return category_page("Circuits")
716
+
717
+
718
+ @app.route("/workshop/")
719
+ def route_workshop():
720
+ return category_page("Workshop")
721
+
722
+
723
+ @app.route("/craft/")
724
+ def route_craft():
725
+ return category_page("Craft")
726
+
727
+
728
+ @app.route("/cooking/")
729
+ def route_cooking():
730
+ return category_page("Cooking")
731
+
732
+
733
+ @app.route("/living/")
734
+ def route_living():
735
+ return category_page("Living")
736
+
737
+
738
+ @app.route("/outside/")
739
+ def route_outside():
740
+ return category_page("Outside")
741
+
742
+
743
+ @app.route("/teachers/")
744
+ def route_teachers():
745
+ return category_page("Teachers", True)
746
+
747
+
748
+ @app.route("/member/<member>/instructables/")
749
+ def route_member_instructables(member):
750
+ try:
751
+ data = urlopen(f"https://www.instructables.com/member/{member}/instructables/")
752
+ except HTTPError as e:
753
+ abort(e.code)
754
+
755
+ soup = BeautifulSoup(data.read().decode(), "html.parser")
756
+
757
+ header = soup.select(".profile-header.profile-header-social")[0]
758
+ header_content = member_header(header)
759
+
760
+ ibles = soup.select("ul.ible-list-items")[0]
761
+ ible_list = []
762
+ for ible in ibles.select("li"):
763
+ link = ible.select("div.thumbnail-image")[0].a.get("href")
764
+ img = proxy(ible.select("div.thumbnail-image a noscript img")[0].get("src"))
765
+ title = ible.select("div.caption-inner a.title")[0].text
766
+
767
+ stats = ible.select("div.ible-stats-right-col")[0]
768
+ views = 0
769
+ if stats.select("span.ible-views") != []:
770
+ views = stats.select("span.ible-views")[0].text
771
+ favorites = 0
772
+ if stats.select("span.ible-favorites") != []:
773
+ favorites = stats.select("span.ible-favorites")[0].text
774
+
775
+ ible_list.append(
776
+ {
777
+ "link": link,
778
+ "img": img,
779
+ "title": title,
780
+ "views": views,
781
+ "favorites": favorites,
782
+ }
783
+ )
784
+
785
+ return render_template(
786
+ "member-instructables.html",
787
+ title=f"{header_content['title']}'s Instructables",
788
+ header_content=header_content,
789
+ ibles=ible_list,
790
+ )
791
+
792
+
793
+ @app.route("/member/<member>/")
794
+ def route_member(member):
795
+ headers = {
796
+ "User-Agent": "Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/113.0"
797
+ }
798
+
799
+ request = Request(
800
+ f"https://www.instructables.com/member/{member}/", headers=headers
801
+ )
802
+
803
+ try:
804
+ data = urlopen(request)
805
+ except HTTPError as e:
806
+ abort(e.code)
807
+
808
+ soup = BeautifulSoup(data.read().decode(), "html.parser")
809
+
810
+ header_content = member_header(soup)
811
+
812
+ body = soup.select("div.member-profile-body")[0]
813
+
814
+ ible_list = body.select(".boxed-content.promoted-content")
815
+
816
+ ible_list_title = ""
817
+ ibles = []
818
+
819
+ if ible_list != []:
820
+ ible_list = ible_list[0]
821
+ ible_list_title = ible_list.select("h2.module-title")[0].text
822
+ for ible in ible_list.select("ul.promoted-items li"):
823
+ ible_title = ible.get("data-title")
824
+ ible_link = ible.select("div.image-wrapper")[0].a.get("href")
825
+ ible_img = proxy(ible.select("div.image-wrapper a img")[0].get("src"))
826
+
827
+ ibles.append({"title": ible_title, "link": ible_link, "img": ible_img})
828
+
829
+ ach_list = body.select(
830
+ "div.two-col-section div.right-col-section.centered-sidebar div.boxed-content.about-me"
831
+ )
832
+
833
+ ach_list_title = ""
834
+ achs = []
835
+
836
+ if len(ach_list) > 1:
837
+ ach_list = ach_list[1]
838
+ ach_list_title = ach_list.select("h2.module-title")[0].text
839
+ for ach in ach_list.select(
840
+ "div.achievements-section.main-achievements.contest-achievements div.achievement-item:not(.two-column-filler)"
841
+ ):
842
+ ach_title = ach.select("div.achievement-info span.achievement-title")[
843
+ 0
844
+ ].text
845
+ ach_desc = ach.select("div.achievement-info span.achievement-description")[
846
+ 0
847
+ ].text
848
+ achs.append([ach_title, ach_desc])
849
+
850
+ return render_template(
851
+ "member.html",
852
+ title=header_content["title"] + "'s Profile",
853
+ header_content=header_content,
854
+ ible_list_title=ible_list_title,
855
+ ibles=ibles,
856
+ ach_list_title=ach_list_title,
857
+ achs=achs,
858
+ )
859
+
860
+
861
+ @app.route("/<article>/")
862
+ def route_article(article):
863
+ try:
864
+ data = urlopen(
865
+ f"https://www.instructables.com/json-api/showInstructableModel?urlString={article}"
866
+ )
867
+ data = json.loads(data.read().decode())
868
+ except HTTPError as e:
869
+ abort(e.code)
870
+
871
+ try:
872
+ title = data["title"]
873
+ author = data["author"]["screenName"]
874
+ author_link = f"/member/{author}"
875
+ category = data["classifications"][0]["title"]
876
+ category_slug = data["classifications"][0]["name"]
877
+ category_link = f"/{category_slug}/"
878
+ channel = data["classifications"][0]["channels"][0]["title"]
879
+ channel_slug = data["classifications"][0]["channels"][0]["name"]
880
+ channel_link = f"/{category_slug}/{channel_slug}/"
881
+
882
+ views = data["views"]
883
+ favorites = data["favorites"]
884
+
885
+ if "steps" in data:
886
+ steps = []
887
+
888
+ if "supplies" in data:
889
+ supplies = data["supplies"]
890
+
891
+ supplies_files = []
892
+
893
+ if "suppliesFiles" in data:
894
+ supplies_files = data["suppliesFiles"]
895
+
896
+ data["steps"].insert(
897
+ 1, {"title": "Supplies", "body": supplies, "files": supplies_files}
898
+ )
899
+
900
+ for step in data["steps"]:
901
+ step_title = step["title"]
902
+ print(step_title)
903
+
904
+ step_imgs = []
905
+ step_videos = [] # TODO: Check if this is still required
906
+ step_iframes = []
907
+ step_downloads = []
908
+
909
+ for file in step["files"]:
910
+ print(file)
911
+ if file["image"] and "embedType" not in "file":
912
+ step_imgs.append(
913
+ {"src": proxy(file["downloadUrl"]), "alt": file["name"]}
914
+ )
915
+
916
+ elif not file["image"]:
917
+ step_downloads.append(
918
+ {"src": proxy(file["downloadUrl"]), "name": file["name"]}
919
+ )
920
+
921
+ else: # Leaves us with embeds
922
+ embed_code = file["embedHtmlCode"]
923
+ soup = BeautifulSoup(embed_code, "html.parser")
924
+
925
+ iframe = soup.select("iframe")[0]
926
+
927
+ src = iframe.get("src")
928
+
929
+ if src.startswith("https://content.instructables.com"):
930
+ src = src.replace(
931
+ "https://content.instructables.com",
932
+ f"/proxy/?url={src}",
933
+ )
934
+
935
+ elif invidious and src.startswith("https://www.youtube.com"):
936
+ src = src.replace("https://www.youtube.com", invidious)
937
+
938
+ elif not unsafe:
939
+ src = "/iframe/?url=" + quote(src)
940
+
941
+ step_iframes.append(
942
+ {
943
+ "src": src,
944
+ "width": file.get("width"),
945
+ "height": file.get("height"),
946
+ }
947
+ )
948
+
949
+ step_text = step["body"]
950
+ step_text = step_text.replace(
951
+ "https://content.instructables.com",
952
+ "/proxy/?url=https://content.instructables.com",
953
+ )
954
+ steps.append(
955
+ {
956
+ "title": step_title,
957
+ "imgs": step_imgs,
958
+ "text": step_text,
959
+ "videos": step_videos,
960
+ "iframes": step_iframes,
961
+ "downloads": step_downloads,
962
+ }
963
+ )
964
+
965
+ comments_list = []
966
+ comment_count = 0
967
+
968
+ # TODO: Fix comments
969
+
970
+ # comments = body.select("section.discussion")[0]
971
+
972
+ # comment_count = comments.select("h2")[0].text
973
+ # comment_list = comments.select("div.posts")
974
+
975
+ # if comment_list != []:
976
+ # comment_list = comment_list[0]
977
+ # comments_list = []
978
+ # replies_used = 0
979
+ # for comment in comment_list.select(".post.js-comment:not(.reply)"):
980
+ # comment_votes = comment.select(".votes")[0].text
981
+ # comment_author_img_src = proxy(comment.select(".avatar a noscript img")[0].get("src"))
982
+ # comment_author_img_alt = comment.select(".avatar a noscript img")[0].get("alt")
983
+ # comment_author = comment.select(".posted-by a")[0].text
984
+ # comment_author_link = comment.select(".posted-by a")[0].get("href")
985
+ # comment_date = comment.select(".posted-by p.posted-date")[0].text
986
+ # comment_text = comment.select("div.text p")[0]
987
+ # comment_reply_count = comment.select("button.js-show-replies")
988
+ # if comment_reply_count != []:
989
+ # comment_reply_count = comment_reply_count[0].get("data-num-hidden")
990
+ # else:
991
+ # comment_reply_count = 0
992
+ # reply_list = []
993
+ # for index, reply in enumerate(comment_list.select(".post.js-comment:not(.reply) ~ .post.js-comment.reply.hide:has(~.post.js-comment:not(.reply))")[replies_used:int(comment_reply_count) + replies_used]):
994
+ # reply_votes = reply.select(".votes")[0].text
995
+ # reply_author_img_src = proxy(reply.select(".avatar a noscript img")[0].get("src"))
996
+ # reply_author_img_alt = reply.select(".avatar a noscript img")[0].get("alt")
997
+ # reply_author = reply.select(".posted-by a")[0].text
998
+ # reply_author_link = reply.select(".posted-by a")[0].get("href")
999
+ # reply_date = reply.select(".posted-by p.posted-date")[0].text
1000
+ # reply_text = reply.select("div.text p")[0]
1001
+
1002
+ # reply_list.append([reply_votes, reply_author_img_src, reply_author_img_alt, reply_author, reply_author_link, reply_date, reply_text])
1003
+ # replies_used += 1
1004
+
1005
+ # comments_list.append([comment_votes, comment_author_img_src, comment_author_img_alt, comment_author, comment_author_link, comment_date, comment_text, comment_reply_count, reply_list])
1006
+ return render_template(
1007
+ "article.html",
1008
+ title=title,
1009
+ author=author,
1010
+ author_link=author_link,
1011
+ category=category,
1012
+ category_link=category_link,
1013
+ channel=channel,
1014
+ channel_link=channel_link,
1015
+ views=views,
1016
+ favorites=favorites,
1017
+ steps=steps,
1018
+ comment_count=comment_count,
1019
+ comments_list=comments_list,
1020
+ enumerate=enumerate,
1021
+ )
1022
+ else:
1023
+ ## Collections
1024
+ thumbnails = []
1025
+ for thumbnail in data["instructables"]:
1026
+ text = thumbnail["title"]
1027
+ link = thumbnail["showUrl"]
1028
+ img = proxy(thumbnail["downloadUrl"])
1029
+ thumbnail_title = thumbnail["title"]
1030
+ thumbnail_author = thumbnail["author"]["screenName"]
1031
+ thumbnail_author_link = f"/member/{thumbnail_author}"
1032
+ thumbnail_channel = thumbnail["classifications"][0]["channels"][0][
1033
+ "title"
1034
+ ]
1035
+ thumbnail_category = thumbnail["classifications"][0]["title"]
1036
+ thumbnail_channel_link = f"/{thumbnail_category}/{thumbnail_channel}"
1037
+
1038
+ thumbnails.append(
1039
+ {
1040
+ "text": text,
1041
+ "link": link,
1042
+ "img": img,
1043
+ "title": thumbnail_title,
1044
+ "author": thumbnail_author,
1045
+ "author_link": thumbnail_author_link,
1046
+ "channel": thumbnail_channel,
1047
+ "channel_link": thumbnail_channel_link,
1048
+ }
1049
+ )
1050
+
1051
+ print(thumbnails[-1])
1052
+
1053
+ return render_template(
1054
+ "collection.html",
1055
+ title=title,
1056
+ author=author,
1057
+ author_link=author_link,
1058
+ category=category,
1059
+ category_link=category_link,
1060
+ channel=channel,
1061
+ channel_link=channel_link,
1062
+ views=views,
1063
+ favorites=favorites,
1064
+ thumbnails=thumbnails,
1065
+ )
1066
+
1067
+ except Exception:
1068
+ print_exc()
1069
+ raise InternalServerError()
1070
+
1071
+
1072
+ @app.route("/<category>/<channel>/")
1073
+ def route_channel_redirect(category, channel):
1074
+ # TODO: Just check if the channel exists
1075
+ if (
1076
+ category == "circuits"
1077
+ or category == "workshop"
1078
+ or category == "craft"
1079
+ or category == "cooking"
1080
+ or category == "living"
1081
+ or category == "outside"
1082
+ or category == "teachers"
1083
+ ):
1084
+ return redirect(f"/{category}/{channel}/projects/", 307)
1085
+ else:
1086
+ raise NotFound()
1087
+
1088
+
1089
+ @app.route("/")
1090
+ def route_explore():
1091
+ try:
1092
+ data = urlopen("https://www.instructables.com/")
1093
+ except HTTPError as e:
1094
+ abort(e.code)
1095
+
1096
+ soup = BeautifulSoup(data.read().decode(), "html.parser")
1097
+
1098
+ explore = soup.select(".home-content-explore-wrap")[0]
1099
+
1100
+ title = explore.select("h2")[0].text
1101
+
1102
+ circuits = explore_lists(
1103
+ explore.select(".home-content-explore-category-circuits")[0]
1104
+ )
1105
+ workshop = explore_lists(
1106
+ explore.select(".home-content-explore-category-workshop")[0]
1107
+ )
1108
+ craft = explore_lists(explore.select(".home-content-explore-category-craft")[0])
1109
+ cooking = explore_lists(explore.select(".home-content-explore-category-cooking")[0])
1110
+ living = explore_lists(explore.select(".home-content-explore-category-living")[0])
1111
+ outside = explore_lists(explore.select(".home-content-explore-category-outside")[0])
1112
+ teachers = explore_lists(
1113
+ explore.select(".home-content-explore-category-teachers")[0]
1114
+ )
1115
+
1116
+ return render_template(
1117
+ "index.html",
1118
+ title=title,
1119
+ sections=[
1120
+ ("Circuits", "/circuits", circuits),
1121
+ ("Workshop", "/workshop", workshop),
1122
+ ("Craft", "/craft", craft),
1123
+ ("Cooking", "/cooking", cooking),
1124
+ ("Living", "/living", living),
1125
+ ("Outside", "/outside", outside),
1126
+ ("Teachers", "/teachers", teachers),
1127
+ ],
1128
+ )
1129
+
1130
+
1131
+ @app.route("/proxy/")
1132
+ def route_proxy():
1133
+ url = request.args.get("url")
1134
+ if url is not None:
1135
+ if url.startswith("https://cdn.instructables.com/") or url.startswith(
1136
+ "https://content.instructables.com/"
1137
+ ):
1138
+
1139
+ def generate():
1140
+ # Subfunction to allow streaming the data instead of
1141
+ # downloading all of it at once
1142
+ try:
1143
+ with urlopen(unquote(url)) as data:
1144
+ while True:
1145
+ chunk = data.read(1024 * 1024)
1146
+ if not chunk:
1147
+ break
1148
+ yield chunk
1149
+ except HTTPError as e:
1150
+ abort(e.code)
1151
+
1152
+ try:
1153
+ with urlopen(unquote(url)) as data:
1154
+ content_type = data.headers["content-type"]
1155
+ except HTTPError as e:
1156
+ abort(e.code)
1157
+ except KeyError:
1158
+ raise InternalServerError()
1159
+
1160
+ return Response(generate(), content_type=content_type)
1161
+ else:
1162
+ raise BadRequest()
1163
+ else:
1164
+ raise BadRequest()
1165
+
1166
+
1167
+ @app.route("/iframe/")
1168
+ def route_iframe():
1169
+ url = request.args.get("url")
1170
+ url = unquote(url)
1171
+ if url is not None:
1172
+ return render_template("iframe.html", url=url)
1173
+ else:
1174
+ raise BadRequest()
1175
+
1176
+
1177
+ @app.route("/privacypolicy/")
1178
+ def privacypolicy():
1179
+ content = "No privacy policy found."
1180
+
1181
+ try:
1182
+ with (pathlib.Path(__file__).parent / "privacy.txt").open() as f:
1183
+ content = f.read()
1184
+ except OSError:
1185
+ pass
1186
+
1187
+ return render_template(
1188
+ "privacypolicy.html", title="Privacy Policy", content=content
1189
+ )
1190
+
1191
+
1192
+ @app.errorhandler(404)
1193
+ def not_found(e):
1194
+ return render_template("404.html")
1195
+
1196
+
1197
+ @app.errorhandler(400)
1198
+ def bad_request(e):
1199
+ return render_template("400.html")
1200
+
1201
+
1202
+ @app.errorhandler(429)
1203
+ def too_many_requests(e):
1204
+ return render_template("429.html")
1205
+
1206
+
1207
+ @app.errorhandler(500)
1208
+ def internal_server_error(e):
1209
+ return render_template("500.html")
1210
+
1211
+
1212
+ def main():
1213
+ global debugmode, invidious, unsafe
1214
+
1215
+ parser = ArgumentParser()
1216
+ parser.add_argument(
1217
+ "-p",
1218
+ "--port",
1219
+ default=8002,
1220
+ type=int,
1221
+ help="Port to listen on",
1222
+ )
1223
+ parser.add_argument(
1224
+ "-d",
1225
+ "--debug",
1226
+ action="store_true",
1227
+ help="Enable debug mode",
1228
+ )
1229
+ parser.add_argument(
1230
+ "-l",
1231
+ "--listen-host",
1232
+ default="127.0.0.1",
1233
+ help="Host to listen on",
1234
+ )
1235
+ parser.add_argument(
1236
+ "-I",
1237
+ "--invidious",
1238
+ help="URL to Invidious instance, e.g. https://invidious.private.coffee/",
1239
+ )
1240
+ parser.add_argument(
1241
+ "-u",
1242
+ "--unsafe",
1243
+ action="store_true",
1244
+ help="Display iframes regardless of origin",
1245
+ )
1246
+ parser.add_argument(
1247
+ "-P",
1248
+ "--privacy-file",
1249
+ default="privacy.txt",
1250
+ help="File to read privacy policy from",
1251
+ )
1252
+ args = parser.parse_args()
1253
+
1254
+ debugmode = os.environ.get(
1255
+ "FLASK_DEBUG", os.environ.get("STRUCTABLES_DEBUG", False)
1256
+ )
1257
+ invidious = os.environ.get("STRUCTABLES_INVIDIOUS")
1258
+ unsafe = os.environ.get("STRUCTABLES_UNSAFE", False)
1259
+
1260
+ if args.debug:
1261
+ debugmode = True
1262
+
1263
+ if args.invidious:
1264
+ invidious = args.invidious
1265
+
1266
+ if args.unsafe:
1267
+ unsafe = True
1268
+
1269
+ print("Loading initial data...")
1270
+
1271
+ update_data()
1272
+
1273
+ print("Started!")
1274
+
1275
+ if debugmode:
1276
+ app.logger.setLevel(logging.DEBUG)
1277
+
1278
+ app.run(port=args.port, host=args.listen_host, debug=debugmode)
1279
+
1280
+
1281
+ if __name__ == "__main__":
1282
+ main()