birdclaw 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +1 -0
- package/bin/birdclaw.mjs +3 -1
- package/package.json +1 -1
- package/src/cli.ts +9 -1
- package/src/lib/queries.ts +50 -0
- package/src/lib/types.ts +5 -0
- package/src/routes/api/query.tsx +15 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 0.1.1 - 2026-04-27
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Add opt-in low-quality timeline filtering for year-scale tweet review, including date windows, originals-only mode, and CLI/API flags for hiding retweets, tiny replies, and link-only noise.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fix fresh npm installs so the packaged `birdclaw` binary includes its TypeScript runtime dependency.
|
|
14
|
+
|
|
5
15
|
## 0.1.0 - 2026-04-27
|
|
6
16
|
|
|
7
17
|
### Added
|
package/README.md
CHANGED
|
@@ -179,6 +179,7 @@ pnpm cli blocks import ~/triage/blocklist.txt --account acct_primary --json
|
|
|
179
179
|
```bash
|
|
180
180
|
pnpm cli search tweets "local-first" --json
|
|
181
181
|
pnpm cli search tweets "sync engine" --limit 20 --json
|
|
182
|
+
pnpm cli search tweets --since 2020-01-01 --until 2021-01-01 --originals-only --hide-low-quality --limit 500 --json
|
|
182
183
|
```
|
|
183
184
|
|
|
184
185
|
### Export mentions for agents
|
package/bin/birdclaw.mjs
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
3
4
|
import { dirname, join } from "node:path";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
|
|
6
7
|
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
7
|
-
const
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
const tsxCli = require.resolve("tsx/cli");
|
|
8
10
|
const birdclawCli = join(packageRoot, "src", "cli.ts");
|
|
9
11
|
|
|
10
12
|
const result = spawnSync(
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -131,10 +131,14 @@ const searchCommand = program
|
|
|
131
131
|
.description("Search local data");
|
|
132
132
|
|
|
133
133
|
searchCommand
|
|
134
|
-
.command("tweets
|
|
134
|
+
.command("tweets [query]")
|
|
135
135
|
.option("--resource <resource>", "home or mentions", "home")
|
|
136
136
|
.option("--replied", "Only replied items")
|
|
137
137
|
.option("--unreplied", "Only unreplied items")
|
|
138
|
+
.option("--since <date>", "Include tweets created at or after this date")
|
|
139
|
+
.option("--until <date>", "Include tweets created before this date")
|
|
140
|
+
.option("--originals-only", "Exclude authored replies that start with @")
|
|
141
|
+
.option("--hide-low-quality", "Hide RTs, tiny replies, and link-only noise")
|
|
138
142
|
.option("--limit <n>", "Limit results", "20")
|
|
139
143
|
.action((query, options) => {
|
|
140
144
|
const replyFilter = options.replied
|
|
@@ -146,6 +150,10 @@ searchCommand
|
|
|
146
150
|
resource: options.resource === "mentions" ? "mentions" : "home",
|
|
147
151
|
search: query,
|
|
148
152
|
replyFilter,
|
|
153
|
+
since: options.since,
|
|
154
|
+
until: options.until,
|
|
155
|
+
includeReplies: !options.originalsOnly,
|
|
156
|
+
qualityFilter: options.hideLowQuality ? "summary" : "all",
|
|
149
157
|
limit: Number(options.limit),
|
|
150
158
|
});
|
|
151
159
|
print(items, program.opts().json ?? false);
|
package/src/lib/queries.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
QueryEnvelope,
|
|
13
13
|
QueryResponse,
|
|
14
14
|
ReplyFilter,
|
|
15
|
+
TimelineQualityFilter,
|
|
15
16
|
TimelineItem,
|
|
16
17
|
TimelineQuery,
|
|
17
18
|
TweetEntities,
|
|
@@ -124,6 +125,36 @@ function buildReplyClause(replyFilter: ReplyFilter) {
|
|
|
124
125
|
return "";
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
function buildTimelineQualityClause(qualityFilter: TimelineQualityFilter) {
|
|
129
|
+
if (qualityFilter === "all") {
|
|
130
|
+
return "";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return `
|
|
134
|
+
and not (
|
|
135
|
+
t.text like 'RT @%'
|
|
136
|
+
or (
|
|
137
|
+
t.like_count < 50
|
|
138
|
+
and (
|
|
139
|
+
(
|
|
140
|
+
length(trim(replace(t.text, 'https://t.co/', ''))) < 16
|
|
141
|
+
and t.media_count = 0
|
|
142
|
+
)
|
|
143
|
+
or (
|
|
144
|
+
t.text like '@%'
|
|
145
|
+
and length(trim(t.text)) < 60
|
|
146
|
+
)
|
|
147
|
+
or (
|
|
148
|
+
t.text glob '*https://t.co/*'
|
|
149
|
+
and t.media_count = 0
|
|
150
|
+
and length(trim(replace(t.text, 'https://t.co/', ''))) < 45
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
`;
|
|
156
|
+
}
|
|
157
|
+
|
|
127
158
|
export async function getQueryEnvelope(): Promise<QueryEnvelope> {
|
|
128
159
|
const db = getDb();
|
|
129
160
|
const counts = await Promise.all([
|
|
@@ -182,6 +213,10 @@ export function listTimelineItems({
|
|
|
182
213
|
account,
|
|
183
214
|
search,
|
|
184
215
|
replyFilter = "all",
|
|
216
|
+
since,
|
|
217
|
+
until,
|
|
218
|
+
includeReplies = true,
|
|
219
|
+
qualityFilter = "all",
|
|
185
220
|
limit = 18,
|
|
186
221
|
}: TimelineQuery): TimelineItem[] {
|
|
187
222
|
const db = getNativeDb();
|
|
@@ -199,6 +234,21 @@ export function listTimelineItems({
|
|
|
199
234
|
"is_replied",
|
|
200
235
|
"t.is_replied",
|
|
201
236
|
);
|
|
237
|
+
where += buildTimelineQualityClause(qualityFilter);
|
|
238
|
+
|
|
239
|
+
if (!includeReplies) {
|
|
240
|
+
where += " and t.text not like '@%'";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (since?.trim()) {
|
|
244
|
+
where += " and t.created_at >= ?";
|
|
245
|
+
params.push(since.trim());
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (until?.trim()) {
|
|
249
|
+
where += " and t.created_at < ?";
|
|
250
|
+
params.push(until.trim());
|
|
251
|
+
}
|
|
202
252
|
|
|
203
253
|
if (search?.trim()) {
|
|
204
254
|
join += " join tweets_fts fts on fts.tweet_id = t.id ";
|
package/src/lib/types.ts
CHANGED
|
@@ -2,6 +2,7 @@ export type ResourceKind = "home" | "mentions" | "dms";
|
|
|
2
2
|
export type InboxKind = "mixed" | "mentions" | "dms";
|
|
3
3
|
|
|
4
4
|
export type ReplyFilter = "all" | "replied" | "unreplied";
|
|
5
|
+
export type TimelineQualityFilter = "all" | "summary";
|
|
5
6
|
|
|
6
7
|
export interface AccountRecord {
|
|
7
8
|
id: string;
|
|
@@ -139,6 +140,10 @@ export interface TimelineQuery {
|
|
|
139
140
|
account?: string;
|
|
140
141
|
search?: string;
|
|
141
142
|
replyFilter?: ReplyFilter;
|
|
143
|
+
since?: string;
|
|
144
|
+
until?: string;
|
|
145
|
+
includeReplies?: boolean;
|
|
146
|
+
qualityFilter?: TimelineQualityFilter;
|
|
142
147
|
limit?: number;
|
|
143
148
|
}
|
|
144
149
|
|
package/src/routes/api/query.tsx
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { createFileRoute } from "@tanstack/react-router";
|
|
2
2
|
import { queryResource } from "#/lib/queries";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
ReplyFilter,
|
|
5
|
+
ResourceKind,
|
|
6
|
+
TimelineQualityFilter,
|
|
7
|
+
} from "#/lib/types";
|
|
4
8
|
|
|
5
9
|
function json(data: unknown) {
|
|
6
10
|
return new Response(JSON.stringify(data), {
|
|
@@ -23,6 +27,10 @@ function parseNumber(value: string | null) {
|
|
|
23
27
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
function parseQualityFilter(value: string | null): TimelineQualityFilter {
|
|
31
|
+
return value === "summary" ? "summary" : "all";
|
|
32
|
+
}
|
|
33
|
+
|
|
26
34
|
export const Route = createFileRoute("/api/query")({
|
|
27
35
|
server: {
|
|
28
36
|
handlers: {
|
|
@@ -34,6 +42,12 @@ export const Route = createFileRoute("/api/query")({
|
|
|
34
42
|
account: url.searchParams.get("account") ?? undefined,
|
|
35
43
|
search: url.searchParams.get("search") ?? undefined,
|
|
36
44
|
replyFilter: parseReplyFilter(url.searchParams.get("replyFilter")),
|
|
45
|
+
since: url.searchParams.get("since") ?? undefined,
|
|
46
|
+
until: url.searchParams.get("until") ?? undefined,
|
|
47
|
+
includeReplies: url.searchParams.get("originalsOnly") !== "true",
|
|
48
|
+
qualityFilter: parseQualityFilter(
|
|
49
|
+
url.searchParams.get("qualityFilter"),
|
|
50
|
+
),
|
|
37
51
|
limit: parseNumber(url.searchParams.get("limit")) ?? undefined,
|
|
38
52
|
};
|
|
39
53
|
|