├── .gitattributes ├── .github └── workflows │ └── build.yaml ├── Dockerfile ├── LICENSE ├── README.md ├── app ├── index.html ├── index.py ├── portable.py └── requirements.txt ├── docker-compose.yaml ├── requirements.txt └── screenshots ├── step-1.png ├── step-2.png ├── step-3.png └── step-4.gif /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | # Controls when the workflow will run 4 | on: 5 | workflow_dispatch: 6 | push: 7 | branches: 8 | - 'main' 9 | - 'dev' 10 | tags: 11 | - 'v*.*.*' 12 | pull_request: 13 | branches: 14 | - 'main' 15 | - 'dev' 16 | 17 | # permissions are needed if pushing to ghcr.io 18 | permissions: 19 | packages: write 20 | 21 | jobs: 22 | build: 23 | name: "Build and release" 24 | runs-on: ubuntu-latest 25 | steps: 26 | # Get the repository's code 27 | - name: ⬇️ checkout 28 | uses: actions/checkout@v2 29 | 30 | # https://github.com/docker/setup-qemu-action 31 | - name: ⚙ set up qemu 32 | uses: docker/setup-qemu-action@v1 33 | 34 | # https://github.com/docker/setup-buildx-action 35 | - name: ⚙ set up docker buildx 36 | id: buildx 37 | uses: docker/setup-buildx-action@v1 38 | 39 | - name: 👤 login to ghcr 40 | if: github.event_name != 'pull_request' 41 | uses: docker/login-action@v1 42 | with: 43 | registry: ghcr.io 44 | username: ${{ github.repository_owner }} 45 | password: ${{ secrets.GITHUB_TOKEN }} 46 | 47 | - name: 🏷 docker meta 48 | id: docker_meta # you'll use this in the next step 49 | uses: docker/metadata-action@v3 50 | with: 51 | # list of Docker images to use as base name for tags 52 | images: | 53 | ghcr.io/${{ github.repository_owner }}/13ft 54 | # Docker tags based on the following events/attributes 55 | tags: | 56 | type=schedule 57 | type=ref,event=branch 58 | type=ref,event=pr 59 | type=semver,pattern={{version}} 60 | type=semver,pattern={{major}}.{{minor}} 61 | type=semver,pattern={{major}} 62 | type=sha 63 | 64 | - name: 📦 build and ⬆ push 65 | uses: docker/build-push-action@v2 66 | with: 67 | context: . 68 | platforms: linux/amd64,linux/arm/v7,linux/arm64 69 | push: ${{ github.event_name != 'pull_request' }} 70 | tags: ${{ steps.docker_meta.outputs.tags }} 71 | labels: ${{ steps.docker_meta.outputs.labels }} 72 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9.18-alpine 2 | 3 | # Generic labels 4 | LABEL maintainer="Arian Mollik Wasi " 5 | LABEL version="0.3.4" 6 | LABEL description="My own custom 12ft.io replacement" 7 | LABEL url="https://github.com/wasi-master/13ft/" 8 | LABEL documentation="https://github.com/wasi-master/13ft/blob/main/README.md" 9 | 10 | # OCI compliant labels 11 | LABEL org.opencontainers.image.source="https://github.com/wasi-master/13ft" 12 | LABEL org.opencontainers.image.authors="Arian Mollik Wasi" 13 | LABEL org.opencontainers.image.created="2023-10-31T22:53:00Z" 14 | LABEL org.opencontainers.image.version="0.3.4" 15 | LABEL org.opencontainers.image.url="https://github.com/wasi-master/13ft/" 16 | LABEL org.opencontainers.image.source="https://github.com/wasi-master/13ft/" 17 | LABEL org.opencontainers.image.description="My own custom 12ft.io replacement" 18 | LABEL org.opencontainers.image.documentation="https://github.com/wasi-master/13ft/blob/main/README.md" 19 | LABEL org.opencontainers.image.licenses=MIT 20 | 21 | COPY . . 22 | RUN pip install -r requirements.txt 23 | WORKDIR /app 24 | EXPOSE 5000 25 | ENTRYPOINT [ "python" ] 26 | CMD [ "portable.py" ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Wasi Master 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 13 Feet Ladder 2 | 3 | A site similar to [12ft.io](https://12ft.io) but is self hosted and works with websites that 12ft.io doesn't work with. 4 | 5 | ## What is this? 6 | 7 | This is a simple self hosted server that has a simple but powerful interface to block ads, paywalls, and other nonsense. Specially for sites like medium, new york times which have paid articles that you normally cannot read. Now I do want you to support the creators you benefit from but if you just wanna see one single article and move on with your day then this might be helpful 8 | 9 | ## How does it work? 10 | 11 | It pretends to be GoogleBot (Google's web crawler) and gets the same content that google will get. Google gets the whole page so that the content of the article can be indexed properly and this takes advantage of that. 12 | 13 | ## How do I use it? 14 | 15 | ### Using Docker 16 | 17 | Requirements: 18 | - docker 19 | - Docker Compose (available as `docker compose`) 20 | 21 | First, clone the repo to your machine then run the following commands: 22 | 23 | ```sh 24 | git clone https://github.com/wasi-master/13ft.git 25 | cd 13ft 26 | docker compose up 27 | ``` 28 | 29 | The image is also available from [DockerHub](https://hub.docker.com/r/wasimaster/13ft "docker pull wasimaster/13ft") or [ghcr.io](https://github.com/wasi-master/13ft/pkgs/container/13ft "docker pull ghcr.io/wasi-master/13ft:0.2.3") so the command `docker pull wasimaster/13ft` also works. 30 | 31 | ### Standard Python script 32 | 33 | First, make sure you have [python](https://python.org) installed on your machine. Next, clone the git repo. Then go to a terminal (`Command Prompt` on Windows, `Terminal` on Mac) and run the following command: 34 | 35 | From the git cloned directory on your computer: 36 | 37 | ```sh 38 | cd app/ 39 | python -m pip install -r requirements.txt 40 | ``` 41 | 42 | If that doesn't work retry but replace `python` with `py`, then try `python3`, then try `py3` 43 | 44 | Then run `portable.py`, click [this link](https://realpython.com/run-python-scripts/) for a tutorial on how to run python scripts. 45 | 46 | ```sh 47 | python portable.py 48 | ``` 49 | 50 | Then open the link shown in the terminal in the browser and you'll be able to use this 51 | 52 | ### Installation using venv and running under specific bind address / port 53 | 54 | ```sh 55 | python3 -m venv venv 56 | source venv/bin/activate 57 | python -m pip install -r requirements.txt 58 | FLASK_APP=app/portable.py flask run --host=127.0.0.1 --port=9982 59 | ``` 60 | 61 | 62 | ## Using as a Bookmarklet in Chrome: 63 | 64 | You can create a bookmarklet that performs the URL transformation by writing a small JavaScript snippet. Below is the JavaScript code for your bookmarklet: 65 | ```javascript 66 | javascript:(function(){window.location.href='https://13ft.wasimaster.me/'+encodeURIComponent(window.location.href);})(); 67 | ``` 68 | You can replace https://13ft.wasimaster.me with your own 13ft instance if desired. 69 | 70 | Steps: 71 | 1. Open Bookmarks Manager: 72 | 73 | 2. Click on the three dots (menu) in the top-right corner of Chrome. 74 | Go to Bookmarks > Bookmark manager, or simply press Ctrl+Shift+O on Windows/Linux or Cmd+Option+B on Mac. 75 | Create a New Bookmark: 76 | 77 | 3. In the Bookmark Manager, click the three-dot menu in the top-right corner of the window and select Add new bookmark. 78 | Enter Bookmark Details: 79 | - Name: Enter a name for your bookmarklet, such as "13ft-ize". This name will show as a bookmark title in the bookmarks bar 80 | - URL: Paste the JavaScript code provided above into the URL field. 81 | 4. Click Save. 82 | 83 | Using the Bookmarklet: 84 | 85 | Navigate to the page whose URL you want to use 13ft on. 86 | 87 | Click on the bookmarklet you saved in your bookmarks bar. The browser will redirect you to the 13ft version of the URL using your service. 88 | 89 | To show Bookmarks in Chrome, click the icon with three horizontal bars in the top right corner to open options. 2. In options, hover over "Bookmarks" to display a second menu where you can click the "Show bookmarks bar" text to toggle the bar on or off. 90 | 91 | Instructions courtesy of [@barakplasma](https://github.com/barakplasma) 92 | 93 | ## Customizing listening host and port, Systemd / Reverse-proxy example 94 | 95 | ### Systemd Service 96 | 97 | ``` 98 | /lib/systemd/system/13ft.service 99 | ``` 100 | 101 | ``` 102 | [Unit] 103 | Description=13ft Flask Service 104 | Wants=network-online.target 105 | After=network-online.target 106 | 107 | [Service] 108 | Type=simple 109 | Restart=on-failure 110 | RestartSec=10 111 | User=www-data 112 | Group=www-data 113 | Environment=APP_PATH=/var/www/paywall-break 114 | Environment=FLASK_APP=app/portable.py 115 | 116 | ExecStart=/bin/bash -c "cd ${APP_PATH};${APP_PATH}/venv/bin/flask run --host=127.0.0.1 --port=22113" 117 | 118 | # Make sure stderr/stdout is captured in the systemd journal. 119 | StandardOutput=journal 120 | StandardError=journal 121 | 122 | [Install] 123 | WantedBy=multi-user.target 124 | ``` 125 | 126 | ### Reverse Proxy 127 | 128 | ``` 129 | 130 | ErrorLog ${APACHE_LOG_DIR}/13ft-error.log 131 | CustomLog ${APACHE_LOG_DIR}/13ft-access.log combined 132 | 133 | ProxyRequests Off 134 | 135 | SSLEngine on 136 | SSLCertificateFile /etc/ssl/certs/ssl-cert-snakeoil.pem 137 | SSLCertificateKeyFile /etc/ssl/private/ssl-cert-snakeoil.key 138 | Header always set Strict-Transport-Security "max-age=63072000" 139 | SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1 140 | 141 | SSLHonorCipherOrder off 142 | SSLSessionTickets off 143 | 144 | Protocols h2 http/1.1 145 | 146 | 147 | Order deny,allow 148 | Allow from all 149 | 150 | 151 | 152 | ProxyPass / http://127.0.0.1:22113/ 153 | ProxyPassReverse / http://127.0.0.1:22113/ 154 | 155 | 156 | 157 | ``` 158 | 159 | ## Screenshots 160 | 161 | ### Step 1 162 | 163 | ![step 1 screenshot](screenshots/step-1.png) 164 | Go to the website at the url shown in the console 165 | 166 | ### Step 2 167 | 168 | ![step 2 screenshot](screenshots/step-2.png) 169 | Click on the input box 170 | 171 | ### Step 3 172 | 173 | ![step 3 screenshot](screenshots/step-3.png) 174 | Paste your desired url 175 | 176 | ### Step 4 177 | 178 | ![step 4 screenshot](screenshots/step-4.gif) 179 | Voilà you now have bypassed the paywall and ads 180 | 181 | ### Alternative method 182 | 183 | You can also append the url at the end of the link and it will also work. (e.g if your server is running at `http://127.0.0.1:5000` then you can go to `http://127.0.0.1:5000/https://example.com` and it will read out the contents of `https://example.com`) 184 | 185 | This feature was implemented by [@atcasanova](https://github.com/atcasanova) 186 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 13ft Ladder 9 | 10 | 142 | 143 | 144 | 145 |
146 | 147 | 148 |
149 |
150 |

