yeevy/centris-passerelle
Composer 安装命令:
composer require yeevy/centris-passerelle
包简介
Unofficial PHP client for the Centris® Passerelle FTP feed — parse, sync & reconcile Quebec MLS listings. Not affiliated with or endorsed by Centris or QFREB.
README 文档
README
Non officiel. Aucune affiliation avec Centris ou l'APCIQ/QFREB, ni endossement de leur part. Requiert une entente de diffusion valide.
Unofficial. Not affiliated with or endorsed by Centris or QFREB. Requires a valid diffusion agreement.
Français
Client PHP open source non officiel pour le flux FTP Centris® Passerelle (données d'inscriptions MLS du Québec distribuées aux courtiers autorisés). Analyse, synchronise et réconcilie les données d'inscriptions.
Noyau PHP pur, sans dépendance à un framework : utilisable depuis une extension WordPress, Laravel, Symfony ou un simple script cron.
Comment fonctionne le flux Passerelle
- Aucune API publique. Le courtier signe une entente de diffusion avec Centris/APCIQ et reçoit des identifiants FTP limités à ses propres inscriptions.
- Centris dépose un instantané complet une ou deux fois par jour (pas de deltas) : les retraits se détectent par différence — une inscription présente en base mais absente du nouveau fichier est vendue, expirée ou retirée.
- Fichiers livrés :
INSCRIPTIONS.TXT(inscriptions),REMARQUES.TXT(descriptions FR/EN),PHOTOS.TXT,ADDENDA.TXT, plus des fichiers de référence (courtiers, agences, caractéristiques, municipalités). - Format : CSV délimité par virgules, champs entre guillemets, encodage Windows-1252, sans ligne d'en-tête, colonnes positionnelles (~150), une inscription par ligne (CRLF).
Prérequis
- PHP 8.2+ avec l'extension
mbstring - Une entente de diffusion Passerelle valide (ce paquet ne fournit aucune donnée)
Installation
composer require yeevy/centris-passerelle
Utilisation
use Yeevy\CentrisPasserelle\Parser\ListingsParser; use Yeevy\CentrisPasserelle\Enums\ListingStatus; $parser = new ListingsParser(); foreach ($parser->parseFile('/chemin/vers/INSCRIPTIONS.TXT') as $listing) { $listing->mlsNumber; // « 9999999 » — clé d'upsert $listing->salePrice; // 975000 (null pour les locations) $listing->status; // ListingStatus::Active | ListingStatus::Sold | null $listing->descriptionFr; // contient du HTML <br/> $listing->descriptionEn; $listing->latitude; $listing->longitude; $listing->dirtyHash; // sha256 de la ligne brute — ignorez les lignes inchangées $listing->row; // ligne brute complète pour les colonnes non cartographiées }
L'analyse est paresseuse (générateur) : les instantanés volumineux ne saturent pas la mémoire. La conversion Windows-1252 → UTF-8 est appliquée automatiquement.
Chaque fichier du dépôt a son analyseur. Les fichiers de détail se joignent aux inscriptions par numéro MLS ; les fichiers de référence par leurs propres codes :
| Fichier | Analyseur | Contenu |
|---|---|---|
REMARQUES.TXT |
RemarksParser |
Descriptions FR/EN |
PHOTOS.TXT |
PhotosParser |
Photos ordonnées, URL media.ashx |
ADDENDA.TXT |
AddendaParser |
Addenda en segments à réassembler |
CARACTERISTIQUES.TXT |
FeaturesParser |
Caractéristiques codées |
DEPENSES.TXT |
ExpensesParser |
Taxes et dépenses |
RENOVATIONS.TXT |
RenovationsParser |
Rénovations déclarées |
LIENS_ADDITIONNELS.TXT |
AdditionalLinksParser |
Visites virtuelles, vidéos |
VISITES_LIBRES.TXT |
OpenHousesParser |
Visites libres planifiées |
UNITES_DETAILLEES.TXT |
UnitsParser |
Unités (principale, logements, intergénération) |
PIECES_UNITES.TXT |
RoomsParser |
Pièces par unité (dimensions, revêtements) |
MEMBRES.TXT |
BrokersParser |
Courtiers (clé : code courtier) |
FIRMES.TXT |
FirmsParser |
Agences (clé : code firme) |
BUREAUX.TXT |
OfficesParser |
Bureaux (clé : code bureau) |
use Yeevy\CentrisPasserelle\Parser\PhotosParser; foreach ((new PhotosParser())->parseFile('/chemin/vers/PHOTOS.TXT') as $photo) { $photo->mlsNumber; // clé de jointure $photo->sequence; // ordre d'affichage $photo->categoryCode; // FACA = façade, CUI = cuisine, SDB = salle de bain… $photo->url; // https://mediaserver.centris.ca/media.ashx?id=… }
Positions de colonnes
Les positions livrées avec le paquet sont observées par la communauté et peuvent varier selon la version de votre entente. Vérifiez-les contre la documentation PDF Passerelle fournie avec votre entente, puis surchargez-les au besoin :
use Yeevy\CentrisPasserelle\Config\ColumnMap; use Yeevy\CentrisPasserelle\Parser\ListingsParser; $columns = ColumnMap::listings()->with([ 'status_code' => 120, // position vérifiée dans votre documentation ]); $parser = new ListingsParser($columns);
Un logger PSR-3 peut être injecté ; les lignes sans numéro MLS sont journalisées puis ignorées au lieu d'interrompre l'instantané :
$parser = new ListingsParser(logger: $monLogger);
Si Centris introduit une nouvelle disposition de colonnes, elle sera publiée comme profil nommé plutôt qu'en écrasant la carte par défaut : ColumnMap::listings('2027') chargera config/listings-2027.php, et les profils existants continueront de fonctionner.
Détection de dérive
Un changement de structure du flux ne provoque aucune erreur par lui-même — il se manifeste par des données décalées importées silencieusement. Validez l'instantané avant l'import :
use Yeevy\CentrisPasserelle\Validation\SnapshotValidator; $validator = new SnapshotValidator($columns); // Échantillonne les lignes et vérifie les invariants (numéro MLS numérique, // format des dates, coordonnées dans les bornes du Québec…). // Lève ColumnMapMismatch si la structure ne correspond plus à la carte, // ou si l'instantané est vide (ce qui dépublierait toutes les inscriptions). $validator->validateFile('/chemin/vers/INSCRIPTIONS.TXT');
Les vérifications sont injectables — ajoutez des invariants propres à votre entente ou assouplissez ceux par défaut :
new SnapshotValidator($columns, checks: [ ...SnapshotValidator::defaultChecks(), fn (array $row, ColumnMap $columns): ?string => /* votre invariant */ null, ]);
Cycle de vie des inscriptions
| Signal | Interprétation |
|---|---|
EV (en vigueur) |
Publier |
VE (vendue) |
Marquer vendue |
| Absente de l'instantané | Dépublier (étape de réconciliation) |
Synchronisation
Le moteur applique un instantané complet à votre stockage : validation de dérive d'abord (rien n'est écrit si elle échoue), upsert avec saut des lignes inchangées (dirty hash), puis réconciliation des retraits. Le paquet ne touche jamais à une base de données — implémentez ListingRepository contre votre propre schéma (Eloquent, PDO, WordPress…) :
use Yeevy\CentrisPasserelle\Sync\ListingsSynchronizer; $synchronizer = new ListingsSynchronizer( repository: $monRepository, // implémente Contracts\ListingRepository events: $dispatcherPsr14, // optionnel — ListingCreated / ListingUpdated / ListingRemoved ); $result = $synchronizer->sync('/chemin/vers/instantane'); // dossier ou fichier // $result->created, $result->updated, $result->skipped, $result->removed
Pour récupérer l'instantané depuis le compte FTP Passerelle, utilisez FlysystemFeedSource (installez league/flysystem et league/flysystem-ftp ou -sftp-v3) :
use Yeevy\CentrisPasserelle\Feed\FlysystemFeedSource; $source = new FlysystemFeedSource($filesystem, localDirectory: '/tmp/centris'); $result = $synchronizer->sync($source);
Si votre entente livre l'instantané en archive ZIP, décorez la source — l'extraction se fait sur place (requiert ext-zip) :
use Yeevy\CentrisPasserelle\Feed\ZipExtractingSource; $result = $synchronizer->sync(new ZipExtractingSource($source));
Téléchargement des photos
PhotoDownloader télécharge les photos via n'importe quel client PSR-18 (p. ex. Guzzle) dans des fichiers adressés par contenu ({sha256}.jpg) — les octets identiques ne sont stockés qu'une seule fois :
use Yeevy\CentrisPasserelle\Photo\PhotoDownloader; $downloader = new PhotoDownloader($clientPsr18, $requestFactory, '/chemin/vers/photos'); foreach ($downloader->downloadAll($photosParser->parseFile('/chemin/vers/PHOTOS.TXT')) as $photo) { $photo->path; // /chemin/vers/photos/{sha256}.jpg $photo->wasDeduplicated; // true si les octets existaient déjà }
download() lève PhotoDownloadFailed ; downloadAll() journalise et ignore les échecs pour qu'une URL brisée n'interrompe jamais le lot. La conversion WebP reste une préoccupation de l'application consommatrice.
Feuille de route
- Enveloppe Laravel :
yeevy/laravel-centris— commandecentris:sync, événements Laravel, jobs de photos en file d'attente à venir
Gestion des versions
Le paquet suit SemVer via les étiquettes git.
- 0.x : les positions de colonnes sont observées par la communauté et l'API se stabilise — des ruptures peuvent survenir dans les versions mineures.
- À partir de 1.0 : correctifs = patch ; nouveaux champs et analyseurs = mineure ; changement d'API = majeure.
- Cartes de colonnes : corriger une position par défaut est publié au minimum en version mineure avec une entrée de changelog explicite — le code compile mais les données changent. Une nouvelle disposition Centris devient un nouveau profil nommé, jamais un écrasement du profil existant.
Tests
composer test # Pest composer analyse # PHPStan niveau 8 composer format # Pint
Important : ne commettez jamais de données réelles du flux. Les tests utilisent exclusivement des fixtures synthétiques.
Licence
MIT — © Digital Unity Inc. (Yeevy)
English
Unofficial open-source PHP client for the Centris® Passerelle FTP feed (Quebec MLS listing data distributed to authorized brokers). Parses, syncs, and reconciles listing data.
Pure PHP core with no framework dependency: consumable from a WordPress plugin, Laravel, Symfony, or a bare cron script.
How the Passerelle feed works
- No public API. The broker signs a diffusion agreement with Centris/QFREB and receives FTP credentials scoped to their own listings.
- Centris drops a full snapshot once or twice daily (no deltas): removals are detected by diffing — a listing present in your database but absent from the new file is sold, expired, or withdrawn.
- Delivered files:
INSCRIPTIONS.TXT(listings master),REMARQUES.TXT(FR/EN descriptions),PHOTOS.TXT,ADDENDA.TXT, plus reference files (brokers, agencies, features, municipalities). - Format: comma-delimited CSV, quoted fields, Windows-1252 encoding, no header row, positional columns (~150), one listing per CRLF line.
Requirements
- PHP 8.2+ with the
mbstringextension - A valid Passerelle diffusion agreement (this package ships no data)
Installation
composer require yeevy/centris-passerelle
Usage
use Yeevy\CentrisPasserelle\Parser\ListingsParser; use Yeevy\CentrisPasserelle\Enums\ListingStatus; $parser = new ListingsParser(); foreach ($parser->parseFile('/path/to/INSCRIPTIONS.TXT') as $listing) { $listing->mlsNumber; // "9999999" — upsert key $listing->salePrice; // 975000 (null for rentals) $listing->status; // ListingStatus::Active | ListingStatus::Sold | null $listing->descriptionFr; // contains HTML <br/> $listing->descriptionEn; $listing->latitude; $listing->longitude; $listing->dirtyHash; // sha256 of the raw row — skip unchanged rows on upsert $listing->row; // full raw row for unmapped columns }
Parsing is lazy (generator-based), so large snapshots don't exhaust memory. Windows-1252 → UTF-8 conversion is applied automatically.
Every file in the drop has its own parser. Detail files join to listings by MLS number; reference files by their own codes:
| File | Parser | Content |
|---|---|---|
REMARQUES.TXT |
RemarksParser |
FR/EN descriptions |
PHOTOS.TXT |
PhotosParser |
Ordered photos, media.ashx URLs |
ADDENDA.TXT |
AddendaParser |
Chunked addenda to reassemble |
CARACTERISTIQUES.TXT |
FeaturesParser |
Coded features |
DEPENSES.TXT |
ExpensesParser |
Taxes and expenses |
RENOVATIONS.TXT |
RenovationsParser |
Declared renovations |
LIENS_ADDITIONNELS.TXT |
AdditionalLinksParser |
Virtual tours, videos |
VISITES_LIBRES.TXT |
OpenHousesParser |
Scheduled open houses |
UNITES_DETAILLEES.TXT |
UnitsParser |
Units (main, rental, intergenerational) |
PIECES_UNITES.TXT |
RoomsParser |
Rooms per unit (dimensions, flooring) |
MEMBRES.TXT |
BrokersParser |
Brokers (key: broker code) |
FIRMES.TXT |
FirmsParser |
Firms (key: firm code) |
BUREAUX.TXT |
OfficesParser |
Offices (key: office code) |
use Yeevy\CentrisPasserelle\Parser\PhotosParser; foreach ((new PhotosParser())->parseFile('/path/to/PHOTOS.TXT') as $photo) { $photo->mlsNumber; // join key $photo->sequence; // display order $photo->categoryCode; // FACA = façade, CUI = kitchen, SDB = bathroom… $photo->url; // https://mediaserver.centris.ca/media.ashx?id=… }
Column positions
The positions shipped with the package are community-observed and may vary by agreement version. Verify them against the Passerelle PDF documentation that came with your agreement, then override as needed:
use Yeevy\CentrisPasserelle\Config\ColumnMap; use Yeevy\CentrisPasserelle\Parser\ListingsParser; $columns = ColumnMap::listings()->with([ 'status_code' => 120, // position verified in your documentation ]); $parser = new ListingsParser($columns);
A PSR-3 logger can be injected; rows without an MLS number are logged and skipped instead of aborting the snapshot:
$parser = new ListingsParser(logger: $myLogger);
If Centris introduces a new column layout, it will ship as a named profile rather than overwriting the default map: ColumnMap::listings('2027') loads config/listings-2027.php, and existing profiles keep working.
Drift detection
A feed structure change raises no error by itself — it shows up as shifted data imported silently. Validate the snapshot before importing:
use Yeevy\CentrisPasserelle\Validation\SnapshotValidator; $validator = new SnapshotValidator($columns); // Samples rows and checks invariants (numeric MLS number, date format, // coordinates within Quebec bounds…). Throws ColumnMapMismatch when the // structure no longer lines up with the map, or when the snapshot is // empty (which would unpublish every listing). $validator->validateFile('/path/to/INSCRIPTIONS.TXT');
Checks are injectable — add per-agreement invariants or relax the defaults:
new SnapshotValidator($columns, checks: [ ...SnapshotValidator::defaultChecks(), fn (array $row, ColumnMap $columns): ?string => /* your invariant */ null, ]);
Listing lifecycle
| Signal | Interpretation |
|---|---|
EV (en vigueur) |
Publish |
VE (vendue) |
Mark sold |
| Absent from snapshot | Unpublish (reconciliation step) |
Synchronization
The engine applies a full snapshot to your storage: drift validation first (nothing is written if it fails), upserts with unchanged-row skipping (dirty hash), then removal reconciliation. The package never touches a database — implement ListingRepository against your own schema (Eloquent, PDO, WordPress…):
use Yeevy\CentrisPasserelle\Sync\ListingsSynchronizer; $synchronizer = new ListingsSynchronizer( repository: $myRepository, // implements Contracts\ListingRepository events: $psr14Dispatcher, // optional — ListingCreated / ListingUpdated / ListingRemoved ); $result = $synchronizer->sync('/path/to/snapshot'); // directory or file // $result->created, $result->updated, $result->skipped, $result->removed
To fetch the snapshot from the Passerelle FTP account, use FlysystemFeedSource (install league/flysystem plus league/flysystem-ftp or -sftp-v3):
use Yeevy\CentrisPasserelle\Feed\FlysystemFeedSource; $source = new FlysystemFeedSource($filesystem, localDirectory: '/tmp/centris'); $result = $synchronizer->sync($source);
If your agreement delivers the snapshot as a ZIP archive, decorate the source — extraction happens in place (requires ext-zip):
use Yeevy\CentrisPasserelle\Feed\ZipExtractingSource; $result = $synchronizer->sync(new ZipExtractingSource($source));
Photo downloads
PhotoDownloader fetches photos through any PSR-18 client (e.g. Guzzle) into content-addressed files ({sha256}.jpg) — identical bytes are stored exactly once:
use Yeevy\CentrisPasserelle\Photo\PhotoDownloader; $downloader = new PhotoDownloader($psr18Client, $requestFactory, '/path/to/photos'); foreach ($downloader->downloadAll($photosParser->parseFile('/path/to/PHOTOS.TXT')) as $photo) { $photo->path; // /path/to/photos/{sha256}.jpg $photo->wasDeduplicated; // true when the bytes already existed }
download() throws PhotoDownloadFailed; downloadAll() logs and skips failures so one broken URL never aborts the batch. WebP conversion stays a consumer-application concern.
Roadmap
- Laravel wrapper:
yeevy/laravel-centris—centris:synccommand, Laravel events, queued photo jobs upcoming
Versioning
The package follows SemVer via git tags.
- 0.x: column positions are community-observed and the API is still settling — breaking changes may land in minor versions.
- From 1.0 on: fixes = patch; new fields and parsers = minor; API changes = major.
- Column maps: correcting a shipped default position is released as at least a minor version with an explicit changelog entry — code still compiles, but data shifts. A new Centris layout becomes a new named profile, never an overwrite of an existing one.
Testing
composer test # Pest composer analyse # PHPStan level 8 composer format # Pint
Important: never commit real feed data. Tests use synthetic fixtures only.
License
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 3
- 依赖项目数: 1
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-07-05