Slowly remove DSL and split frontend into components
This commit is contained in:
parent
0ebb5f7013
commit
3cd7b78d81
11 changed files with 329 additions and 243 deletions
0
backend/__init__.py
Normal file
0
backend/__init__.py
Normal file
|
@ -1,62 +1,26 @@
|
|||
from datetime import datetime
|
||||
from flask import Flask, jsonify, request
|
||||
from elasticsearch_dsl import Document, Date, Integer, Keyword, Text, FacetedSearch, TermsFacet, DateHistogramFacet
|
||||
from elasticsearch_dsl import Search
|
||||
from elasticsearch_dsl.connections import connections
|
||||
from tqdm import tqdm
|
||||
from beeprint import pp
|
||||
import csv
|
||||
import click
|
||||
import re
|
||||
|
||||
class Question(Document):
|
||||
title = Text(analyzer="snowball")
|
||||
body = Text(analyzer="snowball")
|
||||
category = Keyword()
|
||||
date = Date()
|
||||
from .question import Question
|
||||
from .question_search import QuestionSearch
|
||||
|
||||
class Index:
|
||||
name = "goeievraag"
|
||||
|
||||
def save(self, **kwargs):
|
||||
return super(Question, self).save(**kwargs)
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
id_ = self.meta.id
|
||||
if not self.category:
|
||||
return f"https://www.startpagina.nl/v/vraag/{id_}"
|
||||
|
||||
category = self.category.lower()
|
||||
category = re.sub("[^A-Za-z ]", "", category).replace(" ", "-")
|
||||
return f"https://www.startpagina.nl/v/{category}/vraag/{id_}"
|
||||
|
||||
@property
|
||||
def summary(self, length=128):
|
||||
if len(self.body) > length:
|
||||
return self.body[:length - 3] + "..."
|
||||
|
||||
return self.body
|
||||
|
||||
|
||||
|
||||
class QuestionSearch(FacetedSearch):
|
||||
doc_types = Question,
|
||||
fields = "category", "title", "body"
|
||||
|
||||
facets = {
|
||||
"date_frequency": DateHistogramFacet(field="date", interval="month"),
|
||||
"category": TermsFacet(field="category")
|
||||
}
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
connections.create_connection(hosts=["localhost"])
|
||||
Question.init()
|
||||
|
||||
def round_sigfig(value, figures):
|
||||
return float(format(value, f".{figures}g"))
|
||||
|
||||
@app.route("/api/")
|
||||
def index():
|
||||
def round_sigfig(value, figures):
|
||||
return float(format(value, f".{figures}g"))
|
||||
|
||||
query = request.args.get("q")
|
||||
categories = request.args.get("categories", None)
|
||||
|
||||
|
@ -65,19 +29,41 @@ def index():
|
|||
category_list = categories.split(",")
|
||||
facets["category"] = category_list
|
||||
|
||||
search = QuestionSearch(query, facets)
|
||||
search = Search.from_dict({
|
||||
"query": {
|
||||
"query_string": {
|
||||
"query": query,
|
||||
},
|
||||
},
|
||||
"aggregations": {
|
||||
"category": {
|
||||
"terms": {"field": "category"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
response = search.execute()
|
||||
|
||||
date_facets = [{"timestamp": date.timestamp(), "count": count}
|
||||
for date, count, _ in response.facets.date_frequency]
|
||||
category_facets = [{"category": category, "count": round_sigfig(count, 3)}
|
||||
for category, count, _ in response.facets.category]
|
||||
#date_facets = [{"timestamp": date.timestamp(), "count": count}
|
||||
#for date, count, _ in response.facets.date_frequency]
|
||||
category_facets = [
|
||||
{"category": bucket.key, "count": round_sigfig(bucket.doc_count, 3)}
|
||||
for bucket in response.aggregations.category.buckets
|
||||
]
|
||||
|
||||
results = [{"id": hit.meta.id, "score": hit.meta.score, "title": hit.title,
|
||||
"body": hit.summary, "category": hit.category,
|
||||
"date": hit.date, "url": hit.url}
|
||||
for hit in response]
|
||||
date_facets = []
|
||||
|
||||
results = []
|
||||
for hit in response:
|
||||
summary = Question.summary(hit)
|
||||
url = Question.url(hit)
|
||||
|
||||
results.append({
|
||||
"id": hit.meta.id, "score": hit.meta.score,
|
||||
"title": hit.title, "body": summary,
|
||||
"category": hit.category, "date": hit.date,
|
||||
"url": url,
|
||||
})
|
||||
|
||||
return jsonify(
|
||||
facets={"months": date_facets, "categories": category_facets},
|
||||
|
|
24
backend/question.py
Normal file
24
backend/question.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
from elasticsearch_dsl import Document, Date, Keyword, Text
|
||||
|
||||
|
||||
class Question(Document):
|
||||
title = Text(analyzer="snowball")
|
||||
body = Text(analyzer="snowball")
|
||||
category = Keyword()
|
||||
date = Date()
|
||||
|
||||
class Index:
|
||||
name = "goeievraag"
|
||||
|
||||
def save(self, **kwargs):
|
||||
return super(Question, self).save(**kwargs)
|
||||
|
||||
def url(self):
|
||||
id_ = self.meta.id
|
||||
return f"https://www.startpagina.nl/v/vraag/{id_}/"
|
||||
|
||||
def summary(self, length=128):
|
||||
if len(self.body) > length:
|
||||
return self.body[:length - 3] + "..."
|
||||
|
||||
return self.body
|
12
backend/question_search.py
Normal file
12
backend/question_search.py
Normal file
|
@ -0,0 +1,12 @@
|
|||
from elasticsearch_dsl import FacetedSearch, TermsFacet, DateHistogramFacet
|
||||
from .question import Question
|
||||
|
||||
|
||||
class QuestionSearch(FacetedSearch):
|
||||
doc_types = Question,
|
||||
fields = "title", "body"
|
||||
|
||||
facets = {
|
||||
"date_frequency": DateHistogramFacet(field="date", interval="month"),
|
||||
"category": TermsFacet(field="category"),
|
||||
}
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>zoekmachine</title>
|
||||
<title>Goeievraagle</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div id="app">
|
||||
<router-view/>
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -14,6 +14,10 @@ body {
|
|||
font-size: 13px;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#app {
|
||||
font-family: Arial, sans-serif;
|
||||
color: #2c3e50;
|
||||
|
|
|
@ -4,15 +4,33 @@
|
|||
<img class="logo__image" src="@/assets/logo.png">
|
||||
</div>
|
||||
<div class="query">
|
||||
<input class="query__input">
|
||||
<input class="query__input" v-model="query" @keyup.enter="search">
|
||||
<div class="query__buttons">
|
||||
<input type="button" value="Goeievraagle zoeken" class="query__submit">
|
||||
<input type="button" value="Ik doe een gok" class="query__submit">
|
||||
<input type="button" value="Goeievraagle zoeken" class="query__submit" @click="search">
|
||||
<input type="button" value="Ik doe een gok" class="query__submit" @click="lucky">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from "vue";
|
||||
import {Component} from "vue-property-decorator";
|
||||
|
||||
@Component
|
||||
export default class Index extends Vue {
|
||||
query = "";
|
||||
|
||||
search() {
|
||||
const query = encodeURIComponent(this.query);
|
||||
this.$router.push(`/search?q=${query}`);
|
||||
}
|
||||
|
||||
lucky() {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.index {
|
||||
text-align: center;
|
||||
|
|
174
frontend/src/components/ResultBody.vue
Normal file
174
frontend/src/components/ResultBody.vue
Normal file
|
@ -0,0 +1,174 @@
|
|||
<template>
|
||||
<div class="body">
|
||||
<div class="result-count">
|
||||
Ongeveer {{hits}} resultaten ({{responseTime.toFixed(2)}} seconden)
|
||||
</div>
|
||||
|
||||
<div class="body__inner">
|
||||
<div class="body__facets">
|
||||
<div v-for="facet in facets.categories" v-bind:key="facet.category"
|
||||
v-bind:class="{active: activeCategories.includes(facet.category)}"
|
||||
class="category facet" @click="toggleCategory(facet)">
|
||||
<div class="category__facet">{{facet.category}}</div>
|
||||
|
||||
<div v-if="facet.count < 10000" class="category__count">{{facet.count}}</div>
|
||||
<div v-else class="category__count">{{(facet.count / 1000).toFixed()}}K</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="body__results">
|
||||
<div v-for="result in results" v-bind:key="result.id" class="result">
|
||||
<a :href="result.url" target="_blank">
|
||||
<div class="result__title">{{result.title}}</div>
|
||||
<div class="result__link">
|
||||
{{result.url}} • {{result.category}} • {{result.score.toFixed(3)}}
|
||||
</div>
|
||||
<div class="result__body">
|
||||
<span class="result__date">{{result.date}} -</span>
|
||||
{{result.body}}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from "vue";
|
||||
import {Component, Prop, Watch} from "vue-property-decorator";
|
||||
|
||||
@Component
|
||||
export default class ResultBody extends Vue {
|
||||
@Prop() query;
|
||||
|
||||
activeCategories = [];
|
||||
hits = 0;
|
||||
results = [];
|
||||
responseTime = 0;
|
||||
facets = {};
|
||||
|
||||
@Watch("query")
|
||||
async onQueryChanged(value) {
|
||||
await this.search();
|
||||
}
|
||||
|
||||
async toggleCategory(category) {
|
||||
const name = category.category;
|
||||
|
||||
const index = this.activeCategories.indexOf(name);
|
||||
if (index === -1) {
|
||||
this.activeCategories.push(name);
|
||||
} else {
|
||||
this.activeCategories.splice(index, 1);
|
||||
}
|
||||
|
||||
await this.search();
|
||||
}
|
||||
|
||||
async search() {
|
||||
const query = encodeURIComponent(this.query);
|
||||
let queryString = `?q=${query}`;
|
||||
|
||||
if (this.activeCategories.length > 0) {
|
||||
const categories = encodeURIComponent(this.activeCategories.join(","));
|
||||
queryString += `&categories=${categories}`;
|
||||
}
|
||||
|
||||
this.$router.history.push(`/search${queryString}`);
|
||||
const url = `/api/${queryString}`;
|
||||
|
||||
let response = await fetch(url);
|
||||
let json = await response.json();
|
||||
|
||||
this.results = json.results;
|
||||
this.hits = json.hits;
|
||||
this.responseTime = json.took;
|
||||
this.facets = json.facets;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.body__inner {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.result-count {
|
||||
line-height: 43px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
margin-left: 208px;
|
||||
}
|
||||
|
||||
.result {
|
||||
margin: 24px 0;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.result:first-child {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.result__title {
|
||||
font-size: 18px;
|
||||
margin: 1px 0;
|
||||
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.result__link {
|
||||
font-size: 14px;
|
||||
color: #006621;
|
||||
}
|
||||
|
||||
.result__body {
|
||||
margin: 4px 0;
|
||||
|
||||
color: rgba(0, 0, 0, .68);
|
||||
}
|
||||
|
||||
.result__date {
|
||||
color: rgba(0, 0, 0, .5);
|
||||
}
|
||||
|
||||
.body__facets {
|
||||
width: 184px;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
.facet {
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.facet:hover {
|
||||
background-color: rgba(0, 0, 0, .1);
|
||||
}
|
||||
|
||||
.facet.active {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.category {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.category__facet {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.category__count {
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
|
@ -1,202 +1,25 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="topbar">
|
||||
<div class="logo">
|
||||
<img class="logo__image" src="@/assets/logo.png">
|
||||
</div>
|
||||
|
||||
<div class="query">
|
||||
<input class="query__input" v-model="query" @change="getResults">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<div class="result-count">
|
||||
Ongeveer {{hits}} resultaten ({{responseTime.toFixed(2)}} seconden)
|
||||
</div>
|
||||
|
||||
<div class="body__inner">
|
||||
<div class="body__facets">
|
||||
<div v-for="facet in facets.categories" v-bind:key="facet.category"
|
||||
v-bind:class="{active: activeCategories.includes(facet.category)}"
|
||||
class="category facet" @click="toggleCategory(facet)">
|
||||
<div class="category__facet">{{facet.category}}</div>
|
||||
|
||||
<div v-if="facet.count < 10000" class="category__count">{{facet.count}}</div>
|
||||
<div v-else class="category__count">{{(facet.count / 1000).toFixed()}}K</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="body__results">
|
||||
<div v-for="result in results" v-bind:key="result.id" class="result">
|
||||
<a :href="result.url" target="_blank">
|
||||
<div class="result__title">{{result.title}}</div>
|
||||
<div class="result__link">{{result.url}}</div>
|
||||
<div class="result__body">
|
||||
<span class="result__date">{{result.date}} -</span>
|
||||
{{result.body}}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<top-bar v-model="query" />
|
||||
<result-body :query="query" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
background-color: rgb(250, 250, 250);
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo__image {
|
||||
width: 180px;
|
||||
margin: 20px 16px 12px;
|
||||
}
|
||||
|
||||
.query__input {
|
||||
width: 550px;
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.body__inner {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.result-count {
|
||||
line-height: 43px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
margin-left: 208px;
|
||||
}
|
||||
|
||||
.result {
|
||||
margin: 24px 0;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.result:first-child {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.result__title {
|
||||
font-size: 18px;
|
||||
margin: 1px 0;
|
||||
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.result__link {
|
||||
font-size: 14px;
|
||||
color: #006621;
|
||||
}
|
||||
|
||||
.result__body {
|
||||
margin: 4px 0;
|
||||
|
||||
color: rgba(0, 0, 0, .68);
|
||||
}
|
||||
|
||||
.result__date {
|
||||
color: rgba(0, 0, 0, .5);
|
||||
}
|
||||
|
||||
.body__facets {
|
||||
width: 184px;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
.facet {
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.facet:hover {
|
||||
background-color: rgba(0, 0, 0, .1);
|
||||
}
|
||||
|
||||
.facet.active {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.category {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.category__facet {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.category__count {
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import Vue from "vue";
|
||||
import {Component} from "vue-property-decorator";
|
||||
|
||||
@Component
|
||||
import ResultBody from "@/components/ResultBody";
|
||||
import TopBar from "@/components/TopBar";
|
||||
|
||||
@Component({
|
||||
"components": {ResultBody, TopBar},
|
||||
})
|
||||
export default class Results extends Vue {
|
||||
query = "";
|
||||
|
||||
hits = 0;
|
||||
results = [];
|
||||
facets = {};
|
||||
|
||||
activeCategories = [];
|
||||
|
||||
responseTime = 0.0;
|
||||
|
||||
async toggleCategory(category) {
|
||||
const name = category.category;
|
||||
|
||||
const index = this.activeCategories.indexOf(name);
|
||||
if (index === -1) {
|
||||
this.activeCategories.push(name);
|
||||
} else {
|
||||
this.activeCategories.splice(index, 1);
|
||||
}
|
||||
|
||||
await this.getResults();
|
||||
}
|
||||
|
||||
async getResults() {
|
||||
let url = `/api/?q=${this.query}`;
|
||||
|
||||
if (this.activeCategories.length > 0) {
|
||||
const categories = encodeURIComponent(this.activeCategories.join(","));
|
||||
url += `&categories=${categories}`;
|
||||
}
|
||||
|
||||
let response = await fetch(url);
|
||||
let json = await response.json();
|
||||
|
||||
this.results = json.results;
|
||||
this.hits = json.hits;
|
||||
this.responseTime = json.took;
|
||||
this.facets = json.facets;
|
||||
}
|
||||
|
||||
async mounted() {
|
||||
await this.getResults();
|
||||
this.query = this.$route.query.q;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
44
frontend/src/components/TopBar.vue
Normal file
44
frontend/src/components/TopBar.vue
Normal file
|
@ -0,0 +1,44 @@
|
|||
<template>
|
||||
<div class="topbar">
|
||||
<router-link class="logo" to="/">
|
||||
<img class="logo__image" src="@/assets/logo.png">
|
||||
</router-link>
|
||||
|
||||
<div class="query">
|
||||
<input class="query__input" @keyup.enter="search" :value="value">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.topbar {
|
||||
background-color: rgb(250, 250, 250);
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo__image {
|
||||
width: 180px;
|
||||
margin: 20px 16px 12px;
|
||||
}
|
||||
|
||||
.query__input {
|
||||
width: 550px;
|
||||
margin: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import Vue from "vue";
|
||||
import {Component, Prop} from "vue-property-decorator";
|
||||
|
||||
@Component
|
||||
export default class TopBar extends Vue {
|
||||
@Prop() value = "";
|
||||
|
||||
search(event) {
|
||||
this.$emit("input", event.target.value)
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -7,6 +7,7 @@ import Results from "@/components/Results";
|
|||
Vue.use(Router);
|
||||
|
||||
export default new Router({
|
||||
mode: "history",
|
||||
routes: [
|
||||
{
|
||||
path: "/search",
|
||||
|
|
Loading…
Reference in a new issue