We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
Importing from OpenLibrary breaks markdown quotes
**Describe the bug**
When importing a book from OpenLibrary, if the description includes a quote as indicated in markdown (with ">"), the quote is not formatted/Bookwyrm doesn't recognize the code.

**To Reproduce**
1. Find a book with a description on OpenLibrary that uses a quote (example to try: OL27172905W)
2. Import the book to a Bookwyrm instance
3. View the description

**Expected behavior**
The quote should appear formatted correctly like markdown links, italics, etc. do.

**Screenshots**
If applicable, add screenshots to help explain your problem.

**Instance**
bookwyrm.social

**Additional context**
Add any other context about the problem here.

---

**Desktop (please complete the following information):**
    - OS: Windows

**Smartphone (please complete the following information):**
    - OS: Android


--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `bookwyrm/connectors/openlibrary.py`
Content:
```
1 """ openlibrary data connector """
2 import re
3 from typing import Any, Optional, Union, Iterator, Iterable
4 
5 from bookwyrm import models
6 from bookwyrm.book_search import SearchResult
7 from .abstract_connector import AbstractConnector, Mapping, JsonDict
8 from .abstract_connector import get_data, infer_physical_format, unique_physical_format
9 from .connector_manager import ConnectorException, create_edition_task
10 from .openlibrary_languages import languages
11 
12 
13 class Connector(AbstractConnector):
14     """instantiate a connector for OL"""
15 
16     generated_remote_link_field = "openlibrary_link"
17 
18     def __init__(self, identifier: str):
19         super().__init__(identifier)
20 
21         get_first = lambda a, *args: a[0]
22         get_remote_id = lambda a, *args: self.base_url + a
23         self.book_mappings = [
24             Mapping("title"),
25             Mapping("id", remote_field="key", formatter=get_remote_id),
26             Mapping("cover", remote_field="covers", formatter=self.get_cover_url),
27             Mapping("sortTitle", remote_field="sort_title"),
28             Mapping("subtitle"),
29             Mapping("description", formatter=get_description),
30             Mapping("languages", formatter=get_languages),
31             Mapping("series", formatter=get_first),
32             Mapping("seriesNumber", remote_field="series_number"),
33             Mapping("subjects"),
34             Mapping("subjectPlaces", remote_field="subject_places"),
35             Mapping("isbn13", remote_field="isbn_13", formatter=get_first),
36             Mapping("isbn10", remote_field="isbn_10", formatter=get_first),
37             Mapping("lccn", formatter=get_first),
38             Mapping("oclcNumber", remote_field="oclc_numbers", formatter=get_first),
39             Mapping(
40                 "openlibraryKey", remote_field="key", formatter=get_openlibrary_key
41             ),
42             Mapping("goodreadsKey", remote_field="goodreads_key"),
43             Mapping("asin"),
44             Mapping(
45                 "firstPublishedDate",
46                 remote_field="first_publish_date",
47             ),
48             Mapping("publishedDate", remote_field="publish_date"),
49             Mapping("pages", remote_field="number_of_pages"),
50             Mapping(
51                 "physicalFormat",
52                 remote_field="physical_format",
53                 formatter=infer_physical_format,
54             ),
55             Mapping(
56                 "physicalFormatDetail",
57                 remote_field="physical_format",
58                 formatter=unique_physical_format,
59             ),
60             Mapping("publishers"),
61         ]
62 
63         self.author_mappings = [
64             Mapping("id", remote_field="key", formatter=get_remote_id),
65             Mapping("name"),
66             Mapping(
67                 "openlibraryKey", remote_field="key", formatter=get_openlibrary_key
68             ),
69             Mapping("born", remote_field="birth_date"),
70             Mapping("died", remote_field="death_date"),
71             Mapping("bio", formatter=get_description),
72             Mapping(
73                 "isni",
74                 remote_field="remote_ids",
75                 formatter=lambda b: get_dict_field(b, "isni"),
76             ),
77             Mapping(
78                 "asin",
79                 remote_field="remote_ids",
80                 formatter=lambda b: get_dict_field(b, "amazon"),
81             ),
82             Mapping(
83                 "viaf",
84                 remote_field="remote_ids",
85                 formatter=lambda b: get_dict_field(b, "viaf"),
86             ),
87             Mapping(
88                 "wikidata",
89                 remote_field="remote_ids",
90                 formatter=lambda b: get_dict_field(b, "wikidata"),
91             ),
92             Mapping(
93                 "wikipedia_link", remote_field="links", formatter=get_wikipedia_link
94             ),
95             Mapping("inventaire_id", remote_field="links", formatter=get_inventaire_id),
96         ]
97 
98     def get_book_data(self, remote_id: str) -> JsonDict:
99         data = get_data(remote_id)
100         if data.get("type", {}).get("key") == "/type/redirect":
101             remote_id = self.base_url + data.get("location", "")
102             return get_data(remote_id)
103         return data
104 
105     def get_remote_id_from_data(self, data: JsonDict) -> str:
106         """format a url from an openlibrary id field"""
107         try:
108             key = data["key"]
109         except KeyError:
110             raise ConnectorException("Invalid book data")
111         return f"{self.books_url}{key}"
112 
113     def is_work_data(self, data: JsonDict) -> bool:
114         return bool(re.match(r"^[\/\w]+OL\d+W$", data["key"]))
115 
116     def get_edition_from_work_data(self, data: JsonDict) -> JsonDict:
117         try:
118             key = data["key"]
119         except KeyError:
120             raise ConnectorException("Invalid book data")
121         url = f"{self.books_url}{key}/editions"
122         data = self.get_book_data(url)
123         edition = pick_default_edition(data["entries"])
124         if not edition:
125             raise ConnectorException("No editions for work")
126         return edition
127 
128     def get_work_from_edition_data(self, data: JsonDict) -> JsonDict:
129         try:
130             key = data["works"][0]["key"]
131         except (IndexError, KeyError):
132             raise ConnectorException("No work found for edition")
133         url = f"{self.books_url}{key}"
134         return self.get_book_data(url)
135 
136     def get_authors_from_data(self, data: JsonDict) -> Iterator[models.Author]:
137         """parse author json and load or create authors"""
138         for author_blob in data.get("authors", []):
139             author_blob = author_blob.get("author", author_blob)
140             # this id is "/authors/OL1234567A"
141             author_id = author_blob["key"]
142             url = f"{self.base_url}{author_id}"
143             author = self.get_or_create_author(url)
144             if not author:
145                 continue
146             yield author
147 
148     def get_cover_url(self, cover_blob: list[str], size: str = "L") -> Optional[str]:
149         """ask openlibrary for the cover"""
150         if not cover_blob:
151             return None
152         cover_id = cover_blob[0]
153         image_name = f"{cover_id}-{size}.jpg"
154         return f"{self.covers_url}/b/id/{image_name}"
155 
156     def parse_search_data(
157         self, data: JsonDict, min_confidence: float
158     ) -> Iterator[SearchResult]:
159         for idx, search_result in enumerate(data.get("docs", [])):
160             # build the remote id from the openlibrary key
161             key = self.books_url + search_result["key"]
162             author = search_result.get("author_name") or ["Unknown"]
163             cover_blob = search_result.get("cover_i")
164             cover = self.get_cover_url([cover_blob], size="M") if cover_blob else None
165 
166             # OL doesn't provide confidence, but it does sort by an internal ranking, so
167             # this confidence value is relative to the list position
168             confidence = 1 / (idx + 1)
169 
170             yield SearchResult(
171                 title=search_result.get("title"),
172                 key=key,
173                 author=", ".join(author),
174                 connector=self,
175                 year=search_result.get("first_publish_year"),
176                 cover=cover,
177                 confidence=confidence,
178             )
179 
180     def parse_isbn_search_data(self, data: JsonDict) -> Iterator[SearchResult]:
181         for search_result in list(data.values()):
182             # build the remote id from the openlibrary key
183             key = self.books_url + search_result["key"]
184             authors = search_result.get("authors") or [{"name": "Unknown"}]
185             author_names = [author.get("name") for author in authors]
186             yield SearchResult(
187                 title=search_result.get("title"),
188                 key=key,
189                 author=", ".join(author_names),
190                 connector=self,
191                 year=search_result.get("publish_date"),
192             )
193 
194     def load_edition_data(self, olkey: str) -> JsonDict:
195         """query openlibrary for editions of a work"""
196         url = f"{self.books_url}/works/{olkey}/editions"
197         return self.get_book_data(url)
198 
199     def expand_book_data(self, book: models.Book) -> None:
200         work = book
201         # go from the edition to the work, if necessary
202         if isinstance(book, models.Edition):
203             work = book.parent_work
204 
205         # we can mass download edition data from OL to avoid repeatedly querying
206         try:
207             edition_options = self.load_edition_data(work.openlibrary_key)
208         except ConnectorException:
209             # who knows, man
210             return
211 
212         for edition_data in edition_options.get("entries", []):
213             # does this edition have ANY interesting data?
214             if ignore_edition(edition_data):
215                 continue
216             create_edition_task.delay(self.connector.id, work.id, edition_data)
217 
218 
219 def ignore_edition(edition_data: JsonDict) -> bool:
220     """don't load a million editions that have no metadata"""
221     # an isbn, we love to see it
222     if edition_data.get("isbn_13") or edition_data.get("isbn_10"):
223         return False
224     # grudgingly, oclc can stay
225     if edition_data.get("oclc_numbers"):
226         return False
227     # if it has a cover it can stay
228     if edition_data.get("covers"):
229         return False
230     # keep non-english editions
231     if edition_data.get("languages") and "languages/eng" not in str(
232         edition_data.get("languages")
233     ):
234         return False
235     return True
236 
237 
238 def get_description(description_blob: Union[JsonDict, str]) -> Optional[str]:
239     """descriptions can be a string or a dict"""
240     if isinstance(description_blob, dict):
241         return description_blob.get("value")
242     return description_blob
243 
244 
245 def get_openlibrary_key(key: str) -> str:
246     """convert /books/OL27320736M into OL27320736M"""
247     return key.split("/")[-1]
248 
249 
250 def get_languages(language_blob: Iterable[JsonDict]) -> list[Optional[str]]:
251     """/language/eng -> English"""
252     langs = []
253     for lang in language_blob:
254         langs.append(languages.get(lang.get("key", ""), None))
255     return langs
256 
257 
258 def get_dict_field(blob: Optional[JsonDict], field_name: str) -> Optional[Any]:
259     """extract the isni from the remote id data for the author"""
260     if not blob or not isinstance(blob, dict):
261         return None
262     return blob.get(field_name)
263 
264 
265 def get_wikipedia_link(links: list[Any]) -> Optional[str]:
266     """extract wikipedia links"""
267     if not isinstance(links, list):
268         return None
269 
270     for link in links:
271         if not isinstance(link, dict):
272             continue
273         if link.get("title") == "wikipedia":
274             return link.get("url")
275     return None
276 
277 
278 def get_inventaire_id(links: list[Any]) -> Optional[str]:
279     """extract and format inventaire ids"""
280     if not isinstance(links, list):
281         return None
282 
283     for link in links:
284         if not isinstance(link, dict):
285             continue
286         if link.get("title") == "inventaire.io":
287             iv_link = link.get("url")
288             if not isinstance(iv_link, str):
289                 return None
290             return iv_link.split("/")[-1]
291     return None
292 
293 
294 def pick_default_edition(options: list[JsonDict]) -> Optional[JsonDict]:
295     """favor physical copies with covers in english"""
296     if not options:
297         return None
298     if len(options) == 1:
299         return options[0]
300 
301     options = [e for e in options if e.get("covers")] or options
302     options = [
303         e for e in options if "/languages/eng" in str(e.get("languages"))
304     ] or options
305     formats = ["paperback", "hardcover", "mass market paperback"]
306     options = [
307         e for e in options if str(e.get("physical_format")).lower() in formats
308     ] or options
309     options = [e for e in options if e.get("isbn_13")] or options
310     options = [e for e in options if e.get("ocaid")] or options
311     return options[0]
312
```

