Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "jike-skill",
"owner": {
"name": "Alice and contributors",
"github": "MidnightDarling"
"url": "https://github.com/MidnightDarling"
},
"metadata": {
"description": "Jike (即刻) social network skill for AI agents — QR login, feed, posts, comments, search, export",
Expand Down
25 changes: 24 additions & 1 deletion src/jike/__main__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,34 @@
import sys

USAGE = (
"Usage: jike <command> [options]\n"
"\n"
"Commands:\n"
" auth QR login; print or --out tokens\n"
" feed Following feed\n"
" post Create a post\n"
" delete-post Delete a post\n"
" comment Comment on a post\n"
" delete-comment Delete a comment\n"
" search Search content\n"
" profile User profile\n"
" user-posts List a user's posts\n"
" notifications Unread + notification list\n"
" export Export a user's post history to Markdown\n"
"\n"
"Run 'jike <command> --help' for command-specific options."
)


def main() -> None:
if len(sys.argv) < 2:
print("Usage: jike <auth|export|feed|post|search|...>", file=sys.stderr)
print(USAGE, file=sys.stderr)
sys.exit(1)

if sys.argv[1] in ("-h", "--help"):
print(USAGE)
return

if sys.argv[1] == "auth":
from .auth import main as auth_main

Expand Down
7 changes: 7 additions & 0 deletions src/jike/export_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ def md_label(value: object) -> str:

def safe_url(url: object) -> Optional[str]:
text = clean(url).strip()
# A well-formed URL never contains raw whitespace. Rejecting it closes a
# Markdown-injection vector: clean() preserves "\n"/"\t", and an embedded
# newline in a link/image URL could break out of "[title](url)" and smuggle
# a second link (e.g. a javascript: scheme) onto the next line, bypassing the
# scheme allowlist below on lenient renderers.
if not text or any(ch.isspace() for ch in text):
return None
parsed = urlparse(text)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
return None
Expand Down
68 changes: 68 additions & 0 deletions tests/test_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@

from jike.export import fetch_all_posts, fetch_user_profile
from jike.export_utils import (
MAX_IMAGE_BYTES,
download_image,
export_to_markdown,
json_path_for,
post_to_markdown,
safe_url,
validate_username,
)

Expand Down Expand Up @@ -155,6 +157,72 @@ def test_fetch_all_posts_writes_checkpoint(mock_fetch, tmp_path):
assert saved["loadMoreKey"] is None


def test_safe_url_rejects_whitespace_and_newlines():
assert safe_url("https://good.example.com/path") == "https://good.example.com/path"
assert safe_url("https://ok.example.com/has space") is None
assert safe_url("https://e.example.com/a\n[c](javascript:alert(1))") is None


def test_post_to_markdown_drops_url_with_embedded_newline():
post = {
"id": "1",
"createdAt": "2026-01-01T00:00:00Z",
"content": "body",
"linkInfo": {"title": "t", "linkUrl": "https://e.example.com\n[c](javascript:alert(1))"},
}
markdown = post_to_markdown(post, 1)
assert "javascript:" not in markdown
assert "](https://e.example.com" not in markdown


@patch("jike.export_utils.requests.get")
def test_download_image_rejects_oversize_content_length(mock_get, tmp_path):
mock_resp = MagicMock()
mock_resp.__enter__.return_value = mock_resp
mock_resp.__exit__.return_value = None
mock_resp.headers = {"Content-Type": "image/png", "Content-Length": str(MAX_IMAGE_BYTES + 1)}
mock_resp.iter_content.return_value = [b"abc"]
mock_resp.raise_for_status.return_value = None
mock_get.return_value = mock_resp

result = download_image("https://cdn.ruguoapp.com/big.png", tmp_path / "images", tmp_path, 1, "orig_1")

assert result is None
assert not (tmp_path / "images" / "post_0001_orig_1.png").exists()


@patch("jike.export_utils.requests.get")
def test_download_image_rejects_streamed_oversize(mock_get, tmp_path):
mock_resp = MagicMock()
mock_resp.__enter__.return_value = mock_resp
mock_resp.__exit__.return_value = None
# No Content-Length header; oversize is only detectable while streaming.
mock_resp.headers = {"Content-Type": "image/png"}
mock_resp.iter_content.return_value = [b"x" * (MAX_IMAGE_BYTES + 1)]
mock_resp.raise_for_status.return_value = None
mock_get.return_value = mock_resp

result = download_image("https://cdn.ruguoapp.com/big.png", tmp_path / "images", tmp_path, 1, "orig_1")

assert result is None
assert not (tmp_path / "images" / "post_0001_orig_1.png").exists()


@patch("jike.export_utils.requests.get")
def test_download_image_rejects_non_image_content_type(mock_get, tmp_path):
mock_resp = MagicMock()
mock_resp.__enter__.return_value = mock_resp
mock_resp.__exit__.return_value = None
mock_resp.headers = {"Content-Type": "text/html", "Content-Length": "3"}
mock_resp.iter_content.return_value = [b"abc"]
mock_resp.raise_for_status.return_value = None
mock_get.return_value = mock_resp

result = download_image("https://cdn.ruguoapp.com/evil.png", tmp_path / "images", tmp_path, 1, "orig_1")

assert result is None


@patch("jike.export.fetch_user_posts")
def test_fetch_all_posts_resumes_from_checkpoint(mock_fetch, tmp_path):
checkpoint = tmp_path / "checkpoint.json"
Expand Down
8 changes: 8 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,11 @@ def test_export_dispatches_to_export_main(self):
with patch("jike.export.main") as mock_export_main:
main()
mock_export_main.assert_called_once_with(["--username", "alice"])

@patch("jike.__main__.sys.argv", ["jike", "--help"])
def test_help_flag_lists_all_commands(self, capsys):
main()
out = capsys.readouterr().out
assert "auth" in out
assert "export" in out
assert "feed" in out
Loading