Пример #1
0
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"
    )
Пример #2
0
    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
Пример #3
0
    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
Пример #4
0
    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
Пример #5
0
    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
Пример #6
0
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)
Пример #7
0
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",
        ),
    }
Пример #8
0
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,
            ),
        },
    }
Пример #9
0
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",
        ),
    }