forked from tanner/qotnews
Allow manual submission of articles
This commit is contained in:
parent
38b5f2dbeb
commit
2edb3ceba7
|
@ -7,7 +7,7 @@ import requests
|
||||||
import time
|
import time
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
from feeds import hackernews, reddit, tildes
|
from feeds import hackernews, reddit, tildes, manual
|
||||||
|
|
||||||
OUTLINE_API = 'https://outlineapi.com/article'
|
OUTLINE_API = 'https://outlineapi.com/article'
|
||||||
ARCHIVE_API = 'https://archive.fo/submit/'
|
ARCHIVE_API = 'https://archive.fo/submit/'
|
||||||
|
@ -99,6 +99,8 @@ def update_story(story):
|
||||||
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':
|
||||||
|
res = manual.story(story['ref'])
|
||||||
|
|
||||||
if res:
|
if res:
|
||||||
story.update(res) # join dicts
|
story.update(res) # join dicts
|
||||||
|
|
46
apiserver/feeds/manual.py
Normal file
46
apiserver/feeds/manual.py
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import logging
|
||||||
|
logging.basicConfig(
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
level=logging.DEBUG)
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
USER_AGENT = 'Twitterbot/1.0'
|
||||||
|
|
||||||
|
def api(route):
|
||||||
|
try:
|
||||||
|
headers = {'User-Agent': USER_AGENT}
|
||||||
|
r = requests.get(route, headers=headers, timeout=5)
|
||||||
|
if r.status_code != 200:
|
||||||
|
raise Exception('Bad response code ' + str(r.status_code))
|
||||||
|
return r.text
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
raise
|
||||||
|
except BaseException as e:
|
||||||
|
logging.error('Problem hitting manual website: {}'.format(str(e)))
|
||||||
|
return False
|
||||||
|
|
||||||
|
def story(ref):
|
||||||
|
html = api(ref)
|
||||||
|
if not html: return False
|
||||||
|
|
||||||
|
soup = BeautifulSoup(html, features='html.parser')
|
||||||
|
|
||||||
|
s = {}
|
||||||
|
s['author'] = 'manual submission'
|
||||||
|
s['author_link'] = 'https://news.t0.vc'
|
||||||
|
s['score'] = 0
|
||||||
|
s['date'] = int(time.time())
|
||||||
|
s['title'] = str(soup.title.string)
|
||||||
|
s['link'] = ref
|
||||||
|
s['url'] = ref
|
||||||
|
s['comments'] = []
|
||||||
|
s['num_comments'] = 0
|
||||||
|
|
||||||
|
return s
|
||||||
|
|
||||||
|
# scratchpad so I can quickly develop the parser
|
||||||
|
if __name__ == '__main__':
|
||||||
|
print(story('https://www.backblaze.com/blog/what-smart-stats-indicate-hard-drive-failures/'))
|
|
@ -13,7 +13,7 @@ import archive
|
||||||
import feed
|
import feed
|
||||||
from utils import gen_rand_id
|
from utils import gen_rand_id
|
||||||
|
|
||||||
from flask import abort, Flask, request, render_template
|
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
|
||||||
|
|
||||||
|
@ -36,12 +36,37 @@ with shelve.open(DATA_FILE) as db:
|
||||||
news_ref_to_id = db.get('news_ref_to_id', {})
|
news_ref_to_id = db.get('news_ref_to_id', {})
|
||||||
news_cache = db.get('news_cache', {})
|
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: ' + str(e))
|
||||||
|
logging.info('Clearing caches...')
|
||||||
|
news_list = []
|
||||||
|
news_ref_to_id = {}
|
||||||
|
news_cache = {}
|
||||||
|
|
||||||
def get_story(sid):
|
def get_story(sid):
|
||||||
if sid in news_cache:
|
if sid in news_cache:
|
||||||
return news_cache[sid]
|
return news_cache[sid]
|
||||||
else:
|
else:
|
||||||
return archive.get_story(sid)
|
return archive.get_story(sid)
|
||||||
|
|
||||||
|
def new_id():
|
||||||
|
nid = gen_rand_id()
|
||||||
|
while nid in news_cache or archive.get_story(nid):
|
||||||
|
nid = gen_rand_id()
|
||||||
|
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))
|
||||||
|
|
||||||
build_folder = '../webclient/build'
|
build_folder = '../webclient/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='')
|
||||||
cors = CORS(flask_app)
|
cors = CORS(flask_app)
|
||||||
|
@ -66,6 +91,20 @@ def search():
|
||||||
res = []
|
res = []
|
||||||
return {'results': res}
|
return {'results': res}
|
||||||
|
|
||||||
|
@flask_app.route('/api/submit', methods=['POST'], strict_slashes=False)
|
||||||
|
def submit():
|
||||||
|
url = request.form['url']
|
||||||
|
nid = new_id()
|
||||||
|
news_story = dict(id=nid, ref=url, source='manual')
|
||||||
|
news_cache[nid] = news_story
|
||||||
|
valid = feed.update_story(news_story)
|
||||||
|
if valid:
|
||||||
|
archive.update(news_story)
|
||||||
|
return {'nid': nid}
|
||||||
|
else:
|
||||||
|
news_cache.pop(nid, '')
|
||||||
|
abort(400)
|
||||||
|
|
||||||
@flask_app.route('/api/<sid>')
|
@flask_app.route('/api/<sid>')
|
||||||
def story(sid):
|
def story(sid):
|
||||||
story = get_story(sid)
|
story = get_story(sid)
|
||||||
|
@ -105,19 +144,6 @@ def static_story(sid):
|
||||||
url=url,
|
url=url,
|
||||||
description=description)
|
description=description)
|
||||||
|
|
||||||
def new_id():
|
|
||||||
nid = gen_rand_id()
|
|
||||||
while nid in news_cache or archive.get_story(nid):
|
|
||||||
nid = gen_rand_id()
|
|
||||||
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))
|
|
||||||
|
|
||||||
http_server = WSGIServer(('', 33842), flask_app)
|
http_server = WSGIServer(('', 33842), flask_app)
|
||||||
|
|
||||||
def feed_thread():
|
def feed_thread():
|
||||||
|
@ -159,7 +185,9 @@ def feed_thread():
|
||||||
news_index += 1
|
news_index += 1
|
||||||
if news_index == CACHE_LENGTH: news_index = 0
|
if news_index == CACHE_LENGTH: news_index = 0
|
||||||
|
|
||||||
except BaseException as e:
|
except KeyboardInterrupt:
|
||||||
|
logging.info('Ending feed thread...')
|
||||||
|
except ValueError as e:
|
||||||
logging.error('feed_thread error: {} {}'.format(e.__class__.__name__, e))
|
logging.error('feed_thread error: {} {}'.format(e.__class__.__name__, e))
|
||||||
http_server.stop()
|
http_server.stop()
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ 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';
|
||||||
import Search from './Search.js';
|
import Search from './Search.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';
|
||||||
|
|
||||||
|
@ -60,6 +61,7 @@ class App extends React.Component {
|
||||||
<span className='slogan'>Reddit, Hacker News, and Tildes combined, then pre-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} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Route path='/' exact render={(props) => <Feed {...props} updateCache={this.updateCache} />} />
|
<Route path='/' exact render={(props) => <Feed {...props} updateCache={this.updateCache} />} />
|
||||||
|
|
|
@ -34,18 +34,16 @@ class Search extends Component {
|
||||||
const search = this.state.search;
|
const search = this.state.search;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='search'>
|
<span className='search'>
|
||||||
<div className='search-inside'>
|
<form onSubmit={this.searchAgain}>
|
||||||
<form onSubmit={this.searchAgain}>
|
<input
|
||||||
<input
|
placeholder='Search...'
|
||||||
placeholder='Search...'
|
value={search}
|
||||||
value={search}
|
onChange={this.searchArticles}
|
||||||
onChange={this.searchArticles}
|
ref={this.inputRef}
|
||||||
ref={this.inputRef}
|
/>
|
||||||
/>
|
</form>
|
||||||
</form>
|
</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ input {
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: 1px solid #828282;
|
border: 1px solid #828282;
|
||||||
|
margin: 0.25rem;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
@ -173,3 +174,7 @@ span.source {
|
||||||
top: 0.1rem;
|
top: 0.1rem;
|
||||||
left: 0.1rem;
|
left: 0.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search form {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
54
webclient/src/Submit.js
Normal file
54
webclient/src/Submit.js
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { withRouter } from 'react-router-dom';
|
||||||
|
|
||||||
|
class Submit extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
progress: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.inputRef = React.createRef();
|
||||||
|
}
|
||||||
|
|
||||||
|
submitArticle = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const url = event.target[0].value;
|
||||||
|
this.inputRef.current.blur();
|
||||||
|
|
||||||
|
this.setState({ progress: 'Submitting...' });
|
||||||
|
|
||||||
|
let data = new FormData();
|
||||||
|
data.append('url', url);
|
||||||
|
|
||||||
|
fetch('/api/submit', { method: 'POST', body: data })
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(
|
||||||
|
(result) => {
|
||||||
|
this.props.history.replace('/' + result.nid);
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
this.setState({ progress: 'Error' });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const progress = this.state.progress;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className='search'>
|
||||||
|
<form onSubmit={this.submitArticle}>
|
||||||
|
<input
|
||||||
|
placeholder='Submit Article'
|
||||||
|
ref={this.inputRef}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
{progress ? progress : ''}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withRouter(Submit);
|
Loading…
Reference in New Issue
Block a user