Compare commits

..

69 Commits

Author SHA1 Message Date
tanner b252c6a207 Make noscript background white 2020-06-22 20:52:51 +00:00
tanner 02b73a8b14 Fix cache load race condition bug 2020-01-28 04:20:48 +00:00
tanner 72f1043952 Remove preload of news source icons 2020-01-28 04:20:29 +00:00
tanner 7b31fcf690 Remove keys of uncached stories 2020-01-28 04:20:05 +00:00
tanner b3d2eeb67f Fix tildes deleted comment parser error 2020-01-28 04:19:26 +00:00
tanner 9078b567f0 Add del tag and sort tags 2020-01-04 23:37:41 +00:00
tanner ced20390eb Fix back/forward scroll jump issue 2020-01-04 23:36:24 +00:00
tanner 6cd41f0902 Add forward button, convert icons to font 2020-01-03 03:45:56 +00:00
tanner 746932ab96 Add style changes to prevent horizontal scrolling 2019-12-22 21:43:33 +00:00
tanner 2822974b6e Stop using archive.is on articles (hits CAPTCHAs) 2019-12-15 22:47:33 +00:00
tanner 8fd7fc158c Fix search result icons 2019-12-14 07:39:25 +00:00
tanner 17ef7e3a65 Whitelist more html tags 2019-12-14 07:39:10 +00:00
tanner 3363ccd47e Embed base64 logo directly in source to avoid load 2019-12-02 23:54:02 +00:00
tanner 2d80b19414 Grab comments on manually submitted links 2019-12-02 23:15:51 +00:00
tanner ebcbf1b624 Sanitize html 2019-12-01 22:18:41 +00:00
tanner e231cd5c31 Decrease feed cache length to 150 2019-12-01 22:18:14 +00:00
tanner 569e5b16ca Add logo for manual submissions 2019-11-14 08:38:11 +00:00
tanner db5097ac57 Drop articles more than two days old 2019-11-08 21:50:33 +00:00
tanner 2edb3ceba7 Allow manual submission of articles 2019-11-08 05:55:30 +00:00
tanner 38b5f2dbeb Move to gevent production http server 2019-11-08 02:37:57 +00:00
tanner 6826f731c7 Handle hostnames better 2019-11-07 22:10:08 +00:00
tanner bb693ba434 Add subreddit 2019-11-07 22:09:45 +00:00
tanner 632b0276c4 Abort previous search requests 2019-11-07 22:08:28 +00:00
tanner 4cf97304e4 Get rid of lint warnings 2019-10-22 07:31:59 +00:00
tanner 9e55f6e4ec Fix Tildes down for maintenance edge case 2019-10-22 05:01:30 +00:00
tanner edc4c439d7 Prefetch first images 2019-10-19 07:33:06 +00:00
tanner 187c6b8110 Cache articles in memory for speed 2019-10-18 21:26:22 +00:00
tanner 6764bf0d6d Add serviceworker, render logos directly 2019-10-18 05:09:49 +00:00
tanner dc588fee91 Fix underlines 2019-10-18 01:20:38 +00:00
tanner f8998b687e Fix crash from domain and ext check bug 2019-10-16 08:56:31 +00:00
tanner e4f81472fc Fix copy/paste error, switch to info logging 2019-10-16 05:26:47 +00:00
tanner f293f2b5f9 Begin README and add license 2019-10-15 16:40:55 -06:00
tanner 810e8c5ead Archive WSJ articles first, catch KeyboardInterrupt 2019-10-15 21:03:47 +00:00
tanner 9c4766a928 Stop using python keyword id for id 2019-10-15 20:36:20 +00:00
tanner 0f5b2a5ff9 Cache all articles in IndexedDB 2019-10-12 23:41:31 +00:00
tanner 7cb87b59fe Move archive to Whoosh and add search 2019-10-12 05:32:17 +00:00
tanner 45b75b420b Gitkeep archive directory 2019-10-10 21:55:21 +00:00
tanner f0721519e1 Serve client through apiserver, adding meta info 2019-10-10 21:54:29 +00:00
tanner 25a671f58e Set title on article and comment pages, add comment anchors 2019-10-10 21:52:28 +00:00
tanner 5fd4fdb21c Fix Tildes comments with unknown authors 2019-10-08 08:01:17 +00:00
tanner 19e9a80be1 Archive Bloomberg articles first 2019-10-08 08:00:50 +00:00
tanner 5caa4542d8 Gitkeep apiserver data directory 2019-10-08 07:59:30 +00:00
tanner 1ed2baded6 Add huge margin to bottom of body for better pagescroll 2019-09-24 18:40:22 +00:00
tanner c7734eb2bc Add site logos, keep displaying news on error 2019-09-24 08:23:14 +00:00
tanner 0053147226 Ignore certain files and domains, remove refs 2019-09-24 08:22:06 +00:00
tanner 0496fbba45 Ignore new Tildes posts and handle deleted ones 2019-09-24 08:21:26 +00:00
tanner 0a1ebaa8b8 Handle Reddit PRAW exceptions 2019-09-24 08:20:46 +00:00
tanner 2ede5ed6ff Filter out False comments 2019-08-30 06:23:14 +00:00
tanner 20a9d9d452 Settle on serif font, add scroll to top component 2019-08-30 06:22:26 +00:00
tanner 23cdbc9292 Render reddit markdown, poll tildes better, add utils 2019-08-28 04:13:02 +00:00
tanner 10d4ec863e Snip deeply nested comments 2019-08-26 01:37:50 +00:00
tanner fc8ce79e33 Try outline.com for reader mode first 2019-08-25 23:49:08 +00:00
tanner 8eca354a47 Add favicons to webclient 2019-08-25 23:48:24 +00:00
tanner b1275d9a27 Add a button to toggle between article and comments 2019-08-25 08:50:49 +00:00
tanner 9336760ed3 Add fonts, fix styling issues 2019-08-25 07:46:58 +00:00
tanner cf9e197e6c Fix tildes comments parsing bug 2019-08-25 07:46:22 +00:00
tanner 2b1a352917 Clear localstorage cache and add slogan 2019-08-25 01:25:28 +00:00
tanner 1b6c8fc6cb Add tildes to feeds 2019-08-25 00:36:26 +00:00
tanner a2509958da Add reddit to feeds 2019-08-24 21:37:43 +00:00
tanner 4450e93c65 Remove DOMPurify import 2019-08-24 08:49:53 +00:00
tanner d341d4422f Abstract api server feeds 2019-08-24 08:49:11 +00:00
tanner 82074eb8aa Stop running DOMPurify on reader server 2019-08-24 05:09:02 +00:00
tanner c1a81a4d8c Write news stories to disk 2019-08-24 05:07:16 +00:00
tanner dde6ac4566 Finish prototype web client 2019-08-24 05:04:51 +00:00
tanner 62d68da415 Finish prototype api server 2019-08-23 08:23:48 +00:00
tanner c04b5c27f2 Figure out .gitignores 2019-08-23 08:23:26 +00:00
tanner 771c3987ec Change reader server useragent and port 2019-08-23 08:21:25 +00:00
tanner c0607b3fb6 Prototype readability server 2019-08-20 21:49:06 -06:00
tanner a814411c12 Initial commit 2019-08-20 21:48:55 -06:00
54 changed files with 4432 additions and 7938 deletions
-1
View File
@@ -1 +0,0 @@
.aider*
+1 -1
View File
@@ -1,6 +1,6 @@
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2019 Tanner (tanner.vc) Copyright (c) 2019 Tanner Collin
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+8 -6
View File
@@ -20,7 +20,7 @@ $ sudo apt install yarn
Clone this repo: Clone this repo:
```text ```text
$ git clone https://git.tanner.vc/tanner/qotnews.git $ git clone https://gogs.tannercollin.com/tanner/qotnews.git
$ cd qotnews $ cd qotnews
``` ```
@@ -35,7 +35,7 @@ $ source env/bin/activate
(env) $ pip install -r requirements.txt (env) $ pip install -r requirements.txt
``` ```
Configure Praw for your Reddit account (optional): Configure Praw for your Reddit account:
* Go to https://www.reddit.com/prefs/apps * Go to https://www.reddit.com/prefs/apps
* Click "Create app" * Click "Create app"
@@ -44,14 +44,16 @@ Configure Praw for your Reddit account (optional):
* Description: blank * Description: blank
* About URL: blank * About URL: blank
* Redirect URL: your GitHub profile * Redirect URL: your GitHub profile
* Submit, copy the client ID and client secret into `settings.py` below * Submit, copy the client ID and client secret into `praw.ini`:
```text ```text
(env) $ vim settings.py.example (env) $ vim praw.ini
[bot]
client_id=paste here
client_secret=paste here
user_agent=script by github/your-username-here
``` ```
Edit it and save it as `settings.py`.
Now you can run the server: Now you can run the server:
```text ```text
+1 -4
View File
@@ -105,10 +105,7 @@ ENV/
# DB # DB
db.sqlite3 db.sqlite3
settings.py praw.ini
data.db data.db
data.db.bak data.db.bak
data/archive/* data/archive/*
data/backup/*
qotnews.sqlite
praw.ini
+52
View File
@@ -0,0 +1,52 @@
from whoosh.analysis import StemmingAnalyzer, CharsetFilter, NgramFilter
from whoosh.index import create_in, open_dir, exists_in
from whoosh.fields import *
from whoosh.qparser import QueryParser
from whoosh.support.charset import accent_map
analyzer = StemmingAnalyzer() | CharsetFilter(accent_map) | NgramFilter(minsize=3)
title_field = TEXT(analyzer=analyzer, stored=True)
id_field = ID(unique=True, stored=True)
schema = Schema(
id=id_field,
title=title_field,
story=STORED,
)
ARCHIVE_LOCATION = 'data/archive'
ix = None
def init():
global ix
if exists_in(ARCHIVE_LOCATION):
ix = open_dir(ARCHIVE_LOCATION)
else:
ix = create_in(ARCHIVE_LOCATION, schema)
def update(story):
writer = ix.writer()
writer.update_document(
id=story['id'],
title=story['title'],
story=story,
)
writer.commit()
def get_story(sid):
with ix.searcher() as searcher:
result = searcher.document(id=sid)
return result['story'] if result else None
def search(search):
with ix.searcher() as searcher:
query = QueryParser('title', ix.schema).parse(search)
results = searcher.search(query)
stories = [r['story'] for r in results]
for s in stories:
s.pop('text', '')
s.pop('comments', '')
return stories
-122
View File
@@ -1,122 +0,0 @@
import json
from sqlalchemy import create_engine, Column, String, ForeignKey, Integer
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from sqlalchemy.exc import IntegrityError
engine = create_engine('sqlite:///data/qotnews.sqlite', connect_args={'timeout': 360})
Session = sessionmaker(bind=engine)
Base = declarative_base()
class Story(Base):
__tablename__ = 'stories'
sid = Column(String(16), primary_key=True)
ref = Column(String(16), unique=True)
meta_json = Column(String)
full_json = Column(String)
title = Column(String)
class Reflist(Base):
__tablename__ = 'reflist'
rid = Column(Integer, primary_key=True)
ref = Column(String(16), unique=True)
sid = Column(String, ForeignKey('stories.sid'), unique=True)
source = Column(String(16))
def init():
Base.metadata.create_all(engine)
def get_story(sid):
session = Session()
return session.query(Story).get(sid)
def put_story(story):
story = story.copy()
full_json = json.dumps(story)
story.pop('text', None)
story.pop('comments', None)
meta_json = json.dumps(story)
try:
session = Session()
s = Story(
sid=story['id'],
ref=story['ref'],
full_json=full_json,
meta_json=meta_json,
title=story.get('title', None),
)
session.merge(s)
session.commit()
except:
session.rollback()
raise
finally:
session.close()
def get_story_by_ref(ref):
session = Session()
return session.query(Story).filter(Story.ref==ref).first()
def get_reflist(amount):
session = Session()
q = session.query(Reflist).order_by(Reflist.rid.desc()).limit(amount)
return [dict(ref=x.ref, sid=x.sid, source=x.source) for x in q.all()]
def get_stories(amount, skip=0):
session = Session()
q = session.query(Reflist, Story.meta_json).\
order_by(Reflist.rid.desc()).\
join(Story).\
filter(Story.title != None).\
offset(skip).\
limit(amount)
return [x[1] for x in q]
def put_ref(ref, sid, source):
try:
session = Session()
r = Reflist(ref=ref, sid=sid, source=source)
session.add(r)
session.commit()
except:
session.rollback()
raise
finally:
session.close()
def del_ref(ref):
try:
session = Session()
session.query(Reflist).filter(Reflist.ref==ref).delete()
session.commit()
except:
session.rollback()
raise
finally:
session.close()
def count_stories():
try:
session = Session()
return session.query(Story).count()
finally:
session.close()
def get_story_list():
try:
session = Session()
return session.query(Story.sid).all()
finally:
session.close()
if __name__ == '__main__':
init()
#print(get_story_by_ref('hgi3sy'))
print(len(get_reflist(99999)))
+50 -55
View File
@@ -7,40 +7,47 @@ import requests
import time import time
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
import settings from feeds import hackernews, reddit, tildes, manual
from feeds import hackernews, reddit, tildes, manual, lobsters
import utils
INVALID_DOMAINS = ['youtube.com', 'bloomberg.com', 'wsj.com', 'sec.gov'] OUTLINE_API = 'https://outlineapi.com/article'
ARCHIVE_API = 'https://archive.fo/submit/'
READ_API = 'http://127.0.0.1:33843'
INVALID_FILES = ['.pdf', '.png', '.jpg', '.gif']
INVALID_DOMAINS = ['youtube.com', 'bloomberg.com', 'wsj.com']
TWO_DAYS = 60*60*24*2 TWO_DAYS = 60*60*24*2
def list(): def list():
feed = [] feed = []
if settings.NUM_HACKERNEWS: feed += [(x, 'hackernews') for x in hackernews.feed()[:10]]
feed += [(x, 'hackernews') for x in hackernews.feed()[:settings.NUM_HACKERNEWS]] feed += [(x, 'reddit') for x in reddit.feed()[:10]]
feed += [(x, 'tildes') for x in tildes.feed()[:5]]
if settings.NUM_LOBSTERS:
feed += [(x, 'lobsters') for x in lobsters.feed()[:settings.NUM_LOBSTERS]]
if settings.NUM_REDDIT:
feed += [(x, 'reddit') for x in reddit.feed()[:settings.NUM_REDDIT]]
if settings.NUM_TILDES:
feed += [(x, 'tildes') for x in tildes.feed()[:settings.NUM_TILDES]]
return feed return feed
def get_article(url): def get_article(url):
if not settings.READER_URL: try:
logging.info('Readerserver not configured, aborting.') params = {'source_url': url}
headers = {'Referer': 'https://outline.com/'}
r = requests.get(OUTLINE_API, params=params, headers=headers, timeout=20)
if r.status_code == 429:
logging.info('Rate limited by outline, sleeping 30s and skipping...')
time.sleep(30)
return '' return ''
if r.status_code != 200:
raise Exception('Bad response code ' + str(r.status_code))
html = r.json()['data']['html']
if 'URL is not supported by Outline' in html:
raise Exception('URL not supported by Outline')
return html
except KeyboardInterrupt:
raise
except BaseException as e:
logging.error('Problem outlining article: {}'.format(str(e)))
if url.startswith('https://twitter.com'): logging.info('Trying our server instead...')
logging.info('Replacing twitter.com url with nitter.net')
url = url.replace('twitter.com', 'nitter.net')
try: try:
r = requests.post(settings.READER_URL, data=dict(url=url), timeout=20) r = requests.post(READ_API, data=dict(url=url), timeout=10)
if r.status_code != 200: if r.status_code != 200:
raise Exception('Bad response code ' + str(r.status_code)) raise Exception('Bad response code ' + str(r.status_code))
return r.text return r.text
@@ -50,72 +57,58 @@ def get_article(url):
logging.error('Problem getting article: {}'.format(str(e))) logging.error('Problem getting article: {}'.format(str(e)))
return '' return ''
def get_content_type(url): def get_first_image(text):
try: soup = BeautifulSoup(text, features='html.parser')
headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:77.0) Gecko/20100101 Firefox/77.0'}
return requests.get(url, headers=headers, timeout=5).headers['content-type']
except:
return ''
try: try:
headers = { first_img = soup.find('img')
'User-Agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', url = first_img['src']
'X-Forwarded-For': '66.249.66.1', headers = {'User-Agent': 'Twitterbot/1.0'}
} length = requests.get(url, headers=headers).headers['Content-length']
return requests.get(url, headers=headers, timeout=10).headers['content-type'] if int(length) > 1000000: raise
return url
except: except:
pass return ''
def update_story(story, is_manual=False): def update_story(story, is_manual=False):
res = {} res = {}
try: logging.info('Updating story ' + str(story['ref']))
if story['source'] == 'hackernews': if story['source'] == 'hackernews':
res = hackernews.story(story['ref']) res = hackernews.story(story['ref'])
elif story['source'] == 'lobsters':
res = lobsters.story(story['ref'])
elif story['source'] == 'reddit': elif story['source'] == 'reddit':
res = reddit.story(story['ref']) res = reddit.story(story['ref'])
elif story['source'] == 'tildes': elif story['source'] == 'tildes':
res = tildes.story(story['ref']) res = tildes.story(story['ref'])
elif story['source'] == 'manual': elif story['source'] == 'manual':
res = manual.story(story['ref']) res = manual.story(story['ref'])
except BaseException as e:
utils.alert_tanner('Problem updating {} story, ref {}: {}'.format(story['source'], story['ref'], str(e)))
logging.exception(e)
return False
if res: if res:
story.update(res) # join dicts story.update(res) # join dicts
else: else:
logging.info('Story not ready yet') logging.info('Article not ready yet')
return False return False
if story['date'] and not is_manual and story['date'] + TWO_DAYS < time.time(): if story['date'] and not is_manual and story['date'] + TWO_DAYS < time.time():
logging.info('Story too old, removing. Date: {}'.format(story['date'])) logging.info('Article too old, removing')
return False return False
if story.get('url', '') and not story.get('text', ''): if story.get('url', '') and not story.get('text', ''):
if not get_content_type(story['url']).startswith('text/'): if any([story['url'].endswith(ext) for ext in INVALID_FILES]):
logging.info('URL invalid file type / content type:') logging.info('URL invalid file type')
logging.info(story['url'])
return False return False
if any([domain in story['url'] for domain in INVALID_DOMAINS]): if any([domain in story['url'] for domain in INVALID_DOMAINS]):
logging.info('URL invalid domain:') logging.info('URL invalid domain')
logging.info(story['url'])
return False return False
if 'trump' in story['title'].lower() or 'musk' in story['title'].lower() or 'Removed by moderator' in story['title']:
logging.info('Trump / Musk / removed story, skipping')
logging.info(story['url'])
return False
logging.info('Getting article ' + story['url']) logging.info('Getting article ' + story['url'])
story['text'] = get_article(story['url']) story['text'] = get_article(story['url'])
if not story['text']: return False if not story['text']: return False
story['img'] = get_first_image(story['text'])
return True return True
if __name__ == '__main__': if __name__ == '__main__':
@@ -129,7 +122,9 @@ if __name__ == '__main__':
#print(get_article('https://www.bloomberg.com/news/articles/2019-09-23/xi-s-communists-under-pressure-as-high-prices-hit-china-workers')) #print(get_article('https://www.bloomberg.com/news/articles/2019-09-23/xi-s-communists-under-pressure-as-high-prices-hit-china-workers'))
a = get_content_type('https://tefkos.comminfo.rutgers.edu/Courses/e530/Readings/Beal%202008%20full%20text%20searching.pdf') a = get_article('https://blog.joinmastodon.org/2019/10/mastodon-3.0/')
print(a) print(a)
u = get_first_image(a)
print(u)
print('done') print('done')
+13 -99
View File
@@ -12,8 +12,7 @@ import requests
from utils import clean from utils import clean
API_TOPSTORIES = lambda x: 'https://hacker-news.firebaseio.com/v0/topstories.json' API_TOPSTORIES = lambda x: 'https://hacker-news.firebaseio.com/v0/topstories.json'
ALG_API_ITEM = lambda x : 'https://hn.algolia.com/api/v1/items/{}'.format(x) API_ITEM = lambda x : 'https://hn.algolia.com/api/v1/items/{}'.format(x)
BHN_API_ITEM = lambda x : 'https://api.hnpwa.com/v0/item/{}.json'.format(x)
SITE_LINK = lambda x : 'https://news.ycombinator.com/item?id={}'.format(x) SITE_LINK = lambda x : 'https://news.ycombinator.com/item?id={}'.format(x)
SITE_AUTHOR_LINK = lambda x : 'https://news.ycombinator.com/user?id={}'.format(x) SITE_AUTHOR_LINK = lambda x : 'https://news.ycombinator.com/user?id={}'.format(x)
@@ -26,24 +25,14 @@ def api(route, ref=None):
return r.json() return r.json()
except KeyboardInterrupt: except KeyboardInterrupt:
raise raise
except BaseException as e:
logging.error('Problem hitting hackernews API: {}, trying again'.format(str(e)))
try:
r = requests.get(route(ref), timeout=15)
if r.status_code != 200:
raise Exception('Bad response code ' + str(r.status_code))
return r.json()
except KeyboardInterrupt:
raise
except BaseException as e: except BaseException as e:
logging.error('Problem hitting hackernews API: {}'.format(str(e))) logging.error('Problem hitting hackernews API: {}'.format(str(e)))
return False return False
def feed(): def feed():
return [str(x) for x in api(API_TOPSTORIES) or []] return api(API_TOPSTORIES) or []
def alg_comment(i): def comment(i):
if 'author' not in i: if 'author' not in i:
return False return False
@@ -52,25 +41,21 @@ def alg_comment(i):
c['score'] = i.get('points', 0) c['score'] = i.get('points', 0)
c['date'] = i.get('created_at_i', 0) c['date'] = i.get('created_at_i', 0)
c['text'] = clean(i.get('text', '') or '') c['text'] = clean(i.get('text', '') or '')
c['comments'] = [alg_comment(j) for j in i['children']] c['comments'] = [comment(j) for j in i['children']]
c['comments'] = list(filter(bool, c['comments'])) c['comments'] = list(filter(bool, c['comments']))
return c return c
def alg_comment_count(i): def comment_count(i):
alive = 1 if i['author'] else 0 alive = 1 if i['author'] else 0
return sum([alg_comment_count(c) for c in i['comments']]) + alive return sum([comment_count(c) for c in i['comments']]) + alive
def alg_story(ref): def story(ref):
r = api(ALG_API_ITEM, ref) r = api(API_ITEM, ref)
if not r: if not r: return False
logging.info('Bad Algolia Hackernews API response.')
return None
if 'deleted' in r: if 'deleted' in r:
logging.info('Story was deleted.')
return False return False
elif r.get('type', '') != 'story': elif r.get('type', '') != 'story':
logging.info('Type "{}" is not "story".'.format(r.get('type', '')))
return False return False
s = {} s = {}
@@ -81,88 +66,17 @@ def alg_story(ref):
s['title'] = r.get('title', '') s['title'] = r.get('title', '')
s['link'] = SITE_LINK(ref) s['link'] = SITE_LINK(ref)
s['url'] = r.get('url', '') s['url'] = r.get('url', '')
s['comments'] = [alg_comment(i) for i in r['children']] s['comments'] = [comment(i) for i in r['children']]
s['comments'] = list(filter(bool, s['comments'])) s['comments'] = list(filter(bool, s['comments']))
s['num_comments'] = alg_comment_count(s) - 1 s['num_comments'] = comment_count(s) - 1
if 'text' in r and r['text']: if 'text' in r and r['text']:
s['text'] = clean(r['text'] or '') s['text'] = clean(r['text'] or '')
return s return s
def bhn_comment(i):
if 'user' not in i:
return False
c = {}
c['author'] = i.get('user', '')
c['score'] = 0 # Not present?
c['date'] = i.get('time', 0)
c['text'] = clean(i.get('content', '') or '')
c['comments'] = [bhn_comment(j) for j in i['comments']]
c['comments'] = list(filter(bool, c['comments']))
return c
def bhn_story(ref):
r = api(BHN_API_ITEM, ref)
if not r:
logging.info('Bad BetterHN Hackernews API response.')
return None
if 'deleted' in r: # TODO: verify
logging.info('Story was deleted.')
return False
elif r.get('dead', False):
logging.info('Story was deleted.')
return False
elif r.get('type', '') != 'link':
logging.info('Type "{}" is not "link".'.format(r.get('type', '')))
return False
s = {}
s['author'] = r.get('user', '')
s['author_link'] = SITE_AUTHOR_LINK(r.get('user', ''))
s['score'] = r.get('points', 0)
s['date'] = r.get('time', 0)
s['title'] = r.get('title', '')
s['link'] = SITE_LINK(ref)
s['url'] = r.get('url', '')
if s['url'].startswith('item'):
s['url'] = SITE_LINK(ref)
s['comments'] = [bhn_comment(i) for i in r['comments']]
s['comments'] = list(filter(bool, s['comments']))
s['num_comments'] = r.get('comments_count', 0)
if 'content' in r and r['content']:
s['text'] = clean(r['content'] or '')
return s
def story(ref):
s = alg_story(ref)
if s is None:
s = bhn_story(ref)
if not s:
return False
if not s['title']:
return False
if s['score'] < 25 and s['num_comments'] < 10:
logging.info('Score ({}) or num comments ({}) below threshold.'.format(s['score'], s['num_comments']))
return False
return s
# scratchpad so I can quickly develop the parser # scratchpad so I can quickly develop the parser
if __name__ == '__main__': if __name__ == '__main__':
print(feed()) #print(feed())
#print(story(20763961)) #print(story(20763961))
#print(story(20802050)) print(story(20802050))
#print(story(42899834)) # type "job"
#print(story(42900076)) # Ask HN
#print(story(42898201)) # Show HN
#print(story(42899703)) # normal
print(story(42902678)) # bad title?
-120
View File
@@ -1,120 +0,0 @@
import logging
logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
level=logging.DEBUG)
if __name__ == '__main__':
import sys
sys.path.insert(0,'.')
import requests
from datetime import datetime
from utils import clean
API_HOTTEST = lambda x: 'https://lobste.rs/hottest.json'
API_ITEM = lambda x : 'https://lobste.rs/s/{}.json'.format(x)
SITE_LINK = lambda x : 'https://lobste.rs/s/{}'.format(x)
SITE_AUTHOR_LINK = lambda x : 'https://lobste.rs/u/{}'.format(x)
def api(route, ref=None):
try:
r = requests.get(route(ref), timeout=5)
if r.status_code != 200:
raise Exception('Bad response code ' + str(r.status_code))
return r.json()
except KeyboardInterrupt:
raise
except BaseException as e:
logging.error('Problem hitting lobsters API: {}, trying again'.format(str(e)))
try:
r = requests.get(route(ref), timeout=15)
if r.status_code != 200:
raise Exception('Bad response code ' + str(r.status_code))
return r.json()
except KeyboardInterrupt:
raise
except BaseException as e:
logging.error('Problem hitting lobsters API: {}'.format(str(e)))
return False
def feed():
return [x['short_id'] for x in api(API_HOTTEST) or []]
def unix(date_str):
date_str = date_str.replace(':', '')
return int(datetime.strptime(date_str, '%Y-%m-%dT%H%M%S.%f%z').timestamp())
def make_comment(i):
c = {}
try:
c['author'] = i['commenting_user']
except KeyError:
c['author'] = ''
c['score'] = i.get('score', 0)
try:
c['date'] = unix(i['created_at'])
except KeyError:
c['date'] = 0
c['text'] = clean(i.get('comment', '') or '')
c['comments'] = []
return c
def iter_comments(flat_comments):
nested_comments = []
parent_stack = []
for comment in flat_comments:
c = make_comment(comment)
indent = comment['depth']
if indent == 0:
nested_comments.append(c)
parent_stack = [c]
else:
parent_stack = parent_stack[:indent]
p = parent_stack[-1]
p['comments'].append(c)
parent_stack.append(c)
return nested_comments
def story(ref):
r = api(API_ITEM, ref)
if not r:
logging.info('Bad Lobsters API response.')
return False
s = {}
try:
s['author'] = r['submitter_user']
s['author_link'] = SITE_AUTHOR_LINK(s['author'])
except KeyError:
s['author'] = ''
s['author_link'] = ''
s['score'] = r.get('score', 0)
try:
s['date'] = unix(r['created_at'])
except KeyError:
s['date'] = 0
s['title'] = r.get('title', '')
s['link'] = SITE_LINK(ref)
s['url'] = r.get('url', '')
s['comments'] = iter_comments(r['comments'])
s['num_comments'] = r['comment_count']
if s['score'] < 15 and s['num_comments'] < 10:
logging.info('Score ({}) or num comments ({}) below threshold.'.format(s['score'], s['num_comments']))
return False
if 'description' in r and r['description']:
s['text'] = clean(r['description'] or '')
return s
# scratchpad so I can quickly develop the parser
if __name__ == '__main__':
#print(feed())
import json
print(json.dumps(story('fzvd1v'), indent=4))
#print(json.dumps(story('ixyv5u'), indent=4))
+5 -10
View File
@@ -7,15 +7,12 @@ import requests
import time import time
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:77.0) Gecko/20100101 Firefox/77.0' USER_AGENT = 'Twitterbot/1.0'
def api(route): def api(route):
try: try:
headers = { headers = {'User-Agent': USER_AGENT}
'User-Agent': USER_AGENT, r = requests.get(route, headers=headers, timeout=5)
'X-Forwarded-For': '66.249.66.1',
}
r = requests.get(route, headers=headers, timeout=10)
if r.status_code != 200: if r.status_code != 200:
raise Exception('Bad response code ' + str(r.status_code)) raise Exception('Bad response code ' + str(r.status_code))
return r.text return r.text
@@ -27,9 +24,7 @@ def api(route):
def story(ref): def story(ref):
html = api(ref) html = api(ref)
if not html: if not html: return False
logging.info('Bad http GET response.')
return False
soup = BeautifulSoup(html, features='html.parser') soup = BeautifulSoup(html, features='html.parser')
@@ -38,7 +33,7 @@ def story(ref):
s['author_link'] = 'https://news.t0.vc' s['author_link'] = 'https://news.t0.vc'
s['score'] = 0 s['score'] = 0
s['date'] = int(time.time()) s['date'] = int(time.time())
s['title'] = str(soup.title.string) if soup.title else ref s['title'] = str(soup.title.string)
s['link'] = ref s['link'] = ref
s['url'] = ref s['url'] = ref
s['comments'] = [] s['comments'] = []
+13 -22
View File
@@ -12,28 +12,25 @@ from praw.exceptions import PRAWException
from praw.models import MoreComments from praw.models import MoreComments
from prawcore.exceptions import PrawcoreException from prawcore.exceptions import PrawcoreException
import settings
from utils import render_md, clean from utils import render_md, clean
SITE_LINK = lambda x : 'https://old.reddit.com{}'.format(x) SUBREDDITS = 'Economics+Foodforthought+TrueReddit+business+technology+privacy'
SITE_LINK = lambda x : 'https://old.reddit.com/{}'.format(x)
SITE_AUTHOR_LINK = lambda x : 'https://old.reddit.com/u/{}'.format(x) SITE_AUTHOR_LINK = lambda x : 'https://old.reddit.com/u/{}'.format(x)
if settings.NUM_REDDIT: reddit = praw.Reddit('bot')
reddit = praw.Reddit(
client_id=settings.REDDIT_CLIENT_ID,
client_secret=settings.REDDIT_CLIENT_SECRET,
user_agent=settings.REDDIT_USER_AGENT,
)
subs = '+'.join(settings.SUBREDDITS)
def feed(): def feed():
try: try:
return [x.id for x in reddit.subreddit(subs).hot()] return [x.id for x in reddit.subreddit(SUBREDDITS).hot()]
except KeyboardInterrupt: except KeyboardInterrupt:
raise raise
except BaseException as e: except PRAWException as e:
logging.critical('Problem hitting reddit API: {}'.format(str(e))) logging.error('Problem hitting reddit API: {}'.format(str(e)))
return []
except PrawcoreException as e:
logging.error('Problem hitting reddit API: {}'.format(str(e)))
return [] return []
def comment(i): def comment(i):
@@ -56,9 +53,7 @@ def comment(i):
def story(ref): def story(ref):
try: try:
r = reddit.submission(ref) r = reddit.submission(ref)
if not r: if not r: return False
logging.info('Bad Reddit API response.')
return False
s = {} s = {}
s['author'] = r.author.name if r.author else '[Deleted]' s['author'] = r.author.name if r.author else '[Deleted]'
@@ -72,10 +67,6 @@ def story(ref):
s['comments'] = list(filter(bool, s['comments'])) s['comments'] = list(filter(bool, s['comments']))
s['num_comments'] = r.num_comments s['num_comments'] = r.num_comments
if s['score'] < 25 and s['num_comments'] < 10:
logging.info('Score ({}) or num comments ({}) below threshold.'.format(s['score'], s['num_comments']))
return False
if r.selftext: if r.selftext:
s['text'] = render_md(clean(r.selftext)) s['text'] = render_md(clean(r.selftext))
@@ -84,10 +75,10 @@ def story(ref):
except KeyboardInterrupt: except KeyboardInterrupt:
raise raise
except PRAWException as e: except PRAWException as e:
logging.critical('Problem hitting reddit API: {}'.format(str(e))) logging.error('Problem hitting reddit API: {}'.format(str(e)))
return False return False
except PrawcoreException as e: except PrawcoreException as e:
logging.critical('Problem hitting reddit API: {}'.format(str(e))) logging.error('Problem hitting reddit API: {}'.format(str(e)))
return False return False
# scratchpad so I can quickly develop the parser # scratchpad so I can quickly develop the parser
+9 -28
View File
@@ -16,7 +16,7 @@ from utils import clean
# cache the topic groups to prevent redirects # cache the topic groups to prevent redirects
group_lookup = {} group_lookup = {}
USER_AGENT = 'qotnews scraper (github:tanner37)' USER_AGENT = 'qotnews scraper (github:tannercollin)'
API_TOPSTORIES = lambda : 'https://tildes.net' API_TOPSTORIES = lambda : 'https://tildes.net'
API_ITEM = lambda x : 'https://tildes.net/shortener/{}'.format(x) API_ITEM = lambda x : 'https://tildes.net/shortener/{}'.format(x)
@@ -34,7 +34,7 @@ def api(route):
except KeyboardInterrupt: except KeyboardInterrupt:
raise raise
except BaseException as e: except BaseException as e:
logging.critical('Problem hitting tildes website: {}'.format(str(e))) logging.error('Problem hitting tildes website: {}'.format(str(e)))
return False return False
def feed(): def feed():
@@ -71,15 +71,11 @@ def story(ref):
html = api(SITE_LINK(group_lookup[ref], ref)) html = api(SITE_LINK(group_lookup[ref], ref))
else: else:
html = api(API_ITEM(ref)) html = api(API_ITEM(ref))
if not html: if not html: return False
logging.info('Bad Tildes API response.')
return False
soup = BeautifulSoup(html, features='html.parser') soup = BeautifulSoup(html, features='html.parser')
a = soup.find('article', class_='topic-full') a = soup.find('article', class_='topic-full')
if a is None: if a is None: return False
logging.info('Tildes <article> element not found.')
return False
h = a.find('header') h = a.find('header')
lu = h.find('a', class_='link-user') lu = h.find('a', class_='link-user')
@@ -87,7 +83,6 @@ def story(ref):
error = a.find('div', class_='text-error') error = a.find('div', class_='text-error')
if error: if error:
if 'deleted' in error.string or 'removed' in error.string: if 'deleted' in error.string or 'removed' in error.string:
logging.info('Article was deleted or removed.')
return False return False
s = {} s = {}
@@ -96,7 +91,7 @@ def story(ref):
s['score'] = int(h.find('span', class_='topic-voting-votes').string) s['score'] = int(h.find('span', class_='topic-voting-votes').string)
s['date'] = unix(h.find('time')['datetime']) s['date'] = unix(h.find('time')['datetime'])
s['title'] = str(h.h1.string) s['title'] = str(h.h1.string)
s['group'] = str(soup.find('div', class_='site-header-context').a.string) s['group'] = str(soup.find('a', class_='site-header-context').string)
group_lookup[ref] = s['group'] group_lookup[ref] = s['group']
s['link'] = SITE_LINK(s['group'], ref) s['link'] = SITE_LINK(s['group'], ref)
ud = a.find('div', class_='topic-full-link') ud = a.find('div', class_='topic-full-link')
@@ -107,21 +102,7 @@ def story(ref):
ch = a.find('header', class_='topic-comments-header') ch = a.find('header', class_='topic-comments-header')
s['num_comments'] = int(ch.h2.string.split(' ')[0]) if ch else 0 s['num_comments'] = int(ch.h2.string.split(' ')[0]) if ch else 0
if s['group'].split('.')[0] not in [ if s['score'] < 8 and s['num_comments'] < 6:
'~arts',
'~comp',
'~creative',
'~design',
'~engineering',
'~finance',
'~science',
'~tech',
]:
logging.info('Group ({}) not in whitelist.'.format(s['group']))
return False
if s['score'] < 15 and s['num_comments'] < 10:
logging.info('Score ({}) or num comments ({}) below threshold.'.format(s['score'], s['num_comments']))
return False return False
td = a.find('div', class_='topic-full-text') td = a.find('div', class_='topic-full-text')
@@ -132,7 +113,7 @@ def story(ref):
# scratchpad so I can quickly develop the parser # scratchpad so I can quickly develop the parser
if __name__ == '__main__': if __name__ == '__main__':
print(feed()) #print(feed())
#normal = story('gxt') #normal = story('gxt')
#print(normal) #print(normal)
#no_comments = story('gxr') #no_comments = story('gxr')
@@ -141,8 +122,8 @@ if __name__ == '__main__':
#print(self_post) #print(self_post)
#li_comment = story('gqx') #li_comment = story('gqx')
#print(li_comment) #print(li_comment)
#broken = story('q4y') broken = story('l11')
#print(broken) print(broken)
# make sure there's no self-reference # make sure there's no self-reference
#import copy #import copy
+26
View File
@@ -0,0 +1,26 @@
import shelve
import archive
archive.init()
#with shelve.open('data/data') as db:
# to_delete = []
#
# for s in db.values():
# if 'title' in s:
# archive.update(s)
# if 'id' in s:
# to_delete.append(s['id'])
#
# for id in to_delete:
# del db[id]
#
# for s in db['news_cache'].values():
# if 'title' in s:
# archive.update(s)
#with shelve.open('data/whoosh') as db:
# for s in db['news_cache'].values():
# if 'title' in s and not archive.get_story(s['id']):
# archive.update(s)
+4
View File
@@ -0,0 +1,4 @@
[bot]
client_id=
client_secret=
user_agent=
+16 -24
View File
@@ -1,30 +1,22 @@
beautifulsoup4==4.9.1 beautifulsoup4==4.8.1
bleach==3.1.5 certifi==2019.9.11
certifi==2020.6.20
chardet==3.0.4 chardet==3.0.4
click==7.1.2 Click==7.0
commonmark==0.9.1 commonmark==0.9.1
Flask==1.1.2 Flask==1.1.1
Flask-Cors==3.0.8 Flask-Cors==3.0.8
gevent==20.6.2 idna==2.8
greenlet==0.4.16
humanize==4.10.0
idna==2.10
itsdangerous==1.1.0 itsdangerous==1.1.0
Jinja2==2.11.2 Jinja2==2.10.3
MarkupSafe==1.1.1 MarkupSafe==1.1.1
packaging==20.4 pkg-resources==0.0.0
praw==6.4.0 praw==6.4.0
prawcore==1.4.0 prawcore==1.0.1
pyparsing==2.4.7 requests==2.22.0
requests==2.24.0 six==1.12.0
six==1.15.0 soupsieve==1.9.4
soupsieve==2.0.1 update-checker==0.16
SQLAlchemy==1.3.18 urllib3==1.25.6
update-checker==0.17 websocket-client==0.56.0
urllib3==1.25.9 Werkzeug==0.16.0
webencodings==0.5.1 Whoosh==2.7.4
websocket-client==0.57.0
Werkzeug==1.0.1
zope.event==4.4
zope.interface==5.1.0
-52
View File
@@ -1,52 +0,0 @@
import database
import search
import sys
import settings
import logging
import json
import requests
database.init()
search.init()
def database_del_story(sid):
try:
session = database.Session()
session.query(database.Story).filter(database.Story.sid==sid).delete()
session.commit()
except:
session.rollback()
raise
finally:
session.close()
def search_del_story(sid):
try:
r = requests.delete(settings.MEILI_URL + 'indexes/qotnews/documents/'+sid, timeout=2)
if r.status_code != 202:
raise Exception('Bad response code ' + str(r.status_code))
return r.json()
except KeyboardInterrupt:
raise
except BaseException as e:
logging.error('Problem deleting MeiliSearch story: {}'.format(str(e)))
return False
if __name__ == '__main__':
if len(sys.argv) == 2:
sid = sys.argv[1]
else:
print('Usage: python delete-story.py [story id]')
exit(1)
story = database.get_story(sid)
if story:
print('Deleting story:')
print(story.title)
database_del_story(sid)
search_del_story(sid)
database.del_ref(story.ref)
else:
print('Story not found. Exiting.')
-58
View File
@@ -1,58 +0,0 @@
import time
import json
import logging
import feed
import database
import search
database.init()
def fix_gzip_bug(story_list):
FIX_THRESHOLD = 150
count = 1
for sid in story_list:
try:
sid = sid[0]
story = database.get_story(sid)
full_json = json.loads(story.full_json)
meta_json = json.loads(story.meta_json)
text = full_json.get('text', '')
count = text.count('')
if not count: continue
ratio = count / len(text) * 1000
print('Bad story:', sid, 'Num ?:', count, 'Ratio:', ratio)
if ratio < FIX_THRESHOLD: continue
print('Attempting to fix...')
valid = feed.update_story(meta_json, is_manual=True)
if valid:
database.put_story(meta_json)
search.put_story(meta_json)
print('Success')
else:
print('Story was not valid')
time.sleep(3)
except KeyboardInterrupt:
raise
except BaseException as e:
logging.exception(e)
breakpoint()
if __name__ == '__main__':
num_stories = database.count_stories()
print('Fix {} stories?'.format(num_stories))
print('Press ENTER to continue, ctrl-c to cancel')
input()
story_list = database.get_story_list()
fix_gzip_bug(story_list)
-62
View File
@@ -1,62 +0,0 @@
import logging
logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
level=logging.INFO)
import database
from sqlalchemy import select
import search
import sys
import time
import json
import requests
database.init()
search.init()
BATCH_SIZE = 5000
def put_stories(stories):
return search.meili_api(requests.post, 'indexes/qotnews/documents', stories)
def get_update(update_id):
return search.meili_api(requests.get, 'tasks/{}'.format(update_id))
if __name__ == '__main__':
num_stories = database.count_stories()
print('Reindex {} stories?'.format(num_stories))
print('Press ENTER to continue, ctrl-c to cancel')
input()
story_list = database.get_story_list()
count = 1
while len(story_list):
stories = []
for _ in range(BATCH_SIZE):
try:
sid = story_list.pop()
except IndexError:
break
story = database.get_story(sid)
print('Indexing {}/{} id: {} title: {}'.format(count, num_stories, sid[0], story.title))
story_obj = json.loads(story.meta_json)
stories.append(story_obj)
count += 1
res = put_stories(stories)
update_id = res['uid']
print('Waiting for processing', end='')
while get_update(update_id)['status'] != 'succeeded':
time.sleep(0.5)
print('.', end='', flush=True)
print()
print('Done.')
-23
View File
@@ -1,23 +0,0 @@
import time
import requests
def test_search_api():
num_tests = 100
total_time = 0
for i in range(num_tests):
start = time.time()
res = requests.get('http://127.0.0.1:33842/api/search?q=iphone')
res.raise_for_status()
duration = time.time() - start
total_time += duration
avg_time = total_time / num_tests
print('Average search time:', avg_time)
if __name__ == '__main__':
test_search_api()
-60
View File
@@ -1,60 +0,0 @@
import logging
logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
level=logging.DEBUG)
import requests
import settings
SEARCH_ENABLED = bool(settings.MEILI_URL)
def meili_api(method, route, json=None, params=None, parse_json=True):
try:
headers = {'Authorization': 'Bearer ' + settings.MEILI_API_KEY}
r = method(settings.MEILI_URL + route, json=json, params=params, timeout=4)
if r.status_code > 299:
raise Exception('Bad response code ' + str(r.status_code))
if parse_json:
return r.json()
else:
r.encoding = 'utf-8'
return r.text
except KeyboardInterrupt:
raise
except BaseException as e:
logging.error('Problem with MeiliSearch api route: %s: %s', route, str(e))
return False
def create_index():
json = dict(uid='qotnews', primaryKey='id')
return meili_api(requests.post, 'indexes', json=json)
def update_settings():
json = {
'rankingRules': ['typo', 'words', 'proximity', 'date:desc', 'exactness'],
'searchableAttributes': ['title', 'url', 'author'],
'displayedAttributes': ['id', 'ref', 'source', 'author', 'author_link', 'score', 'date', 'title', 'link', 'url', 'num_comments'],
}
return meili_api(requests.post, 'indexes/qotnews/settings', json=json)
def init():
if not SEARCH_ENABLED:
logging.info('Search is not enabled, skipping init.')
return
print(create_index())
update_settings()
def put_story(story):
if not SEARCH_ENABLED: return
return meili_api(requests.post, 'indexes/qotnews/documents', [story])
def search(q):
if not SEARCH_ENABLED: return []
json = dict(q=q, limit=settings.FEED_LENGTH)
r = meili_api(requests.post, 'indexes/qotnews/search', json=json, parse_json=False)
return r
if __name__ == '__main__':
init()
print(search('facebook'))
+107 -210
View File
@@ -1,167 +1,104 @@
import os, logging import logging
DEBUG = os.environ.get('DEBUG')
logging.basicConfig( logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
level=logging.DEBUG if DEBUG else logging.INFO) level=logging.INFO)
import gevent
from gevent import monkey
monkey.patch_all()
from gevent.pywsgi import WSGIServer
import copy import copy
import json
import threading import threading
import traceback
import time import time
import datetime import shelve
import humanize
import urllib.request
from urllib.parse import urlparse, parse_qs from urllib.parse import urlparse, parse_qs
import settings import archive
import database
import search
import feed import feed
from utils import gen_rand_id, NUM_ID_CHARS from utils import gen_rand_id
from flask import abort, Flask, request, render_template, stream_with_context, Response from flask import abort, Flask, request, render_template, stream_with_context, Response
from werkzeug.exceptions import NotFound from werkzeug.exceptions import NotFound
from flask_cors import CORS from flask_cors import CORS
smallweb_set = set() import gevent
def load_smallweb_list(): from gevent import monkey
EXCLUDED = [ from gevent.pywsgi import WSGIServer
'github.com',
]
global smallweb_set monkey.patch_all()
try:
url = 'https://raw.githubusercontent.com/kagisearch/smallweb/refs/heads/main/smallweb.txt'
with urllib.request.urlopen(url, timeout=10) as response:
urls = response.read().decode('utf-8').splitlines()
hosts = {urlparse(u).hostname for u in urls if u and urlparse(u).hostname}
smallweb_set = {h.replace('www.', '') for h in hosts if h not in EXCLUDED}
logging.info('Loaded {} smallweb domains.'.format(len(smallweb_set)))
except Exception as e:
logging.error('Failed to load smallweb list: {}'.format(e))
load_smallweb_list() archive.init()
database.init() CACHE_LENGTH = 150
search.init() DATA_FILE = 'data/data'
news_index = 0 news_index = 0
ref_list = []
current_item = {} with shelve.open(DATA_FILE) as db:
logging.info('Reading caches from disk...')
news_list = db.get('news_list', [])
news_ref_to_id = db.get('news_ref_to_id', {})
news_cache = db.get('news_cache', {})
# clean cache if broken
try:
for ref in news_list:
nid = news_ref_to_id[ref]
_ = news_cache[nid]
except KeyError as e:
logging.error('Unable to find key {}. Trying to remove...'.format(str(e)))
news_list.remove(str(e))
def get_story(sid):
if sid in news_cache:
return news_cache[sid]
else:
return archive.get_story(sid)
def new_id(): def new_id():
nid = gen_rand_id() nid = gen_rand_id()
while database.get_story(nid): while nid in news_cache or archive.get_story(nid):
nid = gen_rand_id() nid = gen_rand_id()
return nid return nid
def remove_ref(old_ref):
while old_ref in news_list:
news_list.remove(old_ref)
old_story = news_cache.pop(news_ref_to_id[old_ref])
old_id = news_ref_to_id.pop(old_ref)
logging.info('Removed ref {} id {}.'.format(old_ref, old_id))
def fromnow(ts): build_folder = '../webclient/build'
return humanize.naturaltime(datetime.datetime.fromtimestamp(ts))
build_folder = './build'
flask_app = Flask(__name__, template_folder=build_folder, static_folder=build_folder, static_url_path='') flask_app = Flask(__name__, template_folder=build_folder, static_folder=build_folder, static_url_path='')
flask_app.jinja_env.filters['fromnow'] = fromnow
cors = CORS(flask_app) cors = CORS(flask_app)
@flask_app.route('/api') @flask_app.route('/api')
def api(): def api():
skip = request.args.get('skip', 0) try:
limit = request.args.get('limit', settings.FEED_LENGTH) front_page = [news_cache[news_ref_to_id[ref]] for ref in news_list]
is_smallweb_filter = request.args.get('smallweb') == 'true' and smallweb_set except KeyError as e:
sources_filter = request.args.getlist('source') logging.error('Unable to find key {}. Trying to remove...'.format(str(e)))
news_list.remove(str(e))
if not is_smallweb_filter and not sources_filter: front_page = [copy.copy(x) for x in front_page if 'title' in x and x['title']]
stories = database.get_stories(limit, skip) front_page = front_page[:60]
else: for story in front_page:
limit = int(limit) story.pop('text', None)
skip = int(skip) story.pop('comments', None)
filtered_stories = []
current_skip = skip
while len(filtered_stories) < limit:
stories_batch = database.get_stories(limit, current_skip)
if not stories_batch:
break
for story_str in stories_batch:
story = json.loads(story_str)
if is_smallweb_filter:
story_url = story.get('url') or story.get('link') or ''
if not story_url:
continue
hostname = urlparse(story_url).hostname
if not hostname or hostname.replace('www.', '') not in smallweb_set:
continue
if sources_filter:
if story.get('source') not in sources_filter:
continue
filtered_stories.append(story_str)
if len(filtered_stories) == limit:
break
if len(filtered_stories) == limit:
break
current_skip += limit
stories = filtered_stories
# hacky nested json
res = Response('{"stories":[' + ','.join(stories) + ']}')
res.headers['content-type'] = 'application/json'
return res
@flask_app.route('/api/stats', strict_slashes=False)
def apistats():
stats = {
'news_index': news_index,
'ref_list': ref_list,
'len_ref_list': len(ref_list),
'current_item': current_item,
'total_stories': database.count_stories(),
'id_space': 26**NUM_ID_CHARS,
}
return stats
return {'stories': front_page}
@flask_app.route('/api/search', strict_slashes=False) @flask_app.route('/api/search', strict_slashes=False)
def apisearch(): def search():
q = request.args.get('q', '') search = request.args.get('q', '')
if len(q) >= 3: if len(search) >= 3:
results = search.search(q) res = archive.search(search)
else: else:
results = '[]' res = []
res = Response(results) return {'results': res}
res.headers['content-type'] = 'application/json'
return res
@flask_app.route('/api/submit', methods=['POST'], strict_slashes=False) @flask_app.route('/api/submit', methods=['POST'], strict_slashes=False)
def submit(): def submit():
try: try:
url = request.form['url'] url = request.form['url']
for prefix in ['http://', 'https://']:
if url.lower().startswith(prefix):
break
else: # for
url = 'http://' + url
nid = new_id() nid = new_id()
logging.info('Manual submission: ' + url)
parse = urlparse(url) parse = urlparse(url)
if 'news.ycombinator.com' in parse.hostname: if 'news.ycombinator.com' in parse.hostname:
source = 'hackernews' source = 'hackernews'
@@ -169,75 +106,40 @@ def submit():
elif 'tildes.net' in parse.hostname and '~' in url: elif 'tildes.net' in parse.hostname and '~' in url:
source = 'tildes' source = 'tildes'
ref = parse.path.split('/')[2] ref = parse.path.split('/')[2]
elif 'lobste.rs' in parse.hostname and '/s/' in url:
source = 'lobsters'
ref = parse.path.split('/')[2]
elif 'reddit.com' in parse.hostname and 'comments' in url: elif 'reddit.com' in parse.hostname and 'comments' in url:
source = 'reddit' source = 'reddit'
ref = parse.path.split('/')[4] ref = parse.path.split('/')[4]
elif 'news.t0.vc' in parse.hostname:
raise Exception('Invalid article')
else: else:
source = 'manual' source = 'manual'
ref = url ref = url
existing = database.get_story_by_ref(ref) news_story = dict(id=nid, ref=ref, source=source)
news_cache[nid] = news_story
if existing and DEBUG: valid = feed.update_story(news_story, is_manual=True)
ref = ref + '#' + str(time.time())
existing = False
if existing:
return {'nid': existing.sid}
else:
story = dict(id=nid, ref=ref, source=source)
valid = feed.update_story(story, is_manual=True)
if valid: if valid:
database.put_story(story) archive.update(news_story)
search.put_story(story)
if DEBUG:
logging.info('Adding manual ref: {}, id: {}, source: {}'.format(ref, nid, source))
database.put_ref(ref, nid, source)
return {'nid': nid} return {'nid': nid}
else: else:
news_cache.pop(nid, '')
raise Exception('Invalid article') raise Exception('Invalid article')
except Exception as e: except BaseException as e:
msg = 'Problem with article submission: {} - {}'.format(e.__class__.__name__, str(e)) logging.error('Problem with article submission: {} - {}'.format(e.__class__.__name__, str(e)))
logging.error(msg) abort(400)
print(traceback.format_exc())
return {'error': msg.split('\n')[0]}, 400
@flask_app.route('/api/<sid>') @flask_app.route('/api/<sid>')
def story(sid): def story(sid):
story = database.get_story(sid) story = get_story(sid)
if story: return dict(story=story) if story else abort(404)
# hacky nested json
res = Response('{"story":' + story.full_json + '}')
res.headers['content-type'] = 'application/json'
return res
else:
return abort(404)
@flask_app.route('/') @flask_app.route('/')
@flask_app.route('/search') @flask_app.route('/search')
def index(): def index():
stories_json = database.get_stories(settings.FEED_LENGTH, 0)
stories = [json.loads(s) for s in stories_json]
for s in stories:
url = urlparse(s.get('url') or s.get('link') or '').hostname or ''
s['hostname'] = url.replace('www.', '')
return render_template('index.html', return render_template('index.html',
title='QotNews', title='Feed',
url='news.t0.vc', url='news.t0.vc',
description='Hacker News, Reddit, Lobsters, and Tildes articles rendered in reader mode', description='Reddit, Hacker News, and Tildes combined, then pre-rendered in reader mode')
robots='index',
stories=stories,
)
@flask_app.route('/<sid>', strict_slashes=False) @flask_app.route('/<sid>', strict_slashes=False)
@flask_app.route('/<sid>/c', strict_slashes=False) @flask_app.route('/<sid>/c', strict_slashes=False)
@@ -247,9 +149,8 @@ def static_story(sid):
except NotFound: except NotFound:
pass pass
story_obj = database.get_story(sid) story = get_story(sid)
if not story_obj: return abort(404) if not story: return abort(404)
story = json.loads(story_obj.full_json)
score = story['score'] score = story['score']
num_comments = story['num_comments'] num_comments = story['num_comments']
@@ -258,78 +159,74 @@ def static_story(sid):
score, 's' if score != 1 else '', score, 's' if score != 1 else '',
num_comments, 's' if num_comments != 1 else '', num_comments, 's' if num_comments != 1 else '',
source) source)
url = urlparse(story.get('url') or story.get('link') or '').hostname or '' url = urlparse(story['url']).hostname or urlparse(story['link']).hostname or ''
url = url.replace('www.', '') url = url.replace('www.', '')
return render_template('index.html', return render_template('index.html',
title=story['title'] + ' | QotNews', title=story['title'],
url=url, url=url,
description=description, description=description)
robots='noindex',
story=story,
show_comments=request.path.endswith('/c'),
)
http_server = WSGIServer(('', 33842), flask_app) http_server = WSGIServer(('', 33842), flask_app)
def feed_thread(): def feed_thread():
global news_index, ref_list, current_item global news_index
try: try:
while True: while True:
# onboard new stories # onboard new stories
if news_index == 0: if news_index == 0:
for ref, source in feed.list(): feed_list = feed.list()
if database.get_story_by_ref(ref): new_items = [(ref, source) for ref, source in feed_list if ref not in news_list]
continue for ref, source in new_items:
try: news_list.insert(0, ref)
nid = new_id() nid = new_id()
logging.info('Adding ref: {}, id: {}, source: {}'.format(ref, nid, source)) news_ref_to_id[ref] = nid
database.put_ref(ref, nid, source) news_cache[nid] = dict(id=nid, ref=ref, source=source)
except database.IntegrityError:
logging.info('Already have ID / ref, skipping.')
continue
ref_list = database.get_reflist(settings.FEED_LENGTH) if len(new_items):
logging.info('Added {} new refs.'.format(len(new_items)))
# drop old ones
while len(news_list) > CACHE_LENGTH:
old_ref = news_list[-1]
remove_ref(old_ref)
# update current stories # update current stories
if news_index < len(ref_list): if news_index < len(news_list):
current_item = ref_list[news_index] update_ref = news_list[news_index]
update_id = news_ref_to_id[update_ref]
try: news_story = news_cache[update_id]
story_json = database.get_story(current_item['sid']).full_json valid = feed.update_story(news_story)
story = json.loads(story_json)
except AttributeError:
story = dict(id=current_item['sid'], ref=current_item['ref'], source=current_item['source'])
logging.info('Updating {} story: {}, index: {}'.format(story['source'], story['ref'], news_index))
valid = feed.update_story(story)
if valid: if valid:
database.put_story(story) archive.update(news_story)
search.put_story(story)
else: else:
database.del_ref(current_item['ref']) remove_ref(update_ref)
logging.info('Removed ref {}'.format(current_item['ref']))
else: else:
logging.info('Skipping index: ' + str(news_index)) logging.info('Skipping update - no story #' + str(news_index+1))
gevent.sleep(6) gevent.sleep(6)
news_index += 1 news_index += 1
if news_index == settings.FEED_LENGTH: news_index = 0 if news_index == CACHE_LENGTH: news_index = 0
except KeyboardInterrupt: except KeyboardInterrupt:
logging.info('Ending feed thread...') logging.info('Ending feed thread...')
except ValueError as e: except ValueError as e:
logging.critical('feed_thread error: {} {}'.format(e.__class__.__name__, e)) logging.error('feed_thread error: {} {}'.format(e.__class__.__name__, e))
http_server.stop() http_server.stop()
logging.info('Starting Feed thread...') print('Starting Feed thread...')
gevent.spawn(feed_thread) gevent.spawn(feed_thread)
logging.info('Starting HTTP thread...') print('Starting HTTP thread...')
try: try:
http_server.serve_forever() http_server.serve_forever()
except KeyboardInterrupt: except KeyboardInterrupt:
logging.info('Exiting...') logging.info('Exiting...')
finally:
with shelve.open(DATA_FILE) as db:
logging.info('Writing caches to disk...')
db['news_list'] = news_list
db['news_ref_to_id'] = news_ref_to_id
db['news_cache'] = news_cache
-50
View File
@@ -1,50 +0,0 @@
# QotNews settings
# edit this file and save it as settings.py
# Feed Lengths
# Number of top items from each site to pull
# set to 0 to disable that site
FEED_LENGTH = 75
NUM_HACKERNEWS = 15
NUM_LOBSTERS = 10
NUM_REDDIT = 15
NUM_TILDES = 5
# Meilisearch server URL
# Leave blank if not using search
#MEILI_URL = 'http://127.0.0.1:7700/'
MEILI_URL = ''
# Readerserver URL
# Leave blank if not using, but that defeats the whole point
READER_URL = 'http://127.0.0.1:33843/'
# Reddit account info
# leave blank if not using Reddit
REDDIT_CLIENT_ID = ''
REDDIT_CLIENT_SECRET = ''
REDDIT_USER_AGENT = ''
SUBREDDITS = [
'Economics',
'AcademicPhilosophy',
'DepthHub',
'Foodforthought',
'HistoryofIdeas',
'LaymanJournals',
'PhilosophyofScience',
'StateOfTheUnion',
'TheAgora',
'TrueReddit',
'culturalstudies',
'hardscience',
'indepthsports',
'indepthstories',
'ludology',
'neurophilosophy',
'resilientcommunities',
'worldevents',
'StallmanWasRight',
'EverythingScience',
'longevity',
]
+1 -10
View File
@@ -8,17 +8,8 @@ import string
from bleach.sanitizer import Cleaner from bleach.sanitizer import Cleaner
def alert_tanner(message):
try:
logging.info('Alerting Tanner: ' + message)
params = dict(qotnews=message)
requests.get('https://tbot.tanner.vc/message', params=params, timeout=4)
except BaseException as e:
logging.error('Problem alerting Tanner: ' + str(e))
NUM_ID_CHARS = 4
def gen_rand_id(): def gen_rand_id():
return ''.join(random.choice(string.ascii_uppercase) for _ in range(NUM_ID_CHARS)) return ''.join(random.choice(string.ascii_uppercase) for _ in range(4))
def render_md(md): def render_md(md):
if md: if md:
+2 -7
View File
@@ -4,7 +4,7 @@ const port = 33843;
const request = require('request'); const request = require('request');
const JSDOM = require('jsdom').JSDOM; const JSDOM = require('jsdom').JSDOM;
const { Readability } = require('readability'); const Readability = require('readability');
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
@@ -35,13 +35,8 @@ app.post('/', (req, res) => {
const url = req.body.url; const url = req.body.url;
const requestOptions = { const requestOptions = {
url: url, url: url,
gzip: true,
//headers: {'User-Agent': 'Googlebot/2.1 (+http://www.google.com/bot.html)'}, //headers: {'User-Agent': 'Googlebot/2.1 (+http://www.google.com/bot.html)'},
//headers: {'User-Agent': 'Twitterbot/1.0'}, headers: {'User-Agent': 'Twitterbot/1.0'},
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:77.0) Gecko/20100101 Firefox/77.0',
'X-Forwarded-For': '66.249.66.1',
},
}; };
console.log('Parse request for:', url); console.log('Parse request for:', url);
+311 -292
View File
File diff suppressed because it is too large Load Diff
-6
View File
@@ -1,6 +0,0 @@
# Editor
*.swp
*.swo
meilisearch-linux-amd64
data.ms/
-14
View File
@@ -1,14 +0,0 @@
# Qotnews Search Server
Download MeiliSearch with:
```
wget https://github.com/meilisearch/meilisearch/releases/download/v0.27.0/meilisearch-linux-amd64
chmod +x meilisearch-linux-amd64
```
Run with:
```
MEILI_NO_ANALYTICS=true ./meilisearch-linux-amd64
```

Before

Width:  |  Height:  |  Size: 538 B

After

Width:  |  Height:  |  Size: 538 B

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Before

Width:  |  Height:  |  Size: 500 B

After

Width:  |  Height:  |  Size: 500 B

-2
View File
@@ -4,14 +4,12 @@
"private": true, "private": true,
"dependencies": { "dependencies": {
"abort-controller": "^3.0.0", "abort-controller": "^3.0.0",
"katex": "^0.16.25",
"localforage": "^1.7.3", "localforage": "^1.7.3",
"moment": "^2.24.0", "moment": "^2.24.0",
"query-string": "^6.8.3", "query-string": "^6.8.3",
"react": "^16.9.0", "react": "^16.9.0",
"react-dom": "^16.9.0", "react-dom": "^16.9.0",
"react-helmet": "^5.2.1", "react-helmet": "^5.2.1",
"react-latex-next": "^3.0.0",
"react-router-dom": "^5.0.1", "react-router-dom": "^5.0.1",
"react-router-hash-link": "^1.2.2", "react-router-hash-link": "^1.2.2",
"react-scripts": "3.1.1" "react-scripts": "3.1.1"
+4 -123
View File
@@ -8,8 +8,6 @@
content="{{ description }}" content="{{ description }}"
/> />
<meta content="{{ url }}" name="og:site_name"> <meta content="{{ url }}" name="og:site_name">
<meta name="robots" content="{{ robots }}">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png"> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
@@ -28,137 +26,20 @@
work correctly both with client-side routing and a non-root public URL. work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`. Learn how to configure a non-root public URL by running `npm run build`.
--> -->
<title>{{ title }}</title> <title>{{ title }} - QotNews</title>
<script>document.documentElement.className = 'js-enabled';</script>
<style> <style>
.js-enabled .static-content {
display: none;
}
html { html {
overflow-y: scroll; overflow-y: scroll;
} }
body { body {
background: #eeeeee; background: #000;
} }
</style> </style>
</head> </head>
<body> <body>
<script> <noscript style="background: white">You need to enable JavaScript to run this app.</noscript>
(function() { <div id="root"></div>
try {
var theme = localStorage.getItem('theme');
if (theme === 'dark') {
document.body.style.backgroundColor = '#1a1a1a';
} else if (theme === 'black' || theme === 'red') {
document.body.style.backgroundColor = '#000';
}
} catch (e) {}
})();
</script>
<div id="root">
<div class="static-content">
{% if False %}
<noscript>
<meta http-equiv="refresh" content="0;url=?/no.script">
</noscript>
{% endif %}
<div class="container menu">
<p>
<a href="/">QotNews</a>
<br />
<span class="slogan">Hacker News, Reddit, Lobsters, and Tildes articles rendered in reader mode.</span>
</p>
</div>
{% if story %}
<div class="{% if show_comments %}container{% else %}article-container{% endif %}">
<div class="article">
<h1>{{ story.title }}</h1>
{% if show_comments %}
<div class="info">
<a href="/{{ story.id }}">View article</a>
</div>
{% else %}
<div class="info">
Source: <a class="source" href="{{ story.url or story.link }}">{{ url }}</a>
</div>
{% endif %}
<div class="info">
{{ story.score }} points
by <a href="{{ story.author_link }}">{{ story.author }}</a>
{{ story.date | fromnow }}
on <a href="{{ story.link }}">{{ story.source }}</a> |
<a href="/{{ story.id }}/c">
{{ story.num_comments }} comment{{ 's' if story.num_comments != 1 }}
</a>
</div>
{% if not show_comments and story.text %}
<div class="story-text">{{ story.text | safe }}</div>
{% elif show_comments %}
{% macro render_comment(comment, level) %}
<dt></dt>
<dd class="comment{% if level > 0 %} lined{% endif %}">
<div class="info">
<p>
{% if comment.author == story.author %}[OP] {% endif %}{{ comment.author or '[Deleted]' }} | <a href="#{{ comment.author }}{{ comment.date }}" id="{{ comment.author }}{{ comment.date }}">{{ comment.date | fromnow }}</a>
</p>
</div>
<div class="text">{{ (comment.text | safe) if comment.text else '<p>[Empty / deleted comment]</p>' }}</div>
{% if comment.comments %}
<dl>
{% for reply in comment.comments %}
{{ render_comment(reply, level + 1) }}
{% endfor %}
</dl>
{% endif %}
</dd>
{% endmacro %}
<dl class="comments">
{% for comment in story.comments %}{{ render_comment(comment, 0) }}{% endfor %}
</dl>
{% endif %}
</div>
<div class='dot toggleDot'>
<div class='button'>
<a href="/{{ story.id }}{{ '/c' if not show_comments else '' }}">
{{ '' if not show_comments else '' }}
</a>
</div>
</div>
</div>
{% elif stories %}
<div class="container">
{% for story in stories %}
<div class='item'>
<div class='title'>
<a class='link' href='/{{ story.id }}'>
<img class='source-logo' src='/logos/{{ story.source }}.png' alt='{{ story.source }}:' /> {{ story.title }}
</a>
<span class='source'>
(<a class='source' href='{{ story.url or story.link }}'>{{ story.hostname }}</a>)
</span>
</div>
<div class='info'>
{{ story.score }} points
by <a href="{{ story.author_link }}">{{ story.author }}</a>
{{ story.date | fromnow }}
on <a href="{{ story.link }}">{{ story.source }}</a> |
<a class="{{ 'hot' if story.num_comments > 99 else '' }}" href="/{{ story.id }}/c">
{{ story.num_comments }} comment{{ 's' if story.num_comments != 1 }}
</a>
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
<!-- <!--
This HTML file is a template. This HTML file is a template.
If you open it directly in the browser, you will see an empty page. If you open it directly in the browser, you will see an empty page.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 981 B

+37 -128
View File
@@ -1,12 +1,10 @@
import React, { useState, useLayoutEffect, useEffect, useRef, useCallback } from 'react'; import React from 'react';
import { BrowserRouter as Router, Route, Link, Switch } from 'react-router-dom'; import { BrowserRouter as Router, Route, Link, Switch } from 'react-router-dom';
import localForage from 'localforage'; import localForage from 'localforage';
import './Style-light.css'; import './Style-light.css';
import './Style-dark.css'; import './Style-dark.css';
import './Style-black.css';
import './Style-red.css';
import './fonts/Fonts.css'; import './fonts/Fonts.css';
import { BackwardDot, ForwardDot } from './utils.js'; import { ForwardDot } from './utils.js';
import Feed from './Feed.js'; import Feed from './Feed.js';
import Article from './Article.js'; import Article from './Article.js';
import Comments from './Comments.js'; import Comments from './Comments.js';
@@ -14,162 +12,73 @@ import Search from './Search.js';
import Submit from './Submit.js'; import Submit from './Submit.js';
import Results from './Results.js'; import Results from './Results.js';
import ScrollToTop from './ScrollToTop.js'; import ScrollToTop from './ScrollToTop.js';
import Settings from './Settings.js';
function App() { class App extends React.Component {
const [theme, setTheme] = useState(localStorage.getItem('theme') || ''); constructor(props) {
const cache = useRef({}); super(props);
const [isFullScreen, setIsFullScreen] = useState(!!document.fullscreenElement);
const [waitingWorker, setWaitingWorker] = useState(null); this.state = {
const [settingsOpen, setSettingsOpen] = useState(false); theme: localStorage.getItem('theme') || '',
const defaultBodyFontSize = 1.0;
const [bodyFontSize, setBodyFontSize] = useState(Number(localStorage.getItem('bodyFontSize')) || defaultBodyFontSize);
const [bodyFont, setBodyFont] = useState(localStorage.getItem('bodyFont') || 'Sans Serif');
const [articleFont, setArticleFont] = useState(localStorage.getItem('articleFont') || 'Apparatus SIL');
const [filterSmallweb, setFilterSmallweb] = useState(() => localStorage.getItem('filterSmallweb') === 'true');
const [feedSources, setFeedSources] = useState(() => {
const saved = localStorage.getItem('feedSources');
return saved ? JSON.parse(saved) : {
hackernews: true,
reddit: true,
lobsters: true,
tildes: true,
}; };
});
const updateCache = useCallback((key, value) => { this.cache = {};
cache.current[key] = value; }
}, []);
useEffect(() => { updateCache = (key, value) => {
const onSWUpdate = e => { this.cache[key] = value;
setWaitingWorker(e.detail.waiting); }
};
window.addEventListener('swUpdate', onSWUpdate);
return () => window.removeEventListener('swUpdate', onSWUpdate);
}, []);
useEffect(() => { light() {
if (Object.keys(cache.current).length === 0) { this.setState({ theme: '' });
localStorage.setItem('theme', '');
}
dark() {
this.setState({ theme: 'dark' });
localStorage.setItem('theme', 'dark');
}
componentDidMount() {
if (!this.cache.length) {
localForage.iterate((value, key) => { localForage.iterate((value, key) => {
updateCache(key, value); this.updateCache(key, value);
}).then(() => {
console.log('loaded cache from localforage');
}); });
console.log('loaded cache from localforage');
} }
}, [updateCache]);
useEffect(() => {
const onFullScreenChange = () => setIsFullScreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onFullScreenChange);
return () => document.removeEventListener('fullscreenchange', onFullScreenChange);
}, []);
useLayoutEffect(() => {
if (theme === 'dark') {
document.body.style.backgroundColor = '#1a1a1a';
} else if (theme === 'black') {
document.body.style.backgroundColor = '#000';
} else if (theme === 'red') {
document.body.style.backgroundColor = '#000';
} else {
document.body.style.backgroundColor = '#eeeeee';
} }
}, [theme]);
useEffect(() => {
document.documentElement.style.fontSize = `${bodyFontSize}rem`;
}, [bodyFontSize]);
const fontMap = {
'Sans Serif': 'sans-serif',
'Serif': 'serif',
'Apparatus SIL': "'Apparatus SIL', sans-serif"
};
useEffect(() => {
document.body.style.fontFamily = fontMap[bodyFont];
}, [bodyFont]);
useEffect(() => {
const styleId = 'article-font-family-style';
let style = document.getElementById(styleId);
if (!style) {
style = document.createElement('style');
style.id = styleId;
document.head.appendChild(style);
}
style.innerHTML = `.story-text { font-family: ${fontMap[articleFont]} !important; }`;
}, [articleFont]);
render() {
const theme = this.state.theme;
document.body.style.backgroundColor = theme === 'dark' ? '#000' : '#eeeeee';
return ( return (
<div className={theme}> <div className={theme}>
<Settings
settingsOpen={settingsOpen}
setSettingsOpen={setSettingsOpen}
theme={theme}
setTheme={setTheme}
isFullScreen={isFullScreen}
filterSmallweb={filterSmallweb}
setFilterSmallweb={setFilterSmallweb}
feedSources={feedSources}
setFeedSources={setFeedSources}
bodyFontSize={bodyFontSize}
setBodyFontSize={setBodyFontSize}
defaultBodyFontSize={defaultBodyFontSize}
bodyFont={bodyFont}
setBodyFont={setBodyFont}
articleFont={articleFont}
setArticleFont={setArticleFont}
/>
{waitingWorker &&
<div className='update-banner'>
Client version mismatch, please refresh:{' '}
<button onClick={() => {
waitingWorker.postMessage({ type: 'SKIP_WAITING' });
const reload = () => window.location.reload();
navigator.serviceWorker.addEventListener('controllerchange', reload, { once: true });
// Fallback for when the controller has already changed (ie. in another tab)
navigator.serviceWorker.getRegistration().then(reg => {
if (!reg || !reg.waiting) {
reload();
}
});
}}>
Refresh
</button>
</div>
}
<Router> <Router>
<div className='container menu'> <div className='container menu'>
<p> <p>
<Link to='/'>QotNews</Link> <Link to='/'>QotNews - Feed</Link>
<span className='theme'>Theme: <a href='#' onClick={() => this.light()}>Light</a> - <a href='#' onClick={() => this.dark()}>Dark</a></span>
<button className="settings-button" onClick={() => setSettingsOpen(true)}>Settings</button>
<br /> <br />
<span className='slogan'>Hacker News, Reddit, Lobsters, and Tildes articles rendered in reader mode.</span> <span className='slogan'>Reddit, Hacker News, and Tildes combined, then pre-rendered in reader mode.</span>
</p> </p>
<Route path='/(|search)' component={Search} /> <Route path='/(|search)' component={Search} />
<Route path='/(|search)' component={Submit} /> <Route path='/(|search)' component={Submit} />
</div> </div>
<Route path='/' exact render={(props) => <Feed {...props} updateCache={updateCache} filterSmallweb={filterSmallweb} feedSources={feedSources} />} /> <Route path='/' exact render={(props) => <Feed {...props} updateCache={this.updateCache} />} />
<Switch> <Switch>
<Route path='/search' component={Results} /> <Route path='/search' component={Results} />
<Route path='/:id' exact render={(props) => <Article {...props} cache={cache.current} />} /> <Route path='/:id' exact render={(props) => <Article {...props} cache={this.cache} />} />
</Switch> </Switch>
<Route path='/:id/c' exact render={(props) => <Comments {...props} cache={cache.current} />} /> <Route path='/:id/c' exact render={(props) => <Comments {...props} cache={this.cache} />} />
<BackwardDot />
<ForwardDot /> <ForwardDot />
<ScrollToTop /> <ScrollToTop />
</Router> </Router>
</div> </div>
); );
}
} }
export default App; export default App;
+31 -185
View File
@@ -1,237 +1,83 @@
import React, { useState, useEffect } from 'react'; import React from 'react';
import { useParams } from 'react-router-dom';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import localForage from 'localforage'; import localForage from 'localforage';
import { sourceLink, similarLink, infoLine, ToggleDot } from './utils.js'; import { sourceLink, infoLine, ToggleDot } from './utils.js';
import Latex from 'react-latex-next';
import 'katex/dist/katex.min.css';
const VOID_ELEMENTS = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']; class Article extends React.Component {
const DANGEROUS_TAGS = ['svg', 'math']; constructor(props) {
super(props);
const latexDelimiters = [ const id = this.props.match ? this.props.match.params.id : 'CLOL';
{ left: '$$', right: '$$', display: true }, const cache = this.props.cache;
{ left: '\\[', right: '\\]', display: true },
{ left: '$', right: '$', display: false },
{ left: '\\(', right: '\\)', display: false }
];
function Article({ cache }) {
const { id } = useParams();
if (id in cache) console.log('cache hit'); if (id in cache) console.log('cache hit');
const [story, setStory] = useState(cache[id] || false); this.state = {
const [error, setError] = useState(''); story: cache[id] || false,
const [pConv, setPConv] = useState([]); error: false,
const [copyButtonText, setCopyButtonText] = useState('\ue92c'); };
}
componentDidMount() {
const id = this.props.match ? this.props.match.params.id : 'CLOL';
useEffect(() => {
localForage.getItem(id) localForage.getItem(id)
.then( .then(
(value) => { (value) => {
if (value) { if (value) {
setStory(value); this.setState({ story: value });
} }
} }
); );
fetch('/api/' + id) fetch('/api/' + id)
.then(res => { .then(res => res.json())
if (!res.ok) {
throw new Error(`Server responded with ${res.status} ${res.statusText}`);
}
return res.json();
})
.then( .then(
(result) => { (result) => {
setStory(result.story); this.setState({ story: result.story });
localForage.setItem(id, result.story); localForage.setItem(id, result.story);
}, },
(error) => { (error) => {
const errorMessage = `Failed to fetch new article content (ID: ${id}). Your connection may be down or the server might be experiencing issues. ${error.toString()}.`; this.setState({ error: true });
setError(errorMessage);
} }
); );
}, [id]);
const copyLink = () => {
navigator.clipboard.writeText(`${story.title}:\n${window.location.href}`).then(() => {
setCopyButtonText('\uea10');
setTimeout(() => setCopyButtonText('\ue92c'), 2000);
}, () => {
setCopyButtonText('\uea0f');
setTimeout(() => setCopyButtonText('\ue92c'), 2000);
});
};
const pConvert = (n) => {
setPConv(prevPConv => [...prevPConv, n]);
};
const isCodeBlock = (v) => {
if (v.localName === 'pre') {
return true;
} }
if (v.localName === 'code') { render() {
if (v.closest('p')) { const id = this.props.match ? this.props.match.params.id : 'CLOL';
return false; const story = this.state.story;
} const error = this.state.error;
const parent = v.parentElement;
if (parent) {
const nonWhitespaceChildren = Array.from(parent.childNodes).filter(n => {
return n.nodeType !== Node.TEXT_NODE || n.textContent.trim() !== '';
});
if (nonWhitespaceChildren.length === 1 && nonWhitespaceChildren[0] === v) {
return true;
}
}
}
return false;
};
const renderNodes = (nodes, keyPrefix = '') => {
return Array.from(nodes).map((v, k) => {
const key = `${keyPrefix}${k}`;
if (pConv.includes(key)) {
return (
<React.Fragment key={key}>
{v.textContent.split('\n\n').map((x, i) =>
<p key={i}>{x}</p>
)}
</React.Fragment>
);
}
if (v.nodeName === '#text') {
const text = v.data;
if (text.includes('\\[') || text.includes('\\(') || text.includes('$$') || /\$(?:[^$]*[^\s$])\$/.test(text)) {
return <Latex key={key} delimiters={latexDelimiters}>{text}</Latex>;
}
// Only wrap top-level text nodes in <p>
if (keyPrefix === '' && v.data.trim() !== '') {
return <p key={key}>{v.data}</p>;
}
return v.data;
}
if (v.nodeType !== Node.ELEMENT_NODE) {
return null;
}
if (DANGEROUS_TAGS.includes(v.localName)) {
return <span key={key} dangerouslySetInnerHTML={{ __html: v.outerHTML }} />;
}
const Tag = v.localName;
if (isCodeBlock(v)) {
return (
<React.Fragment key={key}>
<Tag dangerouslySetInnerHTML={{ __html: v.innerHTML }} />
<button onClick={() => pConvert(key)}>Convert Code to Paragraph</button>
</React.Fragment>
);
}
const textContent = v.textContent.trim();
const isMath = (textContent.startsWith('\\(') && textContent.endsWith('\\)')) ||
(textContent.startsWith('\\[') && textContent.endsWith('\\]')) ||
(textContent.startsWith('$$') && textContent.endsWith('$$')) ||
(textContent.startsWith('$') && textContent.endsWith('$') && textContent.indexOf('$') !== textContent.lastIndexOf('$') && !/\s/.test(textContent.charAt(textContent.length - 2)));
const props = { key: key };
if (v.hasAttributes()) {
for (const attr of v.attributes) {
const name = attr.name === 'class' ? 'className' : attr.name;
props[name] = attr.value;
}
}
if (isMath) {
let mathContent = v.textContent;
// align environment requires display math mode
if (mathContent.includes('\\begin{align')) {
const trimmed = mathContent.trim();
if (trimmed.startsWith('\\(')) {
// Replace \( and \) with \[ and \] to switch to display mode
const firstParen = mathContent.indexOf('\\(');
const lastParen = mathContent.lastIndexOf('\\)');
mathContent = mathContent.substring(0, firstParen) + '\\[' + mathContent.substring(firstParen + 2, lastParen) + '\\]' + mathContent.substring(lastParen + 2);
} else if (trimmed.startsWith('$') && !trimmed.startsWith('$$')) {
// Replace $ with $$
const firstDollar = mathContent.indexOf('$');
const lastDollar = mathContent.lastIndexOf('$');
if (firstDollar !== lastDollar) {
mathContent = mathContent.substring(0, firstDollar) + '$$' + mathContent.substring(firstDollar + 1, lastDollar) + '$$' + mathContent.substring(lastDollar + 1);
}
}
}
return <Tag {...props}><Latex delimiters={latexDelimiters}>{mathContent}</Latex></Tag>;
}
if (VOID_ELEMENTS.includes(Tag)) {
return <Tag {...props} />;
}
return (
<Tag {...props}>
{renderNodes(v.childNodes, `${key}-`)}
</Tag>
);
});
};
const nodes = (s) => {
if (s && s.text) {
let div = document.createElement('div');
div.innerHTML = s.text;
return div.childNodes;
}
return null;
};
const storyNodes = nodes(story);
return ( return (
<div className='article-container'> <div className='article-container'>
{error && {error && <p>Connection error?</p>}
<details style={{marginBottom: '1rem'}}>
<summary>Connection error? Click to expand.</summary>
<p>{error}</p>
{story && <p>Loaded article from cache.</p>}
</details>
}
{story ? {story ?
<div className='article'> <div className='article'>
<Helmet> <Helmet>
<title>{story.title} | QotNews</title> <title>{story.title} - QotNews</title>
<meta name="robots" content="noindex" />
</Helmet> </Helmet>
<h1>{story.title} <button className='copy-button' onClick={copyLink}>{copyButtonText}</button></h1> <h1>{story.title}</h1>
<div className='info'> <div className='info'>
Source: {sourceLink(story)} | {similarLink(story)} Source: {sourceLink(story)}
</div> </div>
{infoLine(story)} {infoLine(story)}
{storyNodes ? {story.text ?
<div className='story-text'> <div className='story-text' dangerouslySetInnerHTML={{ __html: story.text }} />
{renderNodes(storyNodes)}
</div>
: :
<p>Problem getting article :(</p> <p>Problem getting article :(</p>
} }
</div> </div>
: :
<p>Loading...</p> <p>loading...</p>
} }
<ToggleDot id={id} article={false} /> <ToggleDot id={id} article={false} />
</div> </div>
); );
}
} }
export default Article; export default Article;
+41 -73
View File
@@ -1,120 +1,87 @@
import React, { useState, useEffect } from 'react'; import React from 'react';
import { Link, useParams } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { HashLink } from 'react-router-hash-link'; import { HashLink } from 'react-router-hash-link';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import moment from 'moment'; import moment from 'moment';
import localForage from 'localforage'; import localForage from 'localforage';
import { infoLine, ToggleDot } from './utils.js'; import { infoLine, ToggleDot } from './utils.js';
function countComments(c) { class Article extends React.Component {
return c.comments.reduce((sum, x) => sum + countComments(x), 1); constructor(props) {
} super(props);
function Comments({ cache }) { const id = this.props.match.params.id;
const { id } = useParams(); const cache = this.props.cache;
if (id in cache) console.log('cache hit'); if (id in cache) console.log('cache hit');
const [story, setStory] = useState(cache[id] || false); this.state = {
const [error, setError] = useState(''); story: cache[id] || false,
const [collapsed, setCollapsed] = useState([]); error: false,
const [expanded, setExpanded] = useState([]); };
}
componentDidMount() {
const id = this.props.match.params.id;
useEffect(() => {
localForage.getItem(id) localForage.getItem(id)
.then( .then(
(value) => { (value) => {
if (value) { this.setState({ story: value });
setStory(value);
}
} }
); );
fetch('/api/' + id) fetch('/api/' + id)
.then(res => { .then(res => res.json())
if (!res.ok) {
throw new Error(`Server responded with ${res.status} ${res.statusText}`);
}
return res.json();
})
.then( .then(
(result) => { (result) => {
setStory(result.story); this.setState({ story: result.story }, () => {
localForage.setItem(id, result.story);
const hash = window.location.hash.substring(1); const hash = window.location.hash.substring(1);
if (hash) { if (hash) {
setTimeout(() => { document.getElementById(hash).scrollIntoView();
const element = document.getElementById(hash);
if (element) {
element.scrollIntoView();
}
}, 0);
} }
});
localForage.setItem(id, result.story);
}, },
(error) => { (error) => {
const errorMessage = `Failed to fetch comments (ID: ${id}). Your connection may be down or the server might be experiencing issues. ${error.toString()}.`; this.setState({ error: true });
setError(errorMessage);
} }
); );
}, [id]); }
const collapseComment = (cid) => {
setCollapsed(prev => [...prev, cid]);
setExpanded(prev => prev.filter(x => x !== cid));
};
const expandComment = (cid) => {
setCollapsed(prev => prev.filter(x => x !== cid));
setExpanded(prev => [...prev, cid]);
};
const displayComment = (story, c, level) => {
const cid = c.author+c.date;
const isCollapsed = collapsed.includes(cid);
const isExpanded = expanded.includes(cid);
const hidden = isCollapsed || (level == 4 && !isExpanded);
const hasChildren = c.comments.length !== 0;
displayComment(story, c, level) {
return ( return (
<div className={level ? 'comment lined' : 'comment'} key={cid}> <div className={level ? 'comment lined' : 'comment'} key={c.author+c.date}>
<div className='info'> <div className='info'>
<p> <p>
{c.author === story.author ? '[OP]' : ''} {c.author || '[Deleted]'} {c.author === story.author ? '[OP]' : ''} {c.author || '[Deleted]'}
{' '} | <HashLink to={'#'+cid} id={cid}>{moment.unix(c.date).fromNow()}</HashLink> &#8203; | <HashLink to={'#'+c.author+c.date} id={c.author+c.date}>{moment.unix(c.date).fromNow()}</HashLink>
{hidden || hasChildren &&
<button className='collapser pointer' onClick={() => collapseComment(cid)}></button>
}
</p> </p>
</div> </div>
<div className={isCollapsed ? 'text hidden' : 'text'} dangerouslySetInnerHTML={{ __html: c.text || '<p>[Empty / deleted comment]</p>'}} /> <div className='text' dangerouslySetInnerHTML={{ __html: c.text }} />
{hidden && hasChildren ? {level < 5 ?
<button className='comment lined info pointer' onClick={() => expandComment(cid)}>[show {countComments(c)-1} more]</button> c.comments.map(i => this.displayComment(story, i, level + 1))
: :
c.comments.map(i => displayComment(story, i, level + 1)) <div className='info'><p>[replies snipped]</p></div>
} }
</div> </div>
); );
}; }
render() {
const id = this.props.match.params.id;
const story = this.state.story;
const error = this.state.error;
return ( return (
<div className='container'> <div className='container'>
{error && {error && <p>Connection error?</p>}
<details style={{marginBottom: '1rem'}}>
<summary>Connection error? Click to expand.</summary>
<p>{error}</p>
{story && <p>Loaded comments from cache.</p>}
</details>
}
{story ? {story ?
<div className='article'> <div className='article'>
<Helmet> <Helmet>
<title>{story.title} | QotNews</title> <title>{story.title} - QotNews Comments</title>
<meta name="robots" content="noindex" />
</Helmet> </Helmet>
<h1>{story.title}</h1> <h1>{story.title}</h1>
@@ -126,7 +93,7 @@ function Comments({ cache }) {
{infoLine(story)} {infoLine(story)}
<div className='comments'> <div className='comments'>
{story.comments.map(c => displayComment(story, c, 0))} {story.comments.map(c => this.displayComment(story, c, 0))}
</div> </div>
</div> </div>
: :
@@ -135,6 +102,7 @@ function Comments({ cache }) {
<ToggleDot id={id} article={true} /> <ToggleDot id={id} article={true} />
</div> </div>
); );
}
} }
export default Comments; export default Article;
+50 -133
View File
@@ -1,163 +1,81 @@
import React, { useState, useEffect, useRef } from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import localForage from 'localforage'; import localForage from 'localforage';
import { sourceLink, infoLine, logos } from './utils.js'; import { sourceLink, infoLine, logos } from './utils.js';
function Feed({ updateCache, filterSmallweb, feedSources }) { class Feed extends React.Component {
const [stories, setStories] = useState(() => JSON.parse(localStorage.getItem('stories')) || false); constructor(props) {
const [error, setError] = useState(''); super(props);
const [loadingStatus, setLoadingStatus] = useState(null);
const isInitialMount = useRef(true);
useEffect(() => { this.state = {
if (isInitialMount.current) { stories: JSON.parse(localStorage.getItem('stories')) || false,
isInitialMount.current = false; error: false,
} else { };
setStories(false);
} }
}, [filterSmallweb, feedSources]);
useEffect(() => { componentDidMount() {
const controller = new AbortController(); fetch('/api')
.then(res => res.json())
.then(
(result) => {
const updated = !this.state.stories || this.state.stories[0].id !== result.stories[0].id;
console.log('updated:', updated);
if ('serviceWorker' in navigator) { this.setState({ stories: result.stories });
navigator.serviceWorker.getRegistration().then(reg => { localStorage.setItem('stories', JSON.stringify(result.stories));
if (reg) {
console.log('Checking for client update...'); if (updated) {
reg.update(); localForage.clear();
result.stories.forEach((x, i) => {
fetch('/api/' + x.id)
.then(res => res.json())
.then(result => {
localForage.setItem(x.id, result.story)
.then(console.log('preloaded', x.id, x.title));
this.props.updateCache(x.id, result.story);
}, error => {}
);
if (i < 20 && x.img) {
const img = new Image();
img.src = x.img;
console.log('prefetched image', x.img);
} }
}); });
} }
const params = new URLSearchParams();
if (filterSmallweb) {
params.append('smallweb', 'true');
}
const allSources = Object.keys(feedSources);
const enabledSources = allSources.filter(key => feedSources[key]);
if (enabledSources.length > 0 && enabledSources.length < allSources.length) {
enabledSources.forEach(source => params.append('source', source));
}
const apiUrl = `/api?${params.toString()}`;
fetch(apiUrl, { signal: controller.signal })
.then(res => {
if (!res.ok) {
throw new Error(`Server responded with ${res.status} ${res.statusText}`);
}
return res.json();
})
.then(
async (result) => {
const newApiStories = result.stories;
const updated = !stories || stories.map(s => s.id).join() !== newApiStories.map(s => s.id).join();
console.log('New stories available:', updated);
if (!updated) return;
setLoadingStatus({ current: 0, total: newApiStories.length });
let currentStories = Array.isArray(stories) ? [...stories] : [];
let preloadedCount = 0;
for (const [index, newStory] of newApiStories.entries()) {
if (controller.signal.aborted) {
break;
}
try {
const storyFetchController = new AbortController();
const timeoutId = setTimeout(() => storyFetchController.abort(), 10000); // 10-second timeout
const storyRes = await fetch('/api/' + newStory.id, { signal: storyFetchController.signal });
clearTimeout(timeoutId);
if (!storyRes.ok) {
throw new Error(`Server responded with ${storyRes.status} ${storyRes.statusText}`);
}
const storyResult = await storyRes.json();
const fullStory = storyResult.story;
await localForage.setItem(fullStory.id, fullStory);
console.log('Preloaded story:', fullStory.id, fullStory.title);
updateCache(fullStory.id, fullStory);
preloadedCount++;
setLoadingStatus({ current: preloadedCount, total: newApiStories.length });
const existingStoryIndex = currentStories.findIndex(s => s.id === newStory.id);
if (existingStoryIndex > -1) {
currentStories.splice(existingStoryIndex, 1);
}
currentStories.splice(index, 0, newStory);
localStorage.setItem('stories', JSON.stringify(currentStories));
setStories(currentStories);
} catch (error) {
let errorMessage;
if (error.name === 'AbortError') {
errorMessage = `The request to fetch story '${newStory.title}' (${newStory.id}) timed out after 10 seconds. Your connection may be unstable. (${preloadedCount} / ${newApiStories.length} stories preloaded)`;
console.log('Fetch timed out for story:', newStory.id);
} else {
errorMessage = `An error occurred while fetching story '${newStory.title}' (ID: ${newStory.id}): ${error.toString()}. (${preloadedCount} / ${newApiStories.length} stories preloaded)`;
console.log('Fetch failed for story:', newStory.id, error);
}
setError(errorMessage);
break;
}
}
const finalStories = currentStories.slice(0, newApiStories.length);
const removedStories = currentStories.slice(newApiStories.length);
for (const story of removedStories) {
console.log('Removed story:', story.id, story.title);
localForage.removeItem(story.id);
}
localStorage.setItem('stories', JSON.stringify(finalStories));
setStories(finalStories);
setLoadingStatus(null);
}, },
(error) => { (error) => {
if (error.name === 'AbortError') { this.setState({ error: true });
console.log('Feed fetch aborted.');
return;
}
const errorMessage = `Failed to fetch the main story list from the API. Your connection may be down or the server might be experiencing issues. ${error.toString()}.`;
setError(errorMessage);
} }
); );
}
return () => controller.abort(); render() {
}, [updateCache, filterSmallweb, feedSources]); const stories = this.state.stories;
const error = this.state.error;
return ( return (
<div className='container'> <div className='container'>
<Helmet> <Helmet>
<title>QotNews</title> <title>Feed - QotNews</title>
<meta name="robots" content="index" />
</Helmet> </Helmet>
{error && <p>Connection error?</p>}
{error &&
<details style={{marginBottom: '1rem'}}>
<summary>Connection error? Click to expand.</summary>
<p>{error}</p>
{stories && <p>Loaded feed from cache.</p>}
</details>
}
{stories ? {stories ?
<div> <div>
{stories.map(x => {stories.map((x, i) =>
<div className='item' key={x.id}> <div className='item' key={i}>
<div className='num'>
{i+1}.
</div>
<div className='title'> <div className='title'>
<Link className='link' to={'/' + x.id}> <Link className='link' to={'/' + x.id}>
<img className='source-logo' src={logos[x.source]} alt='source logo' /> {x.title} <img className='source-logo' src={logos[x.source]} alt='source logo' /> {x.title}
</Link> </Link>
<span className='source'> <span className='source'>
({sourceLink(x)}) &#8203;({sourceLink(x)})
</span> </span>
</div> </div>
@@ -166,12 +84,11 @@ function Feed({ updateCache, filterSmallweb, feedSources }) {
)} )}
</div> </div>
: :
<p>Loading...</p> <p>loading...</p>
} }
{loadingStatus && <p>Preloading stories {loadingStatus.current} / {loadingStatus.total}...</p>}
</div> </div>
); );
}
} }
export default Feed; export default Feed;
+48 -25
View File
@@ -1,57 +1,80 @@
import React, { useState, useEffect } from 'react'; import React from 'react';
import { Link, useLocation } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { sourceLink, infoLine, logos } from './utils.js'; import { sourceLink, infoLine, logos } from './utils.js';
import AbortController from 'abort-controller'; import AbortController from 'abort-controller';
function Results() { class Results extends React.Component {
const [stories, setStories] = useState(false); constructor(props) {
const [error, setError] = useState(false); super(props);
const location = useLocation();
useEffect(() => { this.state = {
const controller = new AbortController(); stories: false,
const signal = controller.signal; error: false,
};
const search = location.search; this.controller = null;
}
performSearch = () => {
if (this.controller) {
this.controller.abort();
}
this.controller = new AbortController();
const signal = this.controller.signal;
const search = this.props.location.search;
fetch('/api/search' + search, { method: 'get', signal: signal }) fetch('/api/search' + search, { method: 'get', signal: signal })
.then(res => res.json()) .then(res => res.json())
.then( .then(
(result) => { (result) => {
setStories(result.hits); this.setState({ stories: result.results });
}, },
(error) => { (error) => {
if (error.message !== 'The operation was aborted. ') { if (error.message !== 'The operation was aborted. ') {
setError(true); this.setState({ error: true });
} }
} }
); );
}
return () => { componentDidMount() {
controller.abort(); this.performSearch();
}; }
}, [location.search]);
componentDidUpdate(prevProps) {
if (this.props.location.search !== prevProps.location.search) {
this.performSearch();
}
}
render() {
const stories = this.state.stories;
const error = this.state.error;
return ( return (
<div className='container'> <div className='container'>
<Helmet> <Helmet>
<title>Search Results | QotNews</title> <title>Feed - QotNews</title>
</Helmet> </Helmet>
{error && <p>Connection error?</p>} {error && <p>Connection error?</p>}
{stories ? {stories ?
<> <div>
<p>Search results:</p>
<div className='comment lined'>
{stories.length ? {stories.length ?
stories.map(x => stories.map((x, i) =>
<div className='item' key={x.id}> <div className='item' key={i}>
<div className='num'>
{i+1}.
</div>
<div className='title'> <div className='title'>
<Link className='link' to={'/' + x.id}> <Link className='link' to={'/' + x.id}>
<img className='source-logo' src={logos[x.source]} alt='source logo' /> {x.title} <img className='source-logo' src={logos[x.source]} alt='source logo' /> {x.title}
</Link> </Link>
<span className='source'> <span className='source'>
({sourceLink(x)}) &#8203;({sourceLink(x)})
</span> </span>
</div> </div>
@@ -59,15 +82,15 @@ function Results() {
</div> </div>
) )
: :
<p>none</p> <p>no results</p>
} }
</div> </div>
</>
: :
<p>loading...</p> <p>loading...</p>
} }
</div> </div>
); );
}
} }
export default Results; export default Results;
-1
View File
@@ -15,7 +15,6 @@ class ScrollToTop extends React.Component {
} }
window.scrollTo(0, 0); window.scrollTo(0, 0);
document.body.scrollTop = 0;
} }
render() { render() {
+27 -22
View File
@@ -1,46 +1,51 @@
import React, { useState, useRef } from 'react'; import React, { Component } from 'react';
import { useHistory, useLocation } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import queryString from 'query-string'; import queryString from 'query-string';
const getSearch = location => queryString.parse(location.search).q || ''; const getSearch = props => queryString.parse(props.location.search).q;
function Search() { class Search extends Component {
const history = useHistory(); constructor(props) {
const location = useLocation(); super(props);
const [search, setSearch] = useState(getSearch(location)); this.state = {search: getSearch(this.props)};
const inputRef = useRef(null); this.inputRef = React.createRef();
}
const searchArticles = (event) => { searchArticles = (event) => {
const newSearch = event.target.value; const search = event.target.value;
setSearch(newSearch); this.setState({search: search});
if (newSearch.length >= 3) { if (search.length >= 3) {
const searchQuery = queryString.stringify({ 'q': newSearch }); const searchQuery = queryString.stringify({ 'q': search });
history.replace('/search?' + searchQuery); this.props.history.replace('/search?' + searchQuery);
} else { } else {
history.replace('/'); this.props.history.replace('/');
} }
} }
const searchAgain = (event) => { searchAgain = (event) => {
event.preventDefault(); event.preventDefault();
const searchString = queryString.stringify({ 'q': event.target[0].value }); const searchString = queryString.stringify({ 'q': event.target[0].value });
history.push('/search?' + searchString); this.props.history.push('/search?' + searchString);
inputRef.current.blur(); this.inputRef.current.blur();
} }
render() {
const search = this.state.search;
return ( return (
<span className='search'> <span className='search'>
<form onSubmit={searchAgain}> <form onSubmit={this.searchAgain}>
<input <input
placeholder='Search...' placeholder='Search...'
value={search} value={search}
onChange={searchArticles} onChange={this.searchArticles}
ref={inputRef} ref={this.inputRef}
/> />
</form> </form>
</span> </span>
); );
}
} }
export default Search; export default withRouter(Search);
-191
View File
@@ -1,191 +0,0 @@
import React from 'react';
function Settings({
settingsOpen,
setSettingsOpen,
theme,
setTheme,
isFullScreen,
filterSmallweb,
setFilterSmallweb,
feedSources,
setFeedSources,
bodyFontSize,
setBodyFontSize,
defaultBodyFontSize,
bodyFont,
setBodyFont,
articleFont,
setArticleFont,
}) {
const light = () => {
setTheme('');
localStorage.setItem('theme', '');
};
const dark = () => {
setTheme('dark');
localStorage.setItem('theme', 'dark');
};
const black = () => {
setTheme('black');
localStorage.setItem('theme', 'black');
};
const red = () => {
setTheme('red');
localStorage.setItem('theme', 'red');
};
const handleFilterChange = e => {
const isChecked = e.target.checked;
setFilterSmallweb(isChecked);
localStorage.setItem('filterSmallweb', isChecked);
};
const handleFeedSourceChange = (source) => {
setFeedSources(prevSources => {
const newSources = { ...prevSources, [source]: !prevSources[source] };
localStorage.setItem('feedSources', JSON.stringify(newSources));
return newSources;
});
};
const changeBodyFont = (font) => {
setBodyFont(font);
localStorage.setItem('bodyFont', font);
};
const changeArticleFont = (font) => {
setArticleFont(font);
localStorage.setItem('articleFont', font);
};
const changeBodyFontSize = (amount) => {
const newSize = bodyFontSize + amount;
setBodyFontSize(parseFloat(newSize.toFixed(2)));
localStorage.setItem('bodyFontSize', newSize.toFixed(2));
};
const resetBodyFontSize = () => {
setBodyFontSize(defaultBodyFontSize);
localStorage.removeItem('bodyFontSize');
};
const bodyFontSettingsChanged = bodyFontSize !== defaultBodyFontSize;
const goFullScreen = () => {
if ('wakeLock' in navigator) {
navigator.wakeLock.request('screen');
}
document.body.requestFullscreen({ navigationUI: 'hide' });
};
const exitFullScreen = () => {
document.exitFullscreen();
};
const fullScreenAvailable = document.fullscreenEnabled ||
document.mozFullscreenEnabled ||
document.webkitFullscreenEnabled ||
document.msFullscreenEnabled;
if (!settingsOpen) {
return null;
}
return (
<>
<div className="modal-overlay" onClick={() => setSettingsOpen(false)}></div>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<button className="close-modal-button" onClick={() => setSettingsOpen(false)}>&times;</button>
<h3>Settings</h3>
<div className="setting-group">
<h4>Theme</h4>
<button className={theme === '' ? 'active' : ''} onClick={() => { light(); setSettingsOpen(false); }}>Light</button>
<button className={theme === 'dark' ? 'active' : ''} onClick={() => { dark(); setSettingsOpen(false); }}>Dark</button>
<button className={theme === 'black' ? 'active' : ''} onClick={() => { black(); setSettingsOpen(false); }}>Black</button>
<button className={theme === 'red' ? 'active' : ''} onClick={() => { red(); setSettingsOpen(false); }}>Red</button>
{fullScreenAvailable &&
<div style={{ marginTop: '0.5rem' }}>
{!isFullScreen ?
<button onClick={() => { goFullScreen(); setSettingsOpen(false); }}>Enter Fullscreen</button>
:
<button onClick={() => { exitFullScreen(); setSettingsOpen(false); }}>Exit Fullscreen</button>
}
</div>
}
</div>
<div className="setting-group">
<h4>Feed</h4>
<div className="font-option gap">
<input className="checkbox" type="checkbox" id="filter-smallweb" checked={filterSmallweb} onChange={handleFilterChange} />
<label htmlFor="filter-smallweb">Small websites only</label>
</div>
<div className="font-option">
<input className="checkbox" type="checkbox" id="filter-hackernews" name="feed-source" checked={feedSources.hackernews} onChange={() => handleFeedSourceChange('hackernews')} />
<label htmlFor="filter-hackernews">Hacker News</label>
</div>
<div className="font-option">
<input className="checkbox" type="checkbox" id="filter-reddit" name="feed-source" checked={feedSources.reddit} onChange={() => handleFeedSourceChange('reddit')} />
<label htmlFor="filter-reddit">Reddit</label>
</div>
<div className="font-option">
<input className="checkbox" type="checkbox" id="filter-lobsters" name="feed-source" checked={feedSources.lobsters} onChange={() => handleFeedSourceChange('lobsters')} />
<label htmlFor="filter-lobsters">Lobsters</label>
</div>
<div className="font-option">
<input className="checkbox" type="checkbox" id="filter-tildes" name="feed-source" checked={feedSources.tildes} onChange={() => handleFeedSourceChange('tildes')} />
<label htmlFor="filter-tildes">Tildes</label>
</div>
</div>
<div className="setting-group">
<h4>Font Size</h4>
<button onClick={() => changeBodyFontSize(-0.05)}>-</button>
<span className="font-size-display">{bodyFontSize.toFixed(2)}</span>
<button onClick={() => changeBodyFontSize(0.05)}>+</button>
<button onClick={resetBodyFontSize} disabled={!bodyFontSettingsChanged}>Reset</button>
</div>
<div className="setting-group">
<h4>Body Font</h4>
<div className="font-option">
<input className="checkbox" type="radio" id="body-sans-serif" name="body-font" value="Sans Serif" checked={bodyFont === 'Sans Serif'} onChange={() => changeBodyFont('Sans Serif')} />
<label htmlFor="body-sans-serif">Sans Serif *</label>
</div>
<div className="font-option">
<input className="checkbox" type="radio" id="body-serif" name="body-font" value="Serif" checked={bodyFont === 'Serif'} onChange={() => changeBodyFont('Serif')} />
<label htmlFor="body-serif">Serif</label>
</div>
<div className="font-option">
<input className="checkbox" type="radio" id="body-apparatus" name="body-font" value="Apparatus SIL" checked={bodyFont === 'Apparatus SIL'} onChange={() => changeBodyFont('Apparatus SIL')} />
<label htmlFor="body-apparatus">Apparatus SIL</label>
</div>
</div>
<div className="setting-group">
<h4>Article Font</h4>
<div className="font-option">
<input className="checkbox" type="radio" id="article-sans-serif" name="article-font" value="Sans Serif" checked={articleFont === 'Sans Serif'} onChange={() => changeArticleFont('Sans Serif')} />
<label htmlFor="article-sans-serif">Sans Serif</label>
</div>
<div className="font-option">
<input className="checkbox" type="radio" id="article-serif" name="article-font" value="Serif" checked={articleFont === 'Serif'} onChange={() => changeArticleFont('Serif')} />
<label htmlFor="article-serif">Serif</label>
</div>
<div className="font-option">
<input className="checkbox" type="radio" id="article-apparatus" name="article-font" value="Apparatus SIL" checked={articleFont === 'Apparatus SIL'} onChange={() => changeArticleFont('Apparatus SIL')} />
<label htmlFor="article-apparatus">Apparatus SIL *</label>
</div>
</div>
</div>
</>
);
}
export default Settings;
-103
View File
@@ -1,103 +0,0 @@
.black {
color: #ddd;
}
.black a {
color: #ddd;
}
.black input {
color: #ddd;
border: 1px solid #828282;
}
.black .menu button,
.black .story-text button {
background-color: #222222;
border-color: #bbb;
color: #ddd;
}
.black .item {
color: #828282;
}
.black .item .source-logo {
filter: grayscale(1);
}
.black .item a {
color: #828282;
}
.black .item a.link {
color: #ddd;
}
.black .item a.link:visited {
color: #828282;
}
.black .item .info a.hot {
color: #cccccc;
}
.black .article a {
border-bottom: 1px solid #aaaaaa;
}
.black .article u {
border-bottom: 1px solid #aaaaaa;
text-decoration: none;
}
.black .story-text video,
.black .story-text img {
filter: brightness(50%);
}
.black .article .info {
color: #828282;
}
.black .article .info a {
border-bottom: none;
color: #828282;
}
.black .comment.lined {
border-left: 1px solid #444444;
}
.black .checkbox:checked + label::after {
border-color: #eee;
}
.black .copy-button {
color: #828282;
}
.black .update-banner {
background-color: #333;
color: #ddd;
}
.black .update-banner button {
background-color: #222222;
border-color: #bbb;
color: #ddd;
}
.black .modal-content {
background: #222;
border-color: #828282;
color: #ddd;
}
.black .modal-content button {
background-color: #222222;
border-color: #bbb;
}
.black .modal-content button.active {
background-color: #555;
}
+4 -42
View File
@@ -11,17 +11,14 @@
border: 1px solid #828282; border: 1px solid #828282;
} }
.dark .menu button,
.dark .story-text button {
background-color: #222222;
border-color: #bbb;
color: #ddd;
}
.dark .item { .dark .item {
color: #828282; color: #828282;
} }
.dark .item .source-logo {
filter: grayscale(1);
}
.dark .item a { .dark .item a {
color: #828282; color: #828282;
} }
@@ -46,7 +43,6 @@
text-decoration: none; text-decoration: none;
} }
.dark .story-text video,
.dark .story-text img { .dark .story-text img {
filter: brightness(50%); filter: brightness(50%);
} }
@@ -63,37 +59,3 @@
.dark .comment.lined { .dark .comment.lined {
border-left: 1px solid #444444; border-left: 1px solid #444444;
} }
.dark .checkbox:checked + label::after {
border-color: #eee;
}
.dark .copy-button {
color: #828282;
}
.dark .update-banner {
background-color: #333;
color: #ddd;
}
.dark .update-banner button {
background-color: #222222;
border-color: #bbb;
color: #ddd;
}
.dark .modal-content {
background: #222;
border-color: #828282;
color: #ddd;
}
.dark .modal-content button {
background-color: #222222;
border-color: #bbb;
}
.dark .modal-content button.active {
background-color: #555;
}
+21 -322
View File
@@ -1,32 +1,9 @@
body { body {
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
font-size: 1rem; font: 1rem/1.3 sans-serif;
line-height: 1.3;
color: #000000; color: #000000;
margin-bottom: 100vh;
word-break: break-word; word-break: break-word;
font-kerning: normal;
margin: 0;
}
::backdrop {
background-color: rgba(0,0,0,0);
}
body:fullscreen {
overflow-y: scroll !important;
}
body:-ms-fullscreen {
overflow-y: scroll !important;
}
body:-webkit-full-screen {
overflow-y: scroll !important;
}
body:-moz-full-screen {
overflow-y: scroll !important;
}
#root {
margin: 8px 8px 100vh 8px !important;
} }
a { a {
@@ -44,36 +21,10 @@ input {
border-radius: 4px; border-radius: 4px;
} }
.update-banner {
background-color: #ddd;
padding: 0.75rem;
text-align: center;
}
.update-banner button {
margin-left: 1rem;
padding: 0.25rem 0.75rem;
border: 1px solid #828282;
border-radius: 4px;
background-color: transparent;
cursor: pointer;
}
.fullscreen {
margin: 0.25rem;
padding: 0.25rem;
}
pre { pre {
overflow: auto; overflow: auto;
} }
.comments pre {
overflow: auto;
white-space: pre-wrap;
overflow-wrap: break-word;
}
.container { .container {
margin: 1rem auto; margin: 1rem auto;
max-width: 64rem; max-width: 64rem;
@@ -86,7 +37,6 @@ pre {
.slogan { .slogan {
color: #828282; color: #828282;
margin-bottom: 0.5rem;
} }
.theme { .theme {
@@ -96,7 +46,7 @@ pre {
.item { .item {
display: table; display: table;
color: #828282; color: #828282;
margin-bottom: 0.7rem; margin-bottom: 0.6rem;
} }
.item .source-logo { .item .source-logo {
@@ -111,6 +61,11 @@ pre {
text-decoration: underline; text-decoration: underline;
} }
.item .num {
display: table-cell;
width: 2em;
}
.item a.link { .item a.link {
font-size: 1.1rem; font-size: 1.1rem;
color: #000000; color: #000000;
@@ -143,13 +98,6 @@ span.source {
border-bottom: 1px solid #222222; border-bottom: 1px solid #222222;
} }
.article-title {
display: flex;
align-items: center;
margin-top: 0.67em;
margin-bottom: 0.67em;
}
.article h1 { .article h1 {
font-size: 1.6rem; font-size: 1.6rem;
} }
@@ -173,11 +121,6 @@ span.source {
margin: 0; margin: 0;
} }
.article table {
width: 100%;
table-layout: fixed;
}
.article iframe { .article iframe {
display: none; display: none;
} }
@@ -200,20 +143,12 @@ span.source {
} }
.story-text { .story-text {
font-size: 1.2rem; font: 1.2rem/1.5 'Apparatus SIL', sans-serif;
line-height: 1.5;
margin-top: 1em; margin-top: 1em;
} }
.comments { .comments {
margin-left: -1.25rem; margin-left: -1.25rem;
margin-top: 0;
margin-bottom: 0;
padding: 0;
}
.comments dl, .comments dd {
margin: 0;
} }
.comment { .comment {
@@ -226,73 +161,18 @@ span.source {
.comment .text { .comment .text {
margin-top: -0.5rem; margin-top: -0.5rem;
margin-bottom: 1rem;
} }
.comment .text > * { .toggleDot {
margin-bottom: 0;
}
.comment .text.hidden > p {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: none;
color: #828282;
}
.comment .text.hidden > p:first-child {
display: block;
}
.comment .collapser {
padding-left: 0.5rem;
padding-right: 1.5rem;
}
button.collapser {
background: transparent;
border: none;
margin: 0;
padding-top: 0;
padding-bottom: 0;
font: inherit;
color: inherit;
}
button.comment {
background: transparent;
border-top: none;
border-right: none;
border-bottom: none;
margin: 0;
padding-top: 0;
padding-right: 0;
padding-bottom: 0;
font: inherit;
color: inherit;
text-align: left;
width: 100%;
}
.comment .pointer {
cursor: pointer;
}
.dot {
cursor: pointer;
position: fixed; position: fixed;
bottom: 1rem;
left: 1rem;
height: 3rem; height: 3rem;
width: 3rem; width: 3rem;
background-color: #828282; background-color: #828282;
border-radius: 50%; border-radius: 50%;
} }
.toggleDot {
bottom: 1rem;
left: 1rem;
}
.toggleDot .button { .toggleDot .button {
font: 2rem/1 'icomoon'; font: 2rem/1 'icomoon';
position: relative; position: relative;
@@ -301,204 +181,23 @@ button.comment {
} }
.forwardDot { .forwardDot {
cursor: pointer;
position: fixed;
bottom: 1rem; bottom: 1rem;
right: 1rem; right: 1rem;
height: 3rem;
width: 3rem;
background-color: #828282;
border-radius: 50%;
} }
.forwardDot .button { .forwardDot .button {
font: 2rem/1 'icomoon'; font: 2.5rem/1 'icomoon';
position: relative; position: relative;
top: 0.5rem; top: 0.25rem;
left: 0.5rem; left: 0.3rem;
}
.backwardDot {
bottom: 1rem;
right: 5rem;
}
.backwardDot .button {
font: 2rem/1 'icomoon';
position: relative;
top: 0.5rem;
left: 0.5rem;
} }
.search form { .search form {
display: inline; display: inline;
} }
.copy-button {
font: 1.5rem/1 'icomoon2';
color: #828282;
background: transparent;
border: none;
cursor: pointer;
vertical-align: middle;
}
.checkbox {
-webkit-appearance: none;
appearance: none;
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
.checkbox + label {
position: relative;
cursor: pointer;
padding-left: 1.75rem;
user-select: none;
}
.checkbox + label::before {
content: '';
position: absolute;
left: 0;
top: 0.1em;
width: 1rem;
height: 1rem;
border: 1px solid #828282;
background-color: transparent;
border-radius: 3px;
}
.checkbox:checked + label::after {
content: "";
position: absolute;
left: 0.35rem;
top: 0.2em;
width: 0.3rem;
height: 0.6rem;
border-style: solid;
border-color: #000;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.tooltip .tooltiptext {
visibility: hidden;
width: 140px;
background-color: #555;
color: #fff;
text-align: center;
border-radius: 6px;
padding: 5px 0;
position: absolute;
z-index: 1;
bottom: 110%;
left: 50%;
margin-left: -70px;
opacity: 0;
transition: opacity 0.2s;
font-size: 0.9rem;
line-height: 1.3;
}
.forwardDot .tooltiptext {
left: auto;
right: 0;
margin-left: 0;
}
.tooltip.show-tooltip .tooltiptext {
visibility: visible;
opacity: 1;
}
.settings-button {
float: right;
background: none;
border: none;
padding: 0;
cursor: pointer;
color: inherit;
font: inherit;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
z-index: 100;
}
.modal-content {
position: absolute;
top: 1rem;
right: 1rem;
background: #eee;
color: #000;
padding: 1rem;
border-radius: 4px;
z-index: 101;
min-width: 250px;
border: 1px solid #828282;
}
.modal-content h3, .modal-content h4 {
margin-top: 0;
margin-bottom: 0.5rem;
}
.modal-content .setting-group {
margin-bottom: 1rem;
}
.modal-content button {
margin-right: 0.5rem;
padding: 0.25rem 0.75rem;
border: 1px solid #828282;
border-radius: 4px;
background-color: transparent;
cursor: pointer;
font: inherit;
color: inherit;
}
.modal-content button:last-child {
margin-right: 0;
}
.modal-content button.active {
background-color: #ccc;
}
.modal-content button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.modal-content .font-size-display {
display: inline-block;
width: 50px;
text-align: center;
margin: 0 0.25rem;
}
.modal-content .font-option {
margin-bottom: 0.25rem;
}
.modal-content .gap {
margin-bottom: 0.75rem;
}
.close-modal-button {
position: absolute;
top: 0.5rem;
right: 0.75rem;
background: transparent;
border: none;
font-size: 1.5rem;
line-height: 1;
padding: 0;
cursor: pointer;
color: inherit;
}
-121
View File
@@ -1,121 +0,0 @@
.red {
color: #b00;
scrollbar-color: #b00 #440000;
}
.red a {
color: #b00;
}
.red input {
color: #b00;
border: 1px solid #690000;
}
.red input::placeholder {
color: #690000;
}
.red hr {
background-color: #690000;
}
.red .menu button,
.red .story-text button {
background-color: #220000;
border-color: #b00;
color: #b00;
}
.red .item,
.red .slogan {
color: #690000;
}
.red .item .source-logo {
display: none;
}
.red .item a {
color: #690000;
}
.red .item a.link {
color: #b00;
}
.red .item a.link:visited {
color: #690000;
}
.red .item .info a.hot {
color: #cc0000;
}
.red .article a {
border-bottom: 1px solid #aa0000;
}
.red .article u {
border-bottom: 1px solid #aa0000;
text-decoration: none;
}
.red .story-text video,
.red .story-text img {
filter: grayscale(100%) brightness(20%) sepia(100%) hue-rotate(-50deg) saturate(600%) contrast(0.8);
}
.red .article .info {
color: #690000;
}
.red .article .info a {
border-bottom: none;
color: #690000;
}
.red .comment.lined {
border-left: 1px solid #440000;
}
.red .dot {
background-color: #440000;
}
.red .checkbox + label::before {
border: 1px solid #690000;
}
.red .checkbox:checked + label::after {
border-color: #dd0000;
}
.red .copy-button {
color: #690000;
}
.red .update-banner {
background-color: #300;
color: #d00;
}
.red .update-banner button {
background-color: #220000;
border-color: #b00;
color: #d00;
}
.red .modal-content {
background: #100;
border-color: #690000;
color: #b00;
}
.red .modal-content button {
background-color: #220000;
border-color: #b00;
}
.red .modal-content button.active {
background-color: #550000;
}
+34 -33
View File
@@ -1,53 +1,54 @@
import React, { useState, useRef } from 'react'; import React, { Component } from 'react';
import { useHistory } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
function Submit() { class Submit extends Component {
const [progress, setProgress] = useState(null); constructor(props) {
const inputRef = useRef(null); super(props);
const history = useHistory();
const submitArticle = async (event) => { this.state = {
progress: null,
};
this.inputRef = React.createRef();
}
submitArticle = (event) => {
event.preventDefault(); event.preventDefault();
const url = event.target[0].value; const url = event.target[0].value;
inputRef.current.blur(); this.inputRef.current.blur();
setProgress('Submitting...'); this.setState({ progress: 'Submitting...' });
let data = new FormData(); let data = new FormData();
data.append('url', url); data.append('url', url);
try { fetch('/api/submit', { method: 'POST', body: data })
const res = await fetch('/api/submit', { method: 'POST', body: data }); .then(res => res.json())
.then(
(result) => {
this.props.history.replace('/' + result.nid);
},
(error) => {
this.setState({ progress: 'Error' });
}
);
}
if (res.ok) { render() {
const result = await res.json(); const progress = this.state.progress;
history.replace('/' + result.nid);
} else {
let errorData;
try {
errorData = await res.json();
} catch (jsonError) {
// Not a JSON error from our API, so it's a server issue
throw new Error(`Server responded with ${res.status} ${res.statusText}`);
}
setProgress(errorData.error || 'An unknown error occurred.');
}
} catch (error) {
setProgress(`Error: ${error.toString()}`);
}
}
return ( return (
<span className='search'> <span className='search'>
<form onSubmit={submitArticle}> <form onSubmit={this.submitArticle}>
<input <input
placeholder='Submit URL' placeholder='Submit Article'
ref={inputRef} ref={this.inputRef}
/> />
</form> </form>
{progress && <p>{progress}</p>} {progress ? progress : ''}
</span> </span>
); );
}
} }
export default Submit; export default withRouter(Submit);
-5
View File
@@ -26,8 +26,3 @@
font-family: 'Icomoon'; font-family: 'Icomoon';
src: url('icomoon.ttf') format('truetype'); src: url('icomoon.ttf') format('truetype');
} }
@font-face {
font-family: 'Icomoon2';
src: url('icomoon2.ttf') format('truetype');
}
Binary file not shown.
Binary file not shown.
+1 -7
View File
@@ -3,15 +3,9 @@ import ReactDOM from 'react-dom';
import App from './App'; import App from './App';
import * as serviceWorker from './serviceWorker'; import * as serviceWorker from './serviceWorker';
// version 4
ReactDOM.render(<App />, document.getElementById('root')); ReactDOM.render(<App />, document.getElementById('root'));
// If you want your app to work offline and load faster, you can change // If you want your app to work offline and load faster, you can change
// // unregister() to register() below. Note this comes with some pitfalls. // // unregister() to register() below. Note this comes with some pitfalls.
// // Learn more about service workers: https://bit.ly/CRA-PWA // // Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.register({ serviceWorker.register();
onUpdate: registration => {
window.dispatchEvent(new CustomEvent('swUpdate', { detail: registration }));
}
});
+17 -56
View File
File diff suppressed because one or more lines are too long
+3322 -4854
View File
File diff suppressed because it is too large Load Diff