async def test_announcer_collect( mocker: MockerFixture, root: Path, config: Config, post: Post, ) -> None: """ Test collecting interactions on posts and sites. """ gemini_builder = Builder(root, config, "gemini://example.com/", "gemini") html_builder = Builder(root, config, "https://example.com/", "www") Client = mocker.patch("nefelibata.announcers.geminispace.Client") Client.return_value.get = mocker.AsyncMock() Client.return_value.get.return_value.read.return_value = b""" ### 3 cross-capsule backlinks => gemini://example.com/reply.gmi Re: This is your first post => gemini://example.com/another-reply.gmi Re: This is your first post """ announcer = GeminispaceAnnouncer(root, config, builders=[gemini_builder]) # site collect is no-op interactions = await announcer.collect_site() assert interactions == {} interactions = await announcer.collect_post(post) assert interactions == { "backlink,gemini://example.com/reply.gmi": Interaction( id="backlink,gemini://example.com/reply.gmi", name="Re: This is your first post", url="gemini://example.com/reply.gmi", type="backlink", timestamp=None, ), "backlink,gemini://example.com/another-reply.gmi": Interaction( id="backlink,gemini://example.com/another-reply.gmi", name="Re: This is your first post", url="gemini://example.com/another-reply.gmi", type="backlink", timestamp=None, ), } announcer = GeminispaceAnnouncer(root, config, builders=[html_builder]) with pytest.raises(Exception) as excinfo: await announcer.collect_post(post) assert ( str(excinfo.value) == "Geminispace announcer only works with `gemini://` builds" )
async def collect_site(self) -> Dict[Path, Dict[str, Interaction]]: """ Return replies to posts. This is done by scraping the capsule and searching for "Re: " posts. """ response = await self.client.get(URL(self.url)) payload = await response.read() content = payload.decode("utf-8") posts = get_posts(self.root, self.config) interactions: Dict[Path, Dict[str, Interaction]] = defaultdict(dict) for line in content.split("\n"): if not line.startswith("=>"): continue _, url, name = re.split(r"\s+", line, 2) for post in posts: reply = f"Re: {post.title}" if reply in name and await self._link_in_post(post.url, url): id_ = f"reply,{url}" interactions[post.path][id_] = Interaction( id=id_, name=name, url=url, type="reply", ) return interactions
async def collect_post(self, post: Post) -> Dict[str, Interaction]: """ Collect backlinks for a given post. """ interactions: Dict[str, Interaction] = {} for builder in self.builders: if builder.home.scheme != "gemini": raise Exception( "Geminispace announcer only works with `gemini://` builds", ) post_url = urllib.parse.quote_plus(str(builder.absolute_url(post))) url = f"gemini://geminispace.info/backlinks?{post_url}" response = await self.client.get(URL(url)) payload = await response.read() content = payload.decode("utf-8") state = 0 for line in content.split("\n"): if line.startswith("###"): state = 1 if "cross-capsule backlinks" in line else 2 if state == 1 and line.startswith("=>"): _, url, name = re.split(r"\s+", line, 2) id_ = f"backlink,{url}" interactions[id_] = Interaction( id=id_, name=name, url=url, type="backlink", ) return interactions
async def collect_post(self, post: Post) -> Dict[str, Interaction]: interactions: Dict[str, Interaction] = {} async with ClientSession() as session: for builder in self.builders: target = builder.absolute_url(post) payload = {"target": target} async with session.get( "https://webmention.io/api/mentions.jf2", data=payload, ) as response: try: response.raise_for_status() except ClientResponseError: _logger.error("Error fetching webmentions") continue payload = await response.json() for entry in payload["children"]: if entry["type"] != "entry": continue interactions[entry["wm-id"]] = Interaction( id=entry["wm-id"], name=entry.get("name", entry["wm-source"]), summary=entry.get("summary", {}).get("value"), content=entry["content"]["text"], published=dateutil.parser.parse(entry["published"]), updated=None, author=Author( name=entry["author"]["name"], url=entry["author"]["url"], avatar=entry["author"]["photo"], note=entry["author"].get("note", ""), ), url=entry["wm-source"], in_reply_to_id=None, type=INTERACTION_TYPES[entry["wm-property"]], ) return interactions
async def collect_post(self, post: Post) -> Dict[str, Interaction]: interactions: Dict[str, Interaction] = {} announcements = post.metadata.get("announcements", {}) for announcement in announcements.values(): if not announcement["url"].startswith(self.base_url): continue url = announcement["url"] id_ = int(url.rstrip("/").rsplit("/", 1)[1]) try: context = self.client.status_context(id_) except MastodonNotFoundError: _logger.warning("Toot %s not found", url) continue # map from instance ID to URL id_map = {id_: url} for descendant in context["descendants"]: id_ = descendant["url"] id_map[descendant["id"]] = id_ interactions[str(id_)] = Interaction( id=descendant["uri"], name=descendant["url"], summary=None, content=descendant["content"], published=descendant["created_at"], updated=None, author=Author( name=descendant["account"]["display_name"], url=descendant["account"]["url"], avatar=descendant["account"]["avatar"], note=descendant["account"]["note"], ), url=descendant["url"], in_reply_to=id_map[descendant["in_reply_to_id"]], type="reply", ) return interactions
def test_load_yaml(mocker: MockerFixture, fs: FakeFilesystem) -> None: """ Test ``load_yaml``. """ assert load_yaml(Path("/path/to/blog/missing.yaml"), BaseModel) == {} fs.create_file( "/path/to/blog/existing.yaml", contents=""" reply,gemini://ew.srht.site/en/2021/20210915-re-changing-old-code-is-risky.gmi: id: reply,gemini://ew.srht.site/en/2021/20210915-re-changing-old-code-is-risky.gmi name: '2021-09-15 ~ew''s FlightLog: Re: Changing old code is risky' timestamp: null type: reply url: gemini://ew.srht.site/en/2021/20210915-re-changing-old-code-is-risky.gmi """, ) assert load_yaml(Path("/path/to/blog/existing.yaml"), Interaction) == { ("reply,gemini://ew.srht.site/en/2021/" "20210915-re-changing-old-code-is-risky.gmi"): Interaction( id= "reply,gemini://ew.srht.site/en/2021/20210915-re-changing-old-code-is-risky.gmi", name="2021-09-15 ~ew's FlightLog: Re: Changing old code is risky", url= "gemini://ew.srht.site/en/2021/20210915-re-changing-old-code-is-risky.gmi", type="reply", timestamp=None, ), } path = Path("/path/to/blog/invalid.yaml") fs.create_file(path, contents="[1,2,3") _logger = mocker.patch("nefelibata.utils._logger") with pytest.raises(yaml.parser.ParserError): load_yaml(path, BaseModel) assert _logger.warning.called_with("Invalid YAML file: %s", path)
async def test_announcer_collect( mocker: MockerFixture, root: Path, config: Config, post: Post, ) -> None: """ Test collecting interactions on a given post. """ Mastodon = mocker.patch("nefelibata.announcers.mastodon.Mastodon") Mastodon.return_value.status_context.return_value = { "descendants": [ { "uri": "https://example.com/2", "id": 2, "url": "https://example.com/2", "content": "This is a reply", "created_at": datetime(2021, 1, 1), "account": { "display_name": "Alice Doe", "url": "https://alice.example.com/", "avatar": "https://alice.example.com/profile.gif", "note": "Best friends with Bob and Charlie", }, "in_reply_to_id": 1, }, ], } announcer = MastodonAnnouncer( root, config, builders=[], access_token="access_token", base_url="https://example.com/", ) post.metadata["announcements"] = { "mastodon": { "url": "https://example.com/1", }, "something": { "url": "https://foo.example.com/", }, } interactions = await announcer.collect_post(post) assert interactions == { "https://example.com/2": Interaction( id="https://example.com/2", name="https://example.com/2", summary=None, content="This is a reply", published=datetime(2021, 1, 1, 0, 0), updated=None, author=Author( name="Alice Doe", url="https://alice.example.com/", avatar="https://alice.example.com/profile.gif", note="Best friends with Bob and Charlie", ), url="https://example.com/2", in_reply_to="https://example.com/1", type="reply", ), }
async def test_announcer_collect( mocker: MockerFixture, root: Path, config: Config, post: Post, ) -> None: """ Test collecting interactions on posts and sites. """ builder = Builder(root, config, "gemini://example.com/", "gemini") Client = mocker.patch("nefelibata.announcers.gemlog.Client") Client.return_value.get = mocker.AsyncMock() Client.return_value.get.return_value.read.side_effect = [ b""" => gemini://example.com/reply.gmi Re: This is your first post """, b""" => gemini://example.com/first/index.gmi """, ] announcer = CAPCOMAnnouncer(root, config, builders=[builder]) # post collect is no-op interactions = await announcer.collect_post(post) assert interactions == {} interactions = await announcer.collect_site() assert interactions == { Path("/path/to/blog/posts/first/index.mkd"): { "reply,gemini://example.com/reply.gmi": Interaction( id="reply,gemini://example.com/reply.gmi", name="Re: This is your first post", url="gemini://example.com/reply.gmi", type="reply", timestamp=None, ), }, } # test no backlink Client.return_value.get.return_value.read.side_effect = [ b""" => gemini://example.com/reply.gmi Re: This is your first post """, b"", ] announcer = CAPCOMAnnouncer(root, config, builders=[builder]) interactions = await announcer.collect_site() assert interactions == {} # test SSL error response = mocker.AsyncMock() response.read.return_value = b""" => gemini://example.com/reply.gmi Re: This is your first post """ Client.return_value.get.side_effect = [ response, ssl.SSLCertVerificationError("A wild error appears!"), ] announcer = CAPCOMAnnouncer(root, config, builders=[builder]) interactions = await announcer.collect_site() assert interactions == { Path("/path/to/blog/posts/first/index.mkd"): { "reply,gemini://example.com/reply.gmi": Interaction( id="reply,gemini://example.com/reply.gmi", name="Re: This is your first post", url="gemini://example.com/reply.gmi", type="reply", timestamp=None, ), }, }
async def test_announcer_collect( mocker: MockerFixture, root: Path, config: Config, post: Post, ) -> None: """ Test collecting posts. """ gemini_builder = Builder(root, config, "gemini://example.com/", "gemini") gemini_builder.extension = ".gmi" html_builder = Builder(root, config, "https://example.com/", "www") html_builder.extension = ".html" mocker.patch("nefelibata.announcers.webmention.ClientResponseError", Exception) get = mocker.patch("nefelibata.announcers.webmention.ClientSession.get") get_response = get.return_value.__aenter__.return_value get_response.raise_for_status = mocker.MagicMock() get_response.raise_for_status.side_effect = [ Exception("Gemini not supported"), None, Exception("Gemini not supported"), None, ] announcer = WebmentionAnnouncer( root, config, builders=[gemini_builder, html_builder], ) get_response.json = mocker.AsyncMock(return_value={"children": []}) interactions = await announcer.collect_post(post) assert interactions == {} entries = [ { "type": "entry", "wm-id": 1, "wm-source": "https://alice.example.com/posts/one", "name": "This is the title", "summary": { "value": "This is the summary" }, "content": { "text": "This is the post content." }, "published": "2021-01-01T00:00:00Z", "author": { "name": "Alice Doe", "url": "https://alice.example.com/", "photo": "https://alice.example.com/photo.jpg", "note": "My name is Alice", }, "wm-property": "in-reply-to", }, { "type": "entry", "wm-id": 2, "wm-source": "https://bob.example.com/posts/two", "summary": { "value": "This is the summary" }, "content": { "text": "This is the post content." }, "published": "2021-01-01T00:00:00Z", "author": { "name": "Bob Doe", "url": "https://bob.example.com/", "photo": "https://bob.example.com/photo.jpg", }, "wm-property": "like-of", }, { "type": "invalid" }, ] get_response.json = mocker.AsyncMock(return_value={"children": entries}) interactions = await announcer.collect_post(post) assert interactions == { 1: Interaction( id="1", name="This is the title", summary="This is the summary", content="This is the post content.", published=datetime(2021, 1, 1, 0, 0, tzinfo=timezone.utc), updated=None, author=Author( name="Alice Doe", url="https://alice.example.com/", avatar="https://alice.example.com/photo.jpg", note="My name is Alice", ), url="https://alice.example.com/posts/one", in_reply_to=None, type="reply", ), 2: Interaction( id="2", name="https://bob.example.com/posts/two", summary="This is the summary", content="This is the post content.", published=datetime(2021, 1, 1, 0, 0, tzinfo=timezone.utc), updated=None, author=Author( name="Bob Doe", url="https://bob.example.com/", avatar="https://bob.example.com/photo.jpg", note="", ), url="https://bob.example.com/posts/two", in_reply_to=None, type="like", ), }