webcode-tk 1.0.9__tar.gz → 1.1.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {webcode_tk-1.0.9 → webcode_tk-1.1.3}/PKG-INFO +1 -1
- {webcode_tk-1.0.9 → webcode_tk-1.1.3}/pyproject.toml +1 -1
- {webcode_tk-1.0.9 → webcode_tk-1.1.3}/webcode_tk/__init__.py +1 -1
- {webcode_tk-1.0.9 → webcode_tk-1.1.3}/webcode_tk/cascade_tools.py +15 -3
- {webcode_tk-1.0.9 → webcode_tk-1.1.3}/webcode_tk/color_tools.py +5 -0
- {webcode_tk-1.0.9 → webcode_tk-1.1.3}/webcode_tk/html_tools.py +10 -0
- webcode_tk-1.1.3/webcode_tk/ux_tools.py +472 -0
- webcode_tk-1.0.9/webcode_tk/ux_tools.py +0 -240
- {webcode_tk-1.0.9 → webcode_tk-1.1.3}/LICENSE.rst +0 -0
- {webcode_tk-1.0.9 → webcode_tk-1.1.3}/README.rst +0 -0
- {webcode_tk-1.0.9 → webcode_tk-1.1.3}/webcode_tk/color_keywords.py +0 -0
- {webcode_tk-1.0.9 → webcode_tk-1.1.3}/webcode_tk/css_tools.py +0 -0
- {webcode_tk-1.0.9 → webcode_tk-1.1.3}/webcode_tk/font_tools.py +0 -0
- {webcode_tk-1.0.9 → webcode_tk-1.1.3}/webcode_tk/sample_report.json +0 -0
- {webcode_tk-1.0.9 → webcode_tk-1.1.3}/webcode_tk/validator_tools.py +0 -0
|
@@ -726,6 +726,8 @@ class CSSAppliedTree:
|
|
|
726
726
|
|
|
727
727
|
def __get_filename(self):
|
|
728
728
|
"""Extracts filename from path"""
|
|
729
|
+
if isinstance(self.file_path, dict):
|
|
730
|
+
self.file_path = self.file_path.get("file")
|
|
729
731
|
self.filename = clerk.get_file_name(self.file_path)
|
|
730
732
|
|
|
731
733
|
def __get_font_rules(self):
|
|
@@ -1255,9 +1257,19 @@ def does_selector_apply(element: Element, selector: str) -> bool:
|
|
|
1255
1257
|
|
|
1256
1258
|
# If the tag is an anchor, but the selector is not - doesn't apply
|
|
1257
1259
|
# link color can only be changed by a link selector
|
|
1258
|
-
if element_name == "a"
|
|
1259
|
-
|
|
1260
|
-
|
|
1260
|
+
if element_name == "a":
|
|
1261
|
+
# NOTE: turns out a descendant selector targetting an anchor won't
|
|
1262
|
+
# show as a link_selector
|
|
1263
|
+
if is_descendant_selector:
|
|
1264
|
+
selector_list = selector.split()
|
|
1265
|
+
|
|
1266
|
+
# Check the descendant to see if it is a link
|
|
1267
|
+
last_item = selector_list[-1]
|
|
1268
|
+
if "a" != last_item[0]:
|
|
1269
|
+
break
|
|
1270
|
+
elif not is_link_selector:
|
|
1271
|
+
break
|
|
1272
|
+
if is_type_selector:
|
|
1261
1273
|
applies = element_name == sel
|
|
1262
1274
|
if applies:
|
|
1263
1275
|
break
|
|
@@ -244,6 +244,11 @@ def hex_to_rgb(hex_code: str) -> tuple:
|
|
|
244
244
|
"""
|
|
245
245
|
hex_code = hex_code.lower()
|
|
246
246
|
if "#" in hex_code[0]:
|
|
247
|
+
if len(hex_code) == 4:
|
|
248
|
+
r = hex_code[1] * 2
|
|
249
|
+
g = hex_code[2] * 2
|
|
250
|
+
b = hex_code[3] * 2
|
|
251
|
+
hex_code = f"#{r}{g}{b}"
|
|
247
252
|
hex_code = hex_code[1:]
|
|
248
253
|
r = hex_code[:2]
|
|
249
254
|
g = hex_code[2:4]
|
|
@@ -434,6 +434,16 @@ def get_number_of_elements_per_file(
|
|
|
434
434
|
return elements_per_file
|
|
435
435
|
|
|
436
436
|
|
|
437
|
+
def has_text_content(el: Tag) -> bool:
|
|
438
|
+
"""returns whether an element contains text content
|
|
439
|
+
|
|
440
|
+
The test is to see if there is raw text inside the element as its
|
|
441
|
+
content. It must be text that is not inside of a nested tag but
|
|
442
|
+
is the direct text outside of any other HTML elements"""
|
|
443
|
+
has_text_content = False
|
|
444
|
+
return has_text_content
|
|
445
|
+
|
|
446
|
+
|
|
437
447
|
if __name__ == "__main__":
|
|
438
448
|
file_with_inline_styles = "tests/test_files/sample_with_inline_styles.html"
|
|
439
449
|
markup = clerk.file_to_string(file_with_inline_styles)
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
"""A set of tools to conduct some UX (User eXperience) checks.
|
|
2
|
+
|
|
3
|
+
As of now, I just want to check for best UX and SEO practices such
|
|
4
|
+
as checking for best practices in writing for the web.
|
|
5
|
+
|
|
6
|
+
The primary source of information we will begin to use are from
|
|
7
|
+
[Writing Compelling Digital Copy](https://www.nngroup.com/courses/writing/).
|
|
8
|
+
|
|
9
|
+
According to the article...
|
|
10
|
+
* Be Succinct! (Writing for the Web)
|
|
11
|
+
* "The 3 main guidelines for writing for the web are:
|
|
12
|
+
- Be succinct: write no more than 50% of the text you would have used in a
|
|
13
|
+
hardcopy publication
|
|
14
|
+
- Write for scannability: don't require users to read long continuous
|
|
15
|
+
blocks of text
|
|
16
|
+
- Use hypertext to split up long information into multiple pages"
|
|
17
|
+
"""
|
|
18
|
+
import re
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
from typing import Union
|
|
21
|
+
|
|
22
|
+
import textatistic
|
|
23
|
+
from file_clerk import clerk
|
|
24
|
+
from textatistic import Textatistic
|
|
25
|
+
|
|
26
|
+
from webcode_tk import html_tools
|
|
27
|
+
|
|
28
|
+
DEFAULT_GOALS = {
|
|
29
|
+
"avg_words_sentence_range": (10, 25),
|
|
30
|
+
"max_sentences_per_paragraph": 4,
|
|
31
|
+
"min_num_single_sentence_paragraphs": 2,
|
|
32
|
+
"min_word_count": 400,
|
|
33
|
+
}
|
|
34
|
+
DEFAULT_EXCEEDS_GOALS = {
|
|
35
|
+
"max_avg_words_sentence": 20,
|
|
36
|
+
"max_li_per_list": 9,
|
|
37
|
+
"max_words_per_li": 45,
|
|
38
|
+
"max_words_per_paragraph": 80,
|
|
39
|
+
"min_word_count": 800,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_flesch_kincaid_grade_level(path: str) -> float:
|
|
44
|
+
"""returns the required education to be able to understand the text.
|
|
45
|
+
|
|
46
|
+
We're only looking at the paragraphs (not headers or list items) and
|
|
47
|
+
ignoring break tags as being paragraph breaks (that could be up for
|
|
48
|
+
debate in the future).
|
|
49
|
+
|
|
50
|
+
This function first checks to see if it's a project folder or just a
|
|
51
|
+
single file and ascertains for all HTML documents if it's a project.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
path: the path to the page or project that we are measuring.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
grade_level: the US grade level equivalent."""
|
|
58
|
+
|
|
59
|
+
# convert all paragraph content into a single string
|
|
60
|
+
paragraphs = get_all_paragraphs(path)
|
|
61
|
+
paragraph_text = get_paragraph_text(paragraphs)
|
|
62
|
+
|
|
63
|
+
# get stats from textatistic
|
|
64
|
+
r = Textatistic(paragraph_text)
|
|
65
|
+
grade_level = r.fleschkincaid_score
|
|
66
|
+
|
|
67
|
+
# return grade level rounded to 1 decimal point
|
|
68
|
+
return round(grade_level, 1)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_readability_stats(
|
|
72
|
+
path: str, elements: Union[str, Sequence] = "p"
|
|
73
|
+
) -> dict:
|
|
74
|
+
"""returns a dictionary of various readability stats.
|
|
75
|
+
|
|
76
|
+
It will collect all stats from the textastic libary as well as add
|
|
77
|
+
a few of my own (words per sentence, sentences per paragraph).
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
path: the path to the file or folder (only looks at HTML files)
|
|
81
|
+
elements: by default we will only look at the text from paragraph
|
|
82
|
+
tags. You could supply other elements in the form of a list or
|
|
83
|
+
tuple (e.g. ['p', 'div', 'li', 'figcaption']).
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
stats: a dictionary of stats on readability metrics.
|
|
87
|
+
"""
|
|
88
|
+
stats = {}
|
|
89
|
+
|
|
90
|
+
# Get text and numbers
|
|
91
|
+
text = get_text_from_elements(path, elements)
|
|
92
|
+
num_lines = len(text)
|
|
93
|
+
|
|
94
|
+
# get stats from textatistic
|
|
95
|
+
text = "\n".join(text)
|
|
96
|
+
r = Textatistic(text)
|
|
97
|
+
|
|
98
|
+
for key, value in r.scores.items():
|
|
99
|
+
stats[key] = round(value, 2)
|
|
100
|
+
stats["character_count"] = r.char_count
|
|
101
|
+
stats["word_count"] = r.word_count
|
|
102
|
+
stats["sentence_count"] = r.sent_count
|
|
103
|
+
stats["paragraph_count"] = num_lines
|
|
104
|
+
stats["words_per_sentence"] = round(r.word_count / r.sent_count, 2)
|
|
105
|
+
stats["sent_per_paragraph"] = round(r.sent_count / num_lines, 2)
|
|
106
|
+
|
|
107
|
+
return stats
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_paragraph_text(paragraphs: list) -> str:
|
|
111
|
+
"""Returns a string of all the contents of the list of paragraphs
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
paragraphs: a list of paragraph elements.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
paragraph_text: a string of all the content of the paragraph
|
|
118
|
+
elements.
|
|
119
|
+
"""
|
|
120
|
+
paragraph_text = ""
|
|
121
|
+
for paragraph in paragraphs:
|
|
122
|
+
paragraph_text += html_tools.get_element_content(paragraph) + "\n"
|
|
123
|
+
return paragraph_text.strip()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_all_paragraphs(path: str) -> list:
|
|
127
|
+
"""returns a list of paragraph elements from a single file or project
|
|
128
|
+
folder.
|
|
129
|
+
|
|
130
|
+
It checks the path to determine if it's a path to a single page or a
|
|
131
|
+
project folder.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
path: a string path to either an html document or a project folder.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
paragraphs: a list of paragraph elements."""
|
|
138
|
+
|
|
139
|
+
paragraphs = []
|
|
140
|
+
is_single_page = "html" == clerk.get_file_type(path)
|
|
141
|
+
|
|
142
|
+
if not is_single_page:
|
|
143
|
+
# it's a project, so we need to process all html files in the folder
|
|
144
|
+
all_files = clerk.get_all_files_of_type(path, "html")
|
|
145
|
+
for file in all_files:
|
|
146
|
+
paragraphs += html_tools.get_elements("p", file)
|
|
147
|
+
else:
|
|
148
|
+
paragraphs += html_tools.get_elements("p", path)
|
|
149
|
+
return paragraphs
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_text_from_elements(
|
|
153
|
+
path: str, elements: Union[str, Sequence] = "p"
|
|
154
|
+
) -> list:
|
|
155
|
+
"""returns a list of text from elements (as strings) from a single file or
|
|
156
|
+
project folder.
|
|
157
|
+
|
|
158
|
+
It checks the path to determine if it's a path to a single page or a
|
|
159
|
+
project folder. It then uses the elements to determine from which
|
|
160
|
+
HTML element it will grab paragraphs.
|
|
161
|
+
|
|
162
|
+
By default, it will only pull paragraphs from the `<p>` tag, but you
|
|
163
|
+
can specify other elements (e.g. `<div>`, `<li>`, `<figcaption>`, etc.).
|
|
164
|
+
|
|
165
|
+
NOTE: When selecting other tags, pass it a list or tuple of strings. Only
|
|
166
|
+
specify the element without angle brackets (e.g. `['p', 'li', 'div']`)
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
path: a string path to either an html document or a project folder.
|
|
170
|
+
elements: the elements we want to pull our paragrphs from.
|
|
171
|
+
Returns:
|
|
172
|
+
paragraphs: a list of strings - text only without markup (for example
|
|
173
|
+
if an anchor is nested in a pargraph, the markup will be extracted, and
|
|
174
|
+
only the visible text on the page will be present."""
|
|
175
|
+
|
|
176
|
+
paragraphs = []
|
|
177
|
+
is_single_page = "html" == clerk.get_file_type(path)
|
|
178
|
+
if elements == "p":
|
|
179
|
+
elements = ["p"]
|
|
180
|
+
if not is_single_page:
|
|
181
|
+
# it's a project, so we need to process all html files in the folder
|
|
182
|
+
all_files = clerk.get_all_files_of_type(path, "html")
|
|
183
|
+
for file in all_files:
|
|
184
|
+
for element in elements:
|
|
185
|
+
markup = html_tools.get_elements(element, file)
|
|
186
|
+
for tag in markup:
|
|
187
|
+
text = extract_text(tag)
|
|
188
|
+
paragraphs.append(text)
|
|
189
|
+
else:
|
|
190
|
+
for element in elements:
|
|
191
|
+
markup = html_tools.get_elements(element, path)
|
|
192
|
+
for tag in markup:
|
|
193
|
+
text = extract_text(tag)
|
|
194
|
+
paragraphs.append(text)
|
|
195
|
+
return paragraphs
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def remove_extensions(text: str) -> str:
|
|
199
|
+
"""removes extension from filenames.
|
|
200
|
+
|
|
201
|
+
Filenames with extensions end up causing textatistic to count filenames
|
|
202
|
+
as two words instead of one, and we don't really need to count the
|
|
203
|
+
extensions when calculating readability stats.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
text: the text which may or may not have filenames in them.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
newtext: the text without file extensions.
|
|
210
|
+
"""
|
|
211
|
+
newtext = ""
|
|
212
|
+
|
|
213
|
+
# get the last character just in case it gets dropped with extension.
|
|
214
|
+
last_char = text[-1]
|
|
215
|
+
regex = r"\.[a-zA-Z0-9]+"
|
|
216
|
+
newtext = re.sub(regex, "", text)
|
|
217
|
+
|
|
218
|
+
# add back the last character if it was dropped.
|
|
219
|
+
if newtext[-1] != last_char:
|
|
220
|
+
newtext += last_char
|
|
221
|
+
return newtext
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def extract_text(tag) -> str:
|
|
225
|
+
"""extracts only the text from a tag (no markup)
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
tag: this is a Tag (bs4)
|
|
229
|
+
Returns:
|
|
230
|
+
text: just the visible text from the element (with no
|
|
231
|
+
nested markup)."""
|
|
232
|
+
text = tag.text
|
|
233
|
+
text = " ".join(text.split())
|
|
234
|
+
return text
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def get_words_per_paragraph(path: str) -> float:
|
|
238
|
+
"""returns average number of words per paragraph
|
|
239
|
+
|
|
240
|
+
uses Textatistic stats"""
|
|
241
|
+
paragraphs = get_all_paragraphs(path)
|
|
242
|
+
text = get_paragraph_text(paragraphs)
|
|
243
|
+
words = get_word_count(text)
|
|
244
|
+
num_paragraphs = len(paragraphs)
|
|
245
|
+
return round(words / num_paragraphs, 1)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def get_word_count(txt: str) -> int:
|
|
249
|
+
"""returns word count of text.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
txt: the raw text (may or may not contain line returns)
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
word_count: number of words as measured by Textastic
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
text = txt.replace("\n", " ")
|
|
259
|
+
word_count = textatistic.word_count(text)
|
|
260
|
+
return word_count
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def check_li_count(filename: str, list_type: str, max: int, min=3) -> list:
|
|
264
|
+
"""returns any warnings regarding li count
|
|
265
|
+
|
|
266
|
+
Warns if too many lis in a list, too few in a list (default is 3), or
|
|
267
|
+
too much content in a list item.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
filename: name of the HTML document.
|
|
271
|
+
list_type: "ul" or "li" - we don't check definition lists.
|
|
272
|
+
max: maximum recommended list items per list.
|
|
273
|
+
min: minimum recommend list items per list (default is 3)
|
|
274
|
+
|
|
275
|
+
Returns"""
|
|
276
|
+
warnings = []
|
|
277
|
+
uls = html_tools.get_elements(list_type, filename)
|
|
278
|
+
if uls:
|
|
279
|
+
num_lis = 0
|
|
280
|
+
for ul in uls:
|
|
281
|
+
lis = html_tools.get_element_content(ul)
|
|
282
|
+
li_count = lis.count("<li>")
|
|
283
|
+
num_lis += li_count
|
|
284
|
+
if num_lis > max:
|
|
285
|
+
msg = f"warning: ul should have less than {max} "
|
|
286
|
+
msg += f"lis per ul - you have {num_lis} in {filename}"
|
|
287
|
+
warnings.append(msg)
|
|
288
|
+
return warnings
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def check_li_word_count(filepath: str, max=45) -> list:
|
|
292
|
+
"""sends warnings if too many words are in a list item.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
filepath: relative path to the HTML document in question.
|
|
296
|
+
max: maximum number of words per li (default is 45)
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
warnings: any warnings about the li content having too many words.
|
|
300
|
+
"""
|
|
301
|
+
warnings = []
|
|
302
|
+
filename = clerk.get_file_name(filepath)
|
|
303
|
+
lis = html_tools.get_elements("li", filepath)
|
|
304
|
+
for li in lis:
|
|
305
|
+
txt = html_tools.get_element_content(li)
|
|
306
|
+
text = txt.replace("\n", " ")
|
|
307
|
+
word_count = textatistic.word_count(text)
|
|
308
|
+
if word_count > max:
|
|
309
|
+
msg = "warning: An <li> element exceeds recommended word"
|
|
310
|
+
msg += f" count of {max} {filename} has an <li> with "
|
|
311
|
+
msg += f"{word_count} words."
|
|
312
|
+
warnings.append(msg)
|
|
313
|
+
return warnings
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def get_usability_report(
|
|
317
|
+
project_dir: str,
|
|
318
|
+
elements: tuple,
|
|
319
|
+
goals=DEFAULT_GOALS,
|
|
320
|
+
exceeds=DEFAULT_EXCEEDS_GOALS,
|
|
321
|
+
) -> list:
|
|
322
|
+
"""Generates a list of pass, fail, and warnings on usability.
|
|
323
|
+
|
|
324
|
+
If goals or exceeds not set, goals & exceeds should follow the example
|
|
325
|
+
keys...
|
|
326
|
+
goals avg_words_sentence_range, max_sentences_per_paragraph,
|
|
327
|
+
min_word_count, min_num_single_sentence_paragraphs
|
|
328
|
+
|
|
329
|
+
exceeds max_avg_words_sentence, max_li_per_list, max_sentence_paragraph,
|
|
330
|
+
max_words_per_li, max_words_per_paragraph, min_word_count
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
project_dir: path to project folder
|
|
334
|
+
goals: set of goals to test for.
|
|
335
|
+
exceeds: set of metrics to determine if content goes above & beyond.
|
|
336
|
+
elements: set of elements to capture
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
report: a list of pass, fails, and warnings
|
|
340
|
+
"""
|
|
341
|
+
report = []
|
|
342
|
+
readability_report = get_readability_stats(project_dir, elements)
|
|
343
|
+
all_html_files = clerk.get_all_files_of_type(project_dir, "html")
|
|
344
|
+
if not all_html_files and ".html" == project_dir[-5:]:
|
|
345
|
+
all_html_files = [
|
|
346
|
+
project_dir,
|
|
347
|
+
]
|
|
348
|
+
for type, expected in goals.items():
|
|
349
|
+
if type == "avg_words_sentence_range":
|
|
350
|
+
words_per_sentence = readability_report.get("words_per_sentence")
|
|
351
|
+
min, max = expected
|
|
352
|
+
msg = ""
|
|
353
|
+
if words_per_sentence <= min:
|
|
354
|
+
msg = "fail: not enough average words per sentence (actual = "
|
|
355
|
+
msg += f"{words_per_sentence})."
|
|
356
|
+
elif words_per_sentence >= max:
|
|
357
|
+
msg = f"fail: sentences are too long (should be between {min}"
|
|
358
|
+
msg += f" & {max}) (actual = {words_per_sentence} - should be"
|
|
359
|
+
msg += f" no more than {max})"
|
|
360
|
+
else:
|
|
361
|
+
msg += f"pass: at {words_per_sentence} words per sentence, "
|
|
362
|
+
msg += f"you are within expected range ({min}-{max} wps)."
|
|
363
|
+
report.append(msg)
|
|
364
|
+
if type == "max_sentences_per_paragraph":
|
|
365
|
+
max_sent = expected
|
|
366
|
+
min_single_sentence_data = []
|
|
367
|
+
single_sentences = 0
|
|
368
|
+
for file in all_html_files:
|
|
369
|
+
paragraphs = html_tools.get_elements("p", file)
|
|
370
|
+
para_num = 0
|
|
371
|
+
filename = clerk.get_file_name(file)
|
|
372
|
+
for paragraph in paragraphs:
|
|
373
|
+
para_num += 1
|
|
374
|
+
content = html_tools.get_element_content(paragraph).strip()
|
|
375
|
+
sentence_count = textatistic.sent_count(content)
|
|
376
|
+
if content and sentence_count == 0:
|
|
377
|
+
sentence_count = 1
|
|
378
|
+
if sentence_count == 1:
|
|
379
|
+
single_sentences += 1
|
|
380
|
+
if sentence_count > max_sent:
|
|
381
|
+
sentence_msg = sentence_count - max_sent
|
|
382
|
+
if sentence_msg == 1:
|
|
383
|
+
sentence_msg = f"{sentence_msg} more sentence"
|
|
384
|
+
else:
|
|
385
|
+
sentence_msg = f"{sentence_msg} more sentences"
|
|
386
|
+
msg = f"fail: in file, {filename}, paragraph #"
|
|
387
|
+
msg += f"{para_num}, you have {sentence_msg} than the"
|
|
388
|
+
msg += f" maximum number of {max_sent}."
|
|
389
|
+
report.append(msg)
|
|
390
|
+
min_single_sentence_data.append((filename, single_sentences))
|
|
391
|
+
if type == "min_word_count":
|
|
392
|
+
num_words = readability_report.get("word_count")
|
|
393
|
+
if num_words >= expected:
|
|
394
|
+
msg = f"pass: {filename} at {num_words} words has enough "
|
|
395
|
+
msg += "words to effectively measure readability."
|
|
396
|
+
else:
|
|
397
|
+
msg = f"fail: {filename} at only {num_words} words, does not "
|
|
398
|
+
msg += "have enough words to effectively measure readability."
|
|
399
|
+
report.append(msg)
|
|
400
|
+
if type == "min_num_single_sentence_paragraphs":
|
|
401
|
+
for datum in min_single_sentence_data:
|
|
402
|
+
name, paras_with_one_sentence = datum
|
|
403
|
+
if paras_with_one_sentence >= expected:
|
|
404
|
+
msg = f"pass: {name} has enough paragraphs with just one"
|
|
405
|
+
msg += " sentence."
|
|
406
|
+
else:
|
|
407
|
+
msg = f"fail: {name} does not have enough paragraphs with"
|
|
408
|
+
msg += " a single sentence. You should have at least"
|
|
409
|
+
msg += f" {expected}."
|
|
410
|
+
report.append(msg)
|
|
411
|
+
|
|
412
|
+
# Get List data for exceeds:
|
|
413
|
+
li_report = []
|
|
414
|
+
for file in all_html_files:
|
|
415
|
+
filename = clerk.get_file_name(file)
|
|
416
|
+
max_li_per_ul = exceeds.get("max_li_per_list")
|
|
417
|
+
li_report += check_li_count(file, "ul", max_li_per_ul)
|
|
418
|
+
li_report += check_li_word_count(file)
|
|
419
|
+
|
|
420
|
+
for type, goal in exceeds.items():
|
|
421
|
+
if type == "max_avg_words_sentence":
|
|
422
|
+
if words_per_sentence > 20:
|
|
423
|
+
warning = "warning: Average words per sentence should be 20 "
|
|
424
|
+
warning += "or less (you have an average of "
|
|
425
|
+
warning += f"{words_per_sentence})."
|
|
426
|
+
report.append(warning)
|
|
427
|
+
if type == "max_li_per_list":
|
|
428
|
+
if li_report:
|
|
429
|
+
for item in li_report:
|
|
430
|
+
report.append(item)
|
|
431
|
+
if type == "max_sentence_paragraph":
|
|
432
|
+
if sentence_count > goal:
|
|
433
|
+
warning = "warning: for best results, you should have fewer "
|
|
434
|
+
warning += f"than {goal} sentences per paragraph, but you "
|
|
435
|
+
warning += f"have {sentence_count}."
|
|
436
|
+
if type == "max_words_per_li":
|
|
437
|
+
if li_report:
|
|
438
|
+
for item in li_report:
|
|
439
|
+
if "An <li> element exceeds recommended" in item:
|
|
440
|
+
report.append(item)
|
|
441
|
+
if type == "max_words_per_paragraph":
|
|
442
|
+
paragraphs_exceeding = 0
|
|
443
|
+
maximum_num_words = 0
|
|
444
|
+
for paragraph in paragraphs:
|
|
445
|
+
content = html_tools.get_element_content(paragraph)
|
|
446
|
+
word_count = textatistic.word_count(content)
|
|
447
|
+
if word_count > expected:
|
|
448
|
+
paragraphs_exceeding += 1
|
|
449
|
+
if word_count > maximum_num_words:
|
|
450
|
+
maximum_num_words = word_count
|
|
451
|
+
if paragraphs_exceeding:
|
|
452
|
+
msg = f"warning: you should not exceed {expected} words per "
|
|
453
|
+
msg += f"paragraph, but you have {paragraphs_exceeding} "
|
|
454
|
+
msg += "paragraphs that exceed, the maximum having "
|
|
455
|
+
msg += f"{maximum_num_words} words."
|
|
456
|
+
report.append(msg)
|
|
457
|
+
if type == "min_word_count":
|
|
458
|
+
# warn if meets minimum word count but not the goal for exceeds
|
|
459
|
+
min_words = goals.get("min_word_count")
|
|
460
|
+
if num_words > min_words and num_words < goal:
|
|
461
|
+
msg = "warning: for best results, you should have at least"
|
|
462
|
+
msg += f" {goal} words in your project."
|
|
463
|
+
report.append(msg)
|
|
464
|
+
return report
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
if __name__ == "__main__":
|
|
468
|
+
# let's test some stuff out.
|
|
469
|
+
path = "tests/test_files/large_project/"
|
|
470
|
+
all = extract_text(path, ["p", "figcaption"])
|
|
471
|
+
all = get_paragraph_text(all)
|
|
472
|
+
print(all)
|
|
@@ -1,240 +0,0 @@
|
|
|
1
|
-
"""A set of tools to conduct some UX (User eXperience) checks.
|
|
2
|
-
|
|
3
|
-
As of now, I just want to check for best UX and SEO practices such
|
|
4
|
-
as checking for best practices in writing for the web.
|
|
5
|
-
|
|
6
|
-
The primary source of information we will begin to use are from
|
|
7
|
-
[Writing Compelling Digital Copy](https://www.nngroup.com/courses/writing/).
|
|
8
|
-
|
|
9
|
-
According to the article...
|
|
10
|
-
* Be Succinct! (Writing for the Web)
|
|
11
|
-
* "The 3 main guidelines for writing for the web are:
|
|
12
|
-
- Be succinct: write no more than 50% of the text you would have used in a
|
|
13
|
-
hardcopy publication
|
|
14
|
-
- Write for scannability: don't require users to read long continuous
|
|
15
|
-
blocks of text
|
|
16
|
-
- Use hypertext to split up long information into multiple pages"
|
|
17
|
-
"""
|
|
18
|
-
import re
|
|
19
|
-
from collections.abc import Sequence
|
|
20
|
-
from typing import Union
|
|
21
|
-
|
|
22
|
-
from file_clerk import clerk
|
|
23
|
-
from textatistic import Textatistic
|
|
24
|
-
|
|
25
|
-
from webcode_tk import html_tools
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def get_flesch_kincaid_grade_level(path: str) -> float:
|
|
29
|
-
"""returns the required education to be able to understand the text.
|
|
30
|
-
|
|
31
|
-
We're only looking at the paragraphs (not headers or list items) and
|
|
32
|
-
ignoring break tags as being paragraph breaks (that could be up for
|
|
33
|
-
debate in the future).
|
|
34
|
-
|
|
35
|
-
This function first checks to see if it's a project folder or just a
|
|
36
|
-
single file and ascertains for all HTML documents if it's a project.
|
|
37
|
-
|
|
38
|
-
Args:
|
|
39
|
-
path: the path to the page or project that we are measuring.
|
|
40
|
-
|
|
41
|
-
Returns:
|
|
42
|
-
grade_level: the US grade level equivalent."""
|
|
43
|
-
|
|
44
|
-
# convert all paragraph content into a single string
|
|
45
|
-
paragraphs = get_all_paragraphs(path)
|
|
46
|
-
paragraph_text = get_paragraph_text(paragraphs)
|
|
47
|
-
|
|
48
|
-
# get stats from textatistic
|
|
49
|
-
r = Textatistic(paragraph_text)
|
|
50
|
-
grade_level = r.fleschkincaid_score
|
|
51
|
-
|
|
52
|
-
# return grade level rounded to 1 decimal point
|
|
53
|
-
return round(grade_level, 1)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def get_readability_stats(
|
|
57
|
-
path: str, elements: Union[str, Sequence] = "p"
|
|
58
|
-
) -> dict:
|
|
59
|
-
"""returns a dictionary of various readability stats.
|
|
60
|
-
|
|
61
|
-
It will collect all stats from the textastic libary as well as add
|
|
62
|
-
a few of my own (words per sentence, sentences per paragraph).
|
|
63
|
-
|
|
64
|
-
Args:
|
|
65
|
-
path: the path to the file or folder (only looks at HTML files)
|
|
66
|
-
elements: by default we will only look at the text from paragraph
|
|
67
|
-
tags. You could supply other elements in the form of a list or
|
|
68
|
-
tuple (e.g. ['p', 'div', 'li', 'figcaption']).
|
|
69
|
-
|
|
70
|
-
Returns:
|
|
71
|
-
stats: a dictionary of stats on readability metrics.
|
|
72
|
-
"""
|
|
73
|
-
stats = {}
|
|
74
|
-
|
|
75
|
-
# Get text and numbers
|
|
76
|
-
text = get_text_from_elements(path, elements)
|
|
77
|
-
num_lines = len(text)
|
|
78
|
-
|
|
79
|
-
# get stats from textatistic
|
|
80
|
-
text = "\n".join(text)
|
|
81
|
-
r = Textatistic(text)
|
|
82
|
-
|
|
83
|
-
for key, value in r.scores.items():
|
|
84
|
-
stats[key] = round(value, 2)
|
|
85
|
-
stats["character_count"] = r.char_count
|
|
86
|
-
stats["word_count"] = r.word_count
|
|
87
|
-
stats["sentence_count"] = r.sent_count
|
|
88
|
-
stats["paragraph_count"] = num_lines
|
|
89
|
-
stats["words_per_sentence"] = round(r.word_count / r.sent_count, 2)
|
|
90
|
-
stats["sent_per_paragraph"] = round(r.sent_count / num_lines, 2)
|
|
91
|
-
|
|
92
|
-
return stats
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def get_paragraph_text(paragraphs: list) -> str:
|
|
96
|
-
"""Returns a string of all the contents of the list of paragraphs
|
|
97
|
-
|
|
98
|
-
Args:
|
|
99
|
-
paragraphs: a list of paragraph elements.
|
|
100
|
-
|
|
101
|
-
Returns:
|
|
102
|
-
paragraph_text: a string of all the content of the paragraph
|
|
103
|
-
elements.
|
|
104
|
-
"""
|
|
105
|
-
paragraph_text = ""
|
|
106
|
-
for paragraph in paragraphs:
|
|
107
|
-
paragraph_text += html_tools.get_element_content(paragraph) + "\n"
|
|
108
|
-
return paragraph_text.strip()
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def get_all_paragraphs(path: str) -> list:
|
|
112
|
-
"""returns a list of paragraph elements from a single file or project
|
|
113
|
-
folder.
|
|
114
|
-
|
|
115
|
-
It checks the path to determine if it's a path to a single page or a
|
|
116
|
-
project folder.
|
|
117
|
-
|
|
118
|
-
Args:
|
|
119
|
-
path: a string path to either an html document or a project folder.
|
|
120
|
-
|
|
121
|
-
Returns:
|
|
122
|
-
paragraphs: a list of paragraph elements."""
|
|
123
|
-
|
|
124
|
-
paragraphs = []
|
|
125
|
-
is_single_page = "html" == clerk.get_file_type(path)
|
|
126
|
-
|
|
127
|
-
if not is_single_page:
|
|
128
|
-
# it's a project, so we need to process all html files in the folder
|
|
129
|
-
all_files = clerk.get_all_files_of_type(path, "html")
|
|
130
|
-
for file in all_files:
|
|
131
|
-
paragraphs += html_tools.get_elements("p", file)
|
|
132
|
-
else:
|
|
133
|
-
paragraphs += html_tools.get_elements("p", path)
|
|
134
|
-
return paragraphs
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def get_text_from_elements(
|
|
138
|
-
path: str, elements: Union[str, Sequence] = "p"
|
|
139
|
-
) -> list:
|
|
140
|
-
"""returns a list of text from elements (as strings) from a single file or
|
|
141
|
-
project folder.
|
|
142
|
-
|
|
143
|
-
It checks the path to determine if it's a path to a single page or a
|
|
144
|
-
project folder. It then uses the elements to determine from which
|
|
145
|
-
HTML element it will grab paragraphs.
|
|
146
|
-
|
|
147
|
-
By default, it will only pull paragraphs from the `<p>` tag, but you
|
|
148
|
-
can specify other elements (e.g. `<div>`, `<li>`, `<figcaption>`, etc.).
|
|
149
|
-
|
|
150
|
-
NOTE: When selecting other tags, pass it a list or tuple of strings. Only
|
|
151
|
-
specify the element without angle brackets (e.g. `['p', 'li', 'div']`)
|
|
152
|
-
|
|
153
|
-
Args:
|
|
154
|
-
path: a string path to either an html document or a project folder.
|
|
155
|
-
elements: the elements we want to pull our paragrphs from.
|
|
156
|
-
Returns:
|
|
157
|
-
paragraphs: a list of strings - text only without markup (for example
|
|
158
|
-
if an anchor is nested in a pargraph, the markup will be extracted, and
|
|
159
|
-
only the visible text on the page will be present."""
|
|
160
|
-
|
|
161
|
-
paragraphs = []
|
|
162
|
-
is_single_page = "html" == clerk.get_file_type(path)
|
|
163
|
-
if elements == "p":
|
|
164
|
-
elements = ["p"]
|
|
165
|
-
if not is_single_page:
|
|
166
|
-
# it's a project, so we need to process all html files in the folder
|
|
167
|
-
all_files = clerk.get_all_files_of_type(path, "html")
|
|
168
|
-
for file in all_files:
|
|
169
|
-
for element in elements:
|
|
170
|
-
markup = html_tools.get_elements(element, file)
|
|
171
|
-
for tag in markup:
|
|
172
|
-
text = extract_text(tag)
|
|
173
|
-
paragraphs.append(text)
|
|
174
|
-
else:
|
|
175
|
-
for element in elements:
|
|
176
|
-
markup = html_tools.get_elements(element, path)
|
|
177
|
-
for tag in markup:
|
|
178
|
-
text = extract_text(tag)
|
|
179
|
-
paragraphs.append(text)
|
|
180
|
-
return paragraphs
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def remove_extensions(text: str) -> str:
|
|
184
|
-
"""removes extension from filenames.
|
|
185
|
-
|
|
186
|
-
Filenames with extensions end up causing textatistic to count filenames
|
|
187
|
-
as two words instead of one, and we don't really need to count the
|
|
188
|
-
extensions when calculating readability stats.
|
|
189
|
-
|
|
190
|
-
Args:
|
|
191
|
-
text: the text which may or may not have filenames in them.
|
|
192
|
-
|
|
193
|
-
Returns:
|
|
194
|
-
newtext: the text without file extensions.
|
|
195
|
-
"""
|
|
196
|
-
newtext = ""
|
|
197
|
-
|
|
198
|
-
# get the last character just in case it gets dropped with extension.
|
|
199
|
-
last_char = text[-1]
|
|
200
|
-
regex = r"\.[a-zA-Z0-9]+"
|
|
201
|
-
newtext = re.sub(regex, "", text)
|
|
202
|
-
|
|
203
|
-
# add back the last character if it was dropped.
|
|
204
|
-
if newtext[-1] != last_char:
|
|
205
|
-
newtext += last_char
|
|
206
|
-
return newtext
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
def extract_text(tag) -> str:
|
|
210
|
-
"""extracts only the text from a tag (no markup)
|
|
211
|
-
|
|
212
|
-
Args:
|
|
213
|
-
tag: this is a Tag (bs4)
|
|
214
|
-
Returns:
|
|
215
|
-
text: just the visible text from the element (with no
|
|
216
|
-
nested markup)."""
|
|
217
|
-
text = tag.text
|
|
218
|
-
text = " ".join(text.split())
|
|
219
|
-
return text
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
def get_words_per_paragraph(path: str) -> float:
|
|
223
|
-
"""returns average number of words per paragraph
|
|
224
|
-
|
|
225
|
-
uses Textatistic stats"""
|
|
226
|
-
paragraphs = get_all_paragraphs(path)
|
|
227
|
-
text = get_paragraph_text(paragraphs)
|
|
228
|
-
text = text.replace("\n", " ")
|
|
229
|
-
r = Textatistic(text)
|
|
230
|
-
words = r.counts.get("word_count")
|
|
231
|
-
num_paragraphs = len(paragraphs)
|
|
232
|
-
return round(words / num_paragraphs, 1)
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if __name__ == "__main__":
|
|
236
|
-
# let's test some stuff out.
|
|
237
|
-
path = "tests/test_files/large_project/"
|
|
238
|
-
all = extract_text(path, ["p", "figcaption"])
|
|
239
|
-
all = get_paragraph_text(all)
|
|
240
|
-
print(all)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|