├── .editorconfig ├── .eslintrc ├── .gitignore ├── Procfile ├── README.md ├── coldoutreach ├── __init__.py ├── settings.py ├── urls.py └── wsgi.py ├── generator ├── __init__.py ├── admin.py ├── apps.py ├── fixtures │ ├── Christopher_Salat_Ceev.pdf │ ├── DanBernierProfile.pdf │ ├── DylanHirschkornProfile.pdf │ ├── SeanDuganMurphyProfile.pdf │ ├── TariqAliProfile.pdf │ └── Tariq_Ali.pdf ├── letter_generator.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── resume_parser.py ├── resume_saver.py ├── technology_jargon.py ├── templates │ └── generator │ │ ├── email.html │ │ ├── index.html │ │ └── profile.html ├── tests.py ├── urls.py └── views.py ├── manage.py ├── package-lock.json ├── package.json ├── public ├── index.html └── manifest.json ├── requirements.txt └── src ├── App.js ├── actions └── index.js ├── assets ├── fonts │ ├── avenir-next-medium.woff │ ├── avenir-next-regular.woff │ └── avenir-next-thin.woff ├── img │ ├── candidate.svg │ ├── coldoutreach-logo.png │ ├── decor.svg │ ├── email.svg │ ├── generate.svg │ ├── recruit_1.svg │ ├── recruit_2.svg │ ├── recruit_3.svg │ └── slack.svg └── styles │ └── scss │ ├── base.scss │ ├── card.scss │ ├── email.scss │ ├── header.scss │ ├── home.scss │ ├── info.scss │ ├── resume.scss │ └── testimonial.scss ├── components ├── Editor.js ├── Email.js ├── Header.js ├── Home.js ├── Resume.js └── landing │ ├── Card.js │ ├── Info.js │ ├── Slide.js │ └── Testimonial.js ├── containers ├── Email.js ├── Home.js ├── Resume.js └── index.js ├── epics ├── index.js └── resume.js ├── index.js ├── reducers ├── index.js └── resume.js ├── registerServiceWorker.js ├── routes.js └── store.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | # 4 space indentation 12 | [*.{html,scss}] 13 | indent_size = 4 14 | 15 | [*.md] 16 | max_line_length = off 17 | trim_trailing_whitespace = false 18 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["react-app", "plugin:jsx-a11y/recommended"], 3 | "plugins": ["jsx-a11y"] 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | *.pyc 3 | staticfiles 4 | .env 5 | .idea 6 | .vscode 7 | node_modules 8 | db.sqlite3 9 | *.css 10 | build/ 11 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | release: python manage.py migrate 2 | web: gunicorn coldoutreach.wsgi --log-file - -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Coldoutreach 2 | [Coldoutreach Recruitment App](https://github.com/gitcoinco/skunkworks/issues/38) 3 | 4 | 5 | ### Objective 6 | To generate personalized cold-call emails based on the procedure outlined by Kevin Owocki in [this blog post](https://owocki.com/recruit-a-list-engineers-what-a-cold-recruitment-message-should-look-like/) 7 | 8 | ### Frontend 9 | ```sh 10 | $ npm i 11 | $ npm start 12 | ``` 13 | 14 | ### Backend 15 | ```sh 16 | $ pip3 install -r requirements.txt 17 | $ python3 manage.py runserver 18 | ``` 19 | 20 | ### Staging Site 21 | [Coldoutreach Staging](https://coldoutreach-staging.herokuapp.com) 22 | -------------------------------------------------------------------------------- /coldoutreach/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitcoinco/coldoutreach/7bf97c803ec6065493e33b6740aa690569bb1e14/coldoutreach/__init__.py -------------------------------------------------------------------------------- /coldoutreach/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for coldoutreach project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.11. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.11/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.11/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') 18 | STATICFILES_STORAGE = 'whitenoise.django.GzipManifestStaticFilesStorage' 19 | 20 | # Quick-start development settings - unsuitable for production 21 | # See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ 22 | 23 | # SECURITY WARNING: keep the secret key used in production secret! 24 | SECRET_KEY = 'r&5nft-mspqiseydm3-4+o9ifx=y-ji%a!vfy$ocdd2!$elc$$' 25 | 26 | # SECURITY WARNING: don't run with debug turned on in production! 27 | DEBUG = True 28 | 29 | ALLOWED_HOSTS = ["coldoutreach-staging.herokuapp.com", "127.0.0.1"] 30 | 31 | 32 | # Application definition 33 | 34 | INSTALLED_APPS = [ 35 | 'django.contrib.admin', 36 | 'django.contrib.auth', 37 | 'django.contrib.contenttypes', 38 | 'django.contrib.sessions', 39 | 'django.contrib.messages', 40 | 'django.contrib.staticfiles', 41 | 'generator.apps.GeneratorConfig', 42 | 'corsheaders' 43 | ] 44 | 45 | MIDDLEWARE = [ 46 | 'django.middleware.security.SecurityMiddleware', 47 | 'django.contrib.sessions.middleware.SessionMiddleware', 48 | 'corsheaders.middleware.CorsMiddleware', 49 | 'django.middleware.common.CommonMiddleware', 50 | 'django.middleware.csrf.CsrfViewMiddleware', 51 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 52 | 'django.contrib.messages.middleware.MessageMiddleware', 53 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 54 | ] 55 | 56 | ROOT_URLCONF = 'coldoutreach.urls' 57 | 58 | TEMPLATES = [ 59 | { 60 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 61 | 'DIRS': [ 62 | os.path.join(BASE_DIR, 'build') 63 | ], 64 | 'APP_DIRS': True, 65 | 'OPTIONS': { 66 | 'context_processors': [ 67 | 'django.template.context_processors.debug', 68 | 'django.template.context_processors.request', 69 | 'django.contrib.auth.context_processors.auth', 70 | 'django.contrib.messages.context_processors.messages', 71 | ], 72 | }, 73 | }, 74 | ] 75 | 76 | WSGI_APPLICATION = 'coldoutreach.wsgi.application' 77 | 78 | 79 | # Database 80 | # https://docs.djangoproject.com/en/1.11/ref/settings/#databases 81 | 82 | DATABASES = { 83 | 'default': { 84 | 'ENGINE': 'django.db.backends.sqlite3', 85 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 86 | } 87 | } 88 | 89 | 90 | # Password validation 91 | # https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators 92 | 93 | AUTH_PASSWORD_VALIDATORS = [ 94 | { 95 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 96 | }, 97 | { 98 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 99 | }, 100 | { 101 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 102 | }, 103 | { 104 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 105 | }, 106 | ] 107 | 108 | 109 | # Internationalization 110 | # https://docs.djangoproject.com/en/1.11/topics/i18n/ 111 | 112 | LANGUAGE_CODE = 'en-us' 113 | 114 | TIME_ZONE = 'UTC' 115 | 116 | USE_I18N = True 117 | 118 | USE_L10N = True 119 | 120 | USE_TZ = True 121 | 122 | 123 | # Static files (CSS, JavaScript, Images) 124 | # https://docs.djangoproject.com/en/1.11/howto/static-files/ 125 | 126 | STATIC_URL = '/static/' 127 | 128 | STATICFILES_DIRS = [ 129 | os.path.join(BASE_DIR, 'build/static') 130 | ] -------------------------------------------------------------------------------- /coldoutreach/urls.py: -------------------------------------------------------------------------------- 1 | """coldoutreach URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/1.11/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.conf.urls import url, include 14 | 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) 15 | """ 16 | from django.conf.urls import url, include, re_path 17 | from django.contrib import admin 18 | from django.views.generic import TemplateView 19 | 20 | urlpatterns = [ 21 | url(r'^admin/', admin.site.urls), 22 | url(r'api/', include('generator.urls')), 23 | re_path('.*', TemplateView.as_view(template_name='index.html')) 24 | ] 25 | -------------------------------------------------------------------------------- /coldoutreach/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for coldoutreach project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | from whitenoise.django import DjangoWhiteNoise 14 | 15 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "coldoutreach.settings") 16 | 17 | application = get_wsgi_application() 18 | application = DjangoWhiteNoise(application) -------------------------------------------------------------------------------- /generator/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitcoinco/coldoutreach/7bf97c803ec6065493e33b6740aa690569bb1e14/generator/__init__.py -------------------------------------------------------------------------------- /generator/admin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.contrib import admin 5 | 6 | # Register your models here. 7 | -------------------------------------------------------------------------------- /generator/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.apps import AppConfig 5 | 6 | 7 | class GeneratorConfig(AppConfig): 8 | name = 'generator' 9 | -------------------------------------------------------------------------------- /generator/fixtures/Christopher_Salat_Ceev.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitcoinco/coldoutreach/7bf97c803ec6065493e33b6740aa690569bb1e14/generator/fixtures/Christopher_Salat_Ceev.pdf -------------------------------------------------------------------------------- /generator/fixtures/DanBernierProfile.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitcoinco/coldoutreach/7bf97c803ec6065493e33b6740aa690569bb1e14/generator/fixtures/DanBernierProfile.pdf -------------------------------------------------------------------------------- /generator/fixtures/DylanHirschkornProfile.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitcoinco/coldoutreach/7bf97c803ec6065493e33b6740aa690569bb1e14/generator/fixtures/DylanHirschkornProfile.pdf -------------------------------------------------------------------------------- /generator/fixtures/SeanDuganMurphyProfile.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitcoinco/coldoutreach/7bf97c803ec6065493e33b6740aa690569bb1e14/generator/fixtures/SeanDuganMurphyProfile.pdf -------------------------------------------------------------------------------- /generator/fixtures/TariqAliProfile.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitcoinco/coldoutreach/7bf97c803ec6065493e33b6740aa690569bb1e14/generator/fixtures/TariqAliProfile.pdf -------------------------------------------------------------------------------- /generator/fixtures/Tariq_Ali.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitcoinco/coldoutreach/7bf97c803ec6065493e33b6740aa690569bb1e14/generator/fixtures/Tariq_Ali.pdf -------------------------------------------------------------------------------- /generator/letter_generator.py: -------------------------------------------------------------------------------- 1 | import json 2 | from collections import Counter 3 | import tracery 4 | from tracery.modifiers import base_english 5 | 6 | # To generate the letter, we use pytracery (https://github.com/aparrish/pytracery), a Python port of the Tracery text expansion library. 7 | def create_letter(keyword): 8 | rules = { 9 | 'origin': '#introduction# #talk_about_talent# #lead_into_job_description#', 10 | 'introduction': ['#found# your #website# via Google and #loved# #reading# it.', '#found# your #website#, and #loved# #reading# it.', '#found# your #website# via a keyword search.'], 11 | 'found': ['Found', 'Stumbled upon', 'Discovered'], 12 | 'loved': ['loved', 'enjoyed', 'appreciated'], 13 | 'website': ['website', 'blog', 'profile', 'resume'], 14 | 'reading': ['reading', 'looking at'], 15 | 'talk_about_talent': ['We share the same #passion# for KEYWORD.','You look like you #know# #keyword_praise#.'], 16 | 'know': ['know', 'understand'], 17 | 'keyword_praise': ['your KEYWORD-fu', 'KEYWORD deeply', 'KEYWORD down to a science'], 18 | 'passion': ['passion', 'love', 'affection', 'zeal', 'devotion'], 19 | 'lead_into_job_description': ['I have #interesting.a# project for you to look at.', "My company is looking for #interesting# people like you."], 20 | 'interesting': ['interesting', 'cool', 'awesome', 'neat'], 21 | } 22 | grammar = tracery.Grammar(rules) 23 | grammar.add_modifiers(base_english) 24 | letter = grammar.flatten("#origin#").replace("KEYWORD", keyword) 25 | return letter 26 | 27 | def convert_json_string_to_set(json_string): 28 | json_file = json.loads(json_string) 29 | counter = json_file['counter'] 30 | return Counter(counter) 31 | 32 | def generate(recruiter_json, candidate_json): 33 | recruiter_counter = convert_json_string_to_set(recruiter_json) 34 | candidate_counter = convert_json_string_to_set(candidate_json) 35 | combined_set = recruiter_counter & candidate_counter 36 | greatest_keyword = combined_set.most_common(1) 37 | if (len(greatest_keyword) > 0): 38 | greatest_keyword = greatest_keyword[0][0] 39 | else: 40 | greatest_keyword = "software" 41 | generated_letter = create_letter(greatest_keyword) 42 | return generated_letter -------------------------------------------------------------------------------- /generator/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.1 on 2018-02-04 16:43 2 | 3 | from django.db import migrations, models 4 | import uuid 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='LinkedInPDF', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('guid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), 20 | ('json', models.TextField()), 21 | ], 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /generator/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitcoinco/coldoutreach/7bf97c803ec6065493e33b6740aa690569bb1e14/generator/migrations/__init__.py -------------------------------------------------------------------------------- /generator/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models 5 | 6 | import uuid 7 | 8 | # Create your models here. 9 | class LinkedInPDF(models.Model): 10 | guid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) 11 | json = models.TextField() -------------------------------------------------------------------------------- /generator/resume_parser.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # dependency - pdfminer 4 | # or pdfminer.six (for python3) 5 | 6 | # Originally from https://github.com/hellpanderrr/linkedin-pdf-parsing 7 | # But its codebase was changed heavily 8 | # License - GNU 2.0, will probably relicense this up... 9 | 10 | # -*- coding: utf8 -*- 11 | import os 12 | import re 13 | import sqlite3 14 | import argparse 15 | import sys 16 | import glob 17 | # from collections import OrderedDict 18 | from collections import Counter 19 | 20 | import generator.technology_jargon as technology_jargon 21 | 22 | from pdfminer.pdfparser import PDFParser 23 | from pdfminer.pdfdocument import PDFDocument 24 | from pdfminer.pdfpage import PDFPage 25 | from pdfminer.pdfpage import PDFTextExtractionNotAllowed 26 | from pdfminer.pdfinterp import PDFResourceManager 27 | from pdfminer.pdfinterp import PDFPageInterpreter 28 | from pdfminer.pdfdevice import PDFDevice 29 | from pdfminer.layout import LAParams 30 | from pdfminer.converter import PDFPageAggregator 31 | from pdfminer.layout import LAParams, LTTextBox, LTTextLine, LTFigure, LTImage, LTTextLineHorizontal, LTChar, LTLine, \ 32 | LTText 33 | import json 34 | 35 | import re 36 | 37 | isiterable = lambda obj: isinstance(obj, str) or getattr(obj, '__iter__', False) 38 | 39 | def get_objects(layout): 40 | # collecting all objects from the layout, 1 level depth 41 | objs = [] 42 | for obj in layout: 43 | 44 | if isiterable(obj): 45 | for element in obj: 46 | objs.append(element) 47 | else: 48 | objs.append(obj) 49 | return objs 50 | 51 | def convert_pdf_to_objects(input_file): 52 | f = input_file 53 | fp = open(f, 'rb') 54 | # Create a PDF parser object associated with the file object. 55 | parser = PDFParser(fp) 56 | # Create a PDF document object that stores the document structure. 57 | document = PDFDocument(parser) 58 | # Check if the document allows text extraction. If not, abort. 59 | if not document.is_extractable: 60 | raise PDFTextExtractionNotAllowed 61 | # Create a PDF resource manager object that stores shared resources. 62 | rsrcmgr = PDFResourceManager() 63 | # Create a PDF device object. 64 | laparams = LAParams() 65 | # Create a PDF page aggregator object. 66 | device = PDFPageAggregator(rsrcmgr, laparams=laparams) 67 | interpreter = PDFPageInterpreter(rsrcmgr, device) 68 | objs = [] 69 | for page in PDFPage.create_pages(document): 70 | interpreter.process_page(page) 71 | # receive the LTPage object for the page. 72 | layout = device.get_result() 73 | # collecting objects from the all pages, sorting them by their Y coordinate 74 | objs.append(sorted(get_objects(layout), key=lambda x: x.y0, reverse=True)) 75 | objs = sum(objs, []) # flattening to 1D array 76 | # getting objects from the corresponding sections 77 | return objs 78 | 79 | def extract_text(objs): 80 | text = "" 81 | for obj in objs: 82 | if "get_text" in dir(obj): 83 | text += obj.get_text() 84 | cleansed_text = text.replace('\n', ' ') 85 | return cleansed_text 86 | 87 | def count_of_technology_words(word, resume_as_text): 88 | escaped_word = re.escape(word) 89 | regex_pattern = r'(^|\s|[.]){word}($|\s|[.])'.format(word=escaped_word) 90 | array = re.findall(regex_pattern, resume_as_text, re.IGNORECASE) 91 | return len(array) 92 | 93 | def get_counter(resume_as_text): 94 | counter = Counter() 95 | 96 | for word in technology_jargon.keywords: 97 | count = count_of_technology_words(word, resume_as_text) 98 | if count > 0: 99 | counter[word] += count 100 | 101 | return counter 102 | 103 | def extract_data(resume_as_text, regex_pattern): 104 | match_object = re.search(regex_pattern, resume_as_text) 105 | if match_object: 106 | return match_object.group(0) 107 | else: 108 | return "" 109 | 110 | def get_name(resume_as_text): 111 | # Normally, a candidate's name has two words, both of which starts with capital letters 112 | normal_name_regex_pattern = r'([A-Z][a-z]*)(\s[A-Z][a-z]*)' 113 | normal_name = extract_data(resume_as_text, normal_name_regex_pattern) 114 | 115 | # One resume in the test set displays the name of the candidate in all-caps, which is really annoying. So we have this code to handle the edge case. 116 | edge_case_regex_pattern = r'([A-Za-z]*)(\s[A-Za-z]*)' 117 | edge_case_name = extract_data(resume_as_text, edge_case_regex_pattern) 118 | 119 | if len(edge_case_name) > len(normal_name): 120 | return edge_case_name.title() 121 | else: 122 | return normal_name.title() 123 | 124 | def get_email(resume_as_text): 125 | email_regex_pattern = r'([a-z0-9_\.-]+)@([\da-z\.-]+)\.([a-z\.]{2,6})' 126 | email = extract_data(resume_as_text, email_regex_pattern) 127 | return email 128 | 129 | def convert(input_file): 130 | objs = convert_pdf_to_objects(input_file) 131 | 132 | resume_as_text = extract_text(objs) 133 | 134 | name = get_name(resume_as_text) 135 | email = get_email(resume_as_text) 136 | counter = get_counter(resume_as_text) 137 | 138 | dictionary = { "counter": counter, "name": name, "email": email} 139 | 140 | return json.dumps(dictionary) 141 | -------------------------------------------------------------------------------- /generator/resume_saver.py: -------------------------------------------------------------------------------- 1 | from django.core.files.storage import FileSystemStorage 2 | from generator.models import LinkedInPDF 3 | from collections import namedtuple 4 | import generator.resume_parser as resume_parser 5 | import uuid 6 | 7 | Resume = namedtuple('Resume', ['guid', 'json']) 8 | 9 | def save(myfile): 10 | fs = FileSystemStorage() 11 | extension = myfile.name.split('.')[-1] 12 | random_name = uuid.uuid4() 13 | filename ="%s.%s" % (random_name, extension) 14 | fs.save(filename, myfile) 15 | uploaded_file_json = resume_parser.convert(filename) 16 | fs.delete(filename) 17 | 18 | # While this works locally, the problem is that it does not work in Heroku. This is because of Heroku's file system, which does not play nice with SQLite. We'd need to switch over to Postgres. 19 | # pdf = LinkedInPDF(guid=random_name, json=uploaded_file_json) 20 | # pdf.save() 21 | 22 | return Resume(guid=random_name, json=uploaded_file_json) 23 | -------------------------------------------------------------------------------- /generator/technology_jargon.py: -------------------------------------------------------------------------------- 1 | array = [ 2 | # https://github.com/dariusk/corpora/blob/master/data/technology/programming_languages.json 3 | # Accessed Feb. 8, 2018 4 | "ABAP", 5 | "ActionScript", 6 | "Ada", 7 | "Assembly language", 8 | "AutoHotkey", 9 | "AutoIt", 10 | "Awk", 11 | "C", 12 | "C#", 13 | "C++", 14 | "COBOL", 15 | "CSS", 16 | "CoffeeScript", 17 | "D", 18 | "Dart", 19 | "Erlang", 20 | "F#", 21 | "Fortran", 22 | "FoxPro", 23 | "Go", 24 | "Groovy", 25 | "Haskell", 26 | "Java", 27 | "JavaScript", 28 | "LabVIEW", 29 | "Ladder Logic", 30 | "Lisp", 31 | "Logo", 32 | "Lua", 33 | "MATLAB", 34 | "ML", 35 | "Object Pascal", 36 | "Objective-C", 37 | "OpenEdge ABL", 38 | "PHP", 39 | "PL/SQL", 40 | "Pascal", 41 | "Perl", 42 | "Prolog", 43 | "Python", 44 | "R", 45 | "RPG (OS/400)", 46 | "Ruby", 47 | "Rust", 48 | "SAS", 49 | "Swift", 50 | "TeX", 51 | "Transact-SQL", 52 | "VBScript", 53 | "VHDL", 54 | "Visual Basic", 55 | # https://github.com/dariusk/corpora/blob/master/data/technology/computer_sciences.json 56 | # accessed Feb. 8, 2018 57 | ".QL", 58 | "ActionScript", 59 | "ActiveRecord", 60 | "AIM", 61 | "Ajax", 62 | "Algol", 63 | "Amazon", 64 | "Angular", 65 | "Apache", 66 | "AppleScript", 67 | "AppStream", 68 | "ASPnet", 69 | "AutoHotKey", 70 | "Aviato", 71 | "AWS", 72 | "Backbone", 73 | "BASIC", 74 | "Bootstrap", 75 | "Bower", 76 | "Browserify", 77 | "Bundler", 78 | "Csharp", 79 | "Canvas", 80 | "Capistrano", 81 | "Cassandra", 82 | "ClearDB", 83 | "CloudFormation", 84 | "CloudFront", 85 | "CloudSearch", 86 | "CloudTrail", 87 | "CloudWatch", 88 | "CodeCommit", 89 | "CodeDeploy", 90 | "CodePipeline", 91 | "CoffeeScript", 92 | "Cognito", 93 | "CouchDB", 94 | "CrunchBang", 95 | "CSS3", 96 | "Cucumber", 97 | "D3", 98 | "Dart", 99 | "Diaspora", 100 | "Discourse", 101 | "Django", 102 | "Drupal", 103 | "DynamoDB", 104 | "EBS", 105 | "EC2", 106 | "EJS", 107 | "ElacticBeanstalk", 108 | "Elasticache", 109 | "ElasticSearch", 110 | "Emacs", 111 | "Ember", 112 | "ERB", 113 | "Erlang", 114 | "Express", 115 | "Facebook", 116 | "Fedora", 117 | "Foursquare", 118 | "Flash", 119 | "Flickr", 120 | "FORTRAN", 121 | "Foundation", 122 | "FTP", 123 | "Ghost", 124 | "GitHub", 125 | "Glacier", 126 | "Gmail", 127 | "GNUemacs", 128 | "GNUlinux", 129 | "GoogleDocs", 130 | "GoogleMaps", 131 | "GrapheneDB", 132 | "Grunt", 133 | "HacketyHack", 134 | "Hadoop", 135 | "Heroku", 136 | "Hipchat", 137 | "HTML5", 138 | "ICQ", 139 | "IFTT", 140 | "ImageMagick", 141 | "Imgur", 142 | "Indiegogo", 143 | "Instagram", 144 | "IRB", 145 | "IRC", 146 | "IronCache", 147 | "Jasmine", 148 | "Java", 149 | "Javascript", 150 | "Jekyll", 151 | "jQuery", 152 | "KeenIO", 153 | "Kickstarter", 154 | "Knockout", 155 | "LaTeX", 156 | "LeapMotion", 157 | "LevelDB", 158 | "Linux", 159 | "Lisp", 160 | "Lyft", 161 | "MariaDB", 162 | "MarkDown", 163 | "Memcached", 164 | "Middleman", 165 | "Minitest", 166 | "Mocha", 167 | "MongoDB", 168 | "MySql", 169 | "Netflix", 170 | "NewRelic", 171 | "Nginx", 172 | "NLTK", 173 | "Node.js", 174 | "Nokogiri", 175 | "NoSQL", 176 | "NPM", 177 | "OAuth", 178 | "Objective-C", 179 | "OCaml", 180 | "OCR", 181 | "Octopress", 182 | "OculusRift", 183 | "OpenCV", 184 | "Opera", 185 | "Oracle", 186 | "Pandora", 187 | "Passenger", 188 | "Perl", 189 | "PGP", 190 | "PHP", 191 | "PIP", 192 | "Polymer", 193 | "Postgres", 194 | "Processing", 195 | "PubNub", 196 | "PushNotifications", 197 | "Python", 198 | "Quora", 199 | "Rack", 200 | "Rails", 201 | "React", 202 | "RedHat", 203 | "Redis", 204 | "Refinery", 205 | "Route53", 206 | "RSpec", 207 | "Ruby", 208 | "Rust", 209 | "Sails", 210 | "Scala", 211 | "Scheme", 212 | "Scratch", 213 | "Sendgrid", 214 | "SES", 215 | "Silverlight", 216 | "Sinatra", 217 | "Slack", 218 | "SNS", 219 | "Solr", 220 | "Spotify", 221 | "SpoonRocket", 222 | "Sqlite", 223 | "SQS", 224 | "SSH", 225 | "Swift", 226 | "SWF", 227 | "TCP", 228 | "TempoDB", 229 | "Tumblr", 230 | "Twilio", 231 | "Twitter", 232 | "Uber", 233 | "Ubuntu", 234 | "UbuWeb", 235 | "Unicorn", 236 | "VBScript", 237 | "Vim", 238 | "VisualBasic", 239 | "Webaudio", 240 | "Webrick", 241 | "Websockets", 242 | "Wolfram Language", 243 | "WordPress", 244 | "XTags", 245 | "Yahoo", 246 | "Yelp", 247 | "YouTube", 248 | "Zepto", 249 | # https://github.com/dariusk/corpora/blob/master/data/technology/lisp.json 250 | "ABCL", 251 | "ACL", 252 | "ACL2", 253 | "ANSI Common Lisp", 254 | "Allegro CL", 255 | "Arc", 256 | "Armed Bear CL", 257 | "AutoLISP", 258 | "CCL", 259 | "CLISP", 260 | "CLiCC", 261 | "CMU CL", 262 | "CMUCL", 263 | "Clasp", 264 | "Clojure", 265 | "Clozure CL", 266 | "Common Lisp", 267 | "Corman CL", 268 | "Dylan", 269 | "ECL", 270 | "emacs-cl", 271 | "Emacs Lisp", 272 | "Embedded CL", 273 | "EuLisp", 274 | "Franz Lisp", 275 | "GCL", 276 | "GNU CL", 277 | "GNU CLISP", 278 | "Hy", 279 | "IEEE Scheme", 280 | "ISLISP", 281 | "InterLisp", 282 | "LFE", 283 | "LISP 1 ", 284 | "LISP 1.5", 285 | "LeLisp", 286 | "Lisp Machine Lisp", 287 | "LispWorks", 288 | "MACLISP", 289 | "MDL", 290 | "MKCL", 291 | "Maclisp", 292 | "Movitz", 293 | "NIL", 294 | "Newlisp", 295 | "Picolisp", 296 | "Poplog", 297 | "Portable Standard Lisp", 298 | "RPL", 299 | "Racket", 300 | "SBCL", 301 | "SKILL", 302 | "Scheme", 303 | "Scieneer CL", 304 | "Spice Lisp", 305 | "Standard Lisp ", 306 | "Stanford LISP 1.6", 307 | "Steel Bank CL", 308 | "T", 309 | "ThinLisp", 310 | "UABCL", 311 | "WCL", 312 | "XCL", 313 | "XLISP", 314 | "ZetaLisp", 315 | # Additional words not in the corpus but are technology related 316 | ".NET", 317 | "blockchain", 318 | "Ethereum", 319 | "Bitcoin", 320 | ] 321 | 322 | keywords = set(word for word in array) -------------------------------------------------------------------------------- /generator/templates/generator/email.html: -------------------------------------------------------------------------------- 1 | Recruiter's LinkedIn Profile: - "https://www.linkedin.com/in/trali" 2 | Candidate's LinkedIn Profile: 3 | 4 | 7 | 8 | {% load static %} 9 | 10 | 11 | -------------------------------------------------------------------------------- /generator/templates/generator/index.html: -------------------------------------------------------------------------------- 1 |
Here is the parsed JSON of the recruiter PDF:
16 |
17 | {{ uploaded_file_json }}
18 |
19 |
20 | Here is the parsed JSON of the candidate PDF:
21 |
22 | {{ uploaded_file_json_2 }}
23 |
24 |
25 | And here's an example genereated letter:
26 |
27 | {{ generated_text }}
28 |
29 | {% endif %}
30 |
31 | Connect with Linkedin
--------------------------------------------------------------------------------
/generator/templates/generator/profile.html:
--------------------------------------------------------------------------------
1 | YOU AUTHENTICATED! This is just the landing page that shows up when you are logged into our system...
2 |
3 | After you select a profile to market to, just click this link to visit the email page...
--------------------------------------------------------------------------------
/generator/tests.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import unicode_literals
3 |
4 | from django.test import TestCase
5 |
6 | from collections import Counter
7 |
8 | import generator.resume_parser as resume_parser
9 | import os
10 | import json
11 |
12 | class TestResumeParser(TestCase):
13 | def load_resume(self, resume_name):
14 | path_to_directory = "generator/fixtures/{resume_name}.pdf".format(resume_name=resume_name)
15 | file_path = os.path.abspath(path_to_directory)
16 | json_string = resume_parser.convert(file_path)
17 | json_file = json.loads(json_string)
18 | return json_file
19 |
20 | def convert_to_counter(self, json_file):
21 | counter = json_file["counter"]
22 | return Counter(counter)
23 |
24 | def generate_counter(self, resume_name):
25 | json_file = self.load_resume(resume_name)
26 | return self.convert_to_counter(json_file)
27 |
28 | def generate_name(self, resume_name):
29 | json_file = self.load_resume(resume_name)
30 | return json_file["name"]
31 |
32 | def generate_email(self, resume_name):
33 | json_file = self.load_resume(resume_name)
34 | return json_file["email"]
35 |
36 | def test_parse_tariq_ali_profile_counter(self):
37 | expected_counter = Counter({'Ruby': 8, 'Rails': 5, 'WordPress': 3, 'Bootstrap': 2, 'JavaScript': 1, 'jQuery': 1, '.NET': 1, 'C#': 1, 'RSpec': 1, 'Sinatra': 1, 'C++': 1, 'Angular': 1, 'Javascript': 1, 'Ethereum': 1, 'blockchain': 1})
38 | actual_counter = self.generate_counter("TariqAliProfile")
39 | self.assertEqual(expected_counter, actual_counter)
40 |
41 | def test_parse_tariq_ali_profile_name(self):
42 | expected_name = "Tariq Ali"
43 | actual_name = self.generate_name("TariqAliProfile")
44 | self.assertEqual(expected_name, actual_name)
45 |
46 | def test_parse_tariq_ali_profile_email(self):
47 | expected_email = "tra38@nau.edu"
48 | actual_email = self.generate_email("TariqAliProfile")
49 | self.assertEqual(expected_email, actual_email)
50 |
51 | def test_parse_second_tariq_ali_profile_counter(self):
52 | expected_counter = Counter({'Ruby': 15, 'Rails': 5, 'WordPress': 3, 'Angular': 3, 'Sinatra': 2, 'jQuery': 2, 'JavaScript': 2, 'C++': 2, 'Twitter': 2, 'Javascript': 2, 'Bootstrap': 2, 'GitHub': 1, '.NET': 1, 'RSpec': 1, 'blockchain': 1, 'Ethereum': 1, 'Capistrano': 1, 'AWS': 1, 'C#': 1, 'React': 1})
53 | actual_counter = self.generate_counter("Tariq_Ali")
54 | self.assertEqual(expected_counter, actual_counter)
55 |
56 | def test_parse_second_tariq_ali_profile_name(self):
57 | expected_name = "Tariq\xa0Ali"
58 | actual_name = self.generate_name("Tariq_Ali")
59 | self.assertEqual(expected_name, actual_name)
60 |
61 | def test_parse_second_tariq_ali_profile_email(self):
62 | expected_email = "tra38@nau.edu"
63 | actual_email = self.generate_email("Tariq_Ali")
64 | self.assertEqual(expected_email, actual_email)
65 |
66 | def test_parse_dan_bernier_profile_counter(self):
67 | expected_counter = Counter({'Ruby': 7, 'Processing': 4, 'C#': 3, 'Rails': 2, 'Javascript': 1, '.NET': 1, 'JavaScript': 1, 'Scheme': 1})
68 | actual_counter = self.generate_counter("DanBernierProfile")
69 | self.assertEqual(expected_counter, actual_counter)
70 |
71 | def test_parse_dan_bernier_profile_name(self):
72 | expected_name = "Dan Bernier"
73 | actual_name = self.generate_name("DanBernierProfile")
74 | self.assertEqual(expected_name, actual_name)
75 |
76 | def test_parse_dan_bernier_profile_email(self):
77 | expected_email = "danbernier@gmail.com"
78 | actual_email = self.generate_email("DanBernierProfile")
79 | self.assertEqual(expected_email, actual_email)
80 |
81 | def test_parse_dylan_hirschkorn_profile_counter(self):
82 | expected_counter = Counter({'Dylan': 3, 'Visual Basic': 3, 'BASIC': 3, 'C#': 2, 'Swift': 1})
83 | # This is a bug, Dylan only mentioned "Visual Basic", not "Basic" on his resume. However, I do not know of a good way of fixing this specific edge case. Also, Dylan is the name of a programming language, which is why Dylan shows up in the counter.
84 | actual_counter = self.generate_counter("DylanHirschkornProfile")
85 | self.assertEqual(expected_counter, actual_counter)
86 |
87 | def test_parse_dylan_hirschkorn_profile_name(self):
88 | expected_name = "Dylan Hirschkorn"
89 | actual_name = self.generate_name("DylanHirschkornProfile")
90 | self.assertEqual(expected_name, actual_name)
91 |
92 | def test_parse_dylan_hirschkorn_profile_email(self):
93 | expected_email = ""
94 | actual_email = self.generate_email("DylanHirschkornProfile")
95 | self.assertEqual(expected_email, actual_email)
96 |
97 | def test_parse_sean_dugan_murphy_profile_counter(self):
98 | expected_counter = Counter({'Swift': 11, 'Twitter': 3, 'Objective-C': 3, 'Facebook': 3, 'GitHub': 2, 'YouTube': 2, 'CSS': 1, 'C#': 1})
99 | actual_counter = self.generate_counter("SeanDuganMurphyProfile")
100 | self.assertEqual(expected_counter, actual_counter)
101 |
102 | def test_parse_sean_dugan_murphy_profile_name(self):
103 | # The full name of the candidate is Sean Dugan Murphy. However we assume that a candidate only has a first and last name...and ignore the edge case where a candidate has a middle name.
104 | expected_name = "Sean Dugan"
105 | actual_name = self.generate_name("SeanDuganMurphyProfile")
106 | self.assertEqual(expected_name, actual_name)
107 |
108 | def test_parse_sean_dugan_murphy_profile_email(self):
109 | expected_email = ""
110 | actual_email = self.generate_email("SeanDuganMurphyProfile")
111 | self.assertEqual(expected_email, actual_email)
112 |
113 | def test_parse_christopher_salat_ceev_counter(self):
114 | # Note that Christopher Salat does not actually know either PHP or Scratch. He links to several websites that end with the .php extension and he serves as a Scratch DJ. This indicates a problem with relying solely on keywords detached from the context.
115 | expected_counter = Counter({'YouTube': 5, 'PHP': 2, 'Scratch': 1})
116 | actual_counter = self.generate_counter("Christopher_Salat_Ceev")
117 | self.assertEqual(expected_counter, actual_counter)
118 |
119 | def test_parse_christopher_salat_ceev_name(self):
120 | expected_name = "Christopher Salat"
121 | actual_name = self.generate_name("Christopher_Salat_Ceev")
122 | self.assertEqual(expected_name, actual_name)
123 |
124 | def test_parse_christopher_salat_ceev_email(self):
125 | expected_email = "christopherzerker@gmail.com"
126 | actual_email = self.generate_email("Christopher_Salat_Ceev")
127 | self.assertEqual(expected_email, actual_email)
128 |
--------------------------------------------------------------------------------
/generator/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import path
2 |
3 | from . import views
4 |
5 | urlpatterns = [
6 | path('', views.index, name='index'),
7 | path('profile', views.profile, name='profile'),
8 | path('email', views.email, name='email'),
9 | ]
--------------------------------------------------------------------------------
/generator/views.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import unicode_literals
3 |
4 | from django.shortcuts import render
5 |
6 | import generator.resume_saver as resume_saver
7 |
8 | import generator.letter_generator as letter_generator
9 |
10 | from django.http import JsonResponse
11 |
12 | from django.views.decorators.csrf import csrf_exempt
13 |
14 | import json
15 |
16 | def index(request):
17 | context = {}
18 | if request.method == 'POST':
19 | if request.FILES['recruiter_file']:
20 | recruiter_file = request.FILES['recruiter_file']
21 | json_blob_recruiter = resume_saver.save(recruiter_file)
22 | context["uploaded_file_json"] = json_blob_recruiter.json
23 | if request.FILES['candidate_file']:
24 | candidate_file = request.FILES['candidate_file']
25 | json_blob_candidate = resume_saver.save(candidate_file)
26 | context["uploaded_file_json_2"] = json_blob_candidate.json
27 | if context["uploaded_file_json"] and context["uploaded_file_json_2"]:
28 | context["generated_text"] = letter_generator.generate(context["uploaded_file_json"], context["uploaded_file_json_2"])
29 | return render(request, 'generator/index.html', context)
30 |
31 | @csrf_exempt
32 | def profile(request):
33 | context = {}
34 | if request.FILES['recruiter_file']:
35 | recruiter_file = request.FILES['recruiter_file']
36 | json_blob_recruiter = resume_saver.save(recruiter_file)
37 | context["recruiter_json"] = json_blob_recruiter.json
38 | if request.FILES['candidate_file']:
39 | candidate_file = request.FILES['candidate_file']
40 | json_blob_candidate = resume_saver.save(candidate_file)
41 | context["candidate_json"] = json_blob_candidate.json
42 | if context["recruiter_json"] and context["candidate_json"]:
43 | context["generated_text"] = letter_generator.generate(context["recruiter_json"], context["candidate_json"])
44 | return JsonResponse(context)
45 |
46 | @csrf_exempt
47 | def email(request):
48 | context = {}
49 | data = json.loads(request.body)
50 | if data['recruiter_json']:
51 | context["recruiter_json"] = data['recruiter_json']
52 | if data['candidate_json']:
53 | context["candidate_json"] = data['candidate_json']
54 | if context["recruiter_json"] and context["candidate_json"]:
55 | context["generated_text"] = letter_generator.generate(context["recruiter_json"], context["candidate_json"])
56 | return JsonResponse(context)
57 |
--------------------------------------------------------------------------------
/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import os
3 | import sys
4 |
5 | if __name__ == "__main__":
6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "coldoutreach.settings")
7 | try:
8 | from django.core.management import execute_from_command_line
9 | except ImportError:
10 | # The above import may fail for some other reason. Ensure that the
11 | # issue is really that Django is missing to avoid masking other
12 | # exceptions on Python 2.
13 | try:
14 | import django
15 | except ImportError:
16 | raise ImportError(
17 | "Couldn't import Django. Are you sure it's installed and "
18 | "available on your PYTHONPATH environment variable? Did you "
19 | "forget to activate a virtual environment?"
20 | )
21 | raise
22 | execute_from_command_line(sys.argv)
23 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "coldoutreach",
3 | "version": "0.1.0",
4 | "description": "",
5 | "homepage": ".",
6 | "license": "ISC",
7 | "repository": {
8 | "type": "git",
9 | "url": "git://github.com/gitcoinco/coldoutreach.git"
10 | },
11 | "dependencies": {
12 | "antd": "^3.2.3",
13 | "dotenv": "^5.0.1",
14 | "history": "^4.7.2",
15 | "node-sass-chokidar": "1.1.0",
16 | "prettier": "^1.11.1",
17 | "react": "^16.2.0",
18 | "react-copy-to-clipboard": "^5.0.1",
19 | "react-dom": "^16.2.0",
20 | "react-lz-editor": "^0.11.9",
21 | "react-redux": "^5.0.7",
22 | "react-router": "^4.2.0",
23 | "react-router-dom": "^4.2.2",
24 | "react-router-redux": "^5.0.0-alpha.9",
25 | "react-scripts": "^1.1.1",
26 | "redux": "^3.7.2",
27 | "redux-actions": "^2.3.0",
28 | "redux-logger": "^3.0.6",
29 | "redux-observable": "^0.18.0",
30 | "rxjs": "^5.5.6"
31 | },
32 | "devDependencies": {
33 | "babel-eslint": "^8.2.2",
34 | "concurrently": "^3.5.1",
35 | "eslint": "^4.18.2",
36 | "eslint-config-react-app": "^2.1.0",
37 | "eslint-plugin-flowtype": "^2.46.1",
38 | "eslint-plugin-import": "^2.9.0",
39 | "eslint-plugin-jsx-a11y": "^6.0.3",
40 | "eslint-plugin-react": "^7.7.0",
41 | "mocha": "^5.0.4"
42 | },
43 | "lint-staged": {
44 | "src/**/*.{js,jsx,json,css}": [
45 | "prettier --single-quote --write",
46 | "git add"
47 | ]
48 | },
49 | "proxy": {
50 | "/api": {
51 | "changeOrigin": true,
52 | "target": "http://127.0.0.1:8000/"
53 | }
54 | },
55 | "scripts": {
56 | "build-css": "node-sass-chokidar src/assets/styles/scss -o src/assets/styles/css",
57 | "watch-css": "npm run build-css && node-sass-chokidar src/assets/styles/scss/ -o src/assets/styles/css/ --watch --recursive",
58 | "start": "concurrently --kill-others \"react-scripts start\" \"npm run watch-css\"",
59 | "build": "react-scripts build",
60 | "test": "react-scripts test --env=jsdom",
61 | "eject": "react-scripts eject",
62 | "postinstall": "npm run build-css && npm run build"
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Good Morning Amy
13 |This is placeholder information that the tool would generate. 14 | Perhaps something like this…I saw your contribution to a StackOverflow 15 | thread about sequencing Django South migrations in Django 1.4, 16 | you seem like you know your Django-fu. Buzzfeed is a social software company in New York 17 | (looks like you are just down the road) and I am building a Django team there.
18 |24 | Sam 25 | Engineering Manager, Buzzfeed 26 | Job URL 27 |
28 | ` 29 | }; 30 | 31 | this.receiveHtml = this.receiveHtml.bind(this); 32 | } 33 | 34 | receiveHtml = (content) => { 35 | if (this.state.htmlContent !== content) { 36 | this.props.onEdit(content); 37 | } 38 | } 39 | 40 | componentWillReceiveProps(nextProps) { 41 | if (nextProps.email !== this.props.email) { 42 | this.setState({ htmlContent: nextProps.email }); 43 | } 44 | } 45 | 46 | render() { 47 | return ( 48 |1 Email generated for:
62 |{candidate_json.email}
65 | {/*http://www.linkedin.com/in/amykeys
*/} 66 | 67 | {/*27 | Instantly create custom 28 | outreach emails tailored to 29 | each candidate. 30 |
31 | 34 | 35 |{content}
18 | 19 |{quote}
22 | 23 |{author}
{profession}
27 |