Enter Website Link

151 | 152 | 153 | 154 |
155 | 156 | 175 | 176 | 177 | -------------------------------------------------------------------------------- /app/index.py: -------------------------------------------------------------------------------- 1 | import flask 2 | import requests 3 | from flask import request 4 | from bs4 import BeautifulSoup 5 | from urllib.parse import urlparse, urljoin 6 | 7 | app = flask.Flask(__name__) 8 | googlebot_headers = { 9 | "User-Agent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.119 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" 10 | } 11 | 12 | def add_base_tag(html_content, original_url): 13 | soup = BeautifulSoup(html_content, 'html.parser') 14 | parsed_url = urlparse(original_url) 15 | base_url = f"{parsed_url.scheme}://{parsed_url.netloc}/" 16 | 17 | # Handle paths that are not root, e.g., "https://x.com/some/path/w.html" 18 | if parsed_url.path and not parsed_url.path.endswith('/'): 19 | base_url = urljoin(base_url, parsed_url.path.rsplit('/', 1)[0] + '/') 20 | base_tag = soup.find('base') 21 | 22 | print(base_url) 23 | if not base_tag: 24 | new_base_tag = soup.new_tag('base', href=base_url) 25 | if soup.head: 26 | soup.head.insert(0, new_base_tag) 27 | else: 28 | head_tag = soup.new_tag('head') 29 | head_tag.insert(0, new_base_tag) 30 | soup.insert(0, head_tag) 31 | 32 | return str(soup) 33 | 34 | def bypass_paywall(url): 35 | """ 36 | Bypass paywall for a given url 37 | """ 38 | if url.startswith("http"): 39 | response = requests.get(url, headers=googlebot_headers) 40 | response.encoding = response.apparent_encoding 41 | return add_base_tag(response.text, response.url) 42 | 43 | try: 44 | return bypass_paywall("https://" + url) 45 | except requests.exceptions.RequestException as e: 46 | return bypass_paywall("http://" + url) 47 | 48 | 49 | @app.route("/") 50 | def main_page(): 51 | return flask.send_from_directory(".", "index.html") 52 | 53 | 54 | @app.route("/article", methods=["POST"]) 55 | def show_article(): 56 | link = flask.request.form["link"] 57 | try: 58 | return bypass_paywall(link) 59 | except requests.exceptions.RequestException as e: 60 | return str(e), 400 61 | except Exception as exc: 62 | raise exc 63 | 64 | 65 | @app.route("/", defaults={"path": ""}) 66 | @app.route("/", methods=["GET"]) 67 | def get_article(path): 68 | full_url = request.url 69 | parts = full_url.split("/", 4) 70 | if len(parts) >= 5: 71 | actual_url = "https://" + parts[4].lstrip("/") 72 | try: 73 | return bypass_paywall(actual_url) 74 | except requests.exceptions.RequestException as e: 75 | return str(e), 400 76 | except e: 77 | raise e 78 | else: 79 | return "Invalid URL", 400 80 | 81 | 82 | app.run(debug=False) 83 | -------------------------------------------------------------------------------- /app/portable.py: -------------------------------------------------------------------------------- 1 | import flask 2 | import requests 3 | from flask import request 4 | from bs4 import BeautifulSoup 5 | from urllib.parse import urlparse, urljoin 6 | 7 | app = flask.Flask(__name__) 8 | googlebot_headers = { 9 | "User-Agent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.119 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" 10 | } 11 | html = """ 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 13ft Ladder 20 | 21 | 152 | 153 | 154 | 155 |
156 | 157 | 158 |
159 |
160 |