Path: `bookwyrm/utils/sanitizer.py`
Content:
```
1 """Clean user-provided text"""
2 import bleach
3 
4 
5 def clean(input_text):
6     """Run through "bleach" """
7     return bleach.clean(
8         input_text,
9         tags=[
10             "p",
11             "blockquote",
12             "br",
13             "b",
14             "i",
15             "strong",
16             "em",
17             "pre",
18             "a",
19             "span",
20             "ul",
21             "ol",
22             "li",
23         ],
24         attributes=["href", "rel", "src", "alt", "data-mention"],
25         strip=True,
26     )
27
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/bookwyrm/connectors/openlibrary.py b/bookwyrm/connectors/openlibrary.py
--- a/bookwyrm/connectors/openlibrary.py
+++ b/bookwyrm/connectors/openlibrary.py
@@ -2,8 +2,11 @@
 import re
 from typing import Any, Optional, Union, Iterator, Iterable
 
+from markdown import markdown
+
 from bookwyrm import models
 from bookwyrm.book_search import SearchResult
+from bookwyrm.utils.sanitizer import clean
 from .abstract_connector import AbstractConnector, Mapping, JsonDict
 from .abstract_connector import get_data, infer_physical_format, unique_physical_format
 from .connector_manager import ConnectorException, create_edition_task
@@ -235,11 +238,22 @@
     return True
 
 
-def get_description(description_blob: Union[JsonDict, str]) -> Optional[str]:
+def get_description(description_blob: Union[JsonDict, str]) -> str:
     """descriptions can be a string or a dict"""
     if isinstance(description_blob, dict):
-        return description_blob.get("value")
-    return description_blob
+        description = markdown(description_blob.get("value", ""))
+    else:
+        description = markdown(description_blob)
+
+    if (
+        description.startswith("<p>")
+        and description.endswith("</p>")
+        and description.count("<p>") == 1
+    ):
+        # If there is just one <p> tag and it is around the text remove it
+        return description[len("<p>") : -len("</p>")].strip()
+
+    return clean(description)
 
 
 def get_openlibrary_key(key: str) -> str:
diff --git a/bookwyrm/utils/sanitizer.py b/bookwyrm/utils/sanitizer.py
--- a/bookwyrm/utils/sanitizer.py
+++ b/bookwyrm/utils/sanitizer.py
@@ -2,7 +2,7 @@
 import bleach
 
 
-def clean(input_text):
+def clean(input_text: str) -> str:
     """Run through "bleach" """
     return bleach.clean(
         input_text,

{"golden_diff": "diff --git a/bookwyrm/connectors/openlibrary.py b/bookwyrm/connectors/openlibrary.py\n--- a/bookwyrm/connectors/openlibrary.py\n+++ b/bookwyrm/connectors/openlibrary.py\n@@ -2,8 +2,11 @@\n import re\n from typing import Any, Optional, Union, Iterator, Iterable\n \n+from markdown import markdown\n+\n from bookwyrm import models\n from bookwyrm.book_search import SearchResult\n+from bookwyrm.utils.sanitizer import clean\n from .abstract_connector import AbstractConnector, Mapping, JsonDict\n from .abstract_connector import get_data, infer_physical_format, unique_physical_format\n from .connector_manager import ConnectorException, create_edition_task\n@@ -235,11 +238,22 @@\n     return True\n \n \n-def get_description(description_blob: Union[JsonDict, str]) -> Optional[str]:\n+def get_description(description_blob: Union[JsonDict, str]) -> str:\n     \"\"\"descriptions can be a string or a dict\"\"\"\n     if isinstance(description_blob, dict):\n-        return description_blob.get(\"value\")\n-    return description_blob\n+        description = markdown(description_blob.get(\"value\", \"\"))\n+    else:\n+        description = markdown(description_blob)\n+\n+    if (\n+        description.startswith(\"<p>\")\n+        and description.endswith(\"</p>\")\n+        and description.count(\"<p>\") == 1\n+    ):\n+        # If there is just one <p> tag and it is around the text remove it\n+        return description[len(\"<p>\") : -len(\"</p>\")].strip()\n+\n+    return clean(description)\n \n \n def get_openlibrary_key(key: str) -> str:\ndiff --git a/bookwyrm/utils/sanitizer.py b/bookwyrm/utils/sanitizer.py\n--- a/bookwyrm/utils/sanitizer.py\n+++ b/bookwyrm/utils/sanitizer.py\n@@ -2,7 +2,7 @@\n import bleach\n \n \n-def clean(input_text):\n+def clean(input_text: str) -> str:\n     \"\"\"Run through \"bleach\" \"\"\"\n     return bleach.clean(\n         input_text,\n", "issue": "Importing from OpenLibrary breaks markdown quotes\n**Describe the bug**\r\nWhen importing a book from OpenLibrary, if the description includes a quote as indicated in markdown (with \">\"), the quote is not formatted/Bookwyrm doesn't recognize the code.\r\n\r\n**To Reproduce**\r\n1. Find a book with a description on OpenLibrary that uses a quote (example to try: OL27172905W)\r\n2. Import the book to a Bookwyrm instance\r\n3. View the description\r\n\r\n**Expected behavior**\r\nThe quote should appear formatted correctly like markdown links, italics, etc. do.\r\n\r\n**Screenshots**\r\nIf applicable, add screenshots to help explain your problem.\r\n\r\n**Instance**\r\nbookwyrm.social\r\n\r\n**Additional context**\r\nAdd any other context about the problem here.\r\n\r\n---\r\n\r\n**Desktop (please complete the following information):**\r\n    - OS: Windows\r\n\r\n**Smartphone (please complete the following information):**\r\n    - OS: Android\r\n\n", "before_files": [{"content": "\"\"\" openlibrary data connector \"\"\"\nimport re\nfrom typing import Any, Optional, Union, Iterator, Iterable\n\nfrom bookwyrm import models\nfrom bookwyrm.book_search import SearchResult\nfrom .abstract_connector import AbstractConnector, Mapping, JsonDict\nfrom .abstract_connector import get_data, infer_physical_format, unique_physical_format\nfrom .connector_manager import ConnectorException, create_edition_task\nfrom .openlibrary_languages import languages\n\n\nclass Connector(AbstractConnector):\n    \"\"\"instantiate a connector for OL\"\"\"\n\n    generated_remote_link_field = \"openlibrary_link\"\n\n    def __init__(self, identifier: str):\n        super().__init__(identifier)\n\n        get_first = lambda a, *args: a[0]\n        get_remote_id = lambda a, *args: self.base_url + a\n        self.book_mappings = [\n            Mapping(\"title\"),\n            Mapping(\"id\", remote_field=\"key\", formatter=get_remote_id),\n            Mapping(\"cover\", remote_field=\"covers\", formatter=self.get_cover_url),\n            Mapping(\"sortTitle\", remote_field=\"sort_title\"),\n            Mapping(\"subtitle\"),\n            Mapping(\"description\", formatter=get_description),\n            Mapping(\"languages\", formatter=get_languages),\n            Mapping(\"series\", formatter=get_first),\n            Mapping(\"seriesNumber\", remote_field=\"series_number\"),\n            Mapping(\"subjects\"),\n            Mapping(\"subjectPlaces\", remote_field=\"subject_places\"),\n            Mapping(\"isbn13\", remote_field=\"isbn_13\", formatter=get_first),\n            Mapping(\"isbn10\", remote_field=\"isbn_10\", formatter=get_first),\n            Mapping(\"lccn\", formatter=get_first),\n            Mapping(\"oclcNumber\", remote_field=\"oclc_numbers\", formatter=get_first),\n            Mapping(\n                \"openlibraryKey\", remote_field=\"key\", formatter=get_openlibrary_key\n            ),\n            Mapping(\"goodreadsKey\", remote_field=\"goodreads_key\"),\n            Mapping(\"asin\"),\n            Mapping(\n                \"firstPublishedDate\",\n                remote_field=\"first_publish_date\",\n            ),\n            Mapping(\"publishedDate\", remote_field=\"publish_date\"),\n            Mapping(\"pages\", remote_field=\"number_of_pages\"),\n            Mapping(\n                \"physicalFormat\",\n                remote_field=\"physical_format\",\n                formatter=infer_physical_format,\n            ),\n            Mapping(\n                \"physicalFormatDetail\",\n                remote_field=\"physical_format\",\n                formatter=unique_physical_format,\n            ),\n            Mapping(\"publishers\"),\n        ]\n\n        self.author_mappings = [\n            Mapping(\"id\", remote_field=\"key\", formatter=get_remote_id),\n            Mapping(\"name\"),\n            Mapping(\n                \"openlibraryKey\", remote_field=\"key\", formatter=get_openlibrary_key\n            ),\n            Mapping(\"born\", remote_field=\"birth_date\"),\n            Mapping(\"died\", remote_field=\"death_date\"),\n            Mapping(\"bio\", formatter=get_description),\n            Mapping(\n                \"isni\",\n                remote_field=\"remote_ids\",\n                formatter=lambda b: get_dict_field(b, \"isni\"),\n            ),\n            Mapping(\n                \"asin\",\n                remote_field=\"remote_ids\",\n                formatter=lambda b: get_dict_field(b, \"amazon\"),\n            ),\n            Mapping(\n                \"viaf\",\n                remote_field=\"remote_ids\",\n                formatter=lambda b: get_dict_field(b, \"viaf\"),\n            ),\n            Mapping(\n                \"wikidata\",\n                remote_field=\"remote_ids\",\n                formatter=lambda b: get_dict_field(b, \"wikidata\"),\n            ),\n            Mapping(\n                \"wikipedia_link\", remote_field=\"links\", formatter=get_wikipedia_link\n            ),\n            Mapping(\"inventaire_id\", remote_field=\"links\", formatter=get_inventaire_id),\n        ]\n\n    def get_book_data(self, remote_id: str) -> JsonDict:\n        data = get_data(remote_id)\n        if data.get(\"type\", {}).get(\"key\") == \"/type/redirect\":\n            remote_id = self.base_url + data.get(\"location\", \"\")\n            return get_data(remote_id)\n        return data\n\n    def get_remote_id_from_data(self, data: JsonDict) -> str:\n        \"\"\"format a url from an openlibrary id field\"\"\"\n        try:\n            key = data[\"key\"]\n        except KeyError:\n            raise ConnectorException(\"Invalid book data\")\n        return f\"{self.books_url}{key}\"\n\n    def is_work_data(self, data: JsonDict) -> bool:\n        return bool(re.match(r\"^[\\/\\w]+OL\\d+W$\", data[\"key\"]))\n\n    def get_edition_from_work_data(self, data: JsonDict) -> JsonDict:\n        try:\n            key = data[\"key\"]\n        except KeyError:\n            raise ConnectorException(\"Invalid book data\")\n        url = f\"{self.books_url}{key}/editions\"\n        data = self.get_book_data(url)\n        edition = pick_default_edition(data[\"entries\"])\n        if not edition:\n            raise ConnectorException(\"No editions for work\")\n        return edition\n\n    def get_work_from_edition_data(self, data: JsonDict) -> JsonDict:\n        try:\n            key = data[\"works\"][0][\"key\"]\n        except (IndexError, KeyError):\n            raise ConnectorException(\"No work found for edition\")\n        url = f\"{self.books_url}{key}\"\n        return self.get_book_data(url)\n\n    def get_authors_from_data(self, data: JsonDict) -> Iterator[models.Author]:\n        \"\"\"parse author json and load or create authors\"\"\"\n        for author_blob in data.get(\"authors\", []):\n            author_blob = author_blob.get(\"author\", author_blob)\n            # this id is \"/authors/OL1234567A\"\n            author_id = author_blob[\"key\"]\n            url = f\"{self.base_url}{author_id}\"\n            author = self.get_or_create_author(url)\n            if not author:\n                continue\n            yield author\n\n    def get_cover_url(self, cover_blob: list[str], size: str = \"L\") -> Optional[str]:\n        \"\"\"ask openlibrary for the cover\"\"\"\n        if not cover_blob:\n            return None\n        cover_id = cover_blob[0]\n        image_name = f\"{cover_id}-{size}.jpg\"\n        return f\"{self.covers_url}/b/id/{image_name}\"\n\n    def parse_search_data(\n        self, data: JsonDict, min_confidence: float\n    ) -> Iterator[SearchResult]:\n        for idx, search_result in enumerate(data.get(\"docs\", [])):\n            # build the remote id from the openlibrary key\n            key = self.books_url + search_result[\"key\"]\n            author = search_result.get(\"author_name\") or [\"Unknown\"]\n            cover_blob = search_result.get(\"cover_i\")\n            cover = self.get_cover_url([cover_blob], size=\"M\") if cover_blob else None\n\n            # OL doesn't provide confidence, but it does sort by an internal ranking, so\n            # this confidence value is relative to the list position\n            confidence = 1 / (idx + 1)\n\n            yield SearchResult(\n                title=search_result.get(\"title\"),\n                key=key,\n                author=\", \".join(author),\n                connector=self,\n                year=search_result.get(\"first_publish_year\"),\n                cover=cover,\n                confidence=confidence,\n            )\n\n    def parse_isbn_search_data(self, data: JsonDict) -> Iterator[SearchResult]:\n        for search_result in list(data.values()):\n            # build the remote id from the openlibrary key\n            key = self.books_url + search_result[\"key\"]\n            authors = search_result.get(\"authors\") or [{\"name\": \"Unknown\"}]\n            author_names = [author.get(\"name\") for author in authors]\n            yield SearchResult(\n                title=search_result.get(\"title\"),\n                key=key,\n                author=\", \".join(author_names),\n                connector=self,\n                year=search_result.get(\"publish_date\"),\n            )\n\n    def load_edition_data(self, olkey: str) -> JsonDict:\n        \"\"\"query openlibrary for editions of a work\"\"\"\n        url = f\"{self.books_url}/works/{olkey}/editions\"\n        return self.get_book_data(url)\n\n    def expand_book_data(self, book: models.Book) -> None:\n        work = book\n        # go from the edition to the work, if necessary\n        if isinstance(book, models.Edition):\n            work = book.parent_work\n\n        # we can mass download edition data from OL to avoid repeatedly querying\n        try:\n            edition_options = self.load_edition_data(work.openlibrary_key)\n        except ConnectorException:\n            # who knows, man\n            return\n\n        for edition_data in edition_options.get(\"entries\", []):\n            # does this edition have ANY interesting data?\n            if ignore_edition(edition_data):\n                continue\n            create_edition_task.delay(self.connector.id, work.id, edition_data)\n\n\ndef ignore_edition(edition_data: JsonDict) -> bool:\n    \"\"\"don't load a million editions that have no metadata\"\"\"\n    # an isbn, we love to see it\n    if edition_data.get(\"isbn_13\") or edition_data.get(\"isbn_10\"):\n        return False\n    # grudgingly, oclc can stay\n    if edition_data.get(\"oclc_numbers\"):\n        return False\n    # if it has a cover it can stay\n    if edition_data.get(\"covers\"):\n        return False\n    # keep non-english editions\n    if edition_data.get(\"languages\") and \"languages/eng\" not in str(\n        edition_data.get(\"languages\")\n    ):\n        return False\n    return True\n\n\ndef get_description(description_blob: Union[JsonDict, str]) -> Optional[str]:\n    \"\"\"descriptions can be a string or a dict\"\"\"\n    if isinstance(description_blob, dict):\n        return description_blob.get(\"value\")\n    return description_blob\n\n\ndef get_openlibrary_key(key: str) -> str:\n    \"\"\"convert /books/OL27320736M into OL27320736M\"\"\"\n    return key.split(\"/\")[-1]\n\n\ndef get_languages(language_blob: Iterable[JsonDict]) -> list[Optional[str]]:\n    \"\"\"/language/eng -> English\"\"\"\n    langs = []\n    for lang in language_blob:\n        langs.append(languages.get(lang.get(\"key\", \"\"), None))\n    return langs\n\n\ndef get_dict_field(blob: Optional[JsonDict], field_name: str) -> Optional[Any]:\n    \"\"\"extract the isni from the remote id data for the author\"\"\"\n    if not blob or not isinstance(blob, dict):\n        return None\n    return blob.get(field_name)\n\n\ndef get_wikipedia_link(links: list[Any]) -> Optional[str]:\n    \"\"\"extract wikipedia links\"\"\"\n    if not isinstance(links, list):\n        return None\n\n    for link in links:\n        if not isinstance(link, dict):\n            continue\n        if link.get(\"title\") == \"wikipedia\":\n            return link.get(\"url\")\n    return None\n\n\ndef get_inventaire_id(links: list[Any]) -> Optional[str]:\n    \"\"\"extract and format inventaire ids\"\"\"\n    if not isinstance(links, list):\n        return None\n\n    for link in links:\n        if not isinstance(link, dict):\n            continue\n        if link.get(\"title\") == \"inventaire.io\":\n            iv_link = link.get(\"url\")\n            if not isinstance(iv_link, str):\n                return None\n            return iv_link.split(\"/\")[-1]\n    return None\n\n\ndef pick_default_edition(options: list[JsonDict]) -> Optional[JsonDict]:\n    \"\"\"favor physical copies with covers in english\"\"\"\n    if not options:\n        return None\n    if len(options) == 1:\n        return options[0]\n\n    options = [e for e in options if e.get(\"covers\")] or options\n    options = [\n        e for e in options if \"/languages/eng\" in str(e.get(\"languages\"))\n    ] or options\n    formats = [\"paperback\", \"hardcover\", \"mass market paperback\"]\n    options = [\n        e for e in options if str(e.get(\"physical_format\")).lower() in formats\n    ] or options\n    options = [e for e in options if e.get(\"isbn_13\")] or options\n    options = [e for e in options if e.get(\"ocaid\")] or options\n    return options[0]\n", "path": "bookwyrm/connectors/openlibrary.py"}, {"content": "\"\"\"Clean user-provided text\"\"\"\nimport bleach\n\n\ndef clean(input_text):\n    \"\"\"Run through \"bleach\" \"\"\"\n    return bleach.clean(\n        input_text,\n        tags=[\n            \"p\",\n            \"blockquote\",\n            \"br\",\n            \"b\",\n            \"i\",\n            \"strong\",\n            \"em\",\n            \"pre\",\n            \"a\",\n            \"span\",\n            \"ul\",\n            \"ol\",\n            \"li\",\n        ],\n        attributes=[\"href\", \"rel\", \"src\", \"alt\", \"data-mention\"],\n        strip=True,\n    )\n", "path": "bookwyrm/utils/sanitizer.py"}], "after_files": [{"content": "\"\"\" openlibrary data connector \"\"\"\nimport re\nfrom typing import Any, Optional, Union, Iterator, Iterable\n\nfrom markdown import markdown\n\nfrom bookwyrm import models\nfrom bookwyrm.book_search import SearchResult\nfrom bookwyrm.utils.sanitizer import clean\nfrom .abstract_connector import AbstractConnector, Mapping, JsonDict\nfrom .abstract_connector import get_data, infer_physical_format, unique_physical_format\nfrom .connector_manager import ConnectorException, create_edition_task\nfrom .openlibrary_languages import languages\n\n\nclass Connector(AbstractConnector):\n    \"\"\"instantiate a connector for OL\"\"\"\n\n    generated_remote_link_field = \"openlibrary_link\"\n\n    def __init__(self, identifier: str):\n        super().__init__(identifier)\n\n        get_first = lambda a, *args: a[0]\n        get_remote_id = lambda a, *args: self.base_url + a\n        self.book_mappings = [\n            Mapping(\"title\"),\n            Mapping(\"id\", remote_field=\"key\", formatter=get_remote_id),\n            Mapping(\"cover\", remote_field=\"covers\", formatter=self.get_cover_url),\n            Mapping(\"sortTitle\", remote_field=\"sort_title\"),\n            Mapping(\"subtitle\"),\n            Mapping(\"description\", formatter=get_description),\n            Mapping(\"languages\", formatter=get_languages),\n            Mapping(\"series\", formatter=get_first),\n            Mapping(\"seriesNumber\", remote_field=\"series_number\"),\n            Mapping(\"subjects\"),\n            Mapping(\"subjectPlaces\", remote_field=\"subject_places\"),\n            Mapping(\"isbn13\", remote_field=\"isbn_13\", formatter=get_first),\n            Mapping(\"isbn10\", remote_field=\"isbn_10\", formatter=get_first),\n            Mapping(\"lccn\", formatter=get_first),\n            Mapping(\"oclcNumber\", remote_field=\"oclc_numbers\", formatter=get_first),\n            Mapping(\n                \"openlibraryKey\", remote_field=\"key\", formatter=get_openlibrary_key\n            ),\n            Mapping(\"goodreadsKey\", remote_field=\"goodreads_key\"),\n            Mapping(\"asin\"),\n            Mapping(\n                \"firstPublishedDate\",\n                remote_field=\"first_publish_date\",\n            ),\n            Mapping(\"publishedDate\", remote_field=\"publish_date\"),\n            Mapping(\"pages\", remote_field=\"number_of_pages\"),\n            Mapping(\n                \"physicalFormat\",\n                remote_field=\"physical_format\",\n                formatter=infer_physical_format,\n            ),\n            Mapping(\n                \"physicalFormatDetail\",\n                remote_field=\"physical_format\",\n                formatter=unique_physical_format,\n            ),\n            Mapping(\"publishers\"),\n        ]\n\n        self.author_mappings = [\n            Mapping(\"id\", remote_field=\"key\", formatter=get_remote_id),\n            Mapping(\"name\"),\n            Mapping(\n                \"openlibraryKey\", remote_field=\"key\", formatter=get_openlibrary_key\n            ),\n            Mapping(\"born\", remote_field=\"birth_date\"),\n            Mapping(\"died\", remote_field=\"death_date\"),\n            Mapping(\"bio\", formatter=get_description),\n            Mapping(\n                \"isni\",\n                remote_field=\"remote_ids\",\n                formatter=lambda b: get_dict_field(b, \"isni\"),\n            ),\n            Mapping(\n                \"asin\",\n                remote_field=\"remote_ids\",\n                formatter=lambda b: get_dict_field(b, \"amazon\"),\n            ),\n            Mapping(\n                \"viaf\",\n                remote_field=\"remote_ids\",\n                formatter=lambda b: get_dict_field(b, \"viaf\"),\n            ),\n            Mapping(\n                \"wikidata\",\n                remote_field=\"remote_ids\",\n                formatter=lambda b: get_dict_field(b, \"wikidata\"),\n            ),\n            Mapping(\n                \"wikipedia_link\", remote_field=\"links\", formatter=get_wikipedia_link\n            ),\n            Mapping(\"inventaire_id\", remote_field=\"links\", formatter=get_inventaire_id),\n        ]\n\n    def get_book_data(self, remote_id: str) -> JsonDict:\n        data = get_data(remote_id)\n        if data.get(\"type\", {}).get(\"key\") == \"/type/redirect\":\n            remote_id = self.base_url + data.get(\"location\", \"\")\n            return get_data(remote_id)\n        return data\n\n    def get_remote_id_from_data(self, data: JsonDict) -> str:\n        \"\"\"format a url from an openlibrary id field\"\"\"\n        try:\n            key = data[\"key\"]\n        except KeyError:\n            raise ConnectorException(\"Invalid book data\")\n        return f\"{self.books_url}{key}\"\n\n    def is_work_data(self, data: JsonDict) -> bool:\n        return bool(re.match(r\"^[\\/\\w]+OL\\d+W$\", data[\"key\"]))\n\n    def get_edition_from_work_data(self, data: JsonDict) -> JsonDict:\n        try:\n            key = data[\"key\"]\n        except KeyError:\n            raise ConnectorException(\"Invalid book data\")\n        url = f\"{self.books_url}{key}/editions\"\n        data = self.get_book_data(url)\n        edition = pick_default_edition(data[\"entries\"])\n        if not edition:\n            raise ConnectorException(\"No editions for work\")\n        return edition\n\n    def get_work_from_edition_data(self, data: JsonDict) -> JsonDict:\n        try:\n            key = data[\"works\"][0][\"key\"]\n        except (IndexError, KeyError):\n            raise ConnectorException(\"No work found for edition\")\n        url = f\"{self.books_url}{key}\"\n        return self.get_book_data(url)\n\n    def get_authors_from_data(self, data: JsonDict) -> Iterator[models.Author]:\n        \"\"\"parse author json and load or create authors\"\"\"\n        for author_blob in data.get(\"authors\", []):\n            author_blob = author_blob.get(\"author\", author_blob)\n            # this id is \"/authors/OL1234567A\"\n            author_id = author_blob[\"key\"]\n            url = f\"{self.base_url}{author_id}\"\n            author = self.get_or_create_author(url)\n            if not author:\n                continue\n            yield author\n\n    def get_cover_url(self, cover_blob: list[str], size: str = \"L\") -> Optional[str]:\n        \"\"\"ask openlibrary for the cover\"\"\"\n        if not cover_blob:\n            return None\n        cover_id = cover_blob[0]\n        image_name = f\"{cover_id}-{size}.jpg\"\n        return f\"{self.covers_url}/b/id/{image_name}\"\n\n    def parse_search_data(\n        self, data: JsonDict, min_confidence: float\n    ) -> Iterator[SearchResult]:\n        for idx, search_result in enumerate(data.get(\"docs\", [])):\n            # build the remote id from the openlibrary key\n            key = self.books_url + search_result[\"key\"]\n            author = search_result.get(\"author_name\") or [\"Unknown\"]\n            cover_blob = search_result.get(\"cover_i\")\n            cover = self.get_cover_url([cover_blob], size=\"M\") if cover_blob else None\n\n            # OL doesn't provide confidence, but it does sort by an internal ranking, so\n            # this confidence value is relative to the list position\n            confidence = 1 / (idx + 1)\n\n            yield SearchResult(\n                title=search_result.get(\"title\"),\n                key=key,\n                author=\", \".join(author),\n                connector=self,\n                year=search_result.get(\"first_publish_year\"),\n                cover=cover,\n                confidence=confidence,\n            )\n\n    def parse_isbn_search_data(self, data: JsonDict) -> Iterator[SearchResult]:\n        for search_result in list(data.values()):\n            # build the remote id from the openlibrary key\n            key = self.books_url + search_result[\"key\"]\n            authors = search_result.get(\"authors\") or [{\"name\": \"Unknown\"}]\n            author_names = [author.get(\"name\") for author in authors]\n            yield SearchResult(\n                title=search_result.get(\"title\"),\n                key=key,\n                author=\", \".join(author_names),\n                connector=self,\n                year=search_result.get(\"publish_date\"),\n            )\n\n    def load_edition_data(self, olkey: str) -> JsonDict:\n        \"\"\"query openlibrary for editions of a work\"\"\"\n        url = f\"{self.books_url}/works/{olkey}/editions\"\n        return self.get_book_data(url)\n\n    def expand_book_data(self, book: models.Book) -> None:\n        work = book\n        # go from the edition to the work, if necessary\n        if isinstance(book, models.Edition):\n            work = book.parent_work\n\n        # we can mass download edition data from OL to avoid repeatedly querying\n        try:\n            edition_options = self.load_edition_data(work.openlibrary_key)\n        except ConnectorException:\n            # who knows, man\n            return\n\n        for edition_data in edition_options.get(\"entries\", []):\n            # does this edition have ANY interesting data?\n            if ignore_edition(edition_data):\n                continue\n            create_edition_task.delay(self.connector.id, work.id, edition_data)\n\n\ndef ignore_edition(edition_data: JsonDict) -> bool:\n    \"\"\"don't load a million editions that have no metadata\"\"\"\n    # an isbn, we love to see it\n    if edition_data.get(\"isbn_13\") or edition_data.get(\"isbn_10\"):\n        return False\n    # grudgingly, oclc can stay\n    if edition_data.get(\"oclc_numbers\"):\n        return False\n    # if it has a cover it can stay\n    if edition_data.get(\"covers\"):\n        return False\n    # keep non-english editions\n    if edition_data.get(\"languages\") and \"languages/eng\" not in str(\n        edition_data.get(\"languages\")\n    ):\n        return False\n    return True\n\n\ndef get_description(description_blob: Union[JsonDict, str]) -> str:\n    \"\"\"descriptions can be a string or a dict\"\"\"\n    if isinstance(description_blob, dict):\n        description = markdown(description_blob.get(\"value\", \"\"))\n    else:\n        description = markdown(description_blob)\n\n    if (\n        description.startswith(\"<p>\")\n        and description.endswith(\"</p>\")\n        and description.count(\"<p>\") == 1\n    ):\n        # If there is just one <p> tag and it is around the text remove it\n        return description[len(\"<p>\") : -len(\"</p>\")].strip()\n\n    return clean(description)\n\n\ndef get_openlibrary_key(key: str) -> str:\n    \"\"\"convert /books/OL27320736M into OL27320736M\"\"\"\n    return key.split(\"/\")[-1]\n\n\ndef get_languages(language_blob: Iterable[JsonDict]) -> list[Optional[str]]:\n    \"\"\"/language/eng -> English\"\"\"\n    langs = []\n    for lang in language_blob:\n        langs.append(languages.get(lang.get(\"key\", \"\"), None))\n    return langs\n\n\ndef get_dict_field(blob: Optional[JsonDict], field_name: str) -> Optional[Any]:\n    \"\"\"extract the isni from the remote id data for the author\"\"\"\n    if not blob or not isinstance(blob, dict):\n        return None\n    return blob.get(field_name)\n\n\ndef get_wikipedia_link(links: list[Any]) -> Optional[str]:\n    \"\"\"extract wikipedia links\"\"\"\n    if not isinstance(links, list):\n        return None\n\n    for link in links:\n        if not isinstance(link, dict):\n            continue\n        if link.get(\"title\") == \"wikipedia\":\n            return link.get(\"url\")\n    return None\n\n\ndef get_inventaire_id(links: list[Any]) -> Optional[str]:\n    \"\"\"extract and format inventaire ids\"\"\"\n    if not isinstance(links, list):\n        return None\n\n    for link in links:\n        if not isinstance(link, dict):\n            continue\n        if link.get(\"title\") == \"inventaire.io\":\n            iv_link = link.get(\"url\")\n            if not isinstance(iv_link, str):\n                return None\n            return iv_link.split(\"/\")[-1]\n    return None\n\n\ndef pick_default_edition(options: list[JsonDict]) -> Optional[JsonDict]:\n    \"\"\"favor physical copies with covers in english\"\"\"\n    if not options:\n        return None\n    if len(options) == 1:\n        return options[0]\n\n    options = [e for e in options if e.get(\"covers\")] or options\n    options = [\n        e for e in options if \"/languages/eng\" in str(e.get(\"languages\"))\n    ] or options\n    formats = [\"paperback\", \"hardcover\", \"mass market paperback\"]\n    options = [\n        e for e in options if str(e.get(\"physical_format\")).lower() in formats\n    ] or options\n    options = [e for e in options if e.get(\"isbn_13\")] or options\n    options = [e for e in options if e.get(\"ocaid\")] or options\n    return options[0]\n", "path": "bookwyrm/connectors/openlibrary.py"}, {"content": "\"\"\"Clean user-provided text\"\"\"\nimport bleach\n\n\ndef clean(input_text: str) -> str:\n    \"\"\"Run through \"bleach\" \"\"\"\n    return bleach.clean(\n        input_text,\n        tags=[\n            \"p\",\n            \"blockquote\",\n            \"br\",\n            \"b\",\n            \"i\",\n            \"strong\",\n            \"em\",\n            \"pre\",\n            \"a\",\n            \"span\",\n            \"ul\",\n            \"ol\",\n            \"li\",\n        ],\n        attributes=[\"href\", \"rel\", \"src\", \"alt\", \"data-mention\"],\n        strip=True,\n    )\n", "path": "bookwyrm/utils/sanitizer.py"}]}