Add source to git
This commit is contained in:
parent
9c6975dc17
commit
0d48c773ef
|
@ -0,0 +1,5 @@
|
||||||
|
from cli import run
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
# Standard library imports
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Third party imports
|
||||||
|
from flask import Flask, render_template, request, jsonify
|
||||||
|
|
||||||
|
# Application imports
|
||||||
|
from inquisitor import dungeon, core
|
||||||
|
|
||||||
|
# Globals
|
||||||
|
logger = logging.getLogger("inquisitor.app")
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
console = logging.StreamHandler()
|
||||||
|
console.setLevel(logging.INFO)
|
||||||
|
formatter = logging.Formatter('[%(asctime)s %(levelname)s:%(filename)s:%(lineno)d] %(message)s')
|
||||||
|
console.setFormatter(formatter)
|
||||||
|
logger.addHandler(console)
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
dungeon = dungeon.Dungeon("dungeon")
|
||||||
|
itemsources = core.load_all_sources("sources")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/feed/")
|
||||||
|
def root():
|
||||||
|
active_items = dungeon.get_active_items()
|
||||||
|
logger.info("Found {} active items".format(len(active_items)))
|
||||||
|
for item in active_items:
|
||||||
|
item['time_readable'] = str(datetime.fromtimestamp(item['time']))
|
||||||
|
active_items.sort(key=lambda i: i['time'])
|
||||||
|
return render_template("feed.html", items=active_items[:100])
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/deactivate/", methods=['POST'])
|
||||||
|
def deactivate():
|
||||||
|
params = request.get_json()
|
||||||
|
if 'source' not in params and 'itemid' not in params:
|
||||||
|
logger.error("Bad request params: {}".format(params))
|
||||||
|
item = dungeon.deactivate_item(params['source'], params['itemid'])
|
||||||
|
return jsonify({'active': item['active']})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/feed.css")
|
||||||
|
def css():
|
||||||
|
with open("feed.css") as f:
|
||||||
|
return f.read()
|
|
@ -0,0 +1,55 @@
|
||||||
|
# Standard library imports
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Application imports
|
||||||
|
from core import load_all_sources
|
||||||
|
from dungeon import Dungeon
|
||||||
|
|
||||||
|
# Globals
|
||||||
|
logger = logging.getLogger("inquisitor.cli")
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--log", default="INFO", help="Set the log level (default: INFO)")
|
||||||
|
subparsers = parser.add_subparsers(help="Command to execute", dest="command")
|
||||||
|
subparsers.required = True
|
||||||
|
|
||||||
|
update_parser = subparsers.add_parser("update", help="Fetch new items")
|
||||||
|
update_parser.add_argument("--srcdir", help="Path to sources folder (default ./sources)",
|
||||||
|
default="./sources")
|
||||||
|
update_parser.add_argument("--dungeon", help="Path to item cache folder (default ./dungeon)",
|
||||||
|
default="./dungeon")
|
||||||
|
update_parser.add_argument("--sources", help="Sources to update, by name",
|
||||||
|
nargs="*")
|
||||||
|
update_parser.set_defaults(func=update)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
loglevel = getattr(logging, args.log.upper())
|
||||||
|
if not isinstance(loglevel, int):
|
||||||
|
raise ValueError("Invalid log level: {}".format(args.log))
|
||||||
|
logging.basicConfig(format='[%(levelname)s:%(filename)s:%(lineno)d] %(message)s', level=loglevel)
|
||||||
|
|
||||||
|
args.func(args)
|
||||||
|
|
||||||
|
|
||||||
|
def update(args):
|
||||||
|
"""Fetches new items from sources and stores them in the dungeon."""
|
||||||
|
if not os.path.isdir(args.srcdir):
|
||||||
|
logger.error("srcdir must be a directory")
|
||||||
|
exit(-1)
|
||||||
|
if not os.path.isdir(args.dungeon):
|
||||||
|
logger.error("dungeon must be a directory")
|
||||||
|
exit(-1)
|
||||||
|
sources = load_all_sources(args.srcdir)
|
||||||
|
names = args.sources or [s.SOURCE for s in sources]
|
||||||
|
dungeon = Dungeon(args.dungeon)
|
||||||
|
for itemsource in sources:
|
||||||
|
if itemsource.SOURCE in names:
|
||||||
|
new_items = dungeon.update(itemsource)
|
||||||
|
items = dungeon.get_active_items_for_folder(itemsource.SOURCE)
|
||||||
|
logger.info("{} new item{}".format(new_items, "s" if new_items != 1 else ""))
|
|
@ -0,0 +1,42 @@
|
||||||
|
# Standard library imports
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Globals
|
||||||
|
logger = logging.getLogger("inquisitor.core")
|
||||||
|
|
||||||
|
|
||||||
|
def load_source_module(source_path):
|
||||||
|
"""Loads a source module and checks for necessary members."""
|
||||||
|
logger.debug("load_source_module('{}')".format(source_path))
|
||||||
|
spec = importlib.util.spec_from_file_location("itemsource", source_path)
|
||||||
|
itemsource = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(itemsource)
|
||||||
|
if not hasattr(itemsource, 'SOURCE'):
|
||||||
|
raise ImportError("SOURCE missing")
|
||||||
|
if not hasattr(itemsource, 'fetch_new'):
|
||||||
|
raise ImportError("fetch_new missing")
|
||||||
|
return itemsource
|
||||||
|
|
||||||
|
|
||||||
|
def load_all_sources(source_folder):
|
||||||
|
"""Loads all source modules in the given folder."""
|
||||||
|
# Navigate to the sources folder
|
||||||
|
cwd = os.getcwd()
|
||||||
|
os.chdir(source_folder)
|
||||||
|
# Load all sources
|
||||||
|
source_names = [
|
||||||
|
filename
|
||||||
|
for filename in os.listdir()
|
||||||
|
if filename.endswith(".py")]
|
||||||
|
sources = []
|
||||||
|
for source_name in source_names:
|
||||||
|
try:
|
||||||
|
itemsource = load_source_module(source_name)
|
||||||
|
sources.append(itemsource)
|
||||||
|
except ImportError as e:
|
||||||
|
logger.error("Error importing {}: {}".format(source_name, e))
|
||||||
|
# Return to cwd
|
||||||
|
os.chdir(cwd)
|
||||||
|
return sources
|
|
@ -0,0 +1,111 @@
|
||||||
|
# Standard library imports
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import ast
|
||||||
|
|
||||||
|
|
||||||
|
class Dungeon():
|
||||||
|
def __init__(self, path):
|
||||||
|
"""
|
||||||
|
Serves as an interface between Inquisitor and a folder of
|
||||||
|
serialized readable items.
|
||||||
|
"""
|
||||||
|
self.path = path
|
||||||
|
self.log = logging.getLogger("inquisitor.dungeon")
|
||||||
|
|
||||||
|
def load_path(self, path):
|
||||||
|
self.log.debug("Loading item from {}".format(path))
|
||||||
|
with open(path, 'r', encoding='utf-8') as f:
|
||||||
|
item = ast.literal_eval(f.read())
|
||||||
|
return item
|
||||||
|
|
||||||
|
def load_item(self, source, itemid):
|
||||||
|
item_path = os.path.join(self.path, source, itemid + ".item")
|
||||||
|
return self.load_path(item_path)
|
||||||
|
|
||||||
|
def save_item(self, item):
|
||||||
|
path = os.path.join(self.path, item['source'], item['id'] + ".item")
|
||||||
|
self.log.debug("Saving item {} to {}".format(item['id'], path))
|
||||||
|
with open(path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(str(item))
|
||||||
|
|
||||||
|
def update(self, itemsource):
|
||||||
|
"""
|
||||||
|
Fetches items from the given source, saves new active items,
|
||||||
|
and clears out old inactive items.
|
||||||
|
"""
|
||||||
|
new_items = 0
|
||||||
|
self.log.info("Updating source {}".format(itemsource.SOURCE))
|
||||||
|
# Check if the source has a folder.
|
||||||
|
source_folder = os.path.join(self.path, itemsource.SOURCE)
|
||||||
|
source_state = os.path.join(source_folder, "state")
|
||||||
|
if not os.path.isdir(source_folder):
|
||||||
|
self.log.info("Creating folder {}".format(source_folder))
|
||||||
|
os.mkdir(source_folder)
|
||||||
|
# Initialize persistent state.
|
||||||
|
with open(source_state, 'w') as f:
|
||||||
|
f.write("{}")
|
||||||
|
# Load source persistent state.
|
||||||
|
state = self.load_path(source_state)
|
||||||
|
# Any inactive items that no longer show up as new should be
|
||||||
|
# removed. Track which items to check for inactivity.
|
||||||
|
extant_items_to_check = [
|
||||||
|
filename
|
||||||
|
for filename in os.listdir(source_folder)
|
||||||
|
if filename.endswith(".item")]
|
||||||
|
# Get the new items from the source.
|
||||||
|
source_items = itemsource.fetch_new(state)
|
||||||
|
with open(source_state, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(str(state))
|
||||||
|
for source_item in source_items:
|
||||||
|
file_path = os.path.join(source_folder, source_item['id'] + ".item")
|
||||||
|
if os.path.isfile(file_path):
|
||||||
|
# Still-new items are exempt from activity checks.
|
||||||
|
extant_items_to_check.remove(source_item['id'] + ".item")
|
||||||
|
item = self.load_path(file_path)
|
||||||
|
if not item['active']:
|
||||||
|
# Don't reactivate inactive items.
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
new_items += 1
|
||||||
|
# Add new items and update active ones.
|
||||||
|
self.save_item(source_item)
|
||||||
|
# Check old items for inactivity.
|
||||||
|
for extant_item_filename in extant_items_to_check:
|
||||||
|
file_path = os.path.join(source_folder, extant_item_filename)
|
||||||
|
item = self.load_path(file_path)
|
||||||
|
if not item['active']:
|
||||||
|
# Remove old inactive items.
|
||||||
|
self.log.info("Deleting {}".format(file_path))
|
||||||
|
os.remove(file_path)
|
||||||
|
return new_items
|
||||||
|
|
||||||
|
def get_active_items(self):
|
||||||
|
source_folders = os.listdir(self.path)
|
||||||
|
items = []
|
||||||
|
for source_folder_name in source_folders:
|
||||||
|
items.extend(self.get_active_items_for_folder(source_folder_name))
|
||||||
|
return items
|
||||||
|
|
||||||
|
def get_active_items_for_folder(self, source):
|
||||||
|
source_folder = os.path.join(self.path, source)
|
||||||
|
item_filenames = os.listdir(source_folder)
|
||||||
|
items = []
|
||||||
|
for item_filename in item_filenames:
|
||||||
|
if not item_filename.endswith(".item"):
|
||||||
|
continue
|
||||||
|
file_path = os.path.join(source_folder, item_filename)
|
||||||
|
item = self.load_path(file_path)
|
||||||
|
if item['active']:
|
||||||
|
items.append(item)
|
||||||
|
return items
|
||||||
|
|
||||||
|
def deactivate_item(self, source, itemid):
|
||||||
|
item_path = os.path.join(self.path, source, itemid + ".item")
|
||||||
|
if not os.path.isfile(item_path):
|
||||||
|
self.log.error("No item found: {}".format(item_path))
|
||||||
|
return
|
||||||
|
item = self.load_path(item_path)
|
||||||
|
item['active'] = False
|
||||||
|
self.save_item(item)
|
||||||
|
return item
|
|
@ -0,0 +1,39 @@
|
||||||
|
div#wrapper {
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.readable-item {
|
||||||
|
border: 1px solid black;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 5px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.item-title {
|
||||||
|
font-size: 1.4em;
|
||||||
|
}
|
||||||
|
.readable-item button {
|
||||||
|
font-size: 1em;
|
||||||
|
float:right;
|
||||||
|
}
|
||||||
|
.item-link {
|
||||||
|
text-decoration: none;
|
||||||
|
float:right;
|
||||||
|
margin: 0px 2px;
|
||||||
|
font-size: 1em;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.item-info {
|
||||||
|
color: rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
button, summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
summary:focus {
|
||||||
|
outline: 1px dotted gray;
|
||||||
|
}
|
||||||
|
.strikethru span, .strikethru p {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
|
@ -0,0 +1,51 @@
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<link rel="stylesheet" type="text/css" href="/feed.css" />
|
||||||
|
<script>
|
||||||
|
var deactivate = function (source, itemid) {
|
||||||
|
fetch('/deactivate/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json; charset=UTF-8',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({source: source, itemid: itemid}),
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(function (data) {
|
||||||
|
if (!data.active) {
|
||||||
|
document.getElementById(source + "-" + itemid)
|
||||||
|
.classList.add("strikethru")
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="wrapper">
|
||||||
|
{% if items %}
|
||||||
|
{% for item in items %}
|
||||||
|
<div class="readable-item" id="{{item.source}}-{{item.id}}">
|
||||||
|
<button onclick="javascript:deactivate('{{item.source}}', '{{item.id}}')">✕</button>
|
||||||
|
{% if item.link %}
|
||||||
|
<a class="item-link" href="{{item.link}}" target="_blank">⇗</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.body %}
|
||||||
|
<details>
|
||||||
|
<summary><span class="item-title">{{item.title}}</span></summary>
|
||||||
|
<p>{{item.body|safe}}</p>
|
||||||
|
</details>
|
||||||
|
{% else %}
|
||||||
|
<span class="item-title">{{item.title}}</span><br>
|
||||||
|
{% endif %}
|
||||||
|
<span class="item-info">{{item.time_readable}} {{item.author}}</span><br>
|
||||||
|
<span class="item-info">{{item.source}} {{item.id}} {{item.time_readable}}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="readable-item">
|
||||||
|
<span class="item-title">Feed is empty</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
Reference in New Issue