Infomate 1.2 release

This commit is contained in:
vas3k
2020-09-05 16:39:57 +02:00
parent e7d623cd81
commit 8dc8313c98
35 changed files with 582 additions and 769 deletions

View File

@@ -19,3 +19,5 @@ ADD . /app
RUN pip install --no-cache-dir -e . \
&& pip install --no-cache-dir -r $requirements
RUN python -c "import nltk; nltk.download('punkt')"

View File

@@ -122,6 +122,10 @@ boards:
rss: http://feeds.feedburner.com/TechCrunch/
url: https://techcrunch.com
is_parsable: false # do not try to parse pages, show RSS content only
conditions:
- type: not_in
field: title
word: Trump # exclude articles with a word "Trump" in title
```
## Contributing

View File

View File

@@ -1,3 +0,0 @@
from django.contrib import admin
# Register your models here.

View File

@@ -1,5 +0,0 @@
from django.apps import AppConfig
class AuthConfig(AppConfig):
name = 'auth'

View File

@@ -1,7 +0,0 @@
from auth.helpers import authorized_user
def me(request):
return {
"me": authorized_user(request)
}

View File

@@ -1,20 +0,0 @@
from datetime import datetime
import jwt
from django.conf import settings
def authorized_user(request):
token = request.COOKIES.get(settings.AUTH_COOKIE_NAME)
if not token:
return None
try:
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
except (jwt.DecodeError, jwt.ExpiredSignatureError):
return None
if datetime.utcfromtimestamp(payload["exp"]) < datetime.utcnow():
return None
return payload

View File

@@ -1,29 +0,0 @@
# Generated by Django 2.2.8 on 2020-01-05 13:40
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Session',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('token', models.CharField(max_length=256, unique=True)),
('user_id', models.IntegerField()),
('user_name', models.CharField(max_length=32, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('expires_at', models.DateTimeField()),
],
options={
'db_table': 'sessions',
},
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 2.2.8 on 2020-01-07 20:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('auth', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='session',
name='token',
field=models.CharField(max_length=1024, unique=True),
),
]

View File

@@ -1,15 +0,0 @@
import uuid
from django.db import models
class Session(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
token = models.CharField(max_length=1024, unique=True)
user_id = models.IntegerField() # original id of a club user (we don't store profiles)
user_name = models.CharField(max_length=32, null=True)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
class Meta:
db_table = "sessions"

View File

@@ -1,55 +0,0 @@
import logging
from datetime import datetime
import jwt
from django.conf import settings
from django.shortcuts import redirect, render
from auth.models import Session
log = logging.getLogger()
def login(request):
return redirect(f"{settings.AUTH_REDIRECT_URL}?redirect={settings.APP_HOST}/auth/club_callback/")
def club_callback(request):
token = request.GET.get("jwt")
if not token:
return render(request, "message.html", {
"title": "Что-то пошло не так",
"message": "При авторизации потерялся токен. Попробуйте войти еще раз."
})
try:
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
except (jwt.DecodeError, jwt.ExpiredSignatureError) as ex:
log.error(f"JWT token error: {ex}")
return render(request, "message.html", {
"title": "Что-то сломалось",
"message": "Неправильный ключ. Наверное, что-то сломалось. Либо ты ХАКИР!!11"
})
Session.objects.get_or_create(
token=token[:1024],
defaults=dict(
user_id=payload["user_id"],
user_name=str(payload.get("user_name") or "")[:32],
expires_at=datetime.utcfromtimestamp(payload["exp"])
)
)
response = redirect("index")
response.set_cookie(settings.AUTH_COOKIE_NAME, token, max_age=settings.AUTH_COOKIE_MAX_AGE)
return response
def logout(request):
token = request.COOKIES.get(settings.AUTH_COOKIE_NAME)
if token:
Session.objects.filter(token=token).delete()
response = redirect("index")
response.delete_cookie(settings.AUTH_COOKIE_NAME)
return response

View File

@@ -4,7 +4,7 @@ boards:
is_visible: true
is_private: false
curator:
name: Главные новости
name: Новости
title: События в мире
avatar: https://i.vas3k.ru/63cd2ebddba4422aa684b2bd754c636eb061ef0555f542042c31850525d2f5bb.png
bio: Подборка основных новостных изданий, чтобы следить за событиями в России и в мире. Пока бета-версия. Предложения новых источников присылайте в личку
@@ -19,6 +19,7 @@ boards:
url: https://tass.ru/
rss: https://tass.ru/rss/v2.xml
icon: https://i.vas3k.ru/aca2f29518b01b25b3a40d63109d45dde74be15e47877aaa89553ff567b05151.png
is_parsable: false
- name: TJ
url: https://tjournal.ru
rss: https://tjournal.ru/rss/all
@@ -36,13 +37,14 @@ boards:
- name: Дождь
url: https://tvrain.ru/news/
rss: https://tvrain.ru/export/rss/all.xml
is_parsable: false
- name: Lenta.ru
url: https://lenta.ru/
rss: https://lenta.ru/rss
- name: BBC Россия
url: https://www.bbc.com/russian
rss: https://feeds.bbci.co.uk/russian/rss.xml
- name: Телеграм-каналы
- name: Телеграм
slug: tg
feeds:
- name: Varlamov News
@@ -64,34 +66,60 @@ boards:
url: https://t.me/rtvimain
rss: https://infomate.club/parsing/telegram/rtvimain?only=text
icon: https://i.vas3k.ru/de4d266b4744ca0cb8bc9ded3842989593fdd54ec78484c7c982b8951db08617.jpg
- name: Expresso
url: https://t.me/expressotoday
rss: https://infomate.club/parsing/telegram/expressotoday?only=text
icon: https://i.vas3k.ru/8ecf3ff7c82f89b0bfdde441e32082b063d7bf33e7b3b4b13a77dd35b38aa744.jpg
# - name: Expresso
# url: https://t.me/expressotoday
# rss: https://infomate.club/parsing/telegram/expressotoday?only=text
# icon: https://i.vas3k.ru/8ecf3ff7c82f89b0bfdde441e32082b063d7bf33e7b3b4b13a77dd35b38aa744.jpg
- name: США
slug: us
feeds:
- name: Reddit News
url: https://www.reddit.com/r/news/
rss: https://www.reddit.com/r/news.rss
- name: CNN
url: https://cnn.com/
rss: http://rss.cnn.com/rss/edition.rss
icon: https://i.vas3k.ru/d2d88f4a1d1646bf3a70f76d6b585c472ee1735abd73dd30719d7f2f42f5743a.png
- name: NYT
url: https://www.nytimes.com/
rss: https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml
- name: Axios
url: https://www.axios.com/
rss: https://api.axios.com/feed/
icon: https://i.vas3k.ru/17f55ad102b80a85c618d5e56c61f24c17c20d12f8c960a87902845154a5bdfc.jpg
- name: Reuters
url: https://www.reuters.com/news/world
rss: https://news.google.com/rss/search?q=when:24h+allinurl:reuters.com&ceid=US:en&hl=en-US&gl=US
- name: Bloomberg
url: https://www.bloomberg.com/
rss: http://www.bloomberg.com/politics/feeds/site.xml
icon: https://i.vas3k.ru/35c6ae6df0fe47166ed5c656bde6faa974ae1beca949c89443f0aed0b86e0806.png
- name: Reuters
url: https://www.reuters.com/news/world
rss: https://news.google.com/rss/search?q=when:24h+allinurl:reuters.com&ceid=US:en&hl=en-US&gl=US
- name: Reddit News
url: https://www.reddit.com/r/news/
rss: https://www.reddit.com/r/news.rss
is_parsable: false
- name: NYT
url: https://www.nytimes.com/
rss: https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml
is_parsable: false
- name: POLITICO
url: https://www.politico.com/
rss: https://www.politico.com/rss/politicopicks.xml
icon: https://i.vas3k.ru/0281ddd9b3bd890e1476666d5ea74688bc5fcf313500a5fc166127bc433b1287.jpg
is_parsable: false
- name: 🏳️‍🌈 Левые
slug: us_left
view: "blocks/two.html"
feeds:
- view: "feeds/favicons.html"
is_parsable: false
mix:
- http://rss.cnn.com/rss/edition.rss
- https://www.buzzfeed.com/index.xml
- https://www.huffpost.com/section/front-page/feed
- https://www.newyorker.com/feed/news
- https://www.msnbc.com/feed
- name: 💰 Правые
slug: us_right
view: "blocks/two.html"
feeds:
- view: "feeds/favicons.html"
is_parsable: false
mix:
- http://feeds.feedburner.com/foxnews/latest
- https://spectator.org/feed
- https://www.washingtontimes.com/rss/headlines/news/
- https://www.dailymail.co.uk/ushome/index.rss
- name: Европа
slug: eu
feeds:
@@ -127,7 +155,7 @@ boards:
is_private: false
curator:
name: Технологии
title: Основные СМИ
title: Главные новости
avatar: https://i.vas3k.ru/229b722cc79faca1f148c66b1e7240488e7405704f94b8d7f0fadddcf66212f0.jpg
bio: Подборка мейнстримовых новостей о технологиях на русском и английском языках
blocks:
@@ -208,12 +236,11 @@ boards:
- name: addmeto
url: https://t.me/addmeto
rss: https://infomate.club/parsing/telegram/addmeto
icon: https://i.vas3k.ru/cb1fe74c1a42fbe9d145c8538ed9230b7512633d06f680b96464fc4b355b23ef.jpg
- name: Rozetked
url: https://t.me/rozetked
rss: https://infomate.club/parsing/telegram/rozetked
- name: Rozetked
url: https://t.me/rozetked
rss: https://infomate.club/parsing/telegram/rozetked
icon: https://i.vas3k.ru/abe55f96279f22704cd1cc5009be2f6527c8d205f289dc1c7c328a03314f3d5d.jpg
- name: Мейнстрим
slug: mainstream
feeds:
@@ -274,6 +301,14 @@ boards:
url: https://www.reddit.com/r/technology
rss: https://www.reddit.com/r/technology.rss
is_parsable: false
- name: "Pinboard: Popular"
url: https://pinboard.in/popular/
rss: http://feeds.pinboard.in/rss/popular/
icon: https://i.vas3k.ru/adfe6b6f09b2be1398df020bdd8d1b8dade25b139c88c68c0177d26e5ae0bce0.jpg
conditions:
- type: not_in
field: title
word: Trump
- name: ZDNet
rss: https://www.zdnet.com/news/rss.xml
url: https://www.zdnet.com
@@ -284,100 +319,21 @@ boards:
- name: Slashdot
rss: http://rss.slashdot.org/Slashdot/slashdotMain
url: https://slashdot.org
icon: https://i.vas3k.ru/2da938e66d63ca719a8854ead38a09d17d6ab17725aaf15fa68f401aa937340e.png
- name: 'Medium: Technology'
icon: https://i.vas3k.ru/fhb.png
url: https://medium.com/topic/technology
rss: https://medium.com/feed/topic/technology
is_parsable: false
- name: Hacker Noon
url: https://hackernoon.com/
rss: https://hackernoon.com/feed
- name: Европейское айти
slug: eu
feeds:
- name: "EU-startups"
url: https://www.eu-startups.com/
rss: https://www.eu-startups.com/feed/
icon: https://i.vas3k.ru/fkp.jpg
- name: Tech.eu
url: https://tech.eu/
rss: https://tech.eu/feed/
icon: https://i.vas3k.ru/fl9.jpg
- name: "TechCrunch: Europe"
url: https://techcrunch.com/europe/
rss: http://feeds.feedburner.com/Techcrunch/europe
is_parsable: false
- name: Инди-разработка
slug: make
feeds:
- name: Show HN
url: https://news.ycombinator.com/show
rss: https://hnrss.org/show
- name: Starter Story
url: https://www.starterstory.com
rss: https://www.starterstory.com/feed?format=rss
- name: 'Reddit: /r/SideProject'
url: https://www.reddit.com/r/SideProject/
rss: https://www.reddit.com/r/SideProject.rss
is_parsable: false
- name: Путешествия
slug: travel
feeds:
- name: PeritoBurrito
url: https://perito-burrito.com
rss: http://perito-burrito.com/feed
- name: Vandrouki
url: https://vandrouki.ru
rss: https://feeds.feedburner.com/vandroukiru
icon: https://i.vas3k.ru/fer.jpg
- name: Secret Flying
url: https://www.secretflying.com
rss: https://www.secretflying.com/feed/
- name: 'Atlas Obscura: Stories'
url: https://www.atlasobscura.com/articles
rss: https://www.atlasobscura.com/feeds/latest
- name: "T—Ж"
url: https://journal.tinkoff.ru/chemodan/
rss: https://journal.tinkoff.ru/feed/
- name: Geeky Explorer
url: https://www.geekyexplorer.com
rss: https://www.geekyexplorer.com/feed/
- name: Микс из блогов крупных компаний
slug: engineering
feeds:
- url: http://www.rssmix.com/1/
rss: http://www.rssmix.com/u/10536474/rss.xml
columns: 3
mix:
- http://blog.stackoverflow.com/feed/
- https://machinelearning.apple.com/feed.xml
- http://nerds.airbnb.com/feed/
- http://www.ebaytechblog.com/feed/
- http://blog.agilebits.com/feed/
- http://research.microsoft.com/rss/news.xml
- http://blog.keras.io/feeds/all.atom.xml
- http://techblog.netflix.com/feeds/posts/default
- http://nickcraver.com/blog/feed.xml
- http://googlerussiablog.blogspot.com/atom.xml
- https://eng.uber.com/feed/
- http://web.mit.edu/newsoffice/topic/mitcomputers-rss.xml
- http://feeds.feedburner.com/corner-squareup-com
- http://googleresearch.blogspot.com/atom.xml
- http://githubengineering.com/atom.xml
- http://labs.spotify.com/feed/
- http://blog.kaggle.com/feed/
- http://clubs.ya.ru/company/rss/posts.xml
- name: Блоги людей
slug: people
feeds:
- url: http://www.rssmix.com/2/
rss: http://www.rssmix.com/u/10538572/rss.xml
columns: 3
- columns: 3
view: "feeds/favicons.html"
mix:
- http://nedbatchelder.com/blog/rss.xml
- http://rasskazov.pro/blog/?go=rss/
- http://vas3k.ru/rss/
- http://www.dmitriysivak.com/feed/
- http://nl.livejournal.com/data/rss
- http://sashavolkova.ru/rss/
- http://alexmak.net/blog/feed/
@@ -410,6 +366,81 @@ boards:
- http://feeds.feedburner.com/codinghorror/
- http://theoatmeal.com/feed/rss
- https://waitbutwhy.com/feed
- name: Инди-разработка
slug: make
feeds:
- name: Show HN
url: https://news.ycombinator.com/show
rss: https://hnrss.org/show
- name: Starter Story
url: https://www.starterstory.com
rss: https://www.starterstory.com/feed?format=rss
- name: 'Reddit: /r/SideProject'
url: https://www.reddit.com/r/SideProject/
rss: https://www.reddit.com/r/SideProject.rss
is_parsable: false
- name: Путешествия
slug: travel
feeds:
- name: PeritoBurrito
url: https://perito-burrito.com
rss: http://perito-burrito.com/feed
- name: Vandrouki
url: https://vandrouki.ru
rss: https://feeds.feedburner.com/vandroukiru
icon: https://i.vas3k.ru/fer.jpg
- name: Secret Flying
url: https://www.secretflying.com
rss: https://www.secretflying.com/feed/
- name: 'Atlas Obscura: Stories'
url: https://www.atlasobscura.com/articles
rss: https://www.atlasobscura.com/feeds/latest
icon: https://i.vas3k.ru/345139fb86cb52076134880d1b4ef700d6354c4cf4639ebdfaf1f9891115f7ad.jpg
- name: "T—Ж"
url: https://journal.tinkoff.ru/chemodan/
rss: https://journal.tinkoff.ru/feed/
- name: Geeky Explorer
url: https://www.geekyexplorer.com
rss: https://www.geekyexplorer.com/feed/
- name: Европейское айти
slug: eu
feeds:
- name: "EU-startups"
url: https://www.eu-startups.com/
rss: https://www.eu-startups.com/feed/
icon: https://i.vas3k.ru/fkp.jpg
- name: Tech.eu
url: https://tech.eu/
rss: https://tech.eu/feed/
icon: https://i.vas3k.ru/fl9.jpg
- name: "TechCrunch: Europe"
url: https://techcrunch.com/europe/
rss: http://feeds.feedburner.com/Techcrunch/europe
is_parsable: false
- name: Микс из блогов крупных компаний
slug: engineering
feeds:
- columns: 3
view: "feeds/favicons.html"
mix:
- http://blog.stackoverflow.com/feed/
- https://machinelearning.apple.com/feed.xml
- http://nerds.airbnb.com/feed/
- http://www.ebaytechblog.com/feed/
- http://blog.agilebits.com/feed/
- http://research.microsoft.com/rss/news.xml
- http://blog.keras.io/feeds/all.atom.xml
- http://techblog.netflix.com/feeds/posts/default
- http://nickcraver.com/blog/feed.xml
- http://googlerussiablog.blogspot.com/atom.xml
- https://eng.uber.com/feed/
- http://web.mit.edu/newsoffice/topic/mitcomputers-rss.xml
- http://feeds.feedburner.com/corner-squareup-com
- http://googleresearch.blogspot.com/atom.xml
- http://githubengineering.com/atom.xml
- http://labs.spotify.com/feed/
- http://blog.kaggle.com/feed/
- http://clubs.ya.ru/company/rss/posts.xml
- name: Фотография
slug: photo
feeds:
@@ -629,6 +660,7 @@ boards:
- name: "DeepMind"
url: https://www.deepmind.com/blog
rss: https://www.deepmind.com/blog/feed/basic/
icon: https://i.vas3k.ru/aff485d139d37ac7236f0bdf831812a2ef2419972ca3996c66885f229dccf7e2.jpg
- name: "Google"
url: https://ai.googleblog.com/
rss: http://rssmix.com/u/10966870/rss.xml
@@ -721,6 +753,7 @@ boards:
- name: "DeepMind: The Podcast"
url: https://deepmind.com/blog/article/welcome-to-the-deepmind-podcast
rss: https://feeds.simplecast.com/JT6pbPkg
icon: https://i.vas3k.ru/aff485d139d37ac7236f0bdf831812a2ef2419972ca3996c66885f229dccf7e2.jpg
- name: "Microsoft Research Podcast"
icon: https://i.vas3k.ru/i0t.png
url: https://www.microsoft.com/en-us/research/blog/category/podcast/
@@ -2140,4 +2173,3 @@ boards:
- name: "QA Mania (Украиноязычный канал)"
url: https://t.me/qamania
rss: https://infomate.club/parsing/telegram/qamania

View File

@@ -4,3 +4,25 @@ DOMAIN_ICONS = {
"reddit.com": "fa:fab fa-reddit-alien",
"github.com": "fa:fab fa-github",
}
DOMAIN_FAVICONS = {
"bbc.com": "https://i.vas3k.ru/635c5e5828a4868b73bdb777611084a3459873b628f3f7f9752a34e1516fc505.png",
"bbc.co.uk": "https://i.vas3k.ru/635c5e5828a4868b73bdb777611084a3459873b628f3f7f9752a34e1516fc505.png",
"cnn.com": "https://i.vas3k.ru/d2d88f4a1d1646bf3a70f76d6b585c472ee1735abd73dd30719d7f2f42f5743a.png",
"cnn.it": "https://i.vas3k.ru/d2d88f4a1d1646bf3a70f76d6b585c472ee1735abd73dd30719d7f2f42f5743a.png",
"foxnews.com": "https://i.vas3k.ru/46b5aabb4269c34ff22c90afeae1cf4b2fc64078efb0611dbddd53d336395ea4.png",
"vox.com": "https://i.vas3k.ru/1a7ab1f00077e12d840d236e4438db9cc47a21b2b25c9cedea68ea9b167bb7d3.png",
"buzzfeednews.com": "https://i.vas3k.ru/ebe634861320fccd85c2fe96a5c93efd2b6643c2e191e3c8ee4b2ac0fb5b5e1c.png",
"buzzfeed.com": "https://i.vas3k.ru/ebe634861320fccd85c2fe96a5c93efd2b6643c2e191e3c8ee4b2ac0fb5b5e1c.png",
"huffpost.com": "https://i.vas3k.ru/a357095a972b2846476d95fb045de8880fe6e16f77fb34f7e07cbb13f196cb0b.png",
"newyorker.com": "https://i.vas3k.ru/b807c76f8514e36216d3593d9585bfe0335ff1514a5913e475a97fcc52e1fe21.png",
"dailymail.co.uk": "https://i.vas3k.ru/16371b2d1711d17cae30aa943e337b1486442d8ea471b08e2f3bd29f14239a81.png",
"dailymail.com": "https://i.vas3k.ru/16371b2d1711d17cae30aa943e337b1486442d8ea471b08e2f3bd29f14239a81.png",
"washingtontimes.com": "https://i.vas3k.ru/6da045463763191ed01c6bced5321f47d10d6804d244baee335b7702efbd8bc0.png",
"msnbc.com": "https://i.vas3k.ru/1b87330e504abdd0ffa6767827f9a2a3475c72ecc1eb4c80e3cd73e99b5d348a.png",
"vas3k.ru": "https://i.vas3k.ru/90e7cad4728678bf11dd2379dbcbda94f485b92ccd229d492a1bf2da31441c7c.png",
"vas3k.com": "https://i.vas3k.ru/90e7cad4728678bf11dd2379dbcbda94f485b92ccd229d492a1bf2da31441c7c.png",
"maximilyahov.ru": "https://i.vas3k.ru/5a40caa8b7c7ebeab10516ebc7402151d4c98cd96704b9149d4869f6028bf642.jpg",
"skaplichniy.ru": "https://i.vas3k.ru/c0c5e651be84c1a84b6b819448605def65db8373607fea958bc53a27ea7d3902.jpg",
"mikeozornin.ru": "https://i.vas3k.ru/31344c5d6928534842caf0b0f173205556baaea370ee0966853260c4713004b1.png",
}

View File

@@ -0,0 +1,34 @@
# Generated by Django 2.2.13 on 2020-09-05 12:46
import django.contrib.postgres.fields.jsonb
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boards', '0008_boardfeed_is_parsable'),
]
operations = [
migrations.AddField(
model_name='boardblock',
name='view',
field=models.CharField(default='blocks/three.html', max_length=256),
),
migrations.AddField(
model_name='boardfeed',
name='mix',
field=django.contrib.postgres.fields.jsonb.JSONField(null=True),
),
migrations.AddField(
model_name='boardfeed',
name='view',
field=models.CharField(default='feeds/simple.html', max_length=256),
),
migrations.AlterField(
model_name='boardfeed',
name='url',
field=models.TextField(),
),
]

View File

@@ -6,7 +6,7 @@ from django.db import models
from django.contrib.postgres.fields import JSONField
from slugify import slugify
from boards.icons import DOMAIN_ICONS
from boards.icons import DOMAIN_ICONS, DOMAIN_FAVICONS
class Board(models.Model):
@@ -57,11 +57,15 @@ class Board(models.Model):
class BoardBlock(models.Model):
DEFAULT_VIEW = "blocks/three.html"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
board = models.ForeignKey(Board, related_name="blocks", on_delete=models.CASCADE, db_index=True)
name = models.CharField(max_length=512, null=True)
slug = models.SlugField()
view = models.CharField(max_length=256, default=DEFAULT_VIEW, null=False)
created_at = models.DateTimeField(db_index=True)
updated_at = models.DateTimeField()
@@ -71,6 +75,12 @@ class BoardBlock(models.Model):
db_table = "board_blocks"
ordering = ["index"]
@property
def template(self):
if self.view and self.view.endswith(".html"):
return self.view
return self.DEFAULT_VIEW
def save(self, *args, **kwargs):
if not self.created_at:
self.created_at = datetime.utcnow()
@@ -84,14 +94,19 @@ class BoardBlock(models.Model):
class BoardFeed(models.Model):
DEFAULT_VIEW = "feeds/simple.html"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
board = models.ForeignKey(Board, related_name="feeds", on_delete=models.CASCADE, db_index=True)
block = models.ForeignKey(BoardBlock, related_name="feeds", on_delete=models.CASCADE, db_index=True)
name = models.CharField(max_length=512, null=True)
comment = models.TextField(null=True)
url = models.URLField(max_length=512)
url = models.TextField()
icon = models.URLField(max_length=512, null=True)
rss = models.URLField(max_length=512, null=True)
mix = JSONField(null=True)
view = models.CharField(max_length=256, default=DEFAULT_VIEW, null=False)
created_at = models.DateTimeField(db_index=True)
last_article_at = models.DateTimeField(null=True)
@@ -109,6 +124,12 @@ class BoardFeed(models.Model):
db_table = "board_feeds"
ordering = ["index"]
@property
def template(self):
if self.view and self.view.endswith(".html"):
return self.view
return self.DEFAULT_VIEW
def save(self, *args, **kwargs):
if not self.created_at:
self.created_at = datetime.utcnow()
@@ -167,6 +188,13 @@ class Article(models.Model):
return f"""<img src="{article_icon}" alt="{self.domain}" class="icon"> """
def favicon(self):
if not self.domain:
return None
favicon_domain = self.domain.lower().replace("www.", "")
return DOMAIN_FAVICONS.get(favicon_domain)
def natural_created_at(self):
if not self.created_at:
return None

View File

@@ -6,7 +6,6 @@ from django.shortcuts import render, get_object_or_404
from django.views.decorators.cache import cache_page
from django.views.decorators.http import last_modified
from auth.helpers import authorized_user
from boards.cache import board_last_modified_at
from boards.models import Board, BoardBlock, BoardFeed
@@ -23,13 +22,6 @@ def index(request):
def board(request, board_slug):
board = get_object_or_404(Board, slug=board_slug)
if board.is_private:
me = authorized_user(request)
if not me:
return render(request, "board_no_access.html", {
"board": board
}, status=401)
cached_page = cache.get(f"board_{board.slug}")
if cached_page and board.refreshed_at and board.refreshed_at <= \
datetime.utcnow() - timedelta(seconds=settings.BOARD_CACHE_SECONDS):
@@ -46,15 +38,6 @@ def board(request, board_slug):
return result
@cache_page(settings.STATIC_PAGE_CACHE_SECONDS)
def export(request, board_slug):
board = get_object_or_404(Board, slug=board_slug)
return render(request, "export.html", {
"board": board,
})
@cache_page(settings.STATIC_PAGE_CACHE_SECONDS)
def what(request):
return render(request, "what.html")

View File

@@ -27,7 +27,7 @@ services:
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=infomate
ports:
- 5432
- "54321:5432"
migrate_and_init:
<<: *app

View File

@@ -16,7 +16,6 @@ INSTALLED_APPS = [
"django.contrib.staticfiles",
"django.contrib.humanize",
"django_bleach",
"auth",
"boards",
"parsing"
]
@@ -39,7 +38,6 @@ TEMPLATES = [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"boards.context_processors.settings_processor",
"auth.context_processors.me",
],
},
},

View File

@@ -1,8 +1,7 @@
from django.urls import path
from django.views.decorators.cache import cache_page
from auth.views import login, logout, club_callback
from boards.views import index, board, privacy_policy, what, export
from boards.views import index, board, privacy_policy, what
from infomate import settings
from parsing.views import TelegramChannelFeed
@@ -12,12 +11,7 @@ urlpatterns = [
path("docs/privacy_policy/", privacy_policy, name="privacy_policy"),
path("auth/login/", login, name="login"),
path("auth/club_callback/", club_callback, name="club_callback"),
path("auth/logout/", logout, name="logout"),
path("<slug:board_slug>/", board, name="board"),
path("<slug:board_slug>/export/", export, name="export"),
path("parsing/telegram/<str:channel>/",
cache_page(settings.TELEGRAM_CACHE_SECONDS)(TelegramChannelFeed()),

View File

@@ -8,7 +8,6 @@ beautifulsoup4==4.6.2
pyyaml==5.2
feedparser==5.2.1
sentry-sdk==0.14.1
pyjwt>=1.7.1
nltk==3.4.5
newspaper3k>=0.2.8
telethon==1.10.10

View File

@@ -1,8 +1,18 @@
import logging
import re
import socket
from datetime import datetime
from time import mktime
from urllib.parse import urlparse
from bs4 import BeautifulSoup
from requests import RequestException
from urllib3.exceptions import InsecureRequestWarning
import requests
log = logging.getLogger(__name__)
DEFAULT_REQUEST_HEADERS = {
"User-Agent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 "
@@ -13,3 +23,86 @@ MAX_PARSABLE_CONTENT_LENGTH = 15 * 1024 * 1024 # 15Mb
socket.setdefaulttimeout(DEFAULT_REQUEST_TIMEOUT)
requests.packages.urllib3.disable_warnings(category=InsecureRequestWarning)
def resolve_url(entry_link):
url = str(entry_link)
content_type = None
content_length = MAX_PARSABLE_CONTENT_LENGTH + 1 # don't parse null content-types
depth = 10
while depth > 0:
depth -= 1
try:
response = requests.head(url, timeout=DEFAULT_REQUEST_TIMEOUT, verify=False, stream=True)
except RequestException:
log.warning(f"Failed to resolve URL: {url}")
return None, content_type, content_length
if 300 < response.status_code < 400:
url = response.headers["location"] # follow redirect
else:
content_type = response.headers.get("content-type")
content_length = int(response.headers.get("content-length") or 0)
break
return url, content_type, content_length
def parse_domain(url):
domain = urlparse(url).netloc
if domain.startswith("www."):
domain = domain[4:]
return domain
def parse_datetime(entry):
published_time = entry.get("published_parsed") or entry.get("updated_parsed")
if published_time:
return datetime.fromtimestamp(mktime(published_time))
return datetime.utcnow()
def parse_title(entry):
title = entry.get("title") or entry.get("description") or entry.get("summary")
return re.sub("<[^<]+?>", "", title).strip()
def parse_link(entry):
if entry.get("link"):
return entry["link"]
if entry.get("links"):
return entry["links"][0]["href"]
return None
def parse_rss_image(entry):
if entry.get("media_content"):
images = [m["url"] for m in entry["media_content"] if m.get("medium") == "image" and m.get("url")]
if images:
return images[0]
if entry.get("image"):
if isinstance(entry["image"], dict):
return entry["image"].get("href")
return entry["image"]
return None
def parse_rss_text_and_image(entry):
if not entry.get("summary"):
return "", ""
bs = BeautifulSoup(entry["summary"], features="lxml")
text = re.sub(r"\s\s+", " ", bs.text or "").strip()
img_tags = bs.findAll("img")
for img_tag in img_tags:
src = img_tag.get("src", None)
if src:
return text, src
return text, ""

View File

@@ -11,12 +11,12 @@ from urllib.parse import urljoin
import click
import requests
import yaml
import feedparser
from bs4 import BeautifulSoup
from boards.models import Board, BoardFeed, BoardBlock
from boards.icons import DOMAIN_FAVICONS
from utils.images import upload_image_from_url
from scripts.common import DEFAULT_REQUEST_HEADERS
from scripts.common import DEFAULT_REQUEST_HEADERS, parse_domain
@click.command()
@@ -79,76 +79,84 @@ def initialize(config, board_slug, upload_favicons, always_yes):
slug=block_config["slug"],
defaults=dict(
name=block_name,
index=block_index
index=block_index,
view=block_config.get("view") or BoardBlock.DEFAULT_VIEW,
)
)
if not is_created:
block.index = block_index
block.name = block_name
block.view = block_config.get("view") or BoardBlock.DEFAULT_VIEW
block.save()
if not block_config.get("feeds"):
continue
updated_feed_urls = set()
for feed_index, feed_config in enumerate(block_config.get("feeds") or []):
feed_name = feed_config.get("name")
feed_url = feed_config["url"]
feed_mix = feed_config.get("mix")
if feed_mix:
feed_url = feed_config.get("url") or f"mix:{'|'.join(feed_mix)}"
feed_rss = None
else:
feed_url = feed_config["url"]
feed_rss = feed_config["rss"]
updated_feed_urls.add(feed_url)
print(f"Creating or updating feed: {feed_name}...")
print(f"Creating or updating feed {feed_name} ({feed_url})...")
feed, is_created = BoardFeed.objects.get_or_create(
board=board,
block=block,
url=feed_config["url"],
url=feed_url,
defaults=dict(
rss=feed_config.get("rss"),
rss=feed_rss,
mix=feed_mix,
name=feed_name,
comment=feed_config.get("comment"),
icon=feed_config.get("icon"),
index=feed_index,
columns=feed_config.get("columns") or 1,
conditions=feed_config.get("conditions"),
is_parsable=feed_config.get("is_parsable", True)
is_parsable=feed_config.get("is_parsable", True),
view=feed_config.get("view") or BoardFeed.DEFAULT_VIEW,
)
)
if not is_created:
feed.rss = feed_config.get("rss")
feed.rss = feed_rss
feed.mix = feed_mix
feed.name = feed_name
feed.comment = feed_config.get("comment")
feed.index = feed_index
feed.icon = feed.icon or feed_config.get("icon")
feed.columns = feed_config.get("columns") or 1
feed.conditions = feed_config.get("conditions")
feed.is_parsable = feed_config.get("is_parsable", True)
feed.view = feed_config.get("view") or BoardFeed.DEFAULT_VIEW
html = None
if not feed.rss:
html = html or load_page_html(feed_url)
rss_url = feed_config.get("rss")
if not rss_url:
rss_url = find_rss_feed(feed_url, html)
if not rss_url:
print(f"RSS feed for '{feed_name}' not found. "
f"Please specify 'rss' key.")
exit(1)
print(f"- found RSS: {rss_url}")
if not feed.mix:
if not feed.icon:
feed.icon = DOMAIN_FAVICONS.get(parse_domain(feed_url))
feed.rss = rss_url
if not feed.icon:
html = html or load_page_html(feed_url)
icon = feed_config.get("icon")
if not icon:
icon = find_favicon(feed_url, html)
print(f"- found favicon: {icon}")
if not feed.icon:
html = html or load_page_html(feed_url)
icon = feed_config.get("icon")
if not icon:
icon = find_favicon(feed_url, html)
print(f"- found favicon: {icon}")
if upload_favicons:
icon = upload_image_from_url(icon)
print(f"- uploaded favicon: {icon}")
if upload_favicons:
icon = upload_image_from_url(icon)
print(f"- uploaded favicon: {icon}")
feed.icon = icon
feed.icon = icon
feed.save()
@@ -157,7 +165,7 @@ def initialize(config, board_slug, upload_favicons, always_yes):
board=board,
block=block
).exclude(
url__in={feed["url"] for feed in block_config.get("feeds") or []}
url__in=updated_feed_urls
).delete()
# delete unused blocks
@@ -180,32 +188,32 @@ def load_page_html(url):
).text
def find_rss_feed(url, html):
bs = BeautifulSoup(html, features="lxml")
possible_feeds = set()
feed_urls = bs.findAll("link", rel="alternate")
for feed_url in feed_urls:
t = feed_url.get("type", None)
if t:
if "rss" in t or "xml" in t:
href = feed_url.get("href", None)
if href:
possible_feeds.add(urljoin(url, href))
a_tags = bs.findAll("a")
for a in a_tags:
href = a.get("href", None)
if href:
if "xml" in href or "rss" in href or "feed" in href:
possible_feeds.add(urljoin(url, href))
for feed_url in possible_feeds:
feed = feedparser.parse(feed_url)
if feed.entries:
return feed_url
return None
# def find_rss_feed(url, html):
# bs = BeautifulSoup(html, features="lxml")
# possible_feeds = set()
#
# feed_urls = bs.findAll("link", rel="alternate")
# for feed_url in feed_urls:
# t = feed_url.get("type", None)
# if t:
# if "rss" in t or "xml" in t:
# href = feed_url.get("href", None)
# if href:
# possible_feeds.add(urljoin(url, href))
#
# a_tags = bs.findAll("a")
# for a in a_tags:
# href = a.get("href", None)
# if href:
# if "xml" in href or "rss" in href or "feed" in href:
# possible_feeds.add(urljoin(url, href))
#
# for feed_url in possible_feeds:
# feed = feedparser.parse(feed_url)
# if feed.entries:
# return feed_url
#
# return None
def find_favicon(url, html):

View File

@@ -6,27 +6,25 @@ sys.path.append(os.path.join(os.path.dirname(__file__), ".."))
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "infomate.settings")
django.setup()
import re
import logging
from datetime import timedelta, datetime
from urllib.parse import urlparse
from time import mktime
import threading
import queue
import requests
import click
import feedparser
from bs4 import BeautifulSoup
from requests import RequestException
from newspaper import Article as NewspaperArticle, ArticleException
from boards.models import BoardFeed, Article, Board
from scripts.common import DEFAULT_REQUEST_HEADERS, DEFAULT_REQUEST_TIMEOUT, MAX_PARSABLE_CONTENT_LENGTH
from scripts.common import DEFAULT_REQUEST_HEADERS, DEFAULT_REQUEST_TIMEOUT, MAX_PARSABLE_CONTENT_LENGTH, resolve_url, \
parse_domain, parse_datetime, parse_title, parse_link, parse_rss_image, parse_rss_text_and_image
DEFAULT_NUM_WORKER_THREADS = 5
DEFAULT_ENTRIES_LIMIT = 100
DEFAULT_ENTRIES_LIMIT = 30
MIN_REFRESH_DELTA = timedelta(minutes=30)
DELETE_OLD_ARTICLES_DELTA = timedelta(days=300)
log = logging.getLogger()
queue = queue.Queue()
@@ -57,6 +55,7 @@ def update(num_workers, force, feed):
"board_id": feed.board_id,
"name": feed.name,
"rss": feed.rss,
"mix": feed.mix,
"conditions": feed.conditions,
"is_parsable": feed.is_parsable,
})
@@ -78,6 +77,9 @@ def update(num_workers, force, feed):
updated_boards = {feed.board_id for feed in need_to_update_feeds}
Board.objects.filter(id__in=updated_boards).update(refreshed_at=datetime.utcnow())
# remove old data
Article.objects.filter(created_at__lte=datetime.now() - DELETE_OLD_ARTICLES_DELTA).delete()
# stop workers
for i in range(num_workers):
queue.put(None)
@@ -103,7 +105,28 @@ def worker():
def refresh_feed(item):
print(f"Updating feed {item['name']}...")
feed = feedparser.parse(item['rss'])
if item["mix"]:
for rss in item["mix"]:
fetch_rss(item, rss)
else:
fetch_rss(item, item["rss"])
week_ago = datetime.utcnow() - timedelta(days=7)
frequency = Article.objects.filter(feed_id=item["id"], created_at__gte=week_ago).count()
last_article = Article.objects.filter(feed_id=item["id"]).order_by("-created_at").first()
BoardFeed.objects.filter(id=item["id"]).update(
refreshed_at=datetime.utcnow(),
last_article_at=last_article.created_at if last_article else None,
frequency=frequency or 0
)
def fetch_rss(item, rss):
print(f"Parsing RSS: {rss}")
feed = feedparser.parse(rss)
print(f"Entries found: {len(feed.entries)}")
for entry in feed.entries[:DEFAULT_ENTRIES_LIMIT]:
entry_title = parse_title(entry)
@@ -113,14 +136,14 @@ def refresh_feed(item):
continue
print(f"- article: '{entry_title}' {entry_link}")
conditions = item.get("conditions")
conditions = item.get("conditions")
if conditions:
is_valid = check_conditions(conditions, entry)
if not is_valid:
print(f"Condition {conditions} does not match. Skipped")
continue
article, is_created = Article.objects.get_or_create(
board_id=item["board_id"],
feed_id=item["id"],
@@ -171,16 +194,6 @@ def refresh_feed(item):
article.save()
week_ago = datetime.utcnow() - timedelta(days=7)
frequency = Article.objects.filter(feed_id=item["id"], created_at__gte=week_ago).count()
last_article = Article.objects.filter(feed_id=item["id"]).order_by("-created_at").first()
BoardFeed.objects.filter(id=item["id"]).update(
refreshed_at=datetime.utcnow(),
last_article_at=last_article.created_at if last_article else None,
frequency=frequency or 0
)
def check_conditions(conditions, entry):
if not conditions:
@@ -188,95 +201,16 @@ def check_conditions(conditions, entry):
for condition in conditions:
if condition["type"] == "in":
if condition["in"] not in entry[condition["field"]]:
if condition["word"] not in entry[condition["field"]]:
return False
if condition["type"] == "not_in":
if condition["word"] in entry[condition["field"]]:
return False
return True
def resolve_url(entry_link):
url = str(entry_link)
content_type = None
content_length = MAX_PARSABLE_CONTENT_LENGTH + 1 # don't parse null content-types
depth = 10
while depth > 0:
depth -= 1
try:
response = requests.head(url, timeout=DEFAULT_REQUEST_TIMEOUT, verify=False, stream=True)
except RequestException:
log.warning(f"Failed to resolve URL: {url}")
return None, content_type, content_length
if 300 < response.status_code < 400:
url = response.headers["location"] # follow redirect
else:
content_type = response.headers.get("content-type")
content_length = int(response.headers.get("content-length") or 0)
break
return url, content_type, content_length
def parse_domain(url):
domain = urlparse(url).netloc
if domain.startswith("www."):
domain = domain[4:]
return domain
def parse_datetime(entry):
published_time = entry.get("published_parsed") or entry.get("updated_parsed")
if published_time:
return datetime.fromtimestamp(mktime(published_time))
return datetime.utcnow()
def parse_title(entry):
title = entry.get("title") or entry.get("description") or entry.get("summary")
return re.sub("<[^<]+?>", "", title).strip()
def parse_link(entry):
if entry.get("link"):
return entry["link"]
if entry.get("links"):
return entry["links"][0]["href"]
return None
def parse_rss_image(entry):
if entry.get("media_content"):
images = [m["url"] for m in entry["media_content"] if m.get("medium") == "image" and m.get("url")]
if images:
return images[0]
if entry.get("image"):
if isinstance(entry["image"], dict):
return entry["image"].get("href")
return entry["image"]
return None
def parse_rss_text_and_image(entry):
if not entry.get("summary"):
return "", ""
bs = BeautifulSoup(entry["summary"], features="lxml")
text = re.sub(r"\s\s+", " ", bs.text or "").strip()
img_tags = bs.findAll("img")
for img_tag in img_tags:
src = img_tag.get("src", None)
if src:
return text, src
return text, ""
def load_page_safe(url):
try:
response = requests.get(

View File

@@ -120,7 +120,7 @@
.curator-bio {
font-size: 120%;
padding-top: 20px;
padding-top: 10px;
}
.curator-info a {
@@ -256,10 +256,19 @@
text-decoration: none;
}
.article-favicon {
max-width: 20px;
max-height: 20px;
padding-right: 4px;
display: inline-block;
vertical-align: middle;
}
.article-tooltip {
visibility: hidden;
transition: visibility 0.1s;
width: 100%;
max-width: 400px;
position: absolute;
bottom: 25px;
left: 0;

View File

@@ -46,7 +46,10 @@
}
.board {
display: block;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-start;
position: relative;
max-width: var(--max-content-width);
margin: 0 auto;
@@ -56,21 +59,51 @@
display: block;
}
.block-100 {
width: 100%;
}
.block-50 {
width: 49%;
}
@media only screen and (max-width : 570px) {
.block-100, .block-50 {
width: auto;
}
}
.feeds {
display: grid;
grid-template-columns: 33% 33% 33%;
grid-template-rows: auto auto;
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
align-items: flex-start;
}
.feed {
width: 100%;
box-sizing: border-box;
}
.feeds-3 .feed {
width: 33%;
}
.feeds-2 .feed {
width: 49%;
}
.feed .article-tooltip {
/* Shift tooltip to right */
left: 85%;
left: 250px;
top: -100px;
right: auto;
bottom: auto;
}
.feed:nth-child(3n) .article-tooltip {
.feeds-3 .feed:nth-child(3n) .article-tooltip,
.feeds-2 .feed:nth-child(2n) .article-tooltip {
/* Shift tooltip to left */
left: -105%;
top: -100px;

View File

@@ -0,0 +1,13 @@
<div class="block block-100">
{% if block.name %}
<div class="block-header">{{ block.name }}</div>
{% endif %}
<div class="feeds feeds-3">
{% for feed in feeds %}
{% if feed.block == block %}
{% include feed.template %}
{% endif %}
{% endfor %}
</div>
</div>

13
templates/blocks/two.html Normal file
View File

@@ -0,0 +1,13 @@
<div class="block block-50">
{% if block.name %}
<div class="block-header">{{ block.name }}</div>
{% endif %}
<div class="feeds">
{% for feed in feeds %}
{% if feed.block == block %}
{% include feed.template %}
{% endif %}
{% endfor %}
</div>
</div>

View File

@@ -1,7 +1,6 @@
{% extends "layout.html" %}
{% load text_filters %}
{% load static %}
{% load bleach_tags %}
{% block title %}{{ board.curator_name }}{% if board.curator_title %} | {{ board.curator_title }}{% endif %} | {{ block.super }}{% endblock %}
@@ -46,57 +45,7 @@
</div>
{% endif %}
{% for block in blocks %}
<div class="block">
{% if block.name %}
<div class="block-header">{{ block.name }}</div>
{% endif %}
<div class="feeds">
{% for feed in feeds %}
{% if feed.block == block %}
{% for column, articles in feed.articles_by_column %}
<div class="feed {% if column > 1 %}hide-on-iphone{% endif %}">
{% if feed.name %}
<div class="feed-title {% if column != 0 %}feed-title-hidden{% endif %}">
{% if feed.icon %}
<img src="{{ feed.icon }}" alt="{{ feed.name }}">
{% endif %}
<a href="{{ feed.url }}" target="_blank">{{ feed.name }}</a><br>
<small>последний пост {{ feed.natural_last_article_at }}</small>
</div>
{% endif %}
<div class="articles feed-articles">
{% for article in articles %}
<div class="article {% if article.is_fresh %}is-article-fresh{% endif %}">
<div class="article-title">{{ article.icon|safe }}<a href="{{ article.url }}" target="_blank" class="article-link">{{ article.title|bleach }}</a></div>
<a href="{{ article.url }}" class="article-tooltip" target="_blank">
{% if article.image %}
<img src="{{ article.image }}" alt="{{ article.title|striptags }}" class="article-tooltip-image">
{% endif %}
<span class="article-tooltip-title">{{ article.title|striptags|truncatechars:300 }}</span>
{% if article.description or article.summary %}
<span class="article-tooltip-description">
{% if feed.is_parsable and article.summary %}
{{ article.summary|striptags|truncatechars:700|escape|nl2p|safe }}
{% else %}
{{ article.description|striptags|truncatechars:700|escape|nl2p|safe }}
{% endif %}
</span>
{% endif %}
<span class="article-tooltip-info">{{ article.natural_created_at }} @ {{ article.domain }}</span>
</a>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% endif %}
{% endfor %}
</div>
</div>
{% include block.template %}
{% endfor %}
{% endblock %}
</div>
@@ -110,10 +59,6 @@
{% if board.natural_refreshed_at %}
<br><small>Обновлено {{ board.natural_refreshed_at }}</small>
{% endif %}
<div class="board-footer-export">
<a href="{% url "export" board.slug %}" class="button export-button">Экспортировать подборку себе</a>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -1,230 +0,0 @@
{% extends "board.html" %}
{% block board %}
<div class="block is-block-blurred">
<div class="blocker"></div>
<div class="block-header">Привет, друг! Тоже думал, что самый умный? Штош...</div>
<div class="feeds">
<div class="feed">
<div class="feed-title">
<img src="https://i.vas3k.ru/fhm.png" alt="twitter">
<a href="#" target="_blank">Twitter</a><br>
<small>последний недушный пост: никогда</small>
</div>
<div class="articles feed-articles">
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
</div>
</div>
<div class="feed">
<div class="feed-title">
<img src="https://i.vas3k.ru/fer.jpg" alt="twitter">
<a href="#" target="_blank">Facebook</a><br>
<small>верни мои данные!</small>
</div>
<div class="articles feed-articles">
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
</div>
</div>
<div class="feed">
<div class="feed-title">
<img src="https://i.vas3k.ru/fhc.png" alt="twitter">
<a href="#" target="_blank">Одноклассники</a><br>
<small>все скорее туда</small>
</div>
<div class="articles feed-articles">
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
</div>
</div>
<div class="feed">
<div class="feed-title">
<img src="https://i.vas3k.ru/fhf.png" alt="twitter">
<a href="#" target="_blank">Телеграм</a><br>
<small>чо, многому научились из каналов?</small>
</div>
<div class="articles feed-articles">
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
</div>
</div>
<div class="feed">
<div class="feed-title">
<img src="https://i.vas3k.ru/fhe.png" alt="twitter">
<a href="#" target="_blank">Вконтакте</a><br>
<small>верни стену</small>
</div>
<div class="articles feed-articles">
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
<div class="article">
<div class="article-title">Возьми с полки пирожок, хакир. Ты заслужил!</div>
</div>
</div>
</div>
</div>
</div>
<div class="block-login">
<div class="block-login-window">
<span class="block-login-title">Только для своих</span>
<span class="block-login-description">
Эта подборка доступна только членам <a href="https://vas3k.ru/club/" target="_blank">Вастрик.Клуба</a>.
Присоединяйтесь и заходите!
</span>
<a href="{% url "login" %}?redirect={{ settings.APP_HOST }}{% url "board" board.slug %}" class="button block-login-button">Войти по клубной карте</a>
</div>
</div>
{% endblock %}

View File

@@ -1,21 +0,0 @@
{% extends "layout.html" %}
{% load text_filters %}
{% load static %}
{% block title %}Экспорт | {{ board.curator_name }} | {{ board.curator_title }} | {{ block.super }}{% endblock %}
{% block content %}
<div class="export">
<div class="coming-soon">
<h2>Экспорт скоро будет!</h2>
<p>
Эта идея появилась совсем недавно и мы пока думаем как её сделать удобнее всего.
</p>
<p>
Подпишитесь на мой канал ⭐️ <a href="https://t.me/vas3k_channel">Вастрик.Пынь</a> чтобы узнать сразу когда он появится.
</p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% load static %}
{% load text_filters %}
{% load bleach_tags %}
{% for column, articles in feed.articles_by_column %}
<div class="feed {% if column > 1 %}hide-on-iphone{% endif %}">
{% if feed.name %}
<div class="feed-title {% if column != 0 %}feed-title-hidden{% endif %}">
{% if feed.icon %}
<img src="{{ feed.icon }}" alt="{{ feed.name }}">
{% endif %}
<a href="{{ feed.url }}" target="_blank">{{ feed.name }}</a><br>
<small>последний пост {{ feed.natural_last_article_at }}</small>
</div>
{% endif %}
<div class="articles feed-articles">
{% for article in articles %}
<div class="article {% if article.is_fresh %}is-article-fresh{% endif %}">
<div class="article-title">
{% if article.favicon %}<img src="{{ article.favicon }}" class="article-favicon" alt=">">{% endif %}<a href="{{ article.url }}" rel="noopener noreferrer nofollow" target="_blank" class="article-link">{{ article.title|bleach }}</a>
</div>
{% include "tooltips/simple.html" %}
</div>
{% endfor %}
</div>
</div>
{% endfor %}

View File

@@ -0,0 +1,27 @@
{% load static %}
{% load text_filters %}
{% load bleach_tags %}
{% for column, articles in feed.articles_by_column %}
<div class="feed {% if column > 1 %}hide-on-iphone{% endif %}">
{% if feed.name %}
<div class="feed-title {% if column != 0 %}feed-title-hidden{% endif %}">
{% if feed.icon %}
<img src="{{ feed.icon }}" alt="{{ feed.name }}">
{% endif %}
<a href="{{ feed.url }}" target="_blank">{{ feed.name }}</a><br>
<small>последний пост {{ feed.natural_last_article_at }}</small>
</div>
{% endif %}
<div class="articles feed-articles">
{% for article in articles %}
<div class="article {% if article.is_fresh %}is-article-fresh{% endif %}">
<div class="article-title">
{{ article.icon|safe }}<a href="{{ article.url }}" rel="noopener noreferrer nofollow" target="_blank" class="article-link">{{ article.title|bleach }}</a>
</div>
{% include "tooltips/simple.html" %}
</div>
{% endfor %}
</div>
</div>
{% endfor %}

View File

@@ -41,11 +41,8 @@
{% block footer %}
<div class="footer">
Сделал <a href="https://vas3k.ru">Вастрик</a>. Код проекта <a href="https://github.com/vas3k/infomate.club">открыт</a>.<br><br>
Сайт использует <a href="https://ru.wikipedia.org/wiki/Cookie" target="_blank">куки</a> для авторизации<br> и собирает <a href="{% url "privacy_policy" %}">анонимные данные</a> для статистики.
{% if me %}
<br><a href="{% url "logout" %}" class="button logout-button">Выйти</a>
{% endif %}
Проект <a href="https://vas3k.club">Вастрик.Клуба</a>. Код <a href="https://github.com/vas3k/infomate.club">открыт</a>.<br>
Идеи и предложения новых источников присылайте на гитхаб.<br><br>
</div>
{% endblock %}

View File

@@ -0,0 +1,21 @@
{% load text_filters %}
<a href="{{ article.url }}" class="article-tooltip" rel="noopener noreferrer nofollow" target="_blank">
{% if article.image %}
<img src="{{ article.image }}" alt="{{ article.title|striptags }}" class="article-tooltip-image">
{% endif %}
<span class="article-tooltip-title">{{ article.title|striptags|truncatechars:300 }}</span>
{% if article.description or article.summary %}
<span class="article-tooltip-description">
{% if feed.is_parsable and article.summary %}
{{ article.summary|striptags|truncatechars:700|escape|nl2p|safe }}
{% else %}
{{ article.description|striptags|truncatechars:700|escape|nl2p|safe }}
{% endif %}
</span>
{% endif %}
<span class="article-tooltip-info">{{ article.natural_created_at }} @ {{ article.domain }}</span>
</a>