Compare commits
No commits in common. "main" and "main" have entirely different histories.
21 changed files with 410 additions and 516 deletions
|
@ -1,3 +0,0 @@
|
|||
PORT=8002
|
||||
UWSGI_PROCESSES=4
|
||||
UWSGI_THREADS=4
|
|
@ -1,33 +0,0 @@
|
|||
name: Docker CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
name: Docker Build and Push to Docker Hub
|
||||
container:
|
||||
image: node:20-bookworm
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
apt update
|
||||
apt install -y docker.io
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and push to Docker Hub
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
push: true
|
||||
tags: |
|
||||
privatecoffee/structables:latest
|
||||
privatecoffee/structables:${{ env.GITHUB_REF_NAME }}
|
18
.gitignore
vendored
18
.gitignore
vendored
|
@ -1,13 +1,7 @@
|
|||
.env
|
||||
.vscode
|
||||
__pycache__/
|
||||
docker-compose.yml
|
||||
privacy.md
|
||||
privacy.txt
|
||||
*.pyc
|
||||
venv/
|
||||
|
||||
# Special rules needed for building the Docker image
|
||||
/dist/*
|
||||
!/dist/css/
|
||||
!/dist/css/*
|
||||
*.pyc
|
||||
__pycache__/
|
||||
.vscode
|
||||
privacy.txt
|
||||
privacy.md
|
||||
/dist/
|
21
Dockerfile
21
Dockerfile
|
@ -1,21 +0,0 @@
|
|||
FROM alpine:3.20
|
||||
|
||||
ENV APP_ENV=/opt/venv
|
||||
ENV PATH="${APP_ENV}/bin:$PATH"
|
||||
|
||||
RUN apk add --no-cache py3-pip uwsgi-python3 && \
|
||||
python3 -m venv $APP_ENV
|
||||
|
||||
COPY . /app
|
||||
|
||||
RUN $APP_ENV/bin/pip install --no-cache-dir pip && \
|
||||
$APP_ENV/bin/pip install /app && \
|
||||
adduser -S -D -H structables
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
|
||||
EXPOSE 8002
|
||||
|
||||
USER structables
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
47
README.md
47
README.md
|
@ -13,22 +13,11 @@ An open source alternative front-end to Instructables. This is a fork of <a href
|
|||
|
||||
## Instances
|
||||
|
||||
| URL | Provided by | Country | Comments |
|
||||
| ----------------------------------------------------------------------- | ----------------------------------------------- | ------- | -------- |
|
||||
| [structables.private.coffee](https://structables.private.coffee/) | [Private.coffee](https://private.coffee/) | Austria | |
|
||||
| [structables.bloat.cat](https://structables.bloat.cat/) | [Bloat.cat](https://bloat.cat) | Germany | |
|
||||
| [structables.darkness.services](https://structables.darkness.services/) | [Darkness.services](https://darkness.services/) | USA | |
|
||||
| URL | Provided by | Country | Comments |
|
||||
| -------------------------------------------------------------------------- | ----------------------------------------- | ------- | -------- |
|
||||
| [https://structables.private.coffee/](https://structables.private.coffee/) | [Private.coffee](https://private.coffee/) | Austria | |
|
||||
|
||||
### Tor Hidden Services
|
||||
|
||||
| URL | Provided by | Country | Comments |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | ------- | -------- |
|
||||
| [structables.coffee2m3bjsrrqqycx6ghkxrnejl2q6nl7pjw2j4clchjj6uk5zozad.onion](http://structables.coffee2m3bjsrrqqycx6ghkxrnejl2q6nl7pjw2j4clchjj6uk5zozad.onion/) | [Private.coffee](https://private.coffee/) | Austria | |
|
||||
| [structables.darknessrdor43qkl2ngwitj72zdavfz2cead4t5ed72bybgauww5lyd.onion](http://structables.darknessrdor43qkl2ngwitj72zdavfz2cead4t5ed72bybgauww5lyd.onion/) | [Darkness.services](https://darkness.services/) | USA | |
|
||||
|
||||
### Adding Your Instance
|
||||
|
||||
To add your own instance to this list, please open a pull request or issue, see below.
|
||||
To add your own instance to this list, please open a pull request or issue.
|
||||
|
||||
## Opening Issues
|
||||
|
||||
|
@ -38,7 +27,7 @@ Of course, you can also join our [Matrix room](https://matrix.pcof.fi/#/#structa
|
|||
|
||||
## Run your own instance
|
||||
|
||||
### Production: Manual
|
||||
### Production
|
||||
|
||||
1. Create a virtual environment: `python3 -m venv venv`
|
||||
2. Activate the virtual environment: `source venv/bin/activate`
|
||||
|
@ -46,21 +35,6 @@ Of course, you can also join our [Matrix room](https://matrix.pcof.fi/#/#structa
|
|||
4. Run `uwsgi --plugin python3 --http-socket 0.0.0.0:8002 --module structables.main:app --processes 4 --threads 4`
|
||||
5. Point your reverse proxy to http://localhost:8002 and (optionally) serve static files from the `venv/lib/pythonX.XX/site-packages/structables/static` directory
|
||||
6. Connect to your instance under your domain
|
||||
7. Ensure that `/cron/` is executed at regular intervals so that the app updates its cached data.
|
||||
|
||||
### Production: Docker
|
||||
|
||||
1. Copy `.env.example` to `.env` and adjust the settings as necessary
|
||||
2. Copy `docker-compose-example.yml` to `docker-compose.yml` and adjust it as necessary, for example modifying resource limits or changing the port/host configuration
|
||||
3. Build and run the Docker container:
|
||||
|
||||
```sh
|
||||
docker-compose up [-d]
|
||||
```
|
||||
|
||||
4. Point your reverse proxy to http://127.0.0.1:8002 (or your chosen port, if you modified it) and (optionally) serve static files from `structables/static`
|
||||
5. Connect to your instance under your domain
|
||||
6. Ensure that `/cron/` is executed at regular intervals so that the app updates its cached data.
|
||||
|
||||
### Development
|
||||
|
||||
|
@ -71,17 +45,6 @@ Of course, you can also join our [Matrix room](https://matrix.pcof.fi/#/#structa
|
|||
5. Run `structables`
|
||||
6. Connect to http://localhost:8002
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Structables supports the use of the following environment variables for configuration:
|
||||
|
||||
- `STRUCTABLES_PORT`: The port to listen on (default: 8002)
|
||||
- `STRUCTABLES_LISTEN_HOST`: The host/IP address to listen on (default: 127.0.0.1)
|
||||
- `STRUCTABLES_INVIDIOUS`: The hostname of an Invidious instance to use for embedded YouTube videos (currently not recommended due to YouTube blocks)
|
||||
- `STRUCTABLES_UNSAFE`: If set, allow embedding untrusted iframes (if unset, display a warning and allow loading the content manually)
|
||||
- `STRUCTABLES_PRIVACY_FILE`: The path to a text file or Markdown file (with .md suffix) to use for the Privacy Policy page (if unset, try `privacy.txt` or `privacy.md` in the working directory, or fall back to a generic message)
|
||||
- `STRUCTABLES_DEBUG`: If set, log additional debug information to stdout
|
||||
|
||||
## License
|
||||
|
||||
This project, as well as the two projects it is based on, are licensed under the GNU Affero General Public License v3. See the [LICENSE](LICENSE) file for more information.
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
services:
|
||||
structables:
|
||||
container_name: structables
|
||||
restart: unless-stopped
|
||||
build: .
|
||||
ports:
|
||||
- "127.0.0.1:8002:8002"
|
||||
env_file: .env
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
read_only: true
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 300M
|
|
@ -1,18 +0,0 @@
|
|||
services:
|
||||
structables:
|
||||
container_name: structables
|
||||
restart: unless-stopped
|
||||
image: privatecoffee/structables:latest
|
||||
ports:
|
||||
- "127.0.0.1:8002:8002"
|
||||
env_file: .env
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
read_only: true
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 300M
|
|
@ -1,18 +0,0 @@
|
|||
#!/bin/sh
|
||||
args="--plugin python3 \
|
||||
--http-socket 0.0.0.0:$PORT \
|
||||
--master \
|
||||
--module structables.main:app \
|
||||
-H /opt/venv"
|
||||
|
||||
if [ "$UWSGI_PROCESSES" ]
|
||||
then
|
||||
args="$args --processes $UWSGI_PROCESSES"
|
||||
fi
|
||||
|
||||
if [ "$UWSGI_THREADS" ]
|
||||
then
|
||||
args="$args --threads $UWSGI_THREADS"
|
||||
fi
|
||||
|
||||
exec /usr/sbin/uwsgi $args
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||
|
||||
[project]
|
||||
name = "structables"
|
||||
version = "0.3.15"
|
||||
version = "0.3.6"
|
||||
authors = [
|
||||
{ name="Private.coffee Team", email="support@private.coffee" },
|
||||
]
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
from flask import Flask
|
||||
import threading
|
||||
import time
|
||||
|
||||
from .config import Config
|
||||
from .routes import init_routes
|
||||
|
@ -14,30 +12,12 @@ app.config.from_object(Config)
|
|||
app.typesense_api_key = get_typesense_api_key()
|
||||
|
||||
init_routes(app)
|
||||
update_data(app)
|
||||
|
||||
|
||||
def background_update_data(app):
|
||||
"""Runs the update_data function every 5 minutes.
|
||||
|
||||
This replaces the need for a cron job to update the data.
|
||||
|
||||
Args:
|
||||
app (Flask): The Flask app instance.
|
||||
"""
|
||||
while True:
|
||||
update_data(app)
|
||||
time.sleep(300)
|
||||
|
||||
|
||||
def main():
|
||||
threading.Thread(target=background_update_data, args=(app,), daemon=True).start()
|
||||
app.run(
|
||||
port=app.config["PORT"],
|
||||
host=app.config["LISTEN_HOST"],
|
||||
debug=app.config["DEBUG"],
|
||||
)
|
||||
|
||||
app.run(port=app.config['PORT'], host=app.config['LISTEN_HOST'], debug=app.config['DEBUG'])
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
# Initialize data when the server starts
|
||||
update_data(app)
|
|
@ -1,133 +1,105 @@
|
|||
from flask import render_template, request, abort, url_for
|
||||
from urllib.request import urlopen, Request
|
||||
from flask import render_template, request, abort
|
||||
from urllib.request import urlopen
|
||||
from urllib.error import HTTPError
|
||||
from ..utils.helpers import proxy
|
||||
from bs4 import BeautifulSoup
|
||||
import json
|
||||
|
||||
|
||||
def init_contest_routes(app):
|
||||
@app.route("/contest/archive/")
|
||||
def route_contest_archive():
|
||||
# Default pagination settings
|
||||
limit = 10
|
||||
page = request.args.get("page", default=1, type=int)
|
||||
offset = (page - 1) * limit
|
||||
page = 1
|
||||
if request.args.get("page") is not None:
|
||||
page = request.args.get("page")
|
||||
|
||||
try:
|
||||
# Fetch data using urlopen
|
||||
url = f"https://www.instructables.com/json-api/getClosedContests?limit={limit}&offset={offset}"
|
||||
response = urlopen(url)
|
||||
data = json.loads(response.read().decode())
|
||||
data = urlopen(f"https://www.instructables.com/contest/archive/?page={page}")
|
||||
except HTTPError as e:
|
||||
abort(e.code)
|
||||
except Exception as e:
|
||||
abort(500) # Handle other exceptions like JSON decode errors
|
||||
|
||||
contests = data.get("contests", [])
|
||||
full_list_size = data.get("fullListSize", 0)
|
||||
soup = BeautifulSoup(data.read().decode(), "html.parser")
|
||||
|
||||
main = soup.select("div#contest-archive-wrapper")[0]
|
||||
|
||||
contest_count = main.select("p.contest-count")[0].text
|
||||
|
||||
contest_list = []
|
||||
for contest in contests:
|
||||
contest_details = {
|
||||
"title": contest["title"],
|
||||
"link": url_for("route_contest", contest=contest["urlString"]),
|
||||
"deadline": contest["deadline"],
|
||||
"startDate": contest["startDate"],
|
||||
"numEntries": contest["numEntries"],
|
||||
"state": contest["state"],
|
||||
"bannerUrl": proxy(contest["bannerUrlMedium"]),
|
||||
}
|
||||
contest_list.append(contest_details)
|
||||
for index, year in enumerate(main.select("div.contest-archive-list h2")):
|
||||
year_list = main.select(
|
||||
"div.contest-archive-list div.contest-archive-list-year"
|
||||
)[index]
|
||||
year_name = year.text
|
||||
month_list = []
|
||||
for month in year_list.select("div.contest-archive-list-month"):
|
||||
month_name = month.select("h3")[0].text
|
||||
month_contest_list = []
|
||||
for p in month.select("p"):
|
||||
date = p.select("span")[0].text
|
||||
link = p.select("a")[0].get("href")
|
||||
title = p.select("a")[0].text
|
||||
month_contest_list.append([date, link, title])
|
||||
month_list.append([month_name, month_contest_list])
|
||||
contest_list.append([year_name, month_list])
|
||||
|
||||
# Calculate total pages
|
||||
total_pages = (full_list_size + limit - 1) // limit
|
||||
|
||||
# Create pagination
|
||||
pagination = {
|
||||
"current_page": page,
|
||||
"total_pages": total_pages,
|
||||
"has_prev": page > 1,
|
||||
"has_next": page < total_pages,
|
||||
"limit": limit,
|
||||
}
|
||||
pagination = main.select("nav.pagination ul.pagination")[0]
|
||||
|
||||
return render_template(
|
||||
"archives.html",
|
||||
title=f"Contest Archives (Page {page})",
|
||||
page=page,
|
||||
contest_count=contest_count,
|
||||
pagination=pagination,
|
||||
contest_list=contest_list,
|
||||
)
|
||||
|
||||
def get_entries(contest):
|
||||
base_url = f"https://www.instructables.com/api_proxy/search/collections/projects/documents/search"
|
||||
headers = {"x-typesense-api-key": app.typesense_api_key}
|
||||
page, per_page = 1, 100
|
||||
all_entries = []
|
||||
|
||||
while True:
|
||||
try:
|
||||
url = f"{base_url}?q=*&filter_by=contestPath:{contest}&sort_by=contestEntryDate:desc&per_page={per_page}&page={page}"
|
||||
request = Request(url, headers=headers)
|
||||
response = urlopen(request)
|
||||
data = json.loads(response.read().decode())
|
||||
except HTTPError as e:
|
||||
abort(e.code)
|
||||
|
||||
hits = data.get("hits", [])
|
||||
if not hits:
|
||||
break
|
||||
|
||||
all_entries.extend(hits)
|
||||
if len(hits) < per_page:
|
||||
break
|
||||
page += 1
|
||||
|
||||
return all_entries
|
||||
|
||||
@app.route("/contest/<contest>/")
|
||||
def route_contest(contest):
|
||||
try:
|
||||
data = urlopen(f"https://www.instructables.com/contest/{contest}/")
|
||||
html = data.read().decode()
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
|
||||
title_tag = soup.find("h1")
|
||||
title = title_tag.get_text() if title_tag else "Contest"
|
||||
|
||||
img_tag = soup.find("img", alt=lambda x: x and "Banner" in x)
|
||||
img = img_tag.get("src") if img_tag else "default.jpg"
|
||||
|
||||
entry_count = len(get_entries(contest))
|
||||
prizes_items = soup.select("article")
|
||||
prizes = len(prizes_items) if prizes_items else 0
|
||||
|
||||
overview_section = soup.find("section", id="overview")
|
||||
info = (
|
||||
overview_section.decode_contents()
|
||||
if overview_section
|
||||
else "No Overview"
|
||||
)
|
||||
|
||||
except HTTPError as e:
|
||||
abort(e.code)
|
||||
|
||||
soup = BeautifulSoup(data.read().decode(), "html.parser")
|
||||
|
||||
title = soup.select('meta[property="og:title"]')[0].get("content")
|
||||
|
||||
body = soup.select("div#contest-wrapper")[0]
|
||||
|
||||
img = proxy(body.select("div#contest-masthead img")[0].get("src"))
|
||||
|
||||
entry_count = body.select("li.entries-nav-btn")[0].text.split(" ")[0]
|
||||
prizes = body.select("li.prizes-nav-btn")[0].text.split(" ")[0]
|
||||
|
||||
info = body.select("div.contest-body-column-left")[0]
|
||||
info.select("div#site-announcements-page")[0].decompose()
|
||||
info.select("h3")[0].decompose()
|
||||
info.select("div#contest-body-nav")[0].decompose()
|
||||
info = str(info).replace("https://www.instructables.com", "/")
|
||||
|
||||
body.select("span.contest-entity-count")[0].text
|
||||
|
||||
entry_list = []
|
||||
entries = get_entries(contest)
|
||||
for entry in entries:
|
||||
doc = entry["document"]
|
||||
entry_details = {
|
||||
"link": url_for("route_article", article=doc["urlString"]),
|
||||
"entry_img": doc["coverImageUrl"],
|
||||
"entry_title": doc["title"],
|
||||
"author": doc["screenName"],
|
||||
"author_link": url_for("route_member", member=doc["screenName"]),
|
||||
"channel": doc["channel"][0],
|
||||
"channel_link": f"/{doc['primaryClassification']}",
|
||||
"views": doc.get("views", 0),
|
||||
for entry in body.select("div.contest-entries-list div.contest-entries-list-ible"):
|
||||
link = entry.a["href"]
|
||||
entry_img = proxy(entry.select("a noscript img")[0].get("src"))
|
||||
entry_title = entry.select("a.ible-title")[0].text
|
||||
author = entry.select("div span.ible-author a")[0].text
|
||||
author_link = entry.select("div span.ible-author a")[0].get("href")
|
||||
channel = entry.select("div span.ible-channel a")[0].text
|
||||
channel_link = entry.select("div span.ible-channel a")[0].get("href")
|
||||
views = entry.select(".ible-views")[0].text
|
||||
|
||||
entry_list.append(
|
||||
{
|
||||
"link": link,
|
||||
"entry_img": entry_img,
|
||||
"entry_title": entry_title,
|
||||
"author": author,
|
||||
"author_link": author_link,
|
||||
"channel": channel,
|
||||
"channel_link": channel_link,
|
||||
"views": views,
|
||||
}
|
||||
entry_list.append(entry_details)
|
||||
)
|
||||
|
||||
return render_template(
|
||||
"contest.html",
|
||||
|
@ -142,33 +114,64 @@ def init_contest_routes(app):
|
|||
@app.route("/contest/")
|
||||
def route_contests():
|
||||
try:
|
||||
# Fetch current contests from the JSON API
|
||||
response = urlopen(
|
||||
"https://www.instructables.com/json-api/getCurrentContests?limit=50&offset=0"
|
||||
)
|
||||
data = json.loads(response.read().decode())
|
||||
data = urlopen("https://www.instructables.com/contest/")
|
||||
except HTTPError as e:
|
||||
abort(e.code)
|
||||
except Exception as e:
|
||||
abort(500) # Handle other exceptions such as JSON decode errors
|
||||
|
||||
contests = data.get("contests", [])
|
||||
contest_list = []
|
||||
for contest in contests:
|
||||
contest_details = {
|
||||
"link": url_for("route_contest", contest=contest["urlString"]),
|
||||
"img": proxy(contest["bannerUrlMedium"]),
|
||||
"alt": contest["title"],
|
||||
"title": contest["title"],
|
||||
"deadline": contest["deadline"],
|
||||
"prizes": contest["prizeCount"],
|
||||
"entries": contest["numEntries"],
|
||||
}
|
||||
contest_list.append(contest_details)
|
||||
soup = BeautifulSoup(data.read().decode(), "html.parser")
|
||||
|
||||
contest_count = str(soup.select("p.contest-count")[0])
|
||||
|
||||
contests = []
|
||||
for contest in soup.select("div#cur-contests div.row-fluid div.contest-banner"):
|
||||
link = contest.select("div.contest-banner-inner a")[0].get("href")
|
||||
img = proxy(contest.select("div.contest-banner-inner a img")[0].get("src"))
|
||||
alt = contest.select("div.contest-banner-inner a img")[0].get("alt")
|
||||
deadline = contest.select("span.contest-meta-deadline")[0].get("data-deadline")
|
||||
prizes = contest.select("span.contest-meta-count")[0].text
|
||||
entries = contest.select("span.contest-meta-count")[1].text
|
||||
|
||||
contests.append(
|
||||
{
|
||||
"link": link,
|
||||
"img": img,
|
||||
"alt": alt,
|
||||
"deadline": deadline,
|
||||
"prizes": prizes,
|
||||
"entries": entries,
|
||||
}
|
||||
)
|
||||
|
||||
closed = []
|
||||
for display in soup.select("div.contest-winner-display"):
|
||||
link = display.select("div.contest-banner-inner a")[0].get("href")
|
||||
img = proxy(display.select("div.contest-banner-inner a img")[0].get("src"))
|
||||
alt = display.select("div.contest-banner-inner a img")[0].get("alt")
|
||||
featured_items = []
|
||||
for featured_item in display.select("ul.featured-items li"):
|
||||
item_link = featured_item.select("div.ible-thumb a")[0].get("href")
|
||||
item_img = proxy(featured_item.select("div.ible-thumb a img")[0].get("src"))
|
||||
item_title = featured_item.select("a.title")[0].text
|
||||
item_author = featured_item.select("a.author")[0].text
|
||||
item_author_link = featured_item.select("a.author")[0].get("href")
|
||||
|
||||
featured_items.append(
|
||||
{
|
||||
"link": item_link,
|
||||
"img": item_img,
|
||||
"title": item_title,
|
||||
"author": item_author,
|
||||
"author_link": item_author_link,
|
||||
}
|
||||
)
|
||||
closed.append(
|
||||
{"link": link, "img": img, "alt": alt, "featured_items": featured_items}
|
||||
)
|
||||
|
||||
return render_template(
|
||||
"contests.html",
|
||||
title="Contests",
|
||||
contest_count=len(contest_list),
|
||||
contests=contest_list,
|
||||
)
|
||||
contest_count=contest_count,
|
||||
contests=contests,
|
||||
closed=closed,
|
||||
)
|
|
@ -158,54 +158,49 @@ def init_main_routes(app):
|
|||
for file in step["files"]:
|
||||
if file["image"] and "embedType" not in "file":
|
||||
step_imgs.append(
|
||||
{
|
||||
"src": proxy(file["downloadUrl"], file["name"]),
|
||||
"alt": file["name"],
|
||||
}
|
||||
{"src": proxy(file["downloadUrl"], file["name"]), "alt": file["name"]}
|
||||
)
|
||||
|
||||
elif not file["image"]:
|
||||
if "downloadUrl" in file.keys():
|
||||
step_downloads.append(
|
||||
{
|
||||
"src": proxy(file["downloadUrl"], file["name"]),
|
||||
"name": file["name"],
|
||||
}
|
||||
step_downloads.append(
|
||||
{
|
||||
"src": proxy(file["downloadUrl"], file["name"]),
|
||||
"name": file["name"],
|
||||
}
|
||||
)
|
||||
|
||||
else: # Leaves us with embeds
|
||||
embed_code = file["embedHtmlCode"]
|
||||
soup = BeautifulSoup(embed_code, "html.parser")
|
||||
|
||||
iframe = soup.select("iframe")[0]
|
||||
|
||||
src = iframe.get("src")
|
||||
|
||||
if src.startswith("https://content.instructables.com"):
|
||||
src = src.replace(
|
||||
"https://content.instructables.com",
|
||||
f"/proxy/?url={src}",
|
||||
)
|
||||
|
||||
else: # Leaves us with embeds
|
||||
embed_code = file["embedHtmlCode"]
|
||||
soup = BeautifulSoup(embed_code, "html.parser")
|
||||
|
||||
iframe = soup.select("iframe")[0]
|
||||
|
||||
src = iframe.get("src")
|
||||
|
||||
if src.startswith("https://content.instructables.com"):
|
||||
src = src.replace(
|
||||
"https://content.instructables.com",
|
||||
f"/proxy/?url={src}",
|
||||
)
|
||||
|
||||
elif app.config["INVIDIOUS"] and src.startswith(
|
||||
"https://www.youtube.com"
|
||||
):
|
||||
src = src.replace(
|
||||
"https://www.youtube.com",
|
||||
app.config["INVIDIOUS"],
|
||||
)
|
||||
|
||||
elif not app.config["UNSAFE"]:
|
||||
src = "/iframe/?url=" + quote(src)
|
||||
|
||||
step_iframes.append(
|
||||
{
|
||||
"src": src,
|
||||
"width": file.get("width"),
|
||||
"height": file.get("height"),
|
||||
}
|
||||
elif app.config["INVIDIOUS"] and src.startswith(
|
||||
"https://www.youtube.com"
|
||||
):
|
||||
src = src.replace(
|
||||
"https://www.youtube.com", app.config["INVIDIOUS"]
|
||||
)
|
||||
|
||||
elif not app.config["UNSAFE"]:
|
||||
src = "/iframe/?url=" + quote(src)
|
||||
|
||||
step_iframes.append(
|
||||
{
|
||||
"src": src,
|
||||
"width": file.get("width"),
|
||||
"height": file.get("height"),
|
||||
}
|
||||
)
|
||||
|
||||
step_text = step["body"]
|
||||
step_text = step_text.replace(
|
||||
"https://content.instructables.com",
|
||||
|
@ -357,16 +352,17 @@ def init_main_routes(app):
|
|||
elif pathlib.Path("privacy.txt").exists():
|
||||
path = "privacy.txt"
|
||||
|
||||
if path:
|
||||
try:
|
||||
with pathlib.Path(path).open() as f:
|
||||
content = f.read()
|
||||
try:
|
||||
with pathlib.Path(path).open() as f:
|
||||
content = f.read()
|
||||
|
||||
if path.endswith(".md"):
|
||||
content = Markdown().convert(content)
|
||||
print(path, content)
|
||||
|
||||
except OSError:
|
||||
pass
|
||||
if path.endswith(".md"):
|
||||
content = Markdown().convert(content)
|
||||
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return render_template(
|
||||
"privacypolicy.html", title="Privacy Policy", content=content
|
||||
|
|
|
@ -1,32 +1,14 @@
|
|||
from flask import render_template, abort
|
||||
from urllib.request import urlopen
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import quote
|
||||
from ..utils.helpers import proxy, member_header
|
||||
from bs4 import BeautifulSoup
|
||||
from urllib.request import Request
|
||||
|
||||
|
||||
def init_member_routes(app):
|
||||
"""This function initializes all the routes related to Instructables member profiles.
|
||||
|
||||
Args:
|
||||
app (Flask): The Flask app instance.
|
||||
"""
|
||||
|
||||
@app.route("/member/<member>/instructables/")
|
||||
def route_member_instructables(member):
|
||||
"""Route to display a member's Instructables.
|
||||
|
||||
Args:
|
||||
member (str): The member's username.
|
||||
|
||||
Returns:
|
||||
Response: The rendered HTML page.
|
||||
"""
|
||||
|
||||
member = quote(member)
|
||||
|
||||
try:
|
||||
data = urlopen(
|
||||
f"https://www.instructables.com/member/{member}/instructables/"
|
||||
|
@ -73,18 +55,13 @@ def init_member_routes(app):
|
|||
|
||||
@app.route("/member/<member>/")
|
||||
def route_member(member):
|
||||
"""Route to display a member's profile.
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/113.0"
|
||||
}
|
||||
|
||||
Args:
|
||||
member (str): The member's username.
|
||||
|
||||
Returns:
|
||||
Response: The rendered HTML page.
|
||||
"""
|
||||
|
||||
member = quote(member)
|
||||
|
||||
request = Request(f"https://www.instructables.com/member/{member}/")
|
||||
request = Request(
|
||||
f"https://www.instructables.com/member/{member}/", headers=headers
|
||||
)
|
||||
|
||||
try:
|
||||
data = urlopen(request)
|
||||
|
@ -126,9 +103,9 @@ def init_member_routes(app):
|
|||
"div.achievements-section.main-achievements.contest-achievements div.achievement-item:not(.two-column-filler)"
|
||||
):
|
||||
try:
|
||||
ach_title = ach.select(
|
||||
"div.achievement-info span.achievement-title"
|
||||
)[0].text
|
||||
ach_title = ach.select("div.achievement-info span.achievement-title")[
|
||||
0
|
||||
].text
|
||||
ach_desc = ach.select(
|
||||
"div.achievement-info span.achievement-description"
|
||||
)[0].text
|
||||
|
|
|
@ -130,67 +130,144 @@ blockquote {
|
|||
}
|
||||
}
|
||||
|
||||
.contest-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
.ibles {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.contest-item {
|
||||
background-color: #fff;
|
||||
padding: 20px;
|
||||
.ible-small {
|
||||
font-size: 0.7em;
|
||||
font-weight: thin;
|
||||
line-height: 1em;
|
||||
}
|
||||
|
||||
.step-section {
|
||||
background-color: var(--primary-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 2px 4px var(--shadow-color);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.contest-item img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
margin-top: 10px;
|
||||
.step-header h2 {
|
||||
font-size: 2em;
|
||||
color: var(--heading-color);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
.step-images img,
|
||||
.step-videos video,
|
||||
.step-iframes iframe {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px var(--shadow-color);
|
||||
}
|
||||
|
||||
.step-text {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.reply-button,
|
||||
.replies {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reply-button+label {
|
||||
position: relative;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input.reply-button:checked+label+.replies {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 10px 0;
|
||||
list-style-type: none;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.pagination-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pagination-list li {
|
||||
.member-list {
|
||||
display: inline-block;
|
||||
max-width: 200px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.pagination-list a {
|
||||
color: var(--main-color);
|
||||
.ible-list-item {
|
||||
display: inline-block;
|
||||
max-width: 350px;
|
||||
vertical-align: top;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.contest-list-item {
|
||||
display: inline-block;
|
||||
max-width: 500px;
|
||||
vertical-align: top;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.archive-month-wrapper {
|
||||
display: inline-block;
|
||||
width: 30vw;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.archive-month {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: -10px;
|
||||
margin-bottom: 1rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.archive {
|
||||
margin-bottom: -20px;
|
||||
}
|
||||
|
||||
ul.pagination {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 0 33vw;
|
||||
list-style-type: none;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
ul.pagination li.active a,
|
||||
ul.pagination li.disabled a,
|
||||
ul.pagination li.active a:hover,
|
||||
ul.pagination li.disabled a:hover {
|
||||
color: #bbc2cf;
|
||||
text-decoration: none;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.pagination-list a:hover {
|
||||
border-color: var(--link-color);
|
||||
color: var(--link-color);
|
||||
.closed-contest-contest {
|
||||
object-fit: cover;
|
||||
width: 33vw;
|
||||
height: 15vw;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.pagination-list li.active a {
|
||||
background-color: var(--link-color);
|
||||
color: #fff;
|
||||
border-color: var(--link-color);
|
||||
.closed-contest-winner,
|
||||
.closed-contest-winner-img {
|
||||
width: 15vw;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
padding: 0 10px;
|
||||
font-size: 0.8em;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pagination-list li.disabled a {
|
||||
color: #ccc;
|
||||
cursor: not-allowed;
|
||||
.sitemap-group {
|
||||
margin-top: 2em;
|
||||
display: inline-block;
|
||||
width: 30vw;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.sitemap-group h2 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
|
@ -203,6 +280,7 @@ header {
|
|||
|
||||
.go_here_link {
|
||||
background-color: #4caf50;
|
||||
/* Green */
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 15px 32px;
|
||||
|
@ -221,6 +299,10 @@ header {
|
|||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.navbar-logo {
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.img-fluid {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
|
@ -240,17 +322,7 @@ iframe {
|
|||
color: #6c757d !important;
|
||||
}
|
||||
|
||||
.sitemap-group {
|
||||
margin-top: 2em;
|
||||
display: inline-block;
|
||||
width: 30vw;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.sitemap-group h2 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sitemap-group .card {
|
||||
background-color: var(--primary-bg);
|
||||
|
|
7
src/structables/static/dist/js/bootstrap.bundle.min.js
vendored
Normal file
7
src/structables/static/dist/js/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
src/structables/static/dist/js/bootstrap.bundle.min.js.map
vendored
Normal file
1
src/structables/static/dist/js/bootstrap.bundle.min.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
|
@ -1,38 +1,31 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<center>
|
||||
<h1>Past Contests</h1>
|
||||
<p>Total Contests: {{ pagination.total_pages * pagination.limit }}</p>
|
||||
<p><a href="/contest/">See running contests</a></p>
|
||||
</center>
|
||||
<div class="contest-list">
|
||||
{% for contest in contest_list %}
|
||||
<div class="contest-item">
|
||||
<hr>
|
||||
<h2>{{ contest.title }}</h2>
|
||||
<p>
|
||||
<a href="{{ contest.link }}">{{ contest.title }}</a><br>
|
||||
Start Date: {{ contest.startDate }}<br>
|
||||
Deadline: {{ contest.deadline }}<br>
|
||||
Entries: {{ contest.numEntries }}<br>
|
||||
Status: {{ contest.state }}<br>
|
||||
</p>
|
||||
<img src="{{ contest.bannerUrl }}" alt="{{ contest.title }} banner" style="max-width:100%;">
|
||||
<hr>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<hr>
|
||||
<div class="pagination">
|
||||
<ul class="pagination-list">
|
||||
{% if pagination.has_prev %}
|
||||
<li><a href="?page={{ pagination.current_page - 1 }}">Previous</a></li>
|
||||
{% endif %}
|
||||
<li>Page {{ pagination.current_page }} of {{ pagination.total_pages }}</li>
|
||||
{% if pagination.has_next %}
|
||||
<li><a href="?page={{ pagination.current_page + 1 }}">Next</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
<center>
|
||||
<h1>Past Contests</h1>
|
||||
<p>{{ contest_count }}</p>
|
||||
<p><a href="/contest/">See running contests</a></p>
|
||||
</center>
|
||||
{% for year in contest_list %}
|
||||
<div class="archive-year">
|
||||
<hr>
|
||||
<h2>{{ year[0] }}</h2>
|
||||
<hr>
|
||||
{% for month in year[1] %}
|
||||
<div class="archive-month-wrapper">
|
||||
<div class="archive-month">
|
||||
<h3>{{ month[0] }}</h3>
|
||||
{% for contest in month[1] %}
|
||||
<div class="archive">
|
||||
<p>{{ contest[0] }}<a href="{{ contest[1] }}">{{ contest[2] }}</a></p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<br>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<hr>
|
||||
{{ pagination|safe }}
|
||||
{% endblock %}
|
|
@ -1,23 +1,23 @@
|
|||
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<center>
|
||||
<h1>{{ title }}</h1>
|
||||
<img src="{{ img }}" alt="{{ title }}" style="max-width:98vw;">
|
||||
<p>{{ entry_count }} Entries, {{ prizes }} Prizes</p>
|
||||
<br>
|
||||
{{ info|safe }}
|
||||
<div class="ible-list">
|
||||
{% for ible in entry_list %}
|
||||
<div class="ible-list-item">
|
||||
<a href="{{ ible.link }}" style="color:#bbc2cf;">
|
||||
<img style="max-width:350px;" src="{{ ible.entry_img }}" alt="{{ ible.entry_title }}">
|
||||
<p>{{ ible.entry_title }}</p>
|
||||
</a>
|
||||
<p>by <a href="{{ ible.author_link }}">{{ ible.author }}</a> in <a href="{{ ible.channel_link }}">{{ ible.channel }}</a></p>
|
||||
<p>{{ ible.views }} Views</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</center>
|
||||
{% endblock %}
|
||||
<center>
|
||||
<img src="{{ img }}" alt="{{ title }}" style="max-width:98vw;">
|
||||
<p>{{ entry_count }} Entries, {{ prizes }} Prizes</p>
|
||||
<br>
|
||||
{{ info|safe }}
|
||||
<div class="ible-list">
|
||||
{% for ible in entries %}
|
||||
<div class="ible-list-item">
|
||||
<a href="{{ ible.link }}" style="color:#bbc2cf;">
|
||||
<img style="max-width:350px;" src="{{ ible.entry_img }}" alt="{{ ible.entry_title }}">
|
||||
<p>{{ ible.entry_title }}</p>
|
||||
</a>
|
||||
<p>by <a href="{{ ible.author_link }}">{{ ible.author }}</a> in <a href="{{ ible.channel_link }}">{{ ible.channel }}</a></p>
|
||||
<p>{{ ible.views }} Views</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</center>
|
||||
{% endblock %}
|
|
@ -1,21 +1,40 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<center>
|
||||
<h1>{{ title }}</h1>
|
||||
<p>Total Running Contests: {{ contest_count }}</p>
|
||||
<br>
|
||||
<div class="contest-list">
|
||||
{% for contest in contests %}
|
||||
<div class="contest-list-item">
|
||||
<a href="{{ contest.link }}">
|
||||
<img src="{{ contest.img }}" alt="{{ contest.alt }}">
|
||||
</a>
|
||||
<h2>{{ contest.title }}</h2>
|
||||
<p>Closes: {{ contest.deadline }}</p>
|
||||
<p class="ible-small">{{ contest.prizes }} Prizes, {{ contest.entries }} Entries</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</center>
|
||||
{% endblock %}
|
||||
<center>
|
||||
<h1>{{ title }}</h1>
|
||||
{{ contest_count|safe }}
|
||||
<br>
|
||||
<div class="contest-list">
|
||||
{% for contest in contests %}
|
||||
<div class="contest-list-item">
|
||||
<a href="{{ contest.link }}">
|
||||
<img src="{{ contest.img }}" alt="{{ contest.alt }}" style="max-width:500px;">
|
||||
</a>
|
||||
<p>Closes {{ contest.deadline }}</p>
|
||||
<p class="ible-small">{{ contest.prizes }} Prizes, {{ contest.entries }} Entries</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="closed-contests" id="contest-winners">
|
||||
<h2>Winner's Circle</h2>
|
||||
{% for closed in closed %}
|
||||
<div class="closed-contest">
|
||||
<a href="{{ closed.link }}"><img class="closed-contest-contest" src="{{ closed.img }}"
|
||||
alt="{{ closed.alt }}"></a>
|
||||
{% for featured_items in closed.featured_items %}
|
||||
<div class="closed-contest-winner">
|
||||
<a href="{{ featured_items.link }}">
|
||||
<img class="closed-contest-winner-img" src="{{ featured_items.img }}"
|
||||
alt="{{ featured_items.title}}">
|
||||
<br>
|
||||
<b>{{ featured_items.title }}</b>
|
||||
</a>
|
||||
<p>by <a href="{{ featured_items.author_link }}">{{ featured_items.author }}</a></p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</center>
|
||||
{% endblock %}
|
|
@ -4,7 +4,7 @@
|
|||
<center>
|
||||
<img width=150px height=150px style="display:inline-block;" src="{{ header_content.avatar }}" alt="{{ header_content.title }}">
|
||||
<h1>{{ header_content.title }}</h1>
|
||||
<span>{% if header_content.location %}{{ header_content.location }} {% endif %}</span>
|
||||
<span>{{ header_content.location }} </span>
|
||||
<span>{{ header_content.signup }}</span>
|
||||
<br>
|
||||
<span>{{ header_content.instructables }} Instructables </span>
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
<center>
|
||||
<img width=150px height=150px style="display:inline-block;" src="{{ header_content.avatar }}" alt="{{ header_content.title }}">
|
||||
<h1>{{ header_content.title }}</h1>
|
||||
<span>{% if header_content.location %}{{ header_content.location }} {% endif %}</span>
|
||||
<span>{{ header_content.location }} </span>
|
||||
<span>{{ header_content.signup }}</span>
|
||||
<br>
|
||||
<span>{{ header_content.instructables }} Instructables </span>
|
||||
|
|
Loading…
Reference in a new issue