Enter Website Link

161 | 162 | 163 | 164 |
165 | 166 | 185 | 186 | 187 | 188 | """ 189 | 190 | def add_base_tag(html_content, original_url): 191 | soup = BeautifulSoup(html_content, 'html.parser') 192 | parsed_url = urlparse(original_url) 193 | base_url = f"{parsed_url.scheme}://{parsed_url.netloc}/" 194 | 195 | # Handle paths that are not root, e.g., "https://x.com/some/path/w.html" 196 | if parsed_url.path and not parsed_url.path.endswith('/'): 197 | base_url = urljoin(base_url, parsed_url.path.rsplit('/', 1)[0] + '/') 198 | base_tag = soup.find('base') 199 | 200 | print(base_url) 201 | if not base_tag: 202 | new_base_tag = soup.new_tag('base', href=base_url) 203 | if soup.head: 204 | soup.head.insert(0, new_base_tag) 205 | else: 206 | head_tag = soup.new_tag('head') 207 | head_tag.insert(0, new_base_tag) 208 | soup.insert(0, head_tag) 209 | 210 | return str(soup) 211 | 212 | def bypass_paywall(url): 213 | """ 214 | Bypass paywall for a given url 215 | """ 216 | if url.startswith("http"): 217 | response = requests.get(url, headers=googlebot_headers) 218 | response.encoding = response.apparent_encoding 219 | return add_base_tag(response.text, response.url) 220 | 221 | try: 222 | return bypass_paywall("https://" + url) 223 | except requests.exceptions.RequestException as e: 224 | return bypass_paywall("http://" + url) 225 | 226 | 227 | @app.route("/") 228 | def main_page(): 229 | return html 230 | 231 | 232 | @app.route("/article", methods=["POST"]) 233 | def show_article(): 234 | link = flask.request.form["link"] 235 | try: 236 | return bypass_paywall(link) 237 | except requests.exceptions.RequestException as e: 238 | return str(e), 400 239 | except e: 240 | raise e 241 | 242 | 243 | @app.route("/", defaults={"path": ""}) 244 | @app.route("/", methods=["GET"]) 245 | def get_article(path): 246 | full_url = request.url 247 | parts = full_url.split("/", 4) 248 | if len(parts) >= 5: 249 | actual_url = "https://" + parts[4].lstrip("/") 250 | try: 251 | return bypass_paywall(actual_url) 252 | except requests.exceptions.RequestException as e: 253 | return str(e), 400 254 | except e: 255 | raise e 256 | else: 257 | return "Invalid URL", 400 258 | 259 | 260 | app.run(host="0.0.0.0", port=5000, debug=False) 261 | -------------------------------------------------------------------------------- /app/requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | requests 3 | bs4 4 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | 13ft: 3 | container_name: 13ft 4 | hostname: 13ft 5 | image: ghcr.io/wasi-master/13ft:latest 6 | restart: unless-stopped 7 | ports: 8 | - "5000:5000" 9 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | requests 3 | bs4 4 | -------------------------------------------------------------------------------- /screenshots/step-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wasi-master/13ft/9e85fc60b960afc88805066af8259200409a37b5/screenshots/step-1.png -------------------------------------------------------------------------------- /screenshots/step-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wasi-master/13ft/9e85fc60b960afc88805066af8259200409a37b5/screenshots/step-2.png -------------------------------------------------------------------------------- /screenshots/step-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wasi-master/13ft/9e85fc60b960afc88805066af8259200409a37b5/screenshots/step-3.png -------------------------------------------------------------------------------- /screenshots/step-4.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wasi-master/13ft/9e85fc60b960afc88805066af8259200409a37b5/screenshots/step-4.gif --------------------------------------------------------------------------------