birdclaw 0.3.0 → 0.4.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 +25 -0
- package/README.md +37 -9
- package/package.json +4 -6
- package/src/cli.ts +289 -20
- package/src/lib/archive-import.ts +27 -0
- package/src/lib/backup.ts +271 -33
- package/src/lib/bird-actions.ts +17 -0
- package/src/lib/bird.ts +189 -0
- package/src/lib/blocks.ts +3 -3
- package/src/lib/bookmark-sync-job.ts +3 -3
- package/src/lib/db.ts +404 -14
- package/src/lib/dms-live.ts +3 -3
- package/src/lib/identity-search-index.ts +369 -0
- package/src/lib/link-index.ts +716 -0
- package/src/lib/mention-threads-live.ts +5 -9
- package/src/lib/mentions-live.ts +18 -7
- package/src/lib/moderation-target.ts +4 -4
- package/src/lib/moderation-write.ts +3 -3
- package/src/lib/profile-affiliation-hydration.ts +189 -0
- package/src/lib/profile-affiliations.ts +262 -0
- package/src/lib/profile-bio-entities.ts +318 -0
- package/src/lib/profile-history.ts +164 -0
- package/src/lib/profile-hydration.ts +4 -28
- package/src/lib/profile-resolver.ts +428 -0
- package/src/lib/queries.ts +391 -57
- package/src/lib/research.ts +11 -0
- package/src/lib/seed.ts +2 -2
- package/src/lib/sqlite.ts +143 -0
- package/src/lib/timeline-collections-live.ts +7 -7
- package/src/lib/timeline-live.ts +16 -9
- package/src/lib/tweet-account-edges.ts +39 -0
- package/src/lib/types.ts +108 -0
- package/src/lib/url-expansion.ts +155 -0
- package/src/lib/whois.ts +1060 -0
- package/src/lib/x-profile.ts +140 -12
- package/src/lib/xurl.ts +7 -6
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.4.1 - 2026-05-11
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Add a first-class short-link index for `t.co` URLs, including `links backfill` and `search links` so DM shares can be found through expanded tweet text, authors, dates, and media filters.
|
|
10
|
+
|
|
11
|
+
## 0.4.0 - 2026-05-09
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Preserve richer X profile metadata during `bird`/`xurl` profile hydration, including profile URLs, profile URL entities, locations, verification type, raw profile JSON, and X affiliation/highlighted-label metadata.
|
|
16
|
+
- Add first-class `profile_affiliations` storage, backup/export/import support, and `whois --json` `profileEvidence` so identity lookups can explain whether a match came from bio text, profile URLs, affiliation badges, DM context, or expanded links.
|
|
17
|
+
- Add profile-change snapshots for hydrated profiles, preserving prior bio/profile URL/location/verification/affiliation states so identity searches can surface current and previous affiliation evidence.
|
|
18
|
+
- Add first-class bio entity extraction for profile bios and profile URLs, including `@handle`, domain, and company-phrase evidence used by fuzzy identity searches such as `whois "blacksmith guy"`.
|
|
19
|
+
- Add a derived `identity_search_index` and `whois` filters for affiliation-oriented identity lookups: `--affiliation`, `--current-affiliation`, and `--exclude-domain-only`.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Use Node's native `node:sqlite` runtime instead of `better-sqlite3`, removing the native npm dependency while preserving the existing synchronous SQLite API surface.
|
|
24
|
+
- Allow Node 26.x in the package engine range and update install docs for the native SQLite runtime.
|
|
25
|
+
- Improve DM `whois` ranking with Sweetistics-style profile evidence scoring: profile URLs and affiliation badges now boost relevant candidates, while cached profile and URL lookups still avoid repeated API/network work.
|
|
26
|
+
- Resolve synthetic X highlighted-label organization badges into real local organization profile ids when `bird` can hydrate the org handle.
|
|
27
|
+
- Rank current affiliation and bio identity evidence above plain profile domains in `whois`, group human output into ambiguity buckets, and explain "why this person?" with the strongest typed evidence first.
|
|
28
|
+
- Use `bird profiles --json` for batch profile hydration when available, falling back to single-profile `bird user --profile-only --json`.
|
|
29
|
+
|
|
5
30
|
## 0.3.0 - 2026-05-05
|
|
6
31
|
|
|
7
32
|
### Added
|
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ Status: WIP. Real and usable. Not done. Expect schema churn, transport gaps, and
|
|
|
17
17
|
|
|
18
18
|
### Local data + storage
|
|
19
19
|
|
|
20
|
-
- one shared SQLite DB for multiple accounts
|
|
20
|
+
- one shared SQLite DB for multiple accounts, with canonical tweets/profiles and account-scoped timeline/collection edges
|
|
21
21
|
- FTS5 search over tweets and DMs
|
|
22
22
|
- archive autodiscovery on macOS
|
|
23
23
|
- archive import for tweets, likes, profiles, and full DMs
|
|
@@ -25,6 +25,7 @@ Status: WIP. Real and usable. Not done. Expect schema churn, transport gaps, and
|
|
|
25
25
|
- live likes and bookmarks sync through `xurl` or `bird`
|
|
26
26
|
- Git-friendly text backups with yearly tweet shards and per-conversation DM shards
|
|
27
27
|
- profile hydration from live Twitter metadata
|
|
28
|
+
- profile-change history, affiliation badge edges, and extracted bio entities for local identity lookups
|
|
28
29
|
- local avatar cache
|
|
29
30
|
- local media cache root under `~/.birdclaw`
|
|
30
31
|
|
|
@@ -112,7 +113,7 @@ export BIRDCLAW_HOME=/path/to/custom/root
|
|
|
112
113
|
|
|
113
114
|
## Requirements
|
|
114
115
|
|
|
115
|
-
- Node `25.8.1`
|
|
116
|
+
- Node `25.8.1` or Node 26.x
|
|
116
117
|
- `pnpm`
|
|
117
118
|
- macOS recommended for Spotlight archive discovery
|
|
118
119
|
- `xurl` optional for live reads / writes
|
|
@@ -259,7 +260,7 @@ Notes:
|
|
|
259
260
|
- `actions.transport` accepts `auto`, `bird`, or `xurl`
|
|
260
261
|
- `bird` mode uses your local `bird` CLI and caches its mentions output into birdclaw's canonical store
|
|
261
262
|
- filters still work in `xurl` mode; filtered payloads are rebuilt from the local canonical store after sync
|
|
262
|
-
- `sync likes`, `sync bookmarks`, `sync timeline`, and `sync mention-threads` store live results in the
|
|
263
|
+
- `sync likes`, `sync bookmarks`, `sync timeline`, and `sync mention-threads` store live results in the canonical local store; per-account home/mention/like/bookmark membership is kept as edges so shared tweets do not clobber account ownership
|
|
263
264
|
|
|
264
265
|
### Research bookmarks and threads
|
|
265
266
|
|
|
@@ -275,11 +276,33 @@ birdclaw research --account acct_primary --out ~/research/codex.md
|
|
|
275
276
|
```bash
|
|
276
277
|
pnpm cli search dms "prototype" --json
|
|
277
278
|
pnpm cli search dms "layout" --min-followers 1000 --min-influence-score 120 --sort influence --json
|
|
279
|
+
pnpm cli search dms "blacksmith" --context 4 --resolve-profiles --expand-urls --no-xurl-fallback --json
|
|
280
|
+
pnpm cli whois "blacksmith guy" --context 4 --no-xurl-fallback --json
|
|
281
|
+
pnpm cli whois "github guy" --current-affiliation github --exclude-domain-only --no-xurl-fallback
|
|
282
|
+
pnpm cli whois "blacksmith" --tweets --context 4 --no-xurl-fallback --json
|
|
278
283
|
pnpm cli dms sync --limit 50 --refresh --json
|
|
279
284
|
pnpm cli dms list --refresh --limit 10 --json
|
|
280
285
|
pnpm cli dms list --unreplied --min-followers 500 --min-influence-score 90 --sort influence --json
|
|
281
286
|
```
|
|
282
287
|
|
|
288
|
+
`--resolve-profiles` fills archive-imported numeric DM profiles through the local
|
|
289
|
+
cache first, then `bird`, then `xurl` unless `--no-xurl-fallback` is set.
|
|
290
|
+
Resolved profiles keep bio, location, profile URL, verification type, structured
|
|
291
|
+
URL entities, raw profile JSON, and any X affiliation badge metadata Birdclaw can
|
|
292
|
+
see. When a highlighted-label badge only gives a synthetic label plus handle,
|
|
293
|
+
Birdclaw tries to hydrate that org handle through `bird` and rewrites the edge to
|
|
294
|
+
the real local organization profile id. Profile changes are snapshotted over
|
|
295
|
+
time, and bios are indexed for `@handles`, domains, and company phrases.
|
|
296
|
+
`whois` uses that profile context plus DM context and cached URL expansion to
|
|
297
|
+
return typed evidence such as `profile_bio`, `profile_url`, `profile_bio_url`,
|
|
298
|
+
`affiliation`, `bio_handle`, `bio_domain`, `bio_company`, `profile_history`,
|
|
299
|
+
`dm_context`, and `expanded_url`. It keeps a derived `identity_search_index`
|
|
300
|
+
for fast local profile-evidence lookups, ranks current affiliation and bio
|
|
301
|
+
identity evidence above plain domains, and groups human output into likely
|
|
302
|
+
affiliated, ecosystem, link-only, and DM-context buckets. Use
|
|
303
|
+
`--affiliation`, `--current-affiliation`, and `--exclude-domain-only` when you
|
|
304
|
+
want "GitHub people" rather than anyone with a `github.com` link.
|
|
305
|
+
|
|
283
306
|
### AI inbox
|
|
284
307
|
|
|
285
308
|
```bash
|
|
@@ -377,8 +400,13 @@ Layout:
|
|
|
377
400
|
manifest.json
|
|
378
401
|
data/accounts.jsonl
|
|
379
402
|
data/profiles.jsonl
|
|
403
|
+
data/profile_affiliations.jsonl
|
|
404
|
+
data/profile_snapshots.jsonl
|
|
405
|
+
data/profile_bio_entities.jsonl
|
|
380
406
|
data/tweets/YYYY.jsonl
|
|
381
407
|
data/tweets/unknown.jsonl
|
|
408
|
+
data/timeline_edges/home.jsonl
|
|
409
|
+
data/timeline_edges/mention.jsonl
|
|
382
410
|
data/collections/likes.jsonl
|
|
383
411
|
data/collections/bookmarks.jsonl
|
|
384
412
|
data/dms/conversations.jsonl
|
|
@@ -387,7 +415,7 @@ data/moderation/blocks.jsonl
|
|
|
387
415
|
data/moderation/mutes.jsonl
|
|
388
416
|
```
|
|
389
417
|
|
|
390
|
-
Tweets are sharded by year for human browsing and yearly analysis. Collection-only tweets whose real creation date is unknown go into `data/tweets/unknown.jsonl` instead of pretending they belong to 1970.
|
|
418
|
+
Tweets are sharded by year for human browsing and yearly analysis. Collection-only tweets whose real creation date is unknown go into `data/tweets/unknown.jsonl` instead of pretending they belong to 1970. Timeline membership is stored in `data/timeline_edges`; likes and bookmarks are stored as account-scoped collection edges in `data/collections`. DMs are sharded by year with `conversation_id` in each row; this keeps Git fast while preserving conversation membership.
|
|
391
419
|
|
|
392
420
|
Use `backup sync` when the target is a private Git repo. It pulls first, merge-imports the remote backup into local SQLite, exports the local union back into text shards, commits, and pushes.
|
|
393
421
|
|
|
@@ -477,11 +505,11 @@ pnpm cli auth status --json
|
|
|
477
505
|
## Testing
|
|
478
506
|
|
|
479
507
|
```bash
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
508
|
+
pnpm check
|
|
509
|
+
pnpm test
|
|
510
|
+
pnpm coverage
|
|
511
|
+
pnpm build
|
|
512
|
+
pnpm e2e
|
|
485
513
|
```
|
|
486
514
|
|
|
487
515
|
Current bar:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "birdclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Local Twitter memory in SQLite for archives, DMs, likes, bookmarks, and moderation",
|
|
5
5
|
"homepage": "https://github.com/steipete/birdclaw#readme",
|
|
6
6
|
"license": "MIT",
|
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
"format:check": "oxfmt --check src scripts playwright vite.config.ts vitest.config.ts playwright.config.ts",
|
|
47
47
|
"lint": "oxlint --import-plugin --node-plugin --vitest-plugin --deny-warnings -A require-mock-type-parameters -A no-control-regex src scripts playwright vite.config.ts vitest.config.ts playwright.config.ts",
|
|
48
48
|
"check": "pnpm run format:check && pnpm run lint",
|
|
49
|
-
"cli": "tsx src/cli.ts"
|
|
49
|
+
"cli": "tsx src/cli.ts",
|
|
50
|
+
"docs:site": "node scripts/build-docs-site.mjs"
|
|
50
51
|
},
|
|
51
52
|
"dependencies": {
|
|
52
53
|
"@steipete/sweet-cookie": "^0.2.0",
|
|
@@ -59,7 +60,6 @@
|
|
|
59
60
|
"@tanstack/react-start": "1.167.61",
|
|
60
61
|
"@tanstack/router-plugin": "^1.167.32",
|
|
61
62
|
"@vitejs/plugin-react": "^6.0.1",
|
|
62
|
-
"better-sqlite3": "^12.9.0",
|
|
63
63
|
"commander": "^14.0.3",
|
|
64
64
|
"kysely": "^0.28.17",
|
|
65
65
|
"lucide-react": "^1.14.0",
|
|
@@ -76,7 +76,6 @@
|
|
|
76
76
|
"@testing-library/dom": "^10.4.1",
|
|
77
77
|
"@testing-library/jest-dom": "^6.9.1",
|
|
78
78
|
"@testing-library/react": "^16.3.2",
|
|
79
|
-
"@types/better-sqlite3": "^7.6.13",
|
|
80
79
|
"@types/node": "^25.6.0",
|
|
81
80
|
"@types/react": "^19.2.14",
|
|
82
81
|
"@types/react-dom": "^19.2.3",
|
|
@@ -89,12 +88,11 @@
|
|
|
89
88
|
"vitest": "^4.1.5"
|
|
90
89
|
},
|
|
91
90
|
"engines": {
|
|
92
|
-
"node": ">=25.8.1 <
|
|
91
|
+
"node": ">=25.8.1 <27"
|
|
93
92
|
},
|
|
94
93
|
"packageManager": "pnpm@10.31.0",
|
|
95
94
|
"pnpm": {
|
|
96
95
|
"onlyBuiltDependencies": [
|
|
97
|
-
"better-sqlite3",
|
|
98
96
|
"esbuild",
|
|
99
97
|
"lightningcss"
|
|
100
98
|
]
|
package/src/cli.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
} from "#/lib/config";
|
|
30
30
|
import { syncDirectMessagesViaCachedBird } from "#/lib/dms-live";
|
|
31
31
|
import { listInboxItems, scoreInbox } from "#/lib/inbox";
|
|
32
|
+
import { backfillLinkIndex, searchLinks } from "#/lib/link-index";
|
|
32
33
|
import { syncMentionThreads } from "#/lib/mention-threads-live";
|
|
33
34
|
import { exportMentionItems } from "#/lib/mentions-export";
|
|
34
35
|
import {
|
|
@@ -36,6 +37,7 @@ import {
|
|
|
36
37
|
exportMentionsViaCachedXurl,
|
|
37
38
|
} from "#/lib/mentions-live";
|
|
38
39
|
import { hydrateProfilesFromX } from "#/lib/profile-hydration";
|
|
40
|
+
import { resolveProfilesForIds } from "#/lib/profile-resolver";
|
|
39
41
|
import { inspectProfileReplies } from "#/lib/profile-replies";
|
|
40
42
|
import { runResearchMode } from "#/lib/research";
|
|
41
43
|
import {
|
|
@@ -51,6 +53,8 @@ import {
|
|
|
51
53
|
type TimelineCollectionMode,
|
|
52
54
|
} from "#/lib/timeline-collections-live";
|
|
53
55
|
import { syncHomeTimeline } from "#/lib/timeline-live";
|
|
56
|
+
import { expandUrlsFromTexts } from "#/lib/url-expansion";
|
|
57
|
+
import { formatWhois, runWhois } from "#/lib/whois";
|
|
54
58
|
|
|
55
59
|
const program = new Command();
|
|
56
60
|
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
@@ -70,6 +74,24 @@ function printError(error: string) {
|
|
|
70
74
|
console.error(JSON.stringify({ error }));
|
|
71
75
|
}
|
|
72
76
|
|
|
77
|
+
function formatLinkSearchItems(items: ReturnType<typeof searchLinks>) {
|
|
78
|
+
return items
|
|
79
|
+
.map((item) => {
|
|
80
|
+
const linked = item.linkedTweet
|
|
81
|
+
? ` -> @${item.linkedTweet.author.handle}/${item.linkedTweet.id}: ${item.linkedTweet.text}`
|
|
82
|
+
: ` -> ${item.expansion.finalUrl}`;
|
|
83
|
+
const source =
|
|
84
|
+
item.occurrence.sourceKind === "dm"
|
|
85
|
+
? `dm ${item.occurrence.direction ?? ""}`.trim()
|
|
86
|
+
: "tweet";
|
|
87
|
+
const participant = item.participant
|
|
88
|
+
? ` @${item.participant.handle}`
|
|
89
|
+
: "";
|
|
90
|
+
return `${item.occurrence.createdAt} ${source}${participant}: ${item.occurrence.shortUrl}${linked}`;
|
|
91
|
+
})
|
|
92
|
+
.join("\n");
|
|
93
|
+
}
|
|
94
|
+
|
|
73
95
|
function parseNonNegativeIntegerOption(
|
|
74
96
|
value: string | undefined,
|
|
75
97
|
option: string,
|
|
@@ -101,6 +123,56 @@ function resolveActionOptions(options: { transport?: string }) {
|
|
|
101
123
|
};
|
|
102
124
|
}
|
|
103
125
|
|
|
126
|
+
async function enrichDmItems(
|
|
127
|
+
query: Parameters<typeof listDmConversations>[0],
|
|
128
|
+
options: {
|
|
129
|
+
resolveProfiles?: boolean;
|
|
130
|
+
expandUrls?: boolean;
|
|
131
|
+
refreshProfileCache?: boolean;
|
|
132
|
+
refreshUrlCache?: boolean;
|
|
133
|
+
xurlFallback?: boolean;
|
|
134
|
+
},
|
|
135
|
+
) {
|
|
136
|
+
let items = listDmConversations(query);
|
|
137
|
+
const profileResolution = options.resolveProfiles
|
|
138
|
+
? await resolveProfilesForIds(
|
|
139
|
+
items.map((item) => item.participant.id),
|
|
140
|
+
{
|
|
141
|
+
refresh: options.refreshProfileCache,
|
|
142
|
+
xurlFallback: options.xurlFallback ?? true,
|
|
143
|
+
},
|
|
144
|
+
)
|
|
145
|
+
: undefined;
|
|
146
|
+
if (profileResolution) {
|
|
147
|
+
items = listDmConversations(query);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const urlExpansions = options.expandUrls
|
|
151
|
+
? await expandUrlsFromTexts(
|
|
152
|
+
items.flatMap((item) => [
|
|
153
|
+
item.lastMessagePreview,
|
|
154
|
+
item.searchSnippet ?? "",
|
|
155
|
+
...(item.matches ?? []).flatMap((match) => [
|
|
156
|
+
...match.before.map((message) => message.text),
|
|
157
|
+
match.message.text,
|
|
158
|
+
...match.after.map((message) => message.text),
|
|
159
|
+
]),
|
|
160
|
+
]),
|
|
161
|
+
{ refresh: options.refreshUrlCache },
|
|
162
|
+
)
|
|
163
|
+
: undefined;
|
|
164
|
+
|
|
165
|
+
if (!profileResolution && !urlExpansions) {
|
|
166
|
+
return items;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
items,
|
|
171
|
+
...(profileResolution ? { profileResolution } : {}),
|
|
172
|
+
...(urlExpansions ? { urlExpansions } : {}),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
104
176
|
async function autoUpdateBeforeRead() {
|
|
105
177
|
const result = await maybeAutoUpdateBackup();
|
|
106
178
|
if (!result.ok) {
|
|
@@ -251,17 +323,37 @@ searchCommand
|
|
|
251
323
|
.option("--min-influence-score <n>", "Minimum derived influence score")
|
|
252
324
|
.option("--max-influence-score <n>", "Maximum derived influence score")
|
|
253
325
|
.option("--sort <mode>", "recent or influence", "recent")
|
|
326
|
+
.option(
|
|
327
|
+
"--context <n>",
|
|
328
|
+
"Include N messages before and after each match",
|
|
329
|
+
"0",
|
|
330
|
+
)
|
|
331
|
+
.option(
|
|
332
|
+
"--resolve-profiles",
|
|
333
|
+
"Resolve placeholder DM profiles through cache/bird/xurl",
|
|
334
|
+
)
|
|
335
|
+
.option("--expand-urls", "Expand URLs through the persistent URL cache")
|
|
336
|
+
.option("--refresh-profile-cache", "Bypass profile lookup cache")
|
|
337
|
+
.option("--refresh-url-cache", "Bypass URL expansion cache")
|
|
338
|
+
.option(
|
|
339
|
+
"--no-xurl-fallback",
|
|
340
|
+
"Do not fall back to xurl after bird profile lookup",
|
|
341
|
+
)
|
|
254
342
|
.option("--replied", "Only replied threads")
|
|
255
343
|
.option("--unreplied", "Only unreplied threads")
|
|
256
344
|
.option("--limit <n>", "Limit results", "20")
|
|
257
345
|
.action(async (query, options) => {
|
|
258
346
|
await autoUpdateBeforeRead();
|
|
347
|
+
const context = parseNonNegativeIntegerOption(options.context, "--context");
|
|
348
|
+
if (context === undefined) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
259
351
|
const replyFilter = options.replied
|
|
260
352
|
? "replied"
|
|
261
353
|
: options.unreplied
|
|
262
354
|
? "unreplied"
|
|
263
355
|
: "all";
|
|
264
|
-
const
|
|
356
|
+
const dmQuery = {
|
|
265
357
|
search: query,
|
|
266
358
|
participant: options.participant,
|
|
267
359
|
minFollowers: options.minFollowers
|
|
@@ -278,11 +370,168 @@ searchCommand
|
|
|
278
370
|
: undefined,
|
|
279
371
|
sort: options.sort === "influence" ? "influence" : "recent",
|
|
280
372
|
replyFilter,
|
|
373
|
+
context,
|
|
281
374
|
limit: Number(options.limit),
|
|
375
|
+
} as const;
|
|
376
|
+
const items = await enrichDmItems(dmQuery, {
|
|
377
|
+
resolveProfiles: Boolean(options.resolveProfiles),
|
|
378
|
+
expandUrls: Boolean(options.expandUrls),
|
|
379
|
+
refreshProfileCache: Boolean(options.refreshProfileCache),
|
|
380
|
+
refreshUrlCache: Boolean(options.refreshUrlCache),
|
|
381
|
+
xurlFallback: options.xurlFallback,
|
|
282
382
|
});
|
|
283
383
|
print(items, program.opts().json ?? false);
|
|
284
384
|
});
|
|
285
385
|
|
|
386
|
+
searchCommand
|
|
387
|
+
.command("links <query>")
|
|
388
|
+
.description("Search indexed short links, expansions, and linked tweets")
|
|
389
|
+
.option("--account <accountIdOrHandle>", "Account id or handle")
|
|
390
|
+
.option("--since <date>", "Include links created at or after this date")
|
|
391
|
+
.option("--until <date>", "Include links created before this date")
|
|
392
|
+
.option("--source <kind>", "dm or tweet")
|
|
393
|
+
.option("--direction <direction>", "inbound or outbound")
|
|
394
|
+
.option("--participant <value>", "DM participant handle or name")
|
|
395
|
+
.option("--media <type>", "image, video, or gif")
|
|
396
|
+
.option("--limit <n>", "Limit results", "20")
|
|
397
|
+
.action(async (query, options) => {
|
|
398
|
+
await autoUpdateBeforeRead();
|
|
399
|
+
const items = searchLinks(query, {
|
|
400
|
+
account: options.account,
|
|
401
|
+
since: options.since,
|
|
402
|
+
until: options.until,
|
|
403
|
+
source:
|
|
404
|
+
options.source === "tweet"
|
|
405
|
+
? "tweet"
|
|
406
|
+
: options.source === "dm"
|
|
407
|
+
? "dm"
|
|
408
|
+
: undefined,
|
|
409
|
+
direction:
|
|
410
|
+
options.direction === "inbound"
|
|
411
|
+
? "inbound"
|
|
412
|
+
: options.direction === "outbound"
|
|
413
|
+
? "outbound"
|
|
414
|
+
: undefined,
|
|
415
|
+
participant: options.participant,
|
|
416
|
+
mediaType:
|
|
417
|
+
options.media === "image" ||
|
|
418
|
+
options.media === "video" ||
|
|
419
|
+
options.media === "gif"
|
|
420
|
+
? options.media
|
|
421
|
+
: undefined,
|
|
422
|
+
limit: Number(options.limit),
|
|
423
|
+
});
|
|
424
|
+
if (program.opts().json) {
|
|
425
|
+
print(items, true);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
console.log(formatLinkSearchItems(items));
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const linksCommand = program
|
|
432
|
+
.command("links")
|
|
433
|
+
.description("Build and inspect the short-link index");
|
|
434
|
+
|
|
435
|
+
linksCommand
|
|
436
|
+
.command("backfill")
|
|
437
|
+
.description("Backfill indexed URL occurrences and t.co expansions")
|
|
438
|
+
.option("--all-urls", "Index all URLs, not only t.co")
|
|
439
|
+
.option("--source <kind>", "dm or tweet")
|
|
440
|
+
.option("--refresh-url-cache", "Re-expand URLs already in the index")
|
|
441
|
+
.option("--limit <n>", "Limit network/cache expansions for this run")
|
|
442
|
+
.option("--concurrency <n>", "Concurrent URL expansion workers", "12")
|
|
443
|
+
.option("--timeout-ms <n>", "Per-redirect fetch timeout", "15000")
|
|
444
|
+
.action(async (options) => {
|
|
445
|
+
const limit = parseNonNegativeIntegerOption(options.limit, "--limit");
|
|
446
|
+
if (options.limit !== undefined && limit === undefined) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const concurrency = parseNonNegativeIntegerOption(
|
|
450
|
+
options.concurrency,
|
|
451
|
+
"--concurrency",
|
|
452
|
+
);
|
|
453
|
+
if (concurrency === undefined) {
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const timeoutMs = parseNonNegativeIntegerOption(
|
|
457
|
+
options.timeoutMs,
|
|
458
|
+
"--timeout-ms",
|
|
459
|
+
);
|
|
460
|
+
if (timeoutMs === undefined) {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const result = await backfillLinkIndex({
|
|
464
|
+
includeAllUrls: Boolean(options.allUrls),
|
|
465
|
+
refresh: Boolean(options.refreshUrlCache),
|
|
466
|
+
source:
|
|
467
|
+
options.source === "tweet"
|
|
468
|
+
? "tweet"
|
|
469
|
+
: options.source === "dm"
|
|
470
|
+
? "dm"
|
|
471
|
+
: undefined,
|
|
472
|
+
limit,
|
|
473
|
+
concurrency,
|
|
474
|
+
timeoutMs,
|
|
475
|
+
});
|
|
476
|
+
await autoSyncAfterWrite();
|
|
477
|
+
print(result, program.opts().json ?? false);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
program
|
|
481
|
+
.command("whois <query>")
|
|
482
|
+
.description("Identify likely people or orgs from local DMs and tweets")
|
|
483
|
+
.option("--account <accountId>", "Account id")
|
|
484
|
+
.option("--no-dms", "Do not search DMs")
|
|
485
|
+
.option("--tweets", "Include local tweet search evidence")
|
|
486
|
+
.option("--no-resolve-profiles", "Do not resolve placeholder profiles")
|
|
487
|
+
.option("--no-expand-urls", "Do not expand URLs")
|
|
488
|
+
.option("--refresh-profile-cache", "Bypass profile lookup cache")
|
|
489
|
+
.option("--refresh-url-cache", "Bypass URL expansion cache")
|
|
490
|
+
.option(
|
|
491
|
+
"--no-xurl-fallback",
|
|
492
|
+
"Do not fall back to xurl after bird profile lookup",
|
|
493
|
+
)
|
|
494
|
+
.option(
|
|
495
|
+
"--affiliation <query>",
|
|
496
|
+
"Require affiliation, bio, or history evidence",
|
|
497
|
+
)
|
|
498
|
+
.option(
|
|
499
|
+
"--current-affiliation <query>",
|
|
500
|
+
"Require an active affiliation badge",
|
|
501
|
+
)
|
|
502
|
+
.option(
|
|
503
|
+
"--exclude-domain-only",
|
|
504
|
+
"Drop candidates that only match domains/URLs",
|
|
505
|
+
)
|
|
506
|
+
.option("--context <n>", "DM messages before and after each match", "4")
|
|
507
|
+
.option("--limit <n>", "Limit candidates", "10")
|
|
508
|
+
.action(async (query, options) => {
|
|
509
|
+
await autoUpdateBeforeRead();
|
|
510
|
+
const context = parseNonNegativeIntegerOption(options.context, "--context");
|
|
511
|
+
if (context === undefined) {
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const result = await runWhois(query, {
|
|
515
|
+
account: options.account,
|
|
516
|
+
dms: options.dms,
|
|
517
|
+
tweets: Boolean(options.tweets),
|
|
518
|
+
resolveProfiles: options.resolveProfiles,
|
|
519
|
+
expandUrls: options.expandUrls,
|
|
520
|
+
refreshProfileCache: Boolean(options.refreshProfileCache),
|
|
521
|
+
refreshUrlCache: Boolean(options.refreshUrlCache),
|
|
522
|
+
xurlFallback: options.xurlFallback,
|
|
523
|
+
affiliation: options.affiliation,
|
|
524
|
+
currentAffiliation: options.currentAffiliation,
|
|
525
|
+
excludeDomainOnly: Boolean(options.excludeDomainOnly),
|
|
526
|
+
context,
|
|
527
|
+
limit: Number(options.limit),
|
|
528
|
+
});
|
|
529
|
+
print(
|
|
530
|
+
program.opts().json ? result : formatWhois(result),
|
|
531
|
+
program.opts().json ?? false,
|
|
532
|
+
);
|
|
533
|
+
});
|
|
534
|
+
|
|
286
535
|
program
|
|
287
536
|
.command("research [query]")
|
|
288
537
|
.description("Build a markdown research brief from bookmarked threads")
|
|
@@ -547,6 +796,17 @@ dmsCommand
|
|
|
547
796
|
.option("--min-influence-score <n>", "Minimum derived influence score")
|
|
548
797
|
.option("--max-influence-score <n>", "Maximum derived influence score")
|
|
549
798
|
.option("--sort <mode>", "recent or influence", "recent")
|
|
799
|
+
.option(
|
|
800
|
+
"--resolve-profiles",
|
|
801
|
+
"Resolve placeholder DM profiles through cache/bird/xurl",
|
|
802
|
+
)
|
|
803
|
+
.option("--expand-urls", "Expand URLs through the persistent URL cache")
|
|
804
|
+
.option("--refresh-profile-cache", "Bypass profile lookup cache")
|
|
805
|
+
.option("--refresh-url-cache", "Bypass URL expansion cache")
|
|
806
|
+
.option(
|
|
807
|
+
"--no-xurl-fallback",
|
|
808
|
+
"Do not fall back to xurl after bird profile lookup",
|
|
809
|
+
)
|
|
550
810
|
.option("--replied", "Only replied threads")
|
|
551
811
|
.option("--unreplied", "Only unreplied threads")
|
|
552
812
|
.option("--limit <n>", "Limit results", "20")
|
|
@@ -567,25 +827,34 @@ dmsCommand
|
|
|
567
827
|
} else {
|
|
568
828
|
await autoUpdateBeforeRead();
|
|
569
829
|
}
|
|
570
|
-
const items =
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
830
|
+
const items = await enrichDmItems(
|
|
831
|
+
{
|
|
832
|
+
account: options.account,
|
|
833
|
+
participant: options.participant,
|
|
834
|
+
minFollowers: options.minFollowers
|
|
835
|
+
? Number(options.minFollowers)
|
|
836
|
+
: undefined,
|
|
837
|
+
maxFollowers: options.maxFollowers
|
|
838
|
+
? Number(options.maxFollowers)
|
|
839
|
+
: undefined,
|
|
840
|
+
minInfluenceScore: options.minInfluenceScore
|
|
841
|
+
? Number(options.minInfluenceScore)
|
|
842
|
+
: undefined,
|
|
843
|
+
maxInfluenceScore: options.maxInfluenceScore
|
|
844
|
+
? Number(options.maxInfluenceScore)
|
|
845
|
+
: undefined,
|
|
846
|
+
sort: options.sort === "influence" ? "influence" : "recent",
|
|
847
|
+
replyFilter,
|
|
848
|
+
limit: Number(options.limit),
|
|
849
|
+
},
|
|
850
|
+
{
|
|
851
|
+
resolveProfiles: Boolean(options.resolveProfiles),
|
|
852
|
+
expandUrls: Boolean(options.expandUrls),
|
|
853
|
+
refreshProfileCache: Boolean(options.refreshProfileCache),
|
|
854
|
+
refreshUrlCache: Boolean(options.refreshUrlCache),
|
|
855
|
+
xurlFallback: options.xurlFallback,
|
|
856
|
+
},
|
|
857
|
+
);
|
|
589
858
|
print(items, program.opts().json ?? false);
|
|
590
859
|
});
|
|
591
860
|
|
|
@@ -286,7 +286,10 @@ function clearImportedData() {
|
|
|
286
286
|
db.exec(`
|
|
287
287
|
delete from ai_scores;
|
|
288
288
|
delete from tweet_actions;
|
|
289
|
+
delete from tweet_account_edges;
|
|
289
290
|
delete from tweet_collections;
|
|
291
|
+
delete from link_occurrences;
|
|
292
|
+
delete from url_expansions;
|
|
290
293
|
delete from dm_fts;
|
|
291
294
|
delete from tweets_fts;
|
|
292
295
|
delete from dm_messages;
|
|
@@ -776,6 +779,16 @@ export async function importArchive(
|
|
|
776
779
|
const insertTweetFts = db.prepare(
|
|
777
780
|
"insert into tweets_fts (tweet_id, text) values (?, ?)",
|
|
778
781
|
);
|
|
782
|
+
const insertTimelineEdge = db.prepare(`
|
|
783
|
+
insert into tweet_account_edges (
|
|
784
|
+
account_id, tweet_id, kind, first_seen_at, last_seen_at, seen_count, source,
|
|
785
|
+
raw_json, updated_at
|
|
786
|
+
) values (?, ?, ?, ?, ?, 1, 'archive', '{}', ?)
|
|
787
|
+
on conflict(account_id, tweet_id, kind) do update set
|
|
788
|
+
first_seen_at = min(tweet_account_edges.first_seen_at, excluded.first_seen_at),
|
|
789
|
+
last_seen_at = max(tweet_account_edges.last_seen_at, excluded.last_seen_at),
|
|
790
|
+
updated_at = max(tweet_account_edges.updated_at, excluded.updated_at)
|
|
791
|
+
`);
|
|
779
792
|
const insertCollection = db.prepare(`
|
|
780
793
|
insert into tweet_collections (
|
|
781
794
|
account_id, tweet_id, kind, collected_at, source, raw_json, updated_at
|
|
@@ -842,6 +855,16 @@ export async function importArchive(
|
|
|
842
855
|
tweet.mediaJson,
|
|
843
856
|
tweet.quotedTweetId,
|
|
844
857
|
);
|
|
858
|
+
if (tweet.kind === "home") {
|
|
859
|
+
insertTimelineEdge.run(
|
|
860
|
+
"acct_primary",
|
|
861
|
+
tweet.id,
|
|
862
|
+
tweet.kind,
|
|
863
|
+
tweet.createdAt,
|
|
864
|
+
tweet.createdAt,
|
|
865
|
+
new Date().toISOString(),
|
|
866
|
+
);
|
|
867
|
+
}
|
|
845
868
|
insertTweetFts.run(tweet.id, tweet.text);
|
|
846
869
|
}
|
|
847
870
|
|
|
@@ -905,8 +928,11 @@ export async function importArchive(
|
|
|
905
928
|
}
|
|
906
929
|
|
|
907
930
|
export const __test__ = {
|
|
931
|
+
normalizeArchivePath,
|
|
908
932
|
extractArchiveJson,
|
|
909
933
|
parseArchiveArray,
|
|
934
|
+
getFirstEntry,
|
|
935
|
+
getMatchingEntries,
|
|
910
936
|
parseTwitterDate,
|
|
911
937
|
asRecord,
|
|
912
938
|
asArray,
|
|
@@ -915,6 +941,7 @@ export const __test__ = {
|
|
|
915
941
|
getTweetMediaCount,
|
|
916
942
|
extractTweetEntities,
|
|
917
943
|
extractTweetMedia,
|
|
944
|
+
extractCollectionTweet,
|
|
918
945
|
buildAccountPayload,
|
|
919
946
|
inferProfileFromDirectory,
|
|
920
947
|
};
|