houses: one row per listing when a hand-added link is scraped later
All checks were successful
Deploy production / deploy (push) Successful in 3m0s
All checks were successful
Deploy production / deploy (push) Successful in 3m0s
The nightly scraper and worker API now take over an existing row (e.g. source=manual) whose link is the same listing (normalized URL, or the Pararius key / Funda, Kamernet, Vesteda id in it) instead of inserting a copy; typed values are kept where the scrape has none. house-listing update/add give manual rows with such links the site key unless another row has it, update accepts --source/--source-id, and lookups by link ignore tracking params. scripts/house-rekey.ts backfills existing manual rows.
This commit is contained in:
@@ -23,6 +23,14 @@ import {
|
||||
type ListingKey,
|
||||
type ManualListing
|
||||
} from '../src/lib/house-tracker/manual';
|
||||
import {
|
||||
containsPattern,
|
||||
linkHost,
|
||||
normalizeListingUrl,
|
||||
pickLinkedListing,
|
||||
siteKeyFromUrl,
|
||||
type LinkCandidate
|
||||
} from '../src/lib/house-tracker/link-key';
|
||||
|
||||
const usage = `usage: bun run house <add|update|list|remove|restore> [options]
|
||||
|
||||
@@ -144,8 +152,34 @@ async function findId(ref: string | undefined): Promise<number> {
|
||||
const key = keyFromUrl(ref);
|
||||
const [row] = await sql<{ id: number }[]>`select id from house.listings
|
||||
where (source = ${key.source} and source_id = ${key.sourceId}) or url = ${ref} limit 1`;
|
||||
if (!row) throw new Error(`no listing for ${ref}`);
|
||||
return row.id;
|
||||
if (row) return row.id;
|
||||
// Same page under a slightly different link (tracking params, www., trailing slash).
|
||||
const wanted = normalizeListingUrl(ref);
|
||||
const byLink = (await candidatesFor(sql, ref, '')).find(
|
||||
(c) => normalizeListingUrl(c.url) === wanted
|
||||
);
|
||||
if (!byLink) throw new Error(`no listing for ${ref}`);
|
||||
return byLink.id;
|
||||
}
|
||||
|
||||
/** Rows on the same host as `url`, except those under `source`, for pickLinkedListing. */
|
||||
async function candidatesFor(
|
||||
db: postgres.Sql | Tx,
|
||||
url: string | null | undefined,
|
||||
source: string
|
||||
): Promise<LinkCandidate[]> {
|
||||
const host = linkHost(url);
|
||||
if (!host) return [];
|
||||
return db<LinkCandidate[]>`select id, source, source_id as "sourceId", url,
|
||||
duplicate_of as "duplicateOf"
|
||||
from house.listings where source <> ${source} and url ilike ${containsPattern(host)}`;
|
||||
}
|
||||
|
||||
/** Id of the row already keyed (source, source_id), other than `id`. */
|
||||
async function keyOwner(tx: Tx, source: string, sourceId: string, id: number) {
|
||||
const [row] = await tx<{ id: number }[]>`select id from house.listings
|
||||
where source = ${source} and source_id = ${sourceId} and id <> ${id}`;
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
// Same convention as the worker: a price_history row whenever the price is new or changes.
|
||||
@@ -185,11 +219,30 @@ async function add() {
|
||||
}
|
||||
|
||||
const row = await sql.begin(async (tx) => {
|
||||
const [before] = await tx<{ id: number; price_eur: number | null }[]>`
|
||||
let [before] = await tx<{ id: number; price_eur: number | null }[]>`
|
||||
select id, price_eur from house.listings
|
||||
where source = ${key.source} and source_id = ${key.sourceId} for update`;
|
||||
let rekey = false;
|
||||
if (!before && key.source !== 'manual' && listing.url) {
|
||||
// The same link already on a hand-added row: update it and give it the site's key.
|
||||
const linked = pickLinkedListing(
|
||||
await candidatesFor(tx, key.url, key.source),
|
||||
key,
|
||||
key.url
|
||||
);
|
||||
if (linked) {
|
||||
[before] = await tx<{ id: number; price_eur: number | null }[]>`
|
||||
select id, price_eur from house.listings where id = ${linked.id} for update`;
|
||||
rekey = Boolean(before);
|
||||
}
|
||||
}
|
||||
if (before) {
|
||||
const update = { ...given, last_seen_at: new Date(), removed_at: null };
|
||||
const update: Record<string, unknown> = {
|
||||
...given,
|
||||
last_seen_at: new Date(),
|
||||
removed_at: null
|
||||
};
|
||||
if (rekey) Object.assign(update, { source: key.source, source_id: key.sourceId });
|
||||
if (listing.offer) Object.assign(update, { offer: listing.offer });
|
||||
const [updated] = await tx`update house.listings set ${tx(update)}
|
||||
where id = ${before.id} returning ${summary}`;
|
||||
@@ -211,18 +264,41 @@ async function update() {
|
||||
if (listing.url) given.url = listing.url;
|
||||
if (listing.offer) given.offer = listing.offer;
|
||||
if (listing.propertyType) given.property_type = listing.propertyType;
|
||||
if (listing.source) given.source = listing.source;
|
||||
if (listing.sourceId) given.source_id = listing.sourceId;
|
||||
if (!Object.keys(given).length) throw new Error('nothing to update');
|
||||
if (flag('dry-run')) return console.log({ id, ...given });
|
||||
const [row] = await sql.begin(async (tx) => {
|
||||
const [before] = await tx<{ price_eur: number | null }[]>`
|
||||
select price_eur from house.listings where id = ${id} for update`;
|
||||
const [before] = await tx<
|
||||
{ price_eur: number | null; source: string; source_id: string; url: string }[]
|
||||
>`select price_eur, source, source_id, url from house.listings where id = ${id} for update`;
|
||||
if (!before) throw new Error(`no listing ${id}`);
|
||||
// A manual row with a Funda/Pararius/Kamernet/Vesteda link gets that site's key, so a
|
||||
// later scrape of the listing updates this row instead of adding a copy.
|
||||
const url = (given.url as string | undefined) ?? before.url;
|
||||
const site = siteKeyFromUrl(url);
|
||||
if (before.source === 'manual' && !given.source && !given.source_id && site) {
|
||||
const owner = await keyOwner(tx, site.source, site.sourceId, id);
|
||||
if (owner == null) Object.assign(given, { source: site.source, source_id: site.sourceId });
|
||||
else
|
||||
console.warn(
|
||||
`kept source=manual: ${site.source}/${site.sourceId} is already #${owner} (merge them by hand)`
|
||||
);
|
||||
} else if (given.source || given.source_id) {
|
||||
const source = (given.source as string | undefined) ?? before.source;
|
||||
const sourceId = (given.source_id as string | undefined) ?? before.source_id;
|
||||
const owner = await keyOwner(tx, source, sourceId, id);
|
||||
if (owner != null) throw new Error(`${source}/${sourceId} is already #${owner}`);
|
||||
}
|
||||
if (flag('dry-run')) {
|
||||
console.log({ id, ...given });
|
||||
return [];
|
||||
}
|
||||
const rows =
|
||||
await tx`update house.listings set ${tx(given)} where id = ${id} returning ${summary}`;
|
||||
await recordPrice(tx, id, before.price_eur, given.price_eur);
|
||||
return rows;
|
||||
});
|
||||
print([row]);
|
||||
if (row) print([row]);
|
||||
}
|
||||
|
||||
async function list() {
|
||||
|
||||
Reference in New Issue
Block a user