wordpress-markdown-blog-loader 1.6.3__py3-none-any.whl

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.
@@ -0,0 +1,621 @@
1
+ import logging
2
+ import os
3
+ import re
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Optional, Union
7
+ from urllib.parse import urlparse, ParseResult
8
+
9
+ import bs4
10
+ import frontmatter
11
+ import markdownify
12
+ import pytz
13
+ from PIL import Image
14
+ from binx_og_image_generator import generate as generate_og_image
15
+ from binx_og_image_generator.generator import Blog as ImageGeneratorBlog
16
+ from markdown import markdown
17
+
18
+ from wordpress_markdown_blog_loader.api import Post, Medium
19
+ from wordpress_markdown_blog_loader.api import Wordpress, WordpressEndpoint
20
+ from wordpress_markdown_blog_loader.remove_newlines import (
21
+ remove_newlines_from_paragraphs,
22
+ )
23
+
24
+
25
+ class Blog(object):
26
+ def __init__(self):
27
+ self.dir: Path = None
28
+ self.path: Path = None
29
+ self.blog: frontmatter.Post = frontmatter.Post(content="")
30
+ self.uploaded_images: dict[str, Media] = {}
31
+ self.markdown_image_pattern = re.compile(
32
+ r'\!\[(?P<alt_text>[^]]*)\]\((?P<url>.*?)(?P<caption>\s*"[^"]*?")?\)'
33
+ )
34
+
35
+ @staticmethod
36
+ def load(path: str) -> "Blog":
37
+ result = Blog()
38
+ result.path = Path(path)
39
+ result.dir = Path(path).parent
40
+ if not result.path.exists():
41
+ raise ValueError(f"{path} does not exist")
42
+
43
+ with open(path, "r") as f:
44
+ result.blog = frontmatter.load(f)
45
+ return result
46
+
47
+ def save(self):
48
+ os.makedirs(self.dir, exist_ok=True)
49
+ with open(self.path, "wb") as f:
50
+ frontmatter.dump(self.blog, f)
51
+
52
+ @property
53
+ def slug(self):
54
+ return self.blog.metadata.get("slug")
55
+
56
+ @slug.setter
57
+ def slug(self, slug):
58
+ self.blog.metadata["slug"] = slug
59
+
60
+ @property
61
+ def permalink_template(self):
62
+ return self.blog.metadata.get("permalink_template")
63
+
64
+ @property
65
+ def canonical(self):
66
+ return self.blog.metadata.get("canonical")
67
+
68
+ @canonical.setter
69
+ def canonical(self, value):
70
+ if value:
71
+ self.blog.metadata["canonical"] = value
72
+ else:
73
+ self.blog.metadata.pop("canonical", "")
74
+
75
+ @property
76
+ def focus_keywords(self):
77
+ """
78
+ Keywords to determine SEO effectiveness. Can only be uploaded, not
79
+ retrieved. You just gotto love WP 😬
80
+ """
81
+ return self.blog.metadata.get("focus-keywords")
82
+
83
+
84
+ @focus_keywords.setter
85
+ def focus_keywords(self, value):
86
+ if value:
87
+ self.blog.metadata["focus-keywords"] = " ".join(value) if isinstance(value, list) else value
88
+ else:
89
+ self.blog.metadata.pop("focus-keywords", "")
90
+
91
+ @permalink_template.setter
92
+ def permalink_template(self, permalink_template):
93
+ self.blog.metadata["permalink_template"] = permalink_template
94
+
95
+ @property
96
+ def author(self):
97
+ return self.blog.metadata.get("author")
98
+
99
+ @property
100
+ def author_id(self):
101
+ """ useful of the author has multiple user accounts on WP and we are not allowed to read
102
+ user's email addresses.
103
+ """
104
+ return self.blog.metadata.get("author-id")
105
+
106
+ @author.setter
107
+ def author(self, author):
108
+ self.blog.metadata["author"] = author
109
+
110
+ @property
111
+ def title(self):
112
+ return self.blog.metadata.get("title")
113
+
114
+ @title.setter
115
+ def title(self, title):
116
+ self.blog.metadata["title"] = title
117
+
118
+ @property
119
+ def subtitle(self):
120
+ return self.blog.metadata.get("subtitle", "")
121
+
122
+ @subtitle.setter
123
+ def subtitle(self, subtitle):
124
+ self.blog.metadata["subtitle"] = subtitle
125
+
126
+ @property
127
+ def status(self):
128
+ return self.blog.metadata.get("status", "draft")
129
+
130
+ @status.setter
131
+ def status(self, status):
132
+ self.blog.metadata["status"] = status
133
+
134
+ @property
135
+ def content(self):
136
+ return self.blog.content
137
+
138
+ @content.setter
139
+ def content(self, content):
140
+ self.blog.content = content
141
+
142
+ @property
143
+ def og(self):
144
+ return self.blog.metadata.get("og", {})
145
+
146
+ @property
147
+ def image(self):
148
+ return self.blog.metadata.get("image")
149
+
150
+ @image.setter
151
+ def image(self, image):
152
+ self.blog.metadata["image"] = image
153
+
154
+ @property
155
+ def og_image(self):
156
+ return self.og.get("image")
157
+
158
+ @property
159
+ def brand(self):
160
+ return self.blog.metadata.get("brand")
161
+
162
+ @brand.setter
163
+ def brand(self, brand):
164
+ self.blog.metadata["brand"] = brand
165
+
166
+ @og_image.setter
167
+ def og_image(self, og_image):
168
+ if "og" in self.blog.metadata:
169
+ self.blog.metadata["og"]["image"] = og_image
170
+ else:
171
+ self.blog.metadata["og"] = {"image": og_image}
172
+
173
+ @property
174
+ def og_description(self):
175
+ return self.og.get("description", "")
176
+
177
+ @og_description.setter
178
+ def og_description(self, og_description):
179
+ if "og" in self.blog.metadata:
180
+ self.blog.metadata["og"]["description"] = og_description
181
+ else:
182
+ self.blog.metadata["og"] = {"description": og_description}
183
+
184
+ @property
185
+ def excerpt(self):
186
+ return self.blog.metadata.get("excerpt")
187
+
188
+ @excerpt.setter
189
+ def excerpt(self, excerpt):
190
+ self.blog.metadata["excerpt"] = excerpt
191
+
192
+ @property
193
+ def categories(self):
194
+ return self.blog.metadata.get("categories", [])
195
+
196
+ @categories.setter
197
+ def categories(self, categories: list[str]):
198
+ self.blog.metadata["categories"] = categories
199
+
200
+ @property
201
+ def industries(self):
202
+ return self.blog.metadata.get("industries", [])
203
+
204
+ @industries.setter
205
+ def industries(self, slugs: list[str]):
206
+ self.blog.metadata["industries"] = slugs
207
+
208
+ @property
209
+ def partners(self):
210
+ return self.blog.metadata.get("partners", [])
211
+
212
+ @partners.setter
213
+ def partners(self, slugs: list[str]):
214
+ self.blog.metadata["partners"] = slugs
215
+
216
+ @property
217
+ def capabilities(self) -> list[str]:
218
+ return self.blog.metadata.get("capabilities", [])
219
+
220
+ @capabilities.setter
221
+ def capabilities(self, slugs: list[str]):
222
+ self.blog.metadata["capabilities"] = slugs
223
+
224
+ @property
225
+ def tags(self):
226
+ return self.blog.metadata.get("tags", [])
227
+
228
+ @tags.setter
229
+ def tags(self, tags: list[str]):
230
+ self.blog.metadata["tags"] = tags
231
+
232
+ @property
233
+ def guid(self):
234
+ return self.blog.metadata.get("guid")
235
+
236
+ @guid.setter
237
+ def guid(self, new_guid):
238
+ self.blog.metadata["guid"] = new_guid
239
+
240
+ @property
241
+ def date(self) -> datetime:
242
+ return self.blog.metadata.get("date")
243
+
244
+ @date.setter
245
+ def date(self, new_date):
246
+ self.blog.metadata["date"] = new_date
247
+
248
+ @property
249
+ def image_path(self) -> Optional[Path]:
250
+ return Path(self.dir).joinpath(self.image) if self.image else None
251
+
252
+ @property
253
+ def email(self) -> Optional[str]:
254
+ return self.blog.metadata.get("email")
255
+
256
+ @email.setter
257
+ def email(self, new_email):
258
+ self.blog.metadata["email"] = new_email
259
+
260
+ @property
261
+ def og_image_path(self) -> Optional[Path]:
262
+ return Path(self.dir).joinpath(self.og_image) if self.og_image else None
263
+
264
+ def generate_og_image(self):
265
+ in_file = str(self.image_path)
266
+
267
+ if not self.og_image:
268
+ self.og_image = "images/og-banner.jpg"
269
+ out_file = str(self.og_image_path)
270
+ logging.info("generating new image in %s", out_file)
271
+ blog = ImageGeneratorBlog(self.title, self.subtitle, self.author, self.email)
272
+ generate_og_image(
273
+ blog,
274
+ in_file,
275
+ out_file,
276
+ overwrite=True,
277
+ gradient_magnitude=0.9,
278
+ brand=self.brand,
279
+ )
280
+
281
+ @property
282
+ def banner(self) -> Optional["Image"]:
283
+ return Image.load(self.image_path) if self.image_path else None
284
+
285
+ @property
286
+ def og_banner(self) -> Optional["Image"]:
287
+ return Image.load(self.og_image_path) if self.og_image_path else None
288
+
289
+ @property
290
+ def rendered(self):
291
+ def replace_references(match: re.Match):
292
+ image = self.uploaded_images.get(match.group("url"))
293
+ if image:
294
+ caption = ""
295
+ if match.group("caption"):
296
+ caption = match.group("caption")
297
+ return f"![{match.group('alt_text')}]({image.url}{caption})"
298
+ return match.group(0)
299
+
300
+ content = self.markdown_image_pattern.sub(replace_references, self.content)
301
+ html = markdown(
302
+ content, extensions=["fenced_code", "attr_list", "tables", "footnotes"]
303
+ )
304
+ return remove_newlines_from_paragraphs(html)
305
+
306
+ @property
307
+ def local_image_references(self) -> set[str]:
308
+ return set(
309
+ map(
310
+ lambda u: u.path,
311
+ filter(
312
+ lambda u: u.scheme in ["", "file"],
313
+ map(
314
+ lambda u: urlparse(u),
315
+ map(
316
+ lambda m: m.group("url"),
317
+ re.finditer(self.markdown_image_pattern, self.content),
318
+ ),
319
+ ),
320
+ ),
321
+ )
322
+ )
323
+
324
+ def remote_image_references(self, endpoint: WordpressEndpoint) -> set[ParseResult]:
325
+ return set(
326
+ filter(
327
+ lambda u: u.scheme in ["https", "http"]
328
+ and endpoint.is_host_for(u)
329
+ and u.path.startswith("/wp-content/uploads/"),
330
+ map(
331
+ lambda u: urlparse(u),
332
+ map(
333
+ lambda m: m.group("url"),
334
+ re.finditer(self.markdown_image_pattern, self.content),
335
+ ),
336
+ ),
337
+ ),
338
+ )
339
+
340
+ def download_media(
341
+ self,
342
+ url: Union[str, ParseResult],
343
+ wordpress: Wordpress,
344
+ path: Path,
345
+ ):
346
+ url = urlparse(url) if isinstance(url, str) else url
347
+ logging.info("downloading %s as %s", url.geturl(), path.name)
348
+ raw = wordpress.get_media(url.geturl())
349
+ os.makedirs(path.parent, exist_ok=True)
350
+ with open(path, "wb") as file:
351
+ file.write(raw)
352
+
353
+ def download_remote_images(self, wp: Wordpress, slug: str = ""):
354
+ self.downloaded_images: dict[str, Path] = {}
355
+ for url in self.remote_image_references(wp.endpoint):
356
+ name = Path(url.path).name.removeprefix(slug)
357
+ name = name.removeprefix(slug)
358
+ path = Path("images").joinpath(name)
359
+ self.download_media(url, wp, Path(self.dir).joinpath(path))
360
+ self.downloaded_images[url.geturl()] = path
361
+
362
+ def replace_remote_image_references(match: re.Match):
363
+ url = match.group("url")
364
+ path = self.downloaded_images.get(url)
365
+ if path:
366
+ caption = ""
367
+ if match.group("caption"):
368
+ caption = match.group("caption")
369
+ return f"![{match.group('alt_text')}]({path}{caption})"
370
+ return match.group(0)
371
+
372
+ self.content = self.markdown_image_pattern.sub(
373
+ replace_remote_image_references, self.content
374
+ )
375
+
376
+ def upload_local_images(self, wp: Wordpress):
377
+ self.uploaded_images = {}
378
+ for filename in self.local_image_references:
379
+ path = Path(self.dir).joinpath(filename)
380
+ if not path.exists():
381
+ logging.warning("%s does not exist", path)
382
+ continue
383
+
384
+ slug = self.slug + "-" + re.sub(r"[/\.\\]+", "-", path.stem.strip("-"))
385
+ self.uploaded_images[filename] = wp.upload_media(slug, path)
386
+
387
+ def to_wordpress(self, wp: Wordpress) -> dict:
388
+ author = wp.get_unique_user_by_name(self.author, self.email, self.author_id)
389
+ self.upload_local_images(wp)
390
+ result = {
391
+ "title": self.title,
392
+ "slug": self.slug,
393
+ "author": author.id,
394
+ "date": self.date.isoformat(),
395
+ "date_gmt": self.date.astimezone(pytz.UTC).isoformat(),
396
+ "content": self.rendered,
397
+ "format": "standard",
398
+ "status": self.status,
399
+ "author": author.id,
400
+ "categories": [wp.get_category_id_by_name(c) for c in self.categories],
401
+ "tags": [wp.get_tag_id_by_name(c) for c in self.tags],
402
+ "acf": {"show_header_image": bool(self.image)},
403
+ "industries_taxonomy": [wp.get_industry_by_name(c) for c in self.industries],
404
+ "partners_taxonomy": [wp.get_partner_by_name(c) for c in self.partners],
405
+ "capabilities": [wp.get_capabilities_by_name(c) for c in self.capabilities],
406
+ }
407
+ if self.permalink_template:
408
+ result["permalink_template"] = self.permalink_template
409
+
410
+ if self.excerpt:
411
+ result["excerpt"] = markdown(
412
+ self.excerpt, extensions=["fenced_code", "attr_list"]
413
+ )
414
+
415
+ metadesc = self.og_description if self.og_description else self.excerpt
416
+ result["meta"] = {
417
+ "rank_math_facebook_description": metadesc,
418
+ "rank_math_twitter_description": metadesc,
419
+ }
420
+ if self.canonical:
421
+ result["meta"]["rank_math_canonical_url"] = self.canonical
422
+
423
+ if self.focus_keywords:
424
+ result["meta"]["rank_math_focus_keyword"] = ','.join(self.focus_keywords.split())
425
+
426
+ return result
427
+
428
+ @staticmethod
429
+ def from_wordpress(
430
+ post: Post, base_directory: Path, wordpress: Wordpress
431
+ ) -> "Blog":
432
+ """
433
+ convert a Wordpress post to a FrontMatter post
434
+ """
435
+ blog = Blog()
436
+ blog.dir = (
437
+ Path(base_directory)
438
+ .joinpath(str(post.date.year))
439
+ .joinpath(f"{post.date.month:02d}")
440
+ .joinpath(post.slug)
441
+ )
442
+ blog.path = Path(blog.dir).joinpath("index.md")
443
+
444
+ if blog.path.exists():
445
+ blog = blog.load(blog.path)
446
+
447
+ blog.title = post.title
448
+ blog.author = wordpress.get_user_by_id(post.author).name
449
+ blog.guid = post.guid
450
+ blog.categories = [wordpress.categories_by_id[c] for c in post.categories]
451
+ blog.industries = [wordpress.industries_taxonomy_by_id[c] for c in post.industries_taxonomy]
452
+ blog.partners = [wordpress.partners_taxonomy_by_id[c] for c in post.partners_taxonomy]
453
+ blog.capabilities = [wordpress.capabilities_by_id[c] for c in post.capabilities]
454
+ if post.tags:
455
+ blog.tags = [wordpress.tags_by_id[t] for t in post.tags]
456
+ blog.date = post.date
457
+ blog.slug = post.slug
458
+ blog.status = post.status
459
+ blog.brand = wordpress.endpoint.host
460
+ blog.canonical = post.get("meta", {}).get(
461
+ "rank_math_canonical_url", blog.canonical
462
+ )
463
+
464
+ if keywords := post.get("meta", {}).get("rank_math_focus_keyword"):
465
+ blog.focus_keywords = keywords.split(",")
466
+
467
+ if post.permalink_template and not blog.permalink_template:
468
+ # keeping the permalink template registered in the blog metadata.
469
+ # Wordpress does not return the template that was set.
470
+ blog.permalink_template = post.permalink_template
471
+
472
+ if post.og_description:
473
+ blog.og_description = post.og_description
474
+
475
+ if post.featured_media:
476
+ if "acf" not in post:
477
+ logging.warning(
478
+ "Advanced Custom Field groups is not enabled for the REST API."
479
+ )
480
+ featured_media: Medium = Medium(wordpress.get("media", post.featured_media))
481
+ url = urlparse(featured_media.url)
482
+
483
+ if not blog.image:
484
+ blog.image = os.path.join("images", "banner" + Path(url.path).suffix)
485
+ blog.download_media(url.geturl(), wordpress, path=blog.image_path)
486
+ else:
487
+ featured_media: Medium = None
488
+
489
+ og_image = next(
490
+ filter(lambda u: wordpress.is_host_for((u)), post.og_images), None
491
+ )
492
+ if og_image and not (
493
+ featured_media and og_image.geturl() == featured_media.url
494
+ ):
495
+ if not blog.og_image:
496
+ blog.og_image = os.path.join(
497
+ "images", "og-banner" + Path(og_image.path).suffix
498
+ )
499
+ blog.download_media(og_image.geturl(), wordpress, blog.og_image_path)
500
+
501
+ if post.excerpt:
502
+ blog.excerpt = markdownify.markdownify(
503
+ post.excerpt,
504
+ STRIP=True,
505
+ MARKDOWN_EXTENSIONS=[
506
+ "markdown.extensions.fenced_code",
507
+ "markdown.extensions.extra",
508
+ ],
509
+ code_language_callback=_code_block_language,
510
+ ).strip()
511
+ blog.content = markdownify.markdownify(
512
+ post.content,
513
+ STRIP=True,
514
+ MARKDOWN_EXTENSIONS=[
515
+ "markdown.extensions.fenced_code",
516
+ "markdown.extensions.extra",
517
+ "markdown.extensions.tables",
518
+ "markdown.extensions.footnotes",
519
+ ],
520
+ code_language_callback=_code_block_language,
521
+ )
522
+
523
+ return blog
524
+
525
+ def remove_empty_lines(self):
526
+ result = []
527
+ lines = self.content.splitlines(keepends=True)
528
+ in_code_block = False
529
+ for i, line in enumerate(lines):
530
+ if line.startswith("```"):
531
+ in_code_block = not in_code_block
532
+
533
+ if line != "\n":
534
+ result.append(line)
535
+ continue
536
+
537
+ if in_code_block:
538
+ if i + 1 >= len(lines) or not lines[i + 1].startswith("```"):
539
+ result.append(line)
540
+ continue
541
+
542
+ if i + 1 < len(lines) and (
543
+ lines[i + 1].startswith("#")
544
+ or lines[i + 1].startswith("-")
545
+ or lines[i + 1].startswith("*")
546
+ or lines[i + 1].startswith("1.")
547
+ or lines[i + 1].startswith("~")
548
+ or lines[i + 1].startswith("`")
549
+ ):
550
+ result.append(line)
551
+ continue
552
+
553
+ if i + 2 < len(lines) and (
554
+ lines[i + 2].startswith("--") or lines[i + 2].startswith("==")
555
+ ):
556
+ result.append(line)
557
+ continue
558
+
559
+ if i > 0 and (
560
+ lines[i - 1].startswith("--") or lines[i - 1].startswith("==")
561
+ ):
562
+ result.append(line)
563
+ self.content = "".join(result)
564
+
565
+ def remove_span_tags(self):
566
+ self.content = remove_span_tags_from_code(self.content)
567
+
568
+
569
+ def remove_span_tags_from_code(markdown: str) -> str:
570
+ r"""
571
+ Removes <span> tags from the markdown code blocks. In some upgrade of Wordpress, the code
572
+ block was corrupted to contain the rendered HTML of the code block. This undoes that.
573
+ If the code block is for HTML, span is left in place.
574
+
575
+ >>> doc = "```python\n<span class=bla>spanned code</span>\n```\n"
576
+ >>> remove_span_tags_from_code(doc)
577
+ '```python\nspanned code\n```\n'
578
+ >>> doc = "```html\n<span class=bla>spanned code</span>\n```\n"
579
+ >>> remove_span_tags_from_code(doc)
580
+ '```html\n<span class=bla>spanned code</span>\n```\n'
581
+ """
582
+ if "</span>" not in markdown:
583
+ return markdown
584
+
585
+ pattern = re.compile(
586
+ "(^```.*?$)(?P<code>.*?)(^```$)", flags=re.MULTILINE | re.DOTALL | re.VERBOSE
587
+ )
588
+
589
+ def _remove_span_tags(match):
590
+ if match.group(0).startswith("```html"):
591
+ return match.group(0)
592
+
593
+ return (
594
+ match.group(1)
595
+ + re.sub(r"</?span[^>]*?>", "", match.group("code"))
596
+ + match.group(3)
597
+ )
598
+
599
+ return pattern.sub(_remove_span_tags, markdown)
600
+
601
+
602
+ def _code_block_language(code_block: bs4.element.Tag) -> str:
603
+ """
604
+ determines the code block language from tag attribute class
605
+ >>> import bs4
606
+ >>> _code_block_language(bs4.BeautifulSoup('<code class="code language-python">...</code>', 'lxml'))
607
+ 'python'
608
+ >>> _code_block_language(bs4.BeautifulSoup('<code class="code">...</code>', 'lxml'))
609
+ ''
610
+ """
611
+ code_tag = code_block.find("code")
612
+ return next(
613
+ map(
614
+ lambda l: l.replace("language-", ""),
615
+ filter(
616
+ lambda c: c.startswith("language-"),
617
+ code_tag.get("class", []) if code_tag else "",
618
+ ),
619
+ ),
620
+ "",
621
+ )
@@ -0,0 +1,77 @@
1
+ import requests
2
+ from bs4 import BeautifulSoup
3
+
4
+
5
+ def check_links(content: str):
6
+ """
7
+ Checks a given URL for broken links in its HTML document.
8
+ Returns a list of all broken links found.
9
+ """
10
+ soup = BeautifulSoup(content, "html.parser")
11
+
12
+ broken_links = []
13
+ for link in soup.find_all("a"):
14
+ href = link.get("href")
15
+ if href and href.startswith("http"):
16
+ try:
17
+ response = requests.get(href, headers={"User-Agent": "curl/7.86.0"})
18
+ if response.status_code in [400, 404]:
19
+ broken_links.append((response.status_code, href))
20
+ except requests.exceptions.RequestException:
21
+ broken_links.append((-400, href))
22
+
23
+ return broken_links
24
+
25
+
26
+ import logging
27
+
28
+ import click
29
+
30
+ from wordpress_markdown_blog_loader.api import Wordpress, Post
31
+
32
+
33
+ @click.command(name="check-links")
34
+ @click.option(
35
+ "--host",
36
+ type=str,
37
+ required=True,
38
+ nargs=1,
39
+ help="wordpress host to check blog post links of",
40
+ )
41
+ @click.argument(
42
+ "post-id",
43
+ type=int,
44
+ nargs=-1,
45
+ )
46
+ def command(host: str, post_id: tuple[str]):
47
+ """
48
+ check for broken links in WordPress posts
49
+ """
50
+ wordpress = Wordpress(host)
51
+ wordpress.connect()
52
+
53
+ if post_id:
54
+ posts = list(
55
+ map(
56
+ lambda p: Post(p) if p else None,
57
+ map(
58
+ lambda p: wordpress.get("posts", p, ({"context": "edit"})), post_id
59
+ ),
60
+ )
61
+ )
62
+
63
+ for i, post in enumerate(posts):
64
+ if not post:
65
+ logging.error("post with id %d was not found", post_id[i])
66
+ exit(1)
67
+ else:
68
+ posts = wordpress.posts({"context": "edit"})
69
+
70
+ for post in posts:
71
+ broken_links = check_links(post.content)
72
+ if broken_links:
73
+ logging.error("post %s has %d broken links", post.link, len(broken_links))
74
+ for link in broken_links:
75
+ logging.error(" %s", link)
76
+ else:
77
+ logging.info("post %s has no broken links", post.link)