Skip to content

Commit 1329194

Browse files
authored
Merge pull request #473 from shamanic-technologies/main
chore: sync hotfix v0.39.89 to staging
2 parents 1dbdfd9 + 4527b96 commit 1329194

9 files changed

Lines changed: 234 additions & 26 deletions

‎CLAUDE.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,14 @@ window. `src/lib/linkedin-engagement-spec.ts` (pure) + `src/lib/linkedin-engagem
442442
~1,000 reads/h from lead-service's serve loop) is skipped WITHOUT a call for
443443
`POSTS_FAILED_RETRY_MS` (1h), same answer; an unreadable 200/4xx stops the chain.
444444
No provider resolves `showcase/eimmigration` (all three probed).
445+
**A provider 4xx (other than its own "not found" / treg "no tool") is a REFUSAL: next
446+
provider, not transient** (LI-ENGAGEMENT-NEVER-DONE-1029: tikhub 400s "Request failed.
447+
Please retry" on EVERY page since 2026-10-28 and used to stop the chain). One says not
448+
found + the rest refused, none throttled/down/slow = `posts_status = unreadable`:
449+
permanent like not_found (re-read after 24h, ~$0.002/day/page), NEVER keeps the serve
450+
not-done, and listed in the response's additive `unreadablePages`. Only a TRANSIENT
451+
failure keeps `done=false` (diffco-us kept Beyond Logic's audience not-done to ~18k
452+
serves/hour).
445453
- **`doneReason` says WHY a serve is done** (`/search/next`, engagement only, null
446454
while `done=false`): `no_recent_posts` = no competitor page has a post published
447455
in the window (nothing to read engagers from; NOT exhaustion, new posts may

‎openapi.json‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1157,6 +1157,33 @@
11571157
],
11581158
"description": "source=linkedin_engagement only (absent everywhere else). WHY done is what it is: null while done=false; when done=true, no_recent_posts = no competitor page has a post published inside the audience's window (nothing to read engagers from: NOT exhaustion of an engaged pool, new posts may come), no_engagers = posts exist inside the window but nobody reacted or commented, all_served = there were engagers and every one was already considered for this audience (served, competitor employee or profile unreadable)."
11591159
},
1160+
"unreadablePages": {
1161+
"type": "array",
1162+
"items": {
1163+
"type": "object",
1164+
"properties": {
1165+
"page": {
1166+
"type": "string"
1167+
},
1168+
"state": {
1169+
"type": "string",
1170+
"enum": [
1171+
"not_found",
1172+
"unreadable"
1173+
]
1174+
},
1175+
"reason": {
1176+
"type": "string"
1177+
}
1178+
},
1179+
"required": [
1180+
"page",
1181+
"state",
1182+
"reason"
1183+
]
1184+
},
1185+
"description": "source=linkedin_engagement only. Competitor pages no posts provider can read (not_found = every provider says the page does not exist; unreadable = one says so and the others refuse it). Permanent: re-checked once a day, and they never keep done=false, so done/doneReason describe the READABLE pages. A page that failed transiently (rate limit, outage, timeout) is not listed and keeps done=false."
1186+
},
11601187
"source": {
11611188
"type": "string",
11621189
"enum": [

‎src/lib/linkedin-company-posts.ts‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,16 @@
2727
* provider is asked. "Page not found" passes to the next provider too: it is a
2828
* fact about that provider's coverage, not about the page (scrapecreators did
2929
* not know oxblue-corporation, tikhub listed 50 of its posts). A page is dead
30-
* only when every provider says so. Any other 4xx fails loud.
30+
* only when every provider says so.
31+
*
32+
* Any other 4xx is the provider REFUSING the call: it says nothing about the
33+
* page either, so the next provider is asked, but it is not transient. tikhub
34+
* answers 400 "Request failed. Please retry ... You won't be charged" for EVERY
35+
* page since 2026-10-28 (docketwise-software too), and that answer used to stop
36+
* the chain, so harvestapi was never asked. A page no provider reads while at
37+
* least one says "not found" and the rest refused (none throttled, down or slow)
38+
* is UNREADABLE: permanent, re-checked daily like a not_found
39+
* (LI-ENGAGEMENT-NEVER-DONE-1029). An unreadable 200 body still fails loud.
3140
*/
3241
import type { WirePost } from "./linkedin-engagement-spec.js";
3342
import type { CompetitorPage } from "./linkedin-engagement-spec.js";
@@ -105,8 +114,10 @@ export const POSTS_PROVIDERS: PostsProvider[] = [
105114
export type PostsVerdict =
106115
| { kind: "posts"; posts: WirePost[] }
107116
| { kind: "not_found"; reason: string }
108-
/** Says nothing about the page: ask the next provider. */
117+
/** Says nothing about the page, transiently (rate limit, outage, withdrawn tool): ask the next provider. */
109118
| { kind: "next"; reason: string }
119+
/** The provider refused the call (a 4xx): says nothing about the page, not transient. Ask the next provider. */
120+
| { kind: "refused"; reason: string }
110121
/** An answer we do not understand: fail loud, do not guess. */
111122
| { kind: "fail"; reason: string };
112123

@@ -131,5 +142,24 @@ export function postsVerdict(provider: PostsProvider, status: number, body: Reco
131142
}
132143
// The provider answered that the page does not exist (scrapecreators: 404 {"error":"not_found","message":"Company not found"}).
133144
if (status === 404 && /not.?found/i.test(`${body?.error ?? ""} ${body?.message ?? ""}`)) return { kind: "not_found", reason: why };
145+
if (status >= 400 && status < 500) return { kind: "refused", reason: why };
134146
return { kind: "fail", reason: why };
135147
}
148+
149+
/** What a page is once every provider was asked and none returned posts. */
150+
export type PageReadVerdict = { kind: "not_found"; reason: string } | { kind: "unreadable"; reason: string } | { kind: "failed"; reason: string };
151+
152+
/**
153+
* not_found = every provider said the page does not exist. unreadable = at least
154+
* one said so and every other one REFUSED (4xx): nobody can read it and waiting
155+
* an hour will not change that (permanent, re-checked daily). failed = any
156+
* provider was throttled, down or slow, or none said "not found": retried soon.
157+
*/
158+
export function settlePageRead(answers: { notFound: string[]; refused: string[]; transient: string[] }, providerCount: number): PageReadVerdict {
159+
const { notFound, refused, transient } = answers;
160+
if (notFound.length === providerCount) return { kind: "not_found", reason: `no posts provider knows the page: ${notFound.join(" | ")}` };
161+
if (transient.length === 0 && notFound.length > 0 && notFound.length + refused.length === providerCount) {
162+
return { kind: "unreadable", reason: `no posts provider can read the page: ${[...notFound, ...refused].join(" | ")}` };
163+
}
164+
return { kind: "failed", reason: `no posts provider could read the page: ${[...notFound, ...refused, ...transient].join(" | ")}` };
165+
}

‎src/lib/linkedin-engagement.ts‎

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ import {
6969
type WireEngagement,
7070
type WirePost,
7171
} from "./linkedin-engagement-spec.js";
72-
import { POSTS_PROVIDERS, postsVerdict, type PostsProvider } from "./linkedin-company-posts.js";
72+
import { POSTS_PROVIDERS, postsVerdict, settlePageRead, type PageReadVerdict, type PostsProvider } from "./linkedin-company-posts.js";
7373

7474
const TREG_BASE = "https://treg.to/call/";
7575
export const ENGAGEMENT_ENDPOINT = "fetchinio.linkedin.post.engagement";
@@ -430,7 +430,7 @@ function failure(endpoint: string, answer: TregAnswer): LinkedinTregError {
430430
// ─── Harvest (posts + engagement → silver) ───────────────────────────────────
431431

432432
/** A page's state after the posts step of one serve. */
433-
export type PageOutcome = { page: CompetitorPage; state: "readable" } | { page: CompetitorPage; state: "not_found" | "failed"; reason: string };
433+
export type PageOutcome = { page: CompetitorPage; state: "readable" } | { page: CompetitorPage; state: "not_found" | "unreadable" | "failed"; reason: string };
434434

435435
/**
436436
* Ask the providers in order until one returns the page's posts. A provider
@@ -440,41 +440,40 @@ export type PageOutcome = { page: CompetitorPage; state: "readable" } | { page:
440440
* posts, 2026-10-04). The page is not_found only when EVERY provider said so; a
441441
* mix of not-found and failures is a failure (retried, never remembered).
442442
*/
443-
async function readPosts(meter: EngagementMeter, page: CompetitorPage, providers: PostsProvider[]): Promise<{ kind: "posts"; posts: WirePost[] } | { kind: "not_found"; reason: string } | { kind: "failed"; reason: string }> {
444-
const skipped: string[] = [];
445-
const notFound: string[] = [];
443+
async function readPosts(meter: EngagementMeter, page: CompetitorPage, providers: PostsProvider[]): Promise<{ kind: "posts"; posts: WirePost[] } | PageReadVerdict> {
444+
const answers = { notFound: [] as string[], refused: [] as string[], transient: [] as string[] };
446445
for (const provider of providers) {
447446
let answer: TregAnswer;
448447
try {
449448
answer = await meter.call(provider.endpoint, { method: "GET", query: provider.query(page), maxMicro: provider.maxMicro, routed: false });
450449
} catch (err) {
451450
if (!(err instanceof LinkedinTregTransientError)) throw err;
452-
skipped.push(messageOf(err));
451+
answers.transient.push(messageOf(err));
453452
continue;
454453
}
455454
const verdict = postsVerdict(provider, answer.status, answer.body);
456-
if (verdict.kind === "next") {
457-
skipped.push(verdict.reason);
455+
if (verdict.kind === "next" || verdict.kind === "refused") {
456+
(verdict.kind === "next" ? answers.transient : answers.refused).push(verdict.reason);
458457
console.warn(`[Apollo Service][linkedin-engagement] posts provider skipped for ${page.url}: ${verdict.reason}`);
459458
continue;
460459
}
461460
if (verdict.kind === "not_found") {
462-
notFound.push(verdict.reason);
461+
answers.notFound.push(verdict.reason);
463462
continue;
464463
}
465464
// An answer we do not understand stops the chain (no other provider is paid) and
466465
// fails the page for this serve; it is remembered like any failure.
467466
if (verdict.kind === "fail") return { kind: "failed", reason: verdict.reason };
468467
return verdict;
469468
}
470-
if (notFound.length === providers.length) return { kind: "not_found", reason: `no posts provider knows the page: ${notFound.join(" | ")}` };
471-
return { kind: "failed", reason: `no posts provider could read the page: ${[...notFound, ...skipped].join(" | ")}` };
469+
return settlePageRead(answers, providers.length);
472470
}
473471

474472
/** The page's remembered posts answer while it is fresh; null = due for a read. */
475473
export function freshPostsOutcome(row: typeof linkedinCompanyPages.$inferSelect | undefined, page: CompetitorPage, now: Date): PageOutcome | null {
476474
if (row?.postsFetchedAt && now.getTime() - row.postsFetchedAt.getTime() < POSTS_REFRESH_MS) {
477-
return row.postsStatus === "not_found" ? { page, state: "not_found", reason: row.postsError ?? "not found" } : { page, state: "readable" };
475+
if (row.postsStatus === "not_found" || row.postsStatus === "unreadable") return { page, state: row.postsStatus, reason: row.postsError ?? row.postsStatus };
476+
return { page, state: "readable" };
478477
}
479478
// Due, but every provider failed on it a moment ago: skipped, no provider call.
480479
const failedAt = row?.postsFailedAt;
@@ -511,11 +510,11 @@ async function readPageUnderLock(meter: EngagementMeter, page: CompetitorPage, n
511510
await tx.insert(linkedinCompanyPages).values({ slug: page.slug, url: page.url, ...failed }).onConflictDoUpdate({ target: linkedinCompanyPages.slug, set: failed });
512511
return { page, state: "failed", reason: read.reason };
513512
}
514-
if (read.kind === "not_found") {
513+
if (read.kind === "not_found" || read.kind === "unreadable") {
515514
// A permanent answer: remembered for the refresh period so no serve pays to re-learn it.
516-
const set = { postsFetchedAt: now, postsCount: 0, postsStatus: "not_found", postsError: read.reason.slice(0, 1000), postsFailedAt: null, postsFailure: null };
515+
const set = { postsFetchedAt: now, postsCount: 0, postsStatus: read.kind, postsError: read.reason.slice(0, 1000), postsFailedAt: null, postsFailure: null };
517516
await tx.insert(linkedinCompanyPages).values({ slug: page.slug, url: page.url, ...set }).onConflictDoUpdate({ target: linkedinCompanyPages.slug, set });
518-
return { page, state: "not_found", reason: read.reason };
517+
return { page, state: read.kind, reason: read.reason };
519518
}
520519

521520
for (const p of read.posts) {
@@ -602,7 +601,7 @@ export async function harvest(meter: EngagementMeter, pages: CompetitorPage[], s
602601
const skipped = outcomes.flatMap((o) => (o.state === "readable" ? [] : [{ page: o.page.url, state: o.state, reason: o.reason }]));
603602
for (const s of skipped) console.warn(`[Apollo Service][linkedin-engagement] competitor page SKIPPED (${s.state}) ${s.page}: ${s.reason}`);
604603
if (skipped.length === pages.length) {
605-
throw new LinkedinCompetitorPagesUnreadableError(skipped.map((s) => ({ page: s.page, reason: s.reason })), skipped.every((s) => s.state === "not_found"));
604+
throw new LinkedinCompetitorPagesUnreadableError(skipped.map((s) => ({ page: s.page, reason: s.reason })), !outcomes.some(blocksCompletion));
606605
}
607606
const stale = new Date(now.getTime() - ENGAGEMENT_REFRESH_MS);
608607
const due = await db
@@ -626,8 +625,19 @@ export async function harvest(meter: EngagementMeter, pages: CompetitorPage[], s
626625
});
627626
const failed = read.find((r) => r?.status === "rejected") as PromiseRejectedResult | undefined;
628627
if (failed) throw failed.reason;
629-
// A page that failed transiently may still hold engagers: not exhausted yet.
630-
return { complete: complete && !skipped.some((s) => s.state === "failed"), skipped };
628+
// A page that failed transiently may still hold engagers: not exhausted yet. A
629+
// not_found or unreadable page is permanent (re-checked daily): it holds nobody
630+
// we can reach, so it never keeps the serve from ending.
631+
return { complete: complete && !outcomes.some(blocksCompletion), skipped };
632+
}
633+
634+
/**
635+
* Only a TRANSIENT failure keeps a harvest incomplete. Before (LI-ENGAGEMENT-NEVER-DONE-1029)
636+
* a page every provider refused counted as failed forever: Beyond Logic's diffco-us
637+
* kept its audience answering "nothing yet, not done" to ~18k serves an hour.
638+
*/
639+
export function blocksCompletion(outcome: PageOutcome): boolean {
640+
return outcome.state === "failed";
631641
}
632642

633643
// ─── Serve ───────────────────────────────────────────────────────────────────
@@ -783,7 +793,7 @@ export interface EngagementServeResult {
783793
unresolvable: number;
784794
/** Lookups that failed transiently: released, retried by a later serve. */
785795
deferred: number;
786-
/** Competitor pages not read this serve: `not_found` (no provider knows the page) or `failed` (every provider failed). */
796+
/** Competitor pages not read this serve: `not_found` (no provider knows the page), `unreadable` (one says not found, the others refuse) or `failed` (transient: retried). */
787797
skippedPages: Array<{ page: string; state: string; reason: string }>;
788798
calls: number;
789799
chargedMicro: number;

‎src/routes/search.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,7 @@ router.post("/search/next", serviceAuth, async (req: AuthenticatedRequest, res)
723723
people: served.people,
724724
done: served.done,
725725
doneReason: served.doneReason,
726+
unreadablePages: served.skippedPages.filter((s) => s.state !== "failed").map((s) => ({ page: s.page, state: s.state, reason: s.reason })),
726727
totalEntries: served.poolSize,
727728
page: 1,
728729
totalPages: 1,

‎src/schemas.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -758,6 +758,12 @@ const SearchNextResponseSchema = z
758758
doneReason: z.enum(["no_recent_posts", "no_engagers", "all_served"]).nullable().optional().openapi({
759759
description: "source=linkedin_engagement only (absent everywhere else). WHY done is what it is: null while done=false; when done=true, no_recent_posts = no competitor page has a post published inside the audience's window (nothing to read engagers from: NOT exhaustion of an engaged pool, new posts may come), no_engagers = posts exist inside the window but nobody reacted or commented, all_served = there were engagers and every one was already considered for this audience (served, competitor employee or profile unreadable).",
760760
}),
761+
unreadablePages: z
762+
.array(z.object({ page: z.string(), state: z.enum(["not_found", "unreadable"]), reason: z.string() }))
763+
.optional()
764+
.openapi({
765+
description: "source=linkedin_engagement only. Competitor pages no posts provider can read (not_found = every provider says the page does not exist; unreadable = one says so and the others refuse it). Permanent: re-checked once a day, and they never keep done=false, so done/doneReason describe the READABLE pages. A page that failed transiently (rate limit, outage, timeout) is not listed and keeps done=false.",
766+
}),
761767
source: z.enum(["quickenrich", "linkedin_engagement"]).optional().openapi({
762768
description: "quickenrich: this page came from the FREE QuickEnrich search (the audience is switched to quickenrich); those people carry an id `qe:<emp_id>`, their full name, LinkedIn URL and company domain. linkedin_engagement: the filters carry a linkedin_engagement buying_signal; up to 5 engagers of the competitor pages per call, each with an id `li:<profileId>`, full name, title, headline, employer and LinkedIn URL, never the same person twice for the audience (x-audience-id, else the campaign), competitor employees excluded; done=true only once nobody is left. For both, POST /enrich with that id finds the email with treg and verifies it. Absent = the Apollo walk, unchanged.",
763769
}),

0 commit comments

Comments
 (0)