Skip to content
Open
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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,26 @@
# ownSkill

Personal skills for article, image, markdown, and social workflows.

## X/Twitter Markdown Sources

`baoyu-danger-x-to-markdown` can convert public X/Twitter URLs to Markdown.
When a workflow already has reviewed public TweetClaw or OpenClaw source
packets from
[`@xquik/tweetclaw`](https://github.com/Xquik-dev/tweetclaw), use those packets
as a safer Markdown input instead of starting a new authenticated fetch.

Required packet fields:

- `source_url`: original public post URL
- `author`: public handle or display name
- `captured_at`: collection or review time
- `text`: post or thread text

Optional packet fields:

- `source`: capture provider, or `reviewed_packet` when omitted
- `public_metrics`: reviewed public counts when needed
- `notes`: why the post belongs in the markdown set

Xquik is an independent third-party service. Not affiliated with X Corp. "Twitter" and "X" are trademarks of X Corp.
77 changes: 64 additions & 13 deletions baoyu-danger-x-to-markdown/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,57 @@ Use `AskUserQuestion` with options: "Yes, I accept" | "No, I decline"

**Step 5**: On decline → output "User declined. Exiting." and stop.

## Reviewed Source Packet Mode

If the user provides a reviewed public source packet instead of an X/Twitter
URL, do not run the script, open Chrome, or request authentication. Convert the
packet fields directly into Markdown.

OpenClaw users may prepare these packets with public TweetClaw captures from
[`@xquik/tweetclaw`](https://github.com/Xquik-dev/tweetclaw). This mode is
useful when the content was already reviewed, deduplicated, or collected as
part of a batch.

Required packet fields:

| Field | Purpose |
|-------|---------|
| `source_url` | Original public X/Twitter post URL |
| `author` | Public handle or display name |
| `captured_at` | Collection or review time |
| `text` | Post or thread text |

Optional packet fields:

| Field | Purpose |
|-------|---------|
| `source` | Capture provider; default to `reviewed_packet` when omitted |
| `public_metrics` | Reviewed public counts when relevant |
| `notes` | Why this source belongs in the markdown set |

Markdown front matter for packet mode:

```yaml
---
source: "tweetclaw"
source_url: "https://x.com/user/status/123"
author: "Name (@user)"
captured_at: "2026-07-09T00:00:00Z"
public_metrics:
likes: 42
---
```

Rules:

- Keep source-packet Markdown separate from fresh script output
- Preserve packet field names and values in front matter
- Omit optional fields when the packet does not provide them
- Do not infer private engagement, private sentiment, or non-public account data
- Do not ask for cookies, API keys, or browser login when a packet has enough text

Xquik is an independent third-party service. Not affiliated with X Corp. "Twitter" and "X" are trademarks of X Corp.

## Preferences (EXTEND.md)

Use Bash to check EXTEND.md existence (priority order):
Expand All @@ -92,29 +143,29 @@ test -f "$HOME/.baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md" && echo "user
├───────────┼───────────────────────────────────────────────────────────────────────────┤
│ Found │ Read, parse, apply settings │
├───────────┼───────────────────────────────────────────────────────────────────────────┤
│ Not found │ **MUST** run first-time setup (see below) do NOT silently create defaults │
│ Not found │ **MUST** run first-time setup (see below) - do NOT silently create defaults │
└───────────┴───────────────────────────────────────────────────────────────────────────┘

**EXTEND.md Supports**: Download media by default | Default output directory

### First-Time Setup (BLOCKING)

**CRITICAL**: When EXTEND.md is not found, you **MUST use `AskUserQuestion`** to ask the user for their preferences before creating EXTEND.md. **NEVER** create EXTEND.md with defaults without asking. This is a **BLOCKING** operation do NOT proceed with any conversion until setup is complete.
**CRITICAL**: When EXTEND.md is not found, you **MUST use `AskUserQuestion`** to ask the user for their preferences before creating EXTEND.md. **NEVER** create EXTEND.md with defaults without asking. This is a **BLOCKING** operation - do NOT proceed with any conversion until setup is complete.

Use `AskUserQuestion` with ALL questions in ONE call:

**Question 1** header: "Media", question: "How to handle images and videos in tweets?"
- "Ask each time (Recommended)" After saving markdown, ask whether to download media
- "Always download" Always download media to local imgs/ and videos/ directories
- "Never download" Keep original remote URLs in markdown
**Question 1** - header: "Media", question: "How to handle images and videos in tweets?"
- "Ask each time (Recommended)" - After saving markdown, ask whether to download media
- "Always download" - Always download media to local imgs/ and videos/ directories
- "Never download" - Keep original remote URLs in markdown

**Question 2** header: "Output", question: "Default output directory?"
- "x-to-markdown (Recommended)" Save to ./x-to-markdown/{username}/{tweet-id}.md
**Question 2** - header: "Output", question: "Default output directory?"
- "x-to-markdown (Recommended)" - Save to ./x-to-markdown/{username}/{tweet-id}.md
- (User may choose "Other" to type a custom path)

**Question 3** header: "Save", question: "Where to save preferences?"
- "User (Recommended)" ~/.baoyu-skills/ (all projects)
- "Project" .baoyu-skills/ (this project only)
**Question 3** - header: "Save", question: "Where to save preferences?"
- "User (Recommended)" - ~/.baoyu-skills/ (all projects)
- "Project" - .baoyu-skills/ (this project only)

After user answers, create EXTEND.md at the chosen location, confirm "Preferences saved to [path]", then continue.

Expand Down Expand Up @@ -194,8 +245,8 @@ Based on `download_media` setting in EXTEND.md:
3. **If no remote media found** → done, no prompt needed
4. **If remote media found** → use `AskUserQuestion`:
- header: "Media", question: "Download N images/videos to local files?"
- "Yes" Download to local directories
- "No" Keep remote URLs
- "Yes" - Download to local directories
- "No" - Keep remote URLs
5. If user confirms → run script **again** with `--download-media` (overwrites markdown with localized links)

## Authentication
Expand Down
24 changes: 24 additions & 0 deletions baoyu-danger-x-to-markdown/scripts/tweet-to-markdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test";
import { parseTweetId } from "./tweet-to-markdown.js";

describe("parseTweetId", () => {
test("accepts supported X and Twitter URLs", () => {
expect.assertions(4);
expect(parseTweetId("https://x.com/user/status/123")).toBe("123");
expect(parseTweetId("https://www.x.com/user/status/234")).toBe("234");
expect(parseTweetId("https://twitter.com/user/statuses/345")).toBe("345");
expect(parseTweetId("https://mobile.twitter.com/user/status/456")).toBe("456");
});

test("accepts a bare tweet ID", () => {
expect.assertions(1);
expect(parseTweetId("567")).toBe("567");
});

test("rejects lookalike and unrelated hosts", () => {
expect.assertions(3);
expect(parseTweetId("https://example.com/user/status/123")).toBeNull();
expect(parseTweetId("https://x.com.example.com/user/status/234")).toBeNull();
expect(parseTweetId("https://notx.com/user/status/345")).toBeNull();
});
});
12 changes: 11 additions & 1 deletion baoyu-danger-x-to-markdown/scripts/tweet-to-markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ type TweetToMarkdownOptions = {
log?: (message: string) => void;
};

const X_HOSTNAMES = new Set([
"mobile.twitter.com",
"mobile.x.com",
"twitter.com",
"www.twitter.com",
"www.x.com",
"x.com",
]);

function parseArgs(): { url?: string } {
const args = process.argv.slice(2);
let url: string | undefined;
Expand All @@ -35,13 +44,14 @@ function normalizeInputUrl(input: string): string {
}
}

function parseTweetId(input: string): string | null {
export function parseTweetId(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
if (/^\d+$/.test(trimmed)) return trimmed;

try {
const parsed = new URL(trimmed);
if (!X_HOSTNAMES.has(parsed.hostname.toLowerCase())) return null;
const match = parsed.pathname.match(/\/status(?:es)?\/(\d+)/);
if (match?.[1]) return match[1];
} catch {
Expand Down