pi-twitterapi.io 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 +51 -0
- package/LICENSE +22 -0
- package/README.md +278 -0
- package/docs/x-search-comparison.md +119 -0
- package/package.json +56 -0
- package/src/backend/media.ts +101 -0
- package/src/backend/model.ts +111 -0
- package/src/backend/runs.ts +687 -0
- package/src/backend/synthesis.ts +344 -0
- package/src/backend.ts +11 -0
- package/src/config.ts +78 -0
- package/src/format.ts +28 -0
- package/src/index.ts +131 -0
- package/src/settings.ts +66 -0
- package/src/synthesize.ts +731 -0
- package/src/tool.ts +491 -0
- package/src/twitterapi/core.ts +124 -0
- package/src/twitterapi/endpoints.ts +957 -0
- package/src/twitterapi/http.ts +340 -0
- package/src/twitterapi/params.ts +81 -0
- package/src/twitterapi/search.ts +233 -0
- package/src/twitterapi/tweet.ts +112 -0
- package/src/twitterapi/window.ts +184 -0
- package/src/twitterapi.ts +16 -0
- package/src/types.ts +16 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { isObject, type Tweet, type TweetMedia, type UserProfile } from "./core.js";
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
function asMedia(raw: unknown): TweetMedia | undefined {
|
|
5
|
+
if (!isObject(raw)) return undefined;
|
|
6
|
+
const str = (v: unknown) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
|
|
7
|
+
const variants = isObject(raw.video_info) && Array.isArray(raw.video_info.variants) ? raw.video_info.variants : [];
|
|
8
|
+
const playable = variants
|
|
9
|
+
.filter((v) => isObject(v) && typeof v.url === "string" && v.content_type === "video/mp4")
|
|
10
|
+
.sort((a, b) => Number((a as Record<string, unknown>).bitrate ?? 0) - Number((b as Record<string, unknown>).bitrate ?? 0))
|
|
11
|
+
.map((v) => String((v as Record<string, unknown>).url));
|
|
12
|
+
const duration = isObject(raw.video_info) && typeof raw.video_info.duration_millis === "number" ? raw.video_info.duration_millis : undefined;
|
|
13
|
+
const media: TweetMedia = {
|
|
14
|
+
type: str(raw.type),
|
|
15
|
+
url: str(raw.media_url_https) ?? str(raw.media_url),
|
|
16
|
+
videoVariants: playable.length > 0 ? playable : undefined,
|
|
17
|
+
durationMillis: duration,
|
|
18
|
+
};
|
|
19
|
+
return media.url || media.videoVariants ? media : undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function asMediaList(raw: unknown): TweetMedia[] | undefined {
|
|
23
|
+
if (!isObject(raw) || !Array.isArray(raw.media)) return undefined;
|
|
24
|
+
const list = raw.media.map(asMedia).filter((m): m is TweetMedia => m !== undefined);
|
|
25
|
+
return list.length > 0 ? list : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function asTweet(raw: unknown): Tweet | undefined {
|
|
29
|
+
if (!isObject(raw) || typeof raw.text !== "string") return undefined;
|
|
30
|
+
const author = isObject(raw.author) ? raw.author : undefined;
|
|
31
|
+
const num = (v: unknown) => (typeof v === "number" ? v : undefined);
|
|
32
|
+
const str = (v: unknown) => (typeof v === "string" ? v : undefined);
|
|
33
|
+
const extended = isObject(raw.extendedEntities)
|
|
34
|
+
? asMediaList(raw.extendedEntities)
|
|
35
|
+
: isObject(raw.extended_entities)
|
|
36
|
+
? asMediaList(raw.extended_entities)
|
|
37
|
+
: undefined;
|
|
38
|
+
const media = extended ?? (isObject(raw.entities) ? asMediaList(raw.entities) : undefined);
|
|
39
|
+
return {
|
|
40
|
+
id: str(raw.id),
|
|
41
|
+
url: str(raw.url),
|
|
42
|
+
text: raw.text,
|
|
43
|
+
createdAt: str(raw.createdAt),
|
|
44
|
+
likeCount: num(raw.likeCount),
|
|
45
|
+
retweetCount: num(raw.retweetCount),
|
|
46
|
+
replyCount: num(raw.replyCount),
|
|
47
|
+
viewCount: num(raw.viewCount),
|
|
48
|
+
author: author ? {
|
|
49
|
+
userName: str(author.userName),
|
|
50
|
+
name: str(author.name),
|
|
51
|
+
followers: num(author.followers),
|
|
52
|
+
} : undefined,
|
|
53
|
+
media,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Status id from an X/Twitter permalink.
|
|
59
|
+
*
|
|
60
|
+
* Only validated x.com / twitter.com hosts are considered, and the id must be a
|
|
61
|
+
* whole path segment, so `https://x.com/search?q=/status/111` and
|
|
62
|
+
* `.../status/111garbage` cannot masquerade as a real post. Real suffixes such
|
|
63
|
+
* as `/photo/1` still resolve. This is the single X-URL parser for the package:
|
|
64
|
+
* `synthesize.ts` re-exports it for citation matching.
|
|
65
|
+
*/
|
|
66
|
+
export function statusIdFromUrl(url: string): string | undefined {
|
|
67
|
+
let parsed: URL;
|
|
68
|
+
try {
|
|
69
|
+
parsed = new URL(url);
|
|
70
|
+
} catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
const host = parsed.hostname.toLowerCase();
|
|
74
|
+
const isX = host === "x.com" || host.endsWith(".x.com") || host === "twitter.com" || host.endsWith(".twitter.com");
|
|
75
|
+
if (!isX) return undefined;
|
|
76
|
+
const match = /\/status(?:es)?\/(\d+)(?:\/|$)/.exec(parsed.pathname);
|
|
77
|
+
return match?.[1];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Accept either a bare post id or an X permalink and return the id.
|
|
82
|
+
* Rejects anything else so a malformed reference fails before the request.
|
|
83
|
+
*/
|
|
84
|
+
export function tweetIdFromInput(value: string | undefined): string | undefined {
|
|
85
|
+
const trimmed = value?.trim();
|
|
86
|
+
if (!trimmed) return undefined;
|
|
87
|
+
if (/^\d{1,25}$/.test(trimmed)) return trimmed;
|
|
88
|
+
return statusIdFromUrl(trimmed);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// --------------------------------------------------------------------- users
|
|
92
|
+
|
|
93
|
+
export function asUser(raw: unknown): UserProfile | undefined {
|
|
94
|
+
if (!isObject(raw)) return undefined;
|
|
95
|
+
const str = (v: unknown) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
|
|
96
|
+
const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
|
|
97
|
+
const handle = str(raw.screen_name) ?? str(raw.userName) ?? str(raw.username);
|
|
98
|
+
if (!handle) return undefined;
|
|
99
|
+
return {
|
|
100
|
+
id: str(raw.id),
|
|
101
|
+
handle,
|
|
102
|
+
name: str(raw.name),
|
|
103
|
+
bio: str(raw.description) ?? (isObject(raw.profile_bio) ? str(raw.profile_bio.description) : undefined),
|
|
104
|
+
followers: num(raw.followers_count) ?? num(raw.followers),
|
|
105
|
+
following: num(raw.following_count) ?? num(raw.following),
|
|
106
|
+
verified: raw.isBlueVerified === true || raw.verified === true,
|
|
107
|
+
profileUrl: `https://x.com/${handle}`,
|
|
108
|
+
location: str(raw.location),
|
|
109
|
+
createdAt: str(raw.created_at) ?? str(raw.createdAt),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { LocalWindow, NormalizedSearchParams } from "./core.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* twitterapi.io evaluates X `since:` / `until:` day boundaries at 04:00 UTC
|
|
5
|
+
* (verified empirically: `until:2026-09-25` returned tweets up to
|
|
6
|
+
* 2026-09-26T03:59:59Z, i.e. a UTC-4 day boundary rather than UTC midnight).
|
|
7
|
+
* Time-of-day components (`until:2026-09-29_12:00:00_UTC`) are not honored.
|
|
8
|
+
*
|
|
9
|
+
* Consequences for local calendar days (offset = local - UTC):
|
|
10
|
+
* - start: the upstream bound may fall after the local one, so pad `since:`
|
|
11
|
+
* backwards by the whole days needed. Padding the start only adds older
|
|
12
|
+
* tweets to the tail of a newest-first scan, which is harmless.
|
|
13
|
+
* - end: the upstream bound may fall before the local one, leaving a gap of
|
|
14
|
+
* max(0, -offset - 4) hours at the end of the day. We deliberately do NOT
|
|
15
|
+
* pad `until:` forward: that pushes a full extra day of newer tweets ahead
|
|
16
|
+
* of the requested range and starves `count` on any busy topic (observed:
|
|
17
|
+
* 0 results for a single local day).
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Hour (UTC) at which twitterapi.io resolves `since:`/`until:` day boundaries.
|
|
21
|
+
* Probed in September, when US Eastern is UTC-4, so a fixed 04:00Z and New York
|
|
22
|
+
* local midnight are indistinguishable. Start padding therefore adds one extra
|
|
23
|
+
* hour (see {@link UPSTREAM_START_PAD_HOURS}): over-padding only adds older tail
|
|
24
|
+
* posts that are trimmed client-side, while under-padding would silently drop
|
|
25
|
+
* the first hour of the local day if the boundary is really 05:00Z in winter.
|
|
26
|
+
*/
|
|
27
|
+
export const UPSTREAM_BOUNDARY_UTC_HOUR = 4;
|
|
28
|
+
/** Conservative start-padding boundary; see {@link UPSTREAM_BOUNDARY_UTC_HOUR}. */
|
|
29
|
+
export const UPSTREAM_START_PAD_HOURS = UPSTREAM_BOUNDARY_UTC_HOUR + 1;
|
|
30
|
+
export const MS_PER_HOUR = 3_600_000;
|
|
31
|
+
export const MS_PER_DAY = 86_400_000;
|
|
32
|
+
|
|
33
|
+
function wallClockAsUtc(y: number, m: number, d: number): number {
|
|
34
|
+
return Date.UTC(y, m - 1, d, 0, 0, 0);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* UTC instant at which a local calendar day begins, resolved against the host
|
|
39
|
+
* timezone.
|
|
40
|
+
*
|
|
41
|
+
* Rather than trusting one offset probe, both plausible offsets (well before and
|
|
42
|
+
* well after the boundary) are turned into candidate instants, each candidate is
|
|
43
|
+
* checked for actually reading as the target local date, and the earliest valid
|
|
44
|
+
* one wins. A single probe is not enough: transitions happen at local midnight in
|
|
45
|
+
* some zones (America/Sao_Paulo) and at 02:00/03:00 in others
|
|
46
|
+
* (Australia/Sydney, America/New_York), so any fixed probe distance is wrong for
|
|
47
|
+
* one of those shapes. Validating candidates also gives the correct answers when
|
|
48
|
+
* local midnight does not exist (spring forward) or happens twice (fall back).
|
|
49
|
+
*/
|
|
50
|
+
function hostLocalMidnightUtc(y: number, m: number, d: number): number {
|
|
51
|
+
const wall = wallClockAsUtc(y, m, d);
|
|
52
|
+
const dayIndex = Math.floor(wall / MS_PER_DAY);
|
|
53
|
+
const probeOffsets = [
|
|
54
|
+
new Date(wall - 12 * MS_PER_HOUR).getTimezoneOffset() * 60_000,
|
|
55
|
+
new Date(wall + 12 * MS_PER_HOUR).getTimezoneOffset() * 60_000,
|
|
56
|
+
];
|
|
57
|
+
const candidates = [...new Set(probeOffsets)].map((offset) => wall + offset).sort((a, b) => a - b);
|
|
58
|
+
|
|
59
|
+
for (const candidate of candidates) {
|
|
60
|
+
const localWall = candidate - new Date(candidate).getTimezoneOffset() * 60_000;
|
|
61
|
+
if (Math.floor(localWall / MS_PER_DAY) === dayIndex) return candidate;
|
|
62
|
+
}
|
|
63
|
+
// Both candidates land on another local day: the requested day is unreachable
|
|
64
|
+
// (a whole skipped day), so the earliest candidate is the closest boundary.
|
|
65
|
+
return candidates[0];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function fixedLocalMidnightUtc(y: number, m: number, d: number, offsetMinutes: number): number {
|
|
69
|
+
return wallClockAsUtc(y, m, d) - offsetMinutes * 60_000;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseDateParts(date: string): [number, number, number] {
|
|
73
|
+
const [y, m, d] = date.split("-").map(Number);
|
|
74
|
+
return [y, m, d];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function addDaysToDate(date: string, days: number): [number, number, number] {
|
|
78
|
+
const [y, m, d] = parseDateParts(date);
|
|
79
|
+
const shifted = new Date(wallClockAsUtc(y, m, d) + days * MS_PER_DAY);
|
|
80
|
+
return [shifted.getUTCFullYear(), shifted.getUTCMonth() + 1, shifted.getUTCDate()];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Latest upstream `since:` date whose 04:00 UTC boundary is at or before the
|
|
85
|
+
* window start, so the requested window is always covered.
|
|
86
|
+
*/
|
|
87
|
+
export function upstreamStartDateString(startMs: number): string {
|
|
88
|
+
return new Date(startMs - UPSTREAM_START_PAD_HOURS * MS_PER_HOUR).toISOString().slice(0, 10);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** UTC instant where the upstream `until:` bound for a date stops returning posts. */
|
|
92
|
+
export function upstreamEndMs(toDate: string): number {
|
|
93
|
+
const [y, m, d] = addDaysToDate(toDate, 1);
|
|
94
|
+
return wallClockAsUtc(y, m, d) + UPSTREAM_BOUNDARY_UTC_HOUR * MS_PER_HOUR;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function shiftDateString(date: string, days: number): string {
|
|
98
|
+
const [y, m, d] = addDaysToDate(date, days);
|
|
99
|
+
return `${String(y).padStart(4, "0")}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const X_DATE_RE = /^[A-Za-z]{3} ([A-Za-z]{3}) (\d{2}) (\d{2}):(\d{2}):(\d{2}) ([+-]\d{4}) (\d{4})$/;
|
|
103
|
+
const MONTHS: Record<string, number> = {
|
|
104
|
+
Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5,
|
|
105
|
+
Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** Parse X's `Thu Oct 01 04:02:43 +0000 2026` (and ISO-8601) into epoch ms. */
|
|
109
|
+
export function parseTweetDate(value: string | undefined): number | undefined {
|
|
110
|
+
if (!value) return undefined;
|
|
111
|
+
const match = X_DATE_RE.exec(value.trim());
|
|
112
|
+
if (match) {
|
|
113
|
+
const month = MONTHS[match[1]];
|
|
114
|
+
if (month === undefined) return undefined;
|
|
115
|
+
const [, , day, hour, minute, second, offset, year] = match;
|
|
116
|
+
const sign = offset.startsWith("-") ? -1 : 1;
|
|
117
|
+
const offsetMinutes = sign * (Number(offset.slice(1, 3)) * 60 + Number(offset.slice(3, 5)));
|
|
118
|
+
const utc = Date.UTC(Number(year), month, Number(day), Number(hour), Number(minute), Number(second));
|
|
119
|
+
return utc - offsetMinutes * 60_000;
|
|
120
|
+
}
|
|
121
|
+
const parsed = Date.parse(value);
|
|
122
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Resolve `from_date` / `to_date` (local calendar days) into a UTC window.
|
|
128
|
+
* One-sided filters stay one-sided: an absent bound is left unbounded.
|
|
129
|
+
* Passing an explicit `localUtcOffsetMinutes` selects fixed-offset mode;
|
|
130
|
+
* otherwise each boundary uses the host's offset at that date (DST-correct).
|
|
131
|
+
*/
|
|
132
|
+
export function resolveLocalWindow(
|
|
133
|
+
params: NormalizedSearchParams,
|
|
134
|
+
localUtcOffsetMinutes?: number,
|
|
135
|
+
): LocalWindow | undefined {
|
|
136
|
+
if (!params.from_date && !params.to_date) return undefined;
|
|
137
|
+
const fixed = localUtcOffsetMinutes !== undefined;
|
|
138
|
+
const midnight = (date: string): number => {
|
|
139
|
+
const [y, m, d] = parseDateParts(date);
|
|
140
|
+
return fixed
|
|
141
|
+
? fixedLocalMidnightUtc(y, m, d, localUtcOffsetMinutes)
|
|
142
|
+
: hostLocalMidnightUtc(y, m, d);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const startMs = params.from_date ? midnight(params.from_date) : Number.NEGATIVE_INFINITY;
|
|
146
|
+
const endMs = params.to_date ? midnight(shiftDateString(params.to_date, 1)) : Number.POSITIVE_INFINITY;
|
|
147
|
+
|
|
148
|
+
let zone: string;
|
|
149
|
+
if (fixed) {
|
|
150
|
+
const sign = localUtcOffsetMinutes < 0 ? "-" : "+";
|
|
151
|
+
const abs = Math.abs(localUtcOffsetMinutes);
|
|
152
|
+
zone = `UTC${sign}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")} (fixed)`;
|
|
153
|
+
} else {
|
|
154
|
+
const reference = Number.isFinite(startMs) ? startMs : endMs;
|
|
155
|
+
const offsetMinutes = -new Date(reference).getTimezoneOffset();
|
|
156
|
+
const sign = offsetMinutes < 0 ? "-" : "+";
|
|
157
|
+
const abs = Math.abs(offsetMinutes);
|
|
158
|
+
zone = `host timezone, UTC${sign}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")} at window ${Number.isFinite(startMs) ? "start" : "end"}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const shortfallHours = params.to_date ? Math.max(0, (endMs - upstreamEndMs(params.to_date)) / MS_PER_HOUR) : 0;
|
|
162
|
+
const trimHours = params.to_date ? Math.max(0, (upstreamEndMs(params.to_date) - endMs) / MS_PER_HOUR) : 0;
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
startMs,
|
|
166
|
+
endMs,
|
|
167
|
+
fromDate: params.from_date,
|
|
168
|
+
toDate: params.to_date,
|
|
169
|
+
zone,
|
|
170
|
+
shortfallHours,
|
|
171
|
+
trimHours,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Pad the upstream `since:` back so the requested window start is always
|
|
177
|
+
* covered. The `until:` bound is never padded forward: that would push a full
|
|
178
|
+
* extra day of newer posts ahead of the range and starve `count` on busy topics.
|
|
179
|
+
*/
|
|
180
|
+
export function withPaddedStart(params: NormalizedSearchParams, window: LocalWindow | undefined): NormalizedSearchParams {
|
|
181
|
+
if (!params.from_date || !window || !Number.isFinite(window.startMs)) return params;
|
|
182
|
+
const padded = upstreamStartDateString(window.startMs);
|
|
183
|
+
return padded === params.from_date ? params : { ...params, from_date: padded };
|
|
184
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* twitterapi.io backend — pure request/response logic (no pi imports).
|
|
3
|
+
* Testable with an injected fetcher.
|
|
4
|
+
*
|
|
5
|
+
* Barrel: internal wiring for the split modules (core types, params, tweet/user
|
|
6
|
+
* mapping, date window, transport, search and endpoint readers). The *published*
|
|
7
|
+
* surface is `src/index.ts`; `export *` here also exposes sibling-module helpers
|
|
8
|
+
* to the rest of the source tree by design.
|
|
9
|
+
*/
|
|
10
|
+
export * from "./twitterapi/core.js";
|
|
11
|
+
export * from "./twitterapi/params.js";
|
|
12
|
+
export * from "./twitterapi/tweet.js";
|
|
13
|
+
export * from "./twitterapi/window.js";
|
|
14
|
+
export * from "./twitterapi/http.js";
|
|
15
|
+
export * from "./twitterapi/search.js";
|
|
16
|
+
export * from "./twitterapi/endpoints.js";
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Result shape shared by the format, synthesis and tool layers.
|
|
3
|
+
*
|
|
4
|
+
* `pi-twitterapi.io` returns an answer plus citation URLs: the retrieved posts
|
|
5
|
+
* are synthesized into `text` and `citations` before being rendered.
|
|
6
|
+
*/
|
|
7
|
+
export interface TwitterSearchDetails {
|
|
8
|
+
query: string;
|
|
9
|
+
model: string;
|
|
10
|
+
text: string;
|
|
11
|
+
citations: string[];
|
|
12
|
+
/** Synthesis completions run while answering (0 when nothing was synthesized). */
|
|
13
|
+
synthesisCalls?: number;
|
|
14
|
+
/** Disclosures worth surfacing to the user; rendered as a trailing `## Notes` section. */
|
|
15
|
+
notes?: string[];
|
|
16
|
+
}